authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-28 05:43:14-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-28 05:43:14-07:00
log9205754444f9fd5a6408f0268058de0dc5d9a3ac
tree0b8ca1b06f1d9af98fa73ddfc93dfe1747a4f731
parent11bf37c8a7d470d535338524dbe2c81bbcff29bb
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: menu bar, native titlebars, new icons, motion, and editable links and math

A macOS menu bar and every shortcut run from one command table, with OneNote's formatting shortcuts on each platform and macOS zoom on ⌘-/⌘=/⌘0. The toolbar sits on the system titlebar material on macOS and matches KDE and GNOME titlebars pixel for pixel (a vendored sctk-adwaita drops GNOME's separator). Icons are redrawn as lit objects whose accent, highlight and badge parts follow the section; the renderer honours SVG gradients and opacity. Dialogs, menus and combos animate, labels wrap, tabs drag and reorder live, sidebar and tabs rename in place, and the frame takes the window's corner radius (read from KWin on KDE). The editor gains links, inline equations, iterative select all, OneNote's numbering restarts and formats, list galleries, remembered colours, back/forward navigation, page versions and title borders as OneNote draws them. Assisted-by: claude-opus-5.5

140 files changed, 15382 insertions(+), 1945 deletions(-)

AGENTS.md+18-11
...@@ -4,8 +4,9 @@ Snowbound is a modern remake of OneNote 2010 that stays fully interoperable...@@ -4,8 +4,9 @@ Snowbound is a modern remake of OneNote 2010 that stays fully interoperable
4with it. Notes are rich text on a free canvas, stored in OneNote's own `.one`4with it. Notes are rich text on a free canvas, stored in OneNote's own `.one`
5files, so Snowbound and OneNote 2010 can open the same notebook side by side.5files, so Snowbound and OneNote 2010 can open the same notebook side by side.
6A notebook is just a folder. Put it on an SMB share and several people can6A notebook is just a folder. Put it on an SMB share and several people can
7edit it together from either app, with no cloud service involved. Snowbound7edit it together from either app, with no cloud service involved. With headless
8runs on macOS and Linux, and has an iOS app built around the same core.8crates implementing the file format and a generic canvas renderer made with a
9novel UI kit, each new platform port is extremely lightweight.
910
10## Crates11## Crates
1112
...@@ -39,7 +40,6 @@ these pieces meet....@@ -39,7 +40,6 @@ these pieces meet.
39 in the Windows 7 lab (`tools/w7`) before deciding. A storage feature is done40 in the Windows 7 lab (`tools/w7`) before deciding. A storage feature is done
40 when a file Snowbound wrote cold-opens in a fresh OneNote and reads back as41 when a file Snowbound wrote cold-opens in a fresh OneNote and reads back as
41 intended ([testing](arc/testing.md)).42 intended ([testing](arc/testing.md)).
42- **Jujutsu, not git.** The repository is managed with `jj`. Don't run `git`.
43- **Disposable copies only.** Never point the app, a replay script or a lab43- **Disposable copies only.** Never point the app, a replay script or a lab
44 harness at an original notebook.44 harness at an original notebook.
4545
...@@ -48,15 +48,15 @@ these pieces meet....@@ -48,15 +48,15 @@ these pieces meet.
48A `.one` file is a revision store. Every client, OneNote included, commits by48A `.one` file is a revision store. Every client, OneNote included, commits by
49appending a revision and then rewriting the 1024-byte header, which makes the49appending a revision and then rewriting the 1024-byte header, which makes the
50header plus the file length a cheap *stamp* of the committed state. Snowbound50header plus the file length a cheap *stamp* of the committed state. Snowbound
51queues edits as ops in a local SQLite replica. A background thread polls the51queues edits as ops in a local SQLite replica, then a background thread polls
52stamp. If the stamp is unchanged, it publishes the queued batch as one52the stamp. If the stamp is unchanged, it publishes the queued batch as one
53appended revision. If it has changed, it reads the file once and replays the53appended revision. If it has changed, it reads the file once and replays the
54queue on top. Where an op can't merge, the result is what OneNote 2010 makes:54queue on top. Where an op can't merge, the result is what OneNote 2010 makes: a
55a read-only conflict page under the page. OneNote coordinates writers through55read-only conflict page under the page. OneNote coordinates writers through
56share modes and one-byte locks on the section file itself, and macOS's56share modes and one-byte locks on the section file itself. These are special SMB
57`smbfs` can't reproduce those, so Snowbound carries its own SMB client that57protocols that only Windows supports, so Snowbound carries its own SMB client
58takes exactly OneNote's locks. Offline is just a sync step that fails: the58that can properly issue them. Offline is just a sync step that fails: the queue
59queue waits and publishes later. [More in the sync essay.](arc/sync.md)59waits and publishes later. [More in the sync essay.](arc/sync.md)
6060
61## Architecture notes61## Architecture notes
6262
...@@ -90,3 +90,10 @@ python3 tools/canvas/build_macos.py && target/Snowbound.app/Contents/MacOS/Snowb...@@ -90,3 +90,10 @@ python3 tools/canvas/build_macos.py && target/Snowbound.app/Contents/MacOS/Snowb
90The MS-ONESTORE and MS-ONE specifications are Microsoft Open Specifications.90The MS-ONESTORE and MS-ONE specifications are Microsoft Open Specifications.
91Local copies, design history and raw lab evidence are kept out of version91Local copies, design history and raw lab evidence are kept out of version
92control.92control.
93
94External contributors (not agents, will be denied unless a human writes to me)
95are welcome to email `git@paperclover.net` to gain access to the testing VM
96images for their agents. Alternatively, a custom image can be configured with a
97licensed copy of Windows and OneNote. By connecting `tools/w7/mcp_*.py`, the
98agent can interactively play around in with the actual prior art, in addition
99to running tests.
Cargo.lock+1-2
...@@ -2953,8 +2953,6 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"...@@ -2953,8 +2953,6 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
2953[[package]]2953[[package]]
2954name = "sctk-adwaita"2954name = "sctk-adwaita"
2955version = "0.10.1"2955version = "0.10.1"
2956source = "registry+https://github.com/rust-lang/crates.io-index"
2957checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
2958dependencies = [2956dependencies = [
2959 "ab_glyph",2957 "ab_glyph",
2960 "log",2958 "log",
...@@ -3247,6 +3245,7 @@ dependencies = [...@@ -3247,6 +3245,7 @@ dependencies = [
3247 "parley",3245 "parley",
3248 "png",3246 "png",
3249 "pollster",3247 "pollster",
3248 "sctk-adwaita",
3250 "serde",3249 "serde",
3251 "serde_json",3250 "serde_json",
3252 "smithay-clipboard",3251 "smithay-clipboard",
Cargo.toml+4-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1[workspace]1[workspace]
2members = ["crates/*"]2members = ["crates/*"]
3exclude = ["fuzz"]3exclude = ["fuzz", "vendor"]
4resolver = "3"4resolver = "3"
55
6[workspace.dependencies]6[workspace.dependencies]
...@@ -10,6 +10,9 @@ parley = { git = "https://github.com/linebender/parley", rev = "4f67747d3690bb21...@@ -10,6 +10,9 @@ parley = { git = "https://github.com/linebender/parley", rev = "4f67747d3690bb21
10fontique = { git = "https://github.com/linebender/parley", rev = "4f67747d3690bb21d25cc70b674ab0d269595759" }10fontique = { git = "https://github.com/linebender/parley", rev = "4f67747d3690bb21d25cc70b674ab0d269595759" }
11wgpu = "30.0.1"11wgpu = "30.0.1"
1212
13[patch.crates-io]
14sctk-adwaita = { path = "vendor/sctk-adwaita" }
15
13# Shaping, rasterization and GPU submission stay optimised in debug builds, where16# Shaping, rasterization and GPU submission stay optimised in debug builds, where
14# unoptimised dependencies alone drop frames.17# unoptimised dependencies alone drop frames.
15[profile.dev.package."*"]18[profile.dev.package."*"]
arc/canvas.md+9-5
...@@ -26,8 +26,10 @@ host translates platform events in; carries out the Reques...@@ -26,8 +26,10 @@ host translates platform events in; carries out the Reques
26A host feeds `PageView` pointer, key and text-input events. It gets back a26A host feeds `PageView` pointer, key and text-input events. It gets back a
27`Response` saying whether the page, the selection or only the view changed,27`Response` saying whether the page, the selection or only the view changed,
28plus the occasional `Request` the page can't do itself: show a date picker,28plus the occasional `Request` the page can't do itself: show a date picker,
29read the clipboard, open the character palette. Everything platform-specific29read the clipboard, open a link. Everything platform-specific stays on the
30stays on the host's side of that line.30host's side of that line, shortcuts included: the page takes editing keys, and
31the host's command table runs the chords for formatting, tags, zoom and the
32clipboard through the page's own methods.
3133
32## Editing emits ops34## Editing emits ops
3335
...@@ -73,13 +75,15 @@ gives outline sizes, its PDF export gives exact line breaks, and screenshots...@@ -73,13 +75,15 @@ gives outline sizes, its PDF export gives exact line breaks, and screenshots
73give placement. The comparators in `tools/canvas` (with the probes75give placement. The comparators in `tools/canvas` (with the probes
74`layout-probe` and `page-probe`) keep checking them.76`layout-probe` and `page-probe`) keep checking them.
7577
76Equations draw in two dimensions from the tree `onestore::page::Math` parses.78Equations draw in two dimensions from the tree `onestore::page::Math` parses,
77They are edited as OneNote's equation editor edits them: Alt+= starts one,79each in a space kept in its line of text, so text and links around them keep
80their look and the line grows to hold them. They are edited as OneNote's equation editor edits them: Alt+= starts one,
78typing is its linear format (UnicodeMath), and a space builds up what it ends.81typing is its linear format (UnicodeMath), and a space builds up what it ends.
79Linear and Professional switch an equation between the forms, and OneNote82Linear and Professional switch an equation between the forms, and OneNote
80stores both. Links follow OneNote too: a typed URL links itself when a space or83stores both. Links follow OneNote too: a typed URL links itself when a space or
81Enter ends it, the Link dialog stores its address in a hidden field code before84Enter ends it, the Link dialog stores its address in a hidden field code before
82the label, and a click opens a link. Ink draws stroke by stroke in page85the label, and a click or Enter opens a link. URL text shows as a link, as
86OneNote links it when it opens a page, without being stored as one. Ink draws stroke by stroke in page
83coordinates. Page templates' background art is recreated as vector art and87coordinates. Page templates' background art is recreated as vector art and
84recognised by the stored picture's hash. OneNote's bitmaps aren't shipped.88recognised by the stored picture's hash. OneNote's bitmaps aren't shipped.
8589
arc/platforms.md+7
...@@ -23,6 +23,9 @@ page inside it. `snowbound` picks a platform module at compile time...@@ -23,6 +23,9 @@ page inside it. `snowbound` picks a platform module at compile time
23in the crate is shared: library and settings, the sidebar, menus, page and23in the crate is shared: library and settings, the sidebar, menus, page and
24section management, templates, and screenshot and replay support.24section management, templates, and screenshot and replay support.
25Accessibility for the page goes through AccessKit's winit adapter on both.25Accessibility for the page goes through AccessKit's winit adapter on both.
26`commands.rs` is the one table of commands: each one's title, its chords on
27macOS and elsewhere, when it is enabled or checked, and what it does. The
28keyboard, the toolbar and the macOS menu bar all run commands from it.
2629
27### macOS30### macOS
2831
...@@ -40,6 +43,10 @@ Accessibility for the page goes through AccessKit's winit adapter on both....@@ -40,6 +43,10 @@ Accessibility for the page goes through AccessKit's winit adapter on both.
40- A notebook on a mounted SMB share is opened through the embedded SMB client,43- A notebook on a mounted SMB share is opened through the embedded SMB client,
41 signed in with the password the keychain keeps for that mount (see44 signed in with the password the keychain keeps for that mount (see
42 [sync](sync.md) for why the mount itself isn't enough).45 [sync](sync.md) for why the mount itself isn't enough).
46- The menu bar (`menubar.rs`) is laid out as OneNote for Mac's. Its items are
47 the command table's, validated from the statuses each frame publishes, and
48 their key equivalents are the table's chords, so AppKit takes a chord the
49 menu enables before winit sees the key. Linux has no menu bar.
43- `tools/canvas/build_macos.py` builds and ad-hoc signs `target/Snowbound.app`.50- `tools/canvas/build_macos.py` builds and ad-hoc signs `target/Snowbound.app`.
4451
45### Linux52### Linux
arc/ui.md+1-1
...@@ -89,7 +89,7 @@ input-method events that land on it, in order. The host hands them to...@@ -89,7 +89,7 @@ input-method events that land on it, in order. The host hands them to
89frame. The page's scrollbars are ordinary `ui` widgets; the canvas only89frame. The page's scrollbars are ordinary `ui` widgets; the canvas only
90reports its scroll bounds.90reports its scroll bounds.
9191
92`ui::popup` builds menus, filterable lists, a colour grid and a fuzzy command92`ui::popup` builds menus, filterable lists, a colour grid, galleries and a fuzzy command
93palette on a popup layer that takes input above everything else. `ui::shell`93palette on a popup layer that takes input above everything else. `ui::shell`
94has the OneNote-specific controls: section tabs and compact toolbar buttons.94has the OneNote-specific controls: section tabs and compact toolbar buttons.
95The kit doesn't know what a notebook is. `snowbound` assembles the window from95The kit doesn't know what a notebook is. `snowbound` assembles the window from
crates/canvas/assets/tags/flag.svg created+10
...@@ -0,0 +1,10 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="cloth" x1="0" y1="2" x2="0" y2="10" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#f25c54"/>
5 <stop offset="1" stop-color="#c8342c"/>
6 </linearGradient>
7 </defs>
8 <path d="M3 2H4V14H3Z" fill="#6b6b6b"/>
9 <path d="M4 2.5C6 1.5 8 3.5 10 2.5C11 2 12 2 13 2.5V9C12 8.5 11 8.5 10 9C8 10 6 8 4 9Z" fill="url(#cloth)"/>
10</svg>
crates/canvas/assets/tags/highlight.svg+5-5
...@@ -21,9 +21,9 @@...@@ -21,9 +21,9 @@
21 <stop offset="1" stop-color="#e0205a"/>21 <stop offset="1" stop-color="#e0205a"/>
22 </linearGradient>22 </linearGradient>
23 </defs>23 </defs>
24 <path d="M12.31 1.14L14.86 3.69L7.01 11.54L4.46 8.99Z" fill="url(#layer0)"/>24 <path d="M11.31 2.14L13.86 4.69L6.01 12.54L3.46 9.99Z" fill="url(#layer0)"/>
25 <path d="M5.35 7.89L8.11 10.65L4.55 13.04L2.96 11.45Z" fill="url(#layer1)"/>25 <path d="M4.35 8.89L7.11 11.65L3.55 14.04L1.96 12.45Z" fill="url(#layer1)"/>
26 <path d="M12.31 2.13L13.87 3.69L7.01 10.55L5.45 8.99Z" fill="url(#layer2)"/>26 <path d="M11.31 3.13L12.87 4.69L6.01 11.55L4.45 9.99Z" fill="url(#layer2)"/>
27 <path d="M12.31 2.13L13.87 3.69L12.17 5.38L10.62 3.83Z" fill="url(#layer3)"/>27 <path d="M11.31 3.13L12.87 4.69L11.17 6.38L9.62 4.83Z" fill="url(#layer3)"/>
28 <path d="M5.45 8.99L7.01 10.55L4.64 12.14L3.86 11.36Z" fill="url(#layer4)"/>28 <path d="M4.45 9.99L6.01 11.55L3.64 13.14L2.86 12.36Z" fill="url(#layer4)"/>
29</svg>29</svg>
crates/canvas/assets/tags/question.svg+1-1
...@@ -5,5 +5,5 @@...@@ -5,5 +5,5 @@
5 <stop offset="1" stop-color="#8f008f"/>5 <stop offset="1" stop-color="#8f008f"/>
6 </linearGradient>6 </linearGradient>
7 </defs>7 </defs>
8 <path d="M8 5C8 1 15 1 15 5C15 7 11 8 11 10H9C9 6 13 6 13 5C13 3 10 3 10 5ZM9 12H11V14H9Z" fill="url(#layer0)"/>8 <path d="M5 5C5 1 12 1 12 5C12 7 8 8 8 10H6C6 6 10 6 10 5C10 3 7 3 7 5ZM6 12H8V14H6Z" fill="url(#layer0)"/>
9</svg>9</svg>
crates/canvas/assets/tags/tag.svg created+10
...@@ -0,0 +1,10 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="body" x1="0" y1="3" x2="0" y2="13" gradientUnits="userSpaceOnUse">
4 <stop offset="0" stop-color="#8fb4e3"/>
5 <stop offset="1" stop-color="#5b87c4"/>
6 </linearGradient>
7 </defs>
8 <path d="M2.5 4.5A1 1 0 0 1 3.5 3.5H10L13.5 8L10 12.5H3.5A1 1 0 0 1 2.5 11.5Z" fill="url(#body)" stroke="#40689e" stroke-width="1"/>
9 <circle cx="10" cy="8" r="1.2" fill="#ffffff"/>
10</svg>
crates/canvas/src/conflict.rs+58-7
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1//! Conflict pages as OneNote shows them.1//! Conflict pages and page versions as OneNote shows them.
22
3use onestore::{3use onestore::{
4 ExGuid,4 ExGuid,
...@@ -8,21 +8,72 @@ use onestore::{...@@ -8,21 +8,72 @@ use onestore::{
8/// OneNote's highlight for conflicting changes on a conflict page, COLORREF.8/// OneNote's highlight for conflicting changes on a conflict page, COLORREF.
9pub const CONFLICTING: u32 = 0xd6d6ff;9pub const CONFLICTING: u32 = 0xd6d6ff;
1010
11/// OneNote's highlight for what a page version changed since the version before it, COLORREF.
12pub const CHANGED: u32 = 0xd6ffd6;
13
11/// `page` with its conflict `objects` marked as OneNote shows a conflict page's: a band14/// `page` with its conflict `objects` marked as OneNote shows a conflict page's: a band
12/// across each conflicting paragraph.15/// across each conflicting paragraph.
13pub fn highlighted(mut page: Page, objects: &[ExGuid]) -> Page {16pub fn highlighted(page: Page, objects: &[ExGuid]) -> Page {
14 fn mark(paragraphs: &mut [PageParagraph], objects: &[ExGuid]) {17 banded(page, objects, CONFLICTING)
18}
19
20/// `version`, a page version, with the text it changed since `older`, the version before it,
21/// banded as OneNote shows it; all of it without one.
22pub fn changes(version: Page, older: Option<&Page>) -> Page {
23 let texts = |page: &Page| {
24 let mut texts = Vec::new();
25 each_text(page, &mut |id, text| texts.push((id, text.to_owned())));
26 texts
27 };
28 let before = older.map(texts).unwrap_or_default();
29 let changed: Vec<ExGuid> = texts(&version)
30 .into_iter()
31 .filter(|text| !before.contains(text))
32 .map(|(id, _)| id)
33 .collect();
34 banded(version, &changed, CHANGED)
35}
36
37fn each_text(page: &Page, f: &mut impl FnMut(ExGuid, &str)) {
38 fn walk(paragraphs: &[PageParagraph], f: &mut impl FnMut(ExGuid, &str)) {
39 for paragraph in paragraphs {
40 match &paragraph.content {
41 ParagraphContent::Table(table) => {
42 for cell in table.rows.iter().flat_map(|row| &row.cells) {
43 walk(&cell.paragraphs, f);
44 }
45 }
46 ParagraphContent::Text(text) => f(text.id, text.text.text()),
47 _ => {}
48 }
49 }
50 }
51 for object in &page.objects {
52 match object {
53 PageObject::Outline(outline) => walk(&outline.paragraphs, f),
54 PageObject::Title(title) => {
55 for outline in &title.outlines {
56 walk(&outline.paragraphs, f);
57 }
58 }
59 _ => {}
60 }
61 }
62}
63
64fn banded(mut page: Page, objects: &[ExGuid], color: u32) -> Page {
65 fn mark(paragraphs: &mut [PageParagraph], objects: &[ExGuid], color: u32) {
15 for paragraph in paragraphs {66 for paragraph in paragraphs {
16 match &mut paragraph.content {67 match &mut paragraph.content {
17 ParagraphContent::Table(table) => {68 ParagraphContent::Table(table) => {
18 for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) {69 for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) {
19 mark(&mut cell.paragraphs, objects);70 mark(&mut cell.paragraphs, objects, color);
20 }71 }
21 }72 }
22 ParagraphContent::Text(text)73 ParagraphContent::Text(text)
23 if objects.contains(&paragraph.id) || objects.contains(&text.id) =>74 if objects.contains(&paragraph.id) || objects.contains(&text.id) =>
24 {75 {
25 paragraph.format.highlight = Some(CONFLICTING);76 paragraph.format.highlight = Some(color);
26 }77 }
27 _ => {}78 _ => {}
28 }79 }
...@@ -30,10 +81,10 @@ pub fn highlighted(mut page: Page, objects: &[ExGuid]) -> Page {...@@ -30,10 +81,10 @@ pub fn highlighted(mut page: Page, objects: &[ExGuid]) -> Page {
30 }81 }
31 for object in &mut page.objects {82 for object in &mut page.objects {
32 match object {83 match object {
33 PageObject::Outline(outline) => mark(&mut outline.paragraphs, objects),84 PageObject::Outline(outline) => mark(&mut outline.paragraphs, objects, color),
34 PageObject::Title(title) => {85 PageObject::Title(title) => {
35 for outline in &mut title.outlines {86 for outline in &mut title.outlines {
36 mark(&mut outline.paragraphs, objects);87 mark(&mut outline.paragraphs, objects, color);
37 }88 }
38 }89 }
39 _ => {}90 _ => {}
crates/canvas/src/document.rs+63-12
...@@ -248,28 +248,27 @@ fn divides_link(text: &Paragraph, offset: u32) -> Result<bool, EditError> {...@@ -248,28 +248,27 @@ fn divides_link(text: &Paragraph, offset: u32) -> Result<bool, EditError> {
248}248}
249249
250/// Whether a split or join keeping `first` up to UTF-16 `start` and `last` from `end` on meets250/// Whether a split or join keeping `first` up to UTF-16 `start` and `last` from `end` on meets
251/// an equation or embedded object at the seam or carries one to another paragraph: their run251/// an embedded object at the seam or carries one to another paragraph, or joins inside an
252/// data belongs to the whole paragraph, and OneNote has not been seen dividing them.252/// equation: the object's run data belongs to its paragraph, and an equation divides only at
253/// its edges (Enter inside one breaks its line instead, `CanvasEditor::enter`).
253fn moves_object(254fn moves_object(
254 first: &Paragraph,255 first: &Paragraph,
255 start: u32,256 start: u32,
256 last: &Paragraph,257 last: &Paragraph,
257 end: u32,258 end: u32,
258) -> Result<bool, EditError> {259) -> Result<bool, EditError> {
259 let object = |text: &Paragraph, span: usize| {260 let math = |text: &Paragraph, offset: u32| {
260 let format = &text.spans()[span].format;261 text.format_at(offset)
261 [format.math, format.embedded_object].contains(&Some(true))262 .is_ok_and(|format| format.math == Some(true))
262 };263 };
263 let (before, after) = (first.byte_offset(start)?, last.byte_offset(end)?);264 let (before, after) = (first.byte_offset(start)?, last.byte_offset(end)?);
265 let following = last.text()[after..]
266 .chars()
267 .next()
268 .map(|c| end + c.len_utf16() as u32);
264 Ok(first.text()[..before].ends_with('\u{fffc}')269 Ok(first.text()[..before].ends_with('\u{fffc}')
265 || last.text()[after..].contains('\u{fffc}')270 || last.text()[after..].contains('\u{fffc}')
266 || before > 0271 || start > 0 && math(first, start) && following.is_some_and(|next| math(last, next)))
267 && first
268 .spans()
269 .iter()
270 .position(|span| before - 1 < span.end)
271 .is_some_and(|span| object(first, span))
272 || (0..last.spans().len()).any(|span| last.spans()[span].end > after && object(last, span)))
273}272}
274273
275/// Paragraph positions of each node's first text leaf, counting from `first`.274/// Paragraph positions of each node's first text leaf, counting from `first`.
...@@ -281,6 +280,50 @@ fn starts(nodes: &[PageParagraph], first: usize) -> impl Iterator<Item = usize>...@@ -281,6 +280,50 @@ fn starts(nodes: &[PageParagraph], first: usize) -> impl Iterator<Item = usize>
281 })280 })
282}281}
283282
283fn enclosing(
284 nodes: &[PageParagraph],
285 first: usize,
286 paragraph: usize,
287 ranges: &mut Vec<Range<usize>>,
288) {
289 let count = |nodes: &[PageParagraph]| leaves(nodes, None).count();
290 let Some((index, start)) = starts(nodes, first)
291 .enumerate()
292 .take_while(|(_, start)| *start <= paragraph)
293 .last()
294 else {
295 return;
296 };
297 let ParagraphContent::Table(table) = &nodes[index].content else {
298 let end = subtree_end(nodes, index);
299 if end > index + 1 {
300 ranges.push(start..start + count(&nodes[index..end]));
301 }
302 return;
303 };
304 let mut cell_start = start;
305 for row in &table.rows {
306 let row_start = cell_start;
307 let mut holder = None;
308 for cell in &row.cells {
309 let cell_end = cell_start + count(&cell.paragraphs);
310 if (cell_start..cell_end).contains(&paragraph) {
311 holder = Some((cell, cell_start..cell_end));
312 }
313 cell_start = cell_end;
314 }
315 if let Some((cell, range)) = holder {
316 enclosing(&cell.paragraphs, range.start, paragraph, ranges);
317 ranges.extend([
318 range,
319 row_start..cell_start,
320 start..start + count(&nodes[index..=index]),
321 ]);
322 return;
323 }
324 }
325}
326
284/// The index after `nodes[index]`'s last descendant. Descendants follow their ancestor327/// The index after `nodes[index]`'s last descendant. Descendants follow their ancestor
285/// contiguously; a paragraph only an outline group indents has no parent to descend from.328/// contiguously; a paragraph only an outline group indents has no parent to descend from.
286pub(crate) fn subtree_end(nodes: &[PageParagraph], index: usize) -> usize {329pub(crate) fn subtree_end(nodes: &[PageParagraph], index: usize) -> usize {
...@@ -551,6 +594,14 @@ impl TextDocument {...@@ -551,6 +594,14 @@ impl TextDocument {
551 }594 }
552 }595 }
553596
597 /// Text leaf ranges holding `paragraph`, innermost first: its subtree when it has
598 /// descendants, then the cell, row and table of each table around it.
599 pub(crate) fn enclosing(&self, paragraph: usize) -> Vec<Range<usize>> {
600 let mut ranges = Vec::new();
601 enclosing(&self.nodes, 0, paragraph, &mut ranges);
602 ranges
603 }
604
554 /// The root node holding a table cell.605 /// The root node holding a table cell.
555 pub(crate) fn root(&self, cell: ExGuid) -> Result<usize, EditError> {606 pub(crate) fn root(&self, cell: ExGuid) -> Result<usize, EditError> {
556 self.tables607 self.tables
crates/canvas/src/editor.rs+325-16
...@@ -24,13 +24,18 @@ use std::{...@@ -24,13 +24,18 @@ use std::{
2424
25pub const DEFAULT_OUTLINE_WIDTH: f32 = 468.0;25pub const DEFAULT_OUTLINE_WIDTH: f32 = 468.0;
2626
27mod equation;
27#[cfg(test)]28#[cfg(test)]
28mod evidence;29mod evidence;
29mod format;30mod format;
31mod link;
30mod ops;32mod ops;
31pub(crate) mod page;33pub(crate) mod page;
32mod table;34mod table;
33pub use format::{Alignment, FormatState, Formatting, NoteTag, Toggle};35pub use format::{
36 Alignment, BULLET_LIBRARY, FormatState, Formatting, ListStyle, NUMBER_LIBRARY, NoteTag, Toggle,
37};
38pub use link::{Link, shown_urls};
34pub use page::ReadOnlyObject;39pub use page::ReadOnlyObject;
3540
36#[derive(Debug)]41#[derive(Debug)]
...@@ -395,6 +400,19 @@ impl TextOutline {...@@ -395,6 +400,19 @@ impl TextOutline {
395 .unwrap()400 .unwrap()
396 }401 }
397402
403 /// The start of the paragraph after visible paragraph `index` when it follows in the same
404 /// container, where a whole-paragraph selection ends so that deleting it takes the break.
405 fn next_sibling_start(&self, index: usize) -> Option<TextPosition> {
406 let next =
407 (index + 1 < self.shaped.paragraphs.len()).then(|| self.source_index(index + 1))?;
408 let (container, local, _) = self.document.leaf(self.source_index(index))?;
409 let (next_container, next_local, _) = self.document.leaf(next)?;
410 (container == next_container && next_local == local + 1).then_some(TextPosition {
411 paragraph: next,
412 offset: 0,
413 })
414 }
415
398 pub fn paragraph_layout(&self, source: usize) -> Result<&ParagraphLayout, EditError> {416 pub fn paragraph_layout(&self, source: usize) -> Result<&ParagraphLayout, EditError> {
399 Ok(&self.shaped.paragraphs[self.visible_index(source)?])417 Ok(&self.shaped.paragraphs[self.visible_index(source)?])
400 }418 }
...@@ -2006,6 +2024,50 @@ impl CanvasEditor {...@@ -2006,6 +2024,50 @@ impl CanvasEditor {
2006 )2024 )
2007 }2025 }
20082026
2027 /// Selects the smallest unit strictly holding the selection, as OneNote 2010's Ctrl+A widens
2028 /// it: the paragraph, its subtree, each enclosing table cell, row and table, then the outline.
2029 pub fn widen_selection(&mut self) -> Result<(), EditError> {
2030 let outline = self.active_outline();
2031 let [anchor, focus] = outline.selection.positions;
2032 let (start, end) = (anchor.min(focus), anchor.max(focus));
2033 let last_shown = |range: Range<usize>| {
2034 let paragraph = range
2035 .rev()
2036 .find(|&paragraph| outline.visible_index(paragraph).is_ok())?;
2037 let text = outline.document.paragraph(paragraph)?;
2038 Some(TextPosition {
2039 paragraph,
2040 offset: text.utf16_offset(text.text().len()).ok()?,
2041 })
2042 };
2043 let first = |paragraph| TextPosition {
2044 paragraph,
2045 offset: 0,
2046 };
2047 let own = outline
2048 .next_sibling_start(outline.visible_index(start.paragraph)?)
2049 .or_else(|| last_shown(start.paragraph..start.paragraph + 1));
2050 let units = std::iter::once((first(start.paragraph), own))
2051 .chain(
2052 outline
2053 .document
2054 .enclosing(start.paragraph)
2055 .into_iter()
2056 .map(|range| (first(range.start), last_shown(range))),
2057 )
2058 .chain([(
2059 first(0),
2060 last_shown(0..outline.document.paragraphs().count()),
2061 )]);
2062 match units
2063 .filter_map(|(unit_start, unit_end)| Some([unit_start, unit_end?]))
2064 .find(|&unit| unit[0] <= start && end <= unit[1] && unit != [start, end])
2065 {
2066 Some(unit) => self.select(unit.into()),
2067 None => Ok(()),
2068 }
2069 }
2070
2009 pub fn select_at(&mut self, x: f32, y: f32, extend: bool) -> Result<(), EditError> {2071 pub fn select_at(&mut self, x: f32, y: f32, extend: bool) -> Result<(), EditError> {
2010 let mut selection = self.selection_at(x, y, SelectionUnit::Grapheme)?;2072 let mut selection = self.selection_at(x, y, SelectionUnit::Grapheme)?;
2011 if extend {2073 if extend {
...@@ -2046,20 +2108,10 @@ impl CanvasEditor {...@@ -2046,20 +2108,10 @@ impl CanvasEditor {
2046 selection.affinities[slot] = cursor.affinity();2108 selection.affinities[slot] = cursor.affinity();
2047 }2109 }
2048 if unit == SelectionUnit::Paragraph2110 if unit == SelectionUnit::Paragraph
2049 && index + 1 < self.active_outline().shaped.paragraphs.len()2111 && let Some(next) = self.active_outline().next_sibling_start(index)
2050 {2112 {
2051 let document = &self.active_outline().document;2113 selection.positions[1] = next;
2052 let source = selection.positions[0].paragraph;2114 selection.affinities[1] = Affinity::Downstream;
2053 let next = self.active_outline().source_index(index + 1);
2054 let (container, local, _) = document.leaf(source).unwrap();
2055 let (next_container, next_local, _) = document.leaf(next).unwrap();
2056 if container == next_container && next_local == local + 1 {
2057 selection.positions[1] = TextPosition {
2058 paragraph: next,
2059 offset: 0,
2060 };
2061 selection.affinities[1] = Affinity::Downstream;
2062 }
2063 }2115 }
2064 Ok(selection)2116 Ok(selection)
2065 }2117 }
...@@ -2093,6 +2145,9 @@ impl CanvasEditor {...@@ -2093,6 +2145,9 @@ impl CanvasEditor {
2093 extend: bool,2145 extend: bool,
2094 ) -> Result<(), EditorError> {2146 ) -> Result<(), EditorError> {
2095 self.finish_composition();2147 self.finish_composition();
2148 if !extend && movement == Movement::Right && self.leave_equation()? {
2149 return Ok(());
2150 }
2096 let [anchor, focus] = self.active_outline().selection.positions;2151 let [anchor, focus] = self.active_outline().selection.positions;
2097 if !extend2152 if !extend
2098 && self.caret_outline().is_some()2153 && self.caret_outline().is_some()
...@@ -2557,7 +2612,9 @@ impl CanvasEditor {...@@ -2557,7 +2612,9 @@ impl CanvasEditor {
2557 )2612 )
2558 }2613 }
25592614
2615 /// Types `text` at the selection; a space ends a typed URL, which becomes a link.
2560 pub fn insert(&mut self, engine: &mut TextEngine, text: &str) -> Result<(), EditorError> {2616 pub fn insert(&mut self, engine: &mut TextEngine, text: &str) -> Result<(), EditorError> {
2617 self.leave_link_code()?;
2561 let start = self.active_outline().selection.positions[0]2618 let start = self.active_outline().selection.positions[0]
2562 .min(self.active_outline().selection.positions[1]);2619 .min(self.active_outline().selection.positions[1]);
2563 let format = self.typing_format(start)?;2620 let format = self.typing_format(start)?;
...@@ -2565,7 +2622,12 @@ impl CanvasEditor {...@@ -2565,7 +2622,12 @@ impl CanvasEditor {
2565 .split('\n')2622 .split('\n')
2566 .map(|line| Paragraph::new(line.to_owned(), format.clone()))2623 .map(|line| Paragraph::new(line.to_owned(), format.clone()))
2567 .collect();2624 .collect();
2568 self.replace(engine, replacement)2625 self.replace(engine, replacement)?;
2626 if matches!(text, " " | "\t") {
2627 self.link_typed_url(engine, start)?;
2628 self.build_typed_equation(engine, start)?;
2629 }
2630 Ok(())
2569 }2631 }
25702632
2571 /// Pastes plain text as OneNote does: lines become plain Calibri 11 paragraphs without style2633 /// Pastes plain text as OneNote does: lines become plain Calibri 11 paragraphs without style
...@@ -2634,7 +2696,13 @@ impl CanvasEditor {...@@ -2634,7 +2696,13 @@ impl CanvasEditor {
2634 fn split(&mut self, engine: &mut TextEngine) -> Result<(), EditorError> {2696 fn split(&mut self, engine: &mut TextEngine) -> Result<(), EditorError> {
2635 let [anchor, focus] = self.active_outline().selection.positions;2697 let [anchor, focus] = self.active_outline().selection.positions;
2636 let (start, end) = (anchor.min(focus), anchor.max(focus));2698 let (start, end) = (anchor.min(focus), anchor.max(focus));
2637 let format = self.typing_format(start)?;2699 let mut format = self.typing_format(start)?;
2700 // A link or equation ends with its paragraph.
2701 link::unlinked(&mut format);
2702 if format.math == Some(true) {
2703 let text = self.active_outline().document.paragraph(start.paragraph);
2704 format = equation::text_after(text.ok_or(EditError::InvalidRange)?);
2705 }
2638 let document = &self.active_outline().document;2706 let document = &self.active_outline().document;
2639 let mut edit = document.replace(2707 let mut edit = document.replace(
2640 start..end,2708 start..end,
...@@ -2746,6 +2814,75 @@ impl CanvasEditor {...@@ -2746,6 +2814,75 @@ impl CanvasEditor {
2746 Ok(true)2814 Ok(true)
2747 }2815 }
27482816
2817 /// Alt+Shift+Up or Down: swaps the selected paragraphs, with their children, and the
2818 /// sibling above or below with its children. Does nothing past the first or last sibling.
2819 pub fn move_paragraphs(
2820 &mut self,
2821 engine: &mut TextEngine,
2822 up: bool,
2823 ) -> Result<bool, EditorError> {
2824 use crate::document::{previous_sibling, subtree_end};
2825 let outline = self.active_outline();
2826 if outline.title {
2827 return Ok(false);
2828 }
2829 let selection = outline.selection;
2830 let [anchor, focus] = selection.positions;
2831 let document = &outline.document;
2832 let (container, first, _) = document
2833 .leaf(anchor.min(focus).paragraph)
2834 .ok_or(EditError::InvalidRange)?;
2835 let (end_container, last, _) = document
2836 .leaf(anchor.max(focus).paragraph)
2837 .ok_or(EditError::InvalidRange)?;
2838 if container != end_container {
2839 return Ok(false);
2840 }
2841 let nodes = document.container(container)?;
2842 let parent = nodes[first].parent;
2843 let mut end = first;
2844 while end <= last {
2845 if nodes[end].parent != parent {
2846 return Ok(false);
2847 }
2848 end = subtree_end(nodes, end);
2849 }
2850 let (range, [moved, other]) = if up {
2851 let Some(sibling) = previous_sibling(nodes, first, &BTreeSet::new()) else {
2852 return Ok(false);
2853 };
2854 (sibling..end, [first..end, sibling..first])
2855 } else {
2856 if nodes.get(end).is_none_or(|node| node.parent != parent) {
2857 return Ok(false);
2858 }
2859 let next = subtree_end(nodes, end);
2860 (first..next, [first..end, end..next])
2861 };
2862 let passed = leaves(&nodes[other.clone()], None).count();
2863 let replacement = if up {
2864 [&nodes[moved], &nodes[other]].concat()
2865 } else {
2866 [&nodes[other], &nodes[moved]].concat()
2867 };
2868 let mut moved = selection;
2869 for position in &mut moved.positions {
2870 position.paragraph = if up {
2871 position.paragraph - passed
2872 } else {
2873 position.paragraph + passed
2874 };
2875 }
2876 let edit = DocumentEdit {
2877 columns: BTreeMap::new(),
2878 container,
2879 range,
2880 replacement,
2881 };
2882 self.commit(engine, edit, moved)?;
2883 Ok(true)
2884 }
2885
2749 /// Deletes the selection or the character beside the caret. Backspace at a paragraph's start2886 /// Deletes the selection or the character beside the caret. Backspace at a paragraph's start
2750 /// first removes its list, then outdents it, then joins it to the paragraph above, whose2887 /// first removes its list, then outdents it, then joins it to the paragraph above, whose
2751 /// properties win; Delete at its end joins the paragraph below (`evidence/structural-edits/2888 /// properties win; Delete at its end joins the paragraph below (`evidence/structural-edits/
...@@ -8872,4 +9009,176 @@ mod tests {...@@ -8872,4 +9009,176 @@ mod tests {
8872 assert!(editor.undo.is_empty());9009 assert!(editor.undo.is_empty());
8873 assert_eq!(editor.take_ops().unwrap(), []);9010 assert_eq!(editor.take_ops().unwrap(), []);
8874 }9011 }
9012
9013 /// Alpha, Parent > Child > Grand, a table [[A1 A2 | B1], [C1 | D1]], Omega: text leaves 0..=9.
9014 fn select_more_editor(engine: &mut TextEngine, collapsed: bool) -> CanvasEditor {
9015 use onestore::page::{ParagraphContent, Table, TableCell, TableColumn, TableRow};
9016 let text = |text: &str| {
9017 crate::document::node(
9018 Paragraph::new(text.into(), Format::default()),
9019 Format::default(),
9020 )
9021 .unwrap()
9022 };
9023 let child = |label: &str, parent: &PageParagraph| PageParagraph {
9024 parent: Some(parent.id),
9025 level: parent.level + 1,
9026 ..text(label)
9027 };
9028 let parent = PageParagraph {
9029 collapsed,
9030 ..text("Parent")
9031 };
9032 let middle = child("Child", &parent);
9033 let grand = child("Grand", &middle);
9034 let mut table = text("");
9035 table.content = ParagraphContent::Table(Table {
9036 id: onestore::page::text::new_id().unwrap(),
9037 columns: vec![
9038 TableColumn {
9039 width: 72.0,
9040 locked: true,
9041 };
9042 2
9043 ],
9044 rows: [[&["A1", "A2"][..], &["B1"]], [&["C1"], &["D1"]]]
9045 .into_iter()
9046 .map(|cells| TableRow {
9047 id: onestore::page::text::new_id().unwrap(),
9048 cells: cells
9049 .into_iter()
9050 .map(|texts| TableCell {
9051 id: onestore::page::text::new_id().unwrap(),
9052 layout: Default::default(),
9053 indents: vec![18.0, 0.0, 27.0],
9054 shading: None,
9055 paragraphs: texts.iter().map(|t| text(t)).collect(),
9056 unsupported: Vec::new(),
9057 })
9058 .collect(),
9059 })
9060 .collect(),
9061 borders: Some(true),
9062 layout: Default::default(),
9063 tags: Vec::new(),
9064 });
9065 let nodes = vec![text("Alpha"), parent, middle, grand, table, text("Omega")];
9066 CanvasEditor::new(engine, TextDocument::from_nodes(nodes).unwrap(), 300.0).unwrap()
9067 }
9068
9069 /// The selections successive `widen_selection` calls give from `start` to `end`.
9070 fn widenings(
9071 editor: &mut CanvasEditor,
9072 start: (usize, u32),
9073 end: (usize, u32),
9074 ) -> Vec<[(usize, u32); 2]> {
9075 let position = |(paragraph, offset)| TextPosition { paragraph, offset };
9076 editor
9077 .select([position(start), position(end)].into())
9078 .unwrap();
9079 (0..6)
9080 .map(|_| {
9081 editor.widen_selection().unwrap();
9082 editor
9083 .selection()
9084 .positions
9085 .map(|position| (position.paragraph, position.offset))
9086 })
9087 .collect()
9088 }
9089
9090 #[test]
9091 fn select_all_widens_as_onenote_does() {
9092 let mut engine = TextEngine::default();
9093 let mut editor = select_more_editor(&mut engine, false);
9094 let outline = [(0, 0), (9, 5)];
9095 let steps = |steps: &[[(usize, u32); 2]]| {
9096 let mut steps = steps.to_vec();
9097 steps.resize(6, outline);
9098 steps
9099 };
9100 // A paragraph takes its break when a sibling follows, then the outline.
9101 assert_eq!(
9102 widenings(&mut editor, (0, 2), (0, 2)),
9103 steps(&[[(0, 0), (1, 0)]])
9104 );
9105 // A parent, then its subtree, then the outline.
9106 assert_eq!(
9107 widenings(&mut editor, (1, 2), (1, 2)),
9108 steps(&[[(1, 0), (2, 0)], [(1, 0), (3, 5)]])
9109 );
9110 // A child widens to its own subtree, never its parent's.
9111 assert_eq!(
9112 widenings(&mut editor, (2, 1), (2, 1)),
9113 steps(&[[(2, 0), (3, 0)], [(2, 0), (3, 5)]])
9114 );
9115 assert_eq!(
9116 widenings(&mut editor, (3, 1), (3, 1)),
9117 steps(&[[(3, 0), (3, 5)]])
9118 );
9119 // In a table: paragraph, cell, row, table.
9120 assert_eq!(
9121 widenings(&mut editor, (4, 1), (4, 1)),
9122 steps(&[
9123 [(4, 0), (5, 0)],
9124 [(4, 0), (5, 2)],
9125 [(4, 0), (6, 2)],
9126 [(4, 0), (8, 2)],
9127 ])
9128 );
9129 // A cell holding one paragraph adds nothing to it.
9130 assert_eq!(
9131 widenings(&mut editor, (6, 1), (6, 1)),
9132 steps(&[[(6, 0), (6, 2)], [(4, 0), (6, 2)], [(4, 0), (8, 2)]])
9133 );
9134 assert_eq!(
9135 widenings(&mut editor, (9, 1), (9, 1)),
9136 steps(&[[(9, 0), (9, 5)]])
9137 );
9138 }
9139
9140 #[test]
9141 fn select_all_starts_from_the_current_selection() {
9142 let mut engine = TextEngine::default();
9143 let mut editor = select_more_editor(&mut engine, false);
9144 let first = |editor: &mut CanvasEditor, start, end| widenings(editor, start, end)[0];
9145 // Part or all of a paragraph's text widens to the paragraph.
9146 assert_eq!(first(&mut editor, (0, 1), (0, 3)), [(0, 0), (1, 0)]);
9147 assert_eq!(first(&mut editor, (0, 0), (0, 5)), [(0, 0), (1, 0)]);
9148 assert_eq!(first(&mut editor, (0, 3), (0, 1)), [(0, 0), (1, 0)]);
9149 // Across paragraphs, the outline; across cells of a row, the row.
9150 assert_eq!(first(&mut editor, (0, 1), (1, 1)), [(0, 0), (9, 5)]);
9151 assert_eq!(first(&mut editor, (5, 1), (6, 1)), [(4, 0), (6, 2)]);
9152 assert_eq!(first(&mut editor, (6, 1), (7, 1)), [(4, 0), (8, 2)]);
9153 // A caret moved after widening starts over at its paragraph.
9154 assert_eq!(first(&mut editor, (9, 1), (9, 1)), [(9, 0), (9, 5)]);
9155 // Selection only: nothing to undo or store.
9156 assert!(editor.undo.is_empty());
9157 assert_eq!(editor.take_ops().unwrap(), []);
9158 }
9159
9160 #[test]
9161 fn select_all_skips_the_hidden_children_of_a_collapsed_parent() {
9162 let mut engine = TextEngine::default();
9163 let mut editor = select_more_editor(&mut engine, true);
9164 assert_eq!(
9165 widenings(&mut editor, (1, 2), (1, 2))[..2],
9166 [[(1, 0), (1, 6)], [(0, 0), (9, 5)]]
9167 );
9168 }
9169
9170 #[test]
9171 fn select_all_in_a_single_paragraph_stops_at_the_paragraph() {
9172 let mut engine = TextEngine::default();
9173 let mut editor = CanvasEditor::new(
9174 &mut engine,
9175 TextDocument::new(vec![Paragraph::new("Title".into(), Format::default())]).unwrap(),
9176 180.0,
9177 )
9178 .unwrap();
9179 assert_eq!(
9180 widenings(&mut editor, (0, 5), (0, 5)),
9181 [[(0, 0), (0, 5)]; 6]
9182 );
9183 }
8875}9184}
crates/canvas/src/editor/equation.rs created+557
...@@ -0,0 +1,557 @@
1//! Equations as OneNote 2010's equation editor edits them (`corpus/math-edit/native-linear`):
2//! Alt+= starts an equation at the caret or makes one of the selected text, text typed into
3//! an equation is its linear format until a space builds it up, and Linear and Professional
4//! switch an equation between the forms, both of which OneNote stores.
5
6use super::link::format_after;
7use super::*;
8use onestore::page::Math;
9
10const OBJECT_START: char = '\u{fdd0}';
11const ARGUMENT_SEPARATOR: char = '\u{fdee}';
12const OBJECT_END: char = '\u{fdef}';
13
14/// The run of math in `text` that UTF-16 `offset` lies in or touches.
15pub(crate) fn zone(text: &Paragraph, offset: u32) -> Option<Range<u32>> {
16 let (mut byte, mut unit) = (0, 0);
17 let mut current: Option<Range<u32>> = None;
18 for span in text.spans() {
19 let end = unit + text.text()[byte..span.end].encode_utf16().count() as u32;
20 if span.format.math == Some(true) {
21 current = Some(
22 current
23 .filter(|zone| zone.end == unit)
24 .map_or(unit, |zone| zone.start)..end,
25 );
26 } else if current
27 .as_ref()
28 .is_some_and(|zone| zone.start <= offset && offset <= zone.end)
29 {
30 return current;
31 } else {
32 current = None;
33 }
34 (byte, unit) = (span.end, end);
35 }
36 current.filter(|zone| zone.start <= offset && offset <= zone.end)
37}
38
39/// The format text typed after an equation in `text` takes: the paragraph's last text's, or
40/// its first run's without the equation editor's.
41pub(super) fn text_after(text: &Paragraph) -> Format {
42 let mut format = text
43 .spans()
44 .iter()
45 .rev()
46 .find(|span| span.format.math != Some(true))
47 .map_or_else(
48 || text.spans()[0].format.clone(),
49 |span| span.format.clone(),
50 );
51 format.math = format.math.map(|_| false);
52 format.embedded_object = None;
53 format.math_object = None;
54 if format.font.as_deref() == Some("Cambria Math") {
55 // OneNote's body font, where the paragraph has no text to take one from.
56 format.font = Some("Calibri".into());
57 format.italic = format.italic.map(|_| false);
58 // The writers' default, where the paragraph has no text to take a language from.
59 format.language = Some(0x409);
60 }
61 format
62}
63
64impl CanvasEditor {
65 /// The paragraph and math run at the caret.
66 fn caret_zone(&self) -> Option<(usize, Range<u32>)> {
67 let [anchor, focus] = self.active_outline().selection.positions;
68 let caret = anchor.min(focus);
69 let text = self.active_outline().document.paragraph(caret.paragraph)?;
70 Some((caret.paragraph, zone(text, caret.offset)?))
71 }
72
73 /// Whether the caret is in an equation, which Linear and Professional act on.
74 pub fn in_equation(&self) -> bool {
75 self.caret_zone().is_some()
76 }
77
78 /// Professional: builds the equation at the caret up from its linear text.
79 pub fn build_equation(&mut self, engine: &mut TextEngine) -> Result<bool, EditorError> {
80 let Some((paragraph, zone)) = self.caret_zone() else {
81 return Ok(false);
82 };
83 self.rebuild(engine, paragraph, zone, None)
84 }
85
86 /// Linear: shows the equation at the caret in its linear format, as text to edit.
87 pub fn linear_equation(&mut self, engine: &mut TextEngine) -> Result<bool, EditorError> {
88 let Some((paragraph, zone)) = self.caret_zone() else {
89 return Ok(false);
90 };
91 let text = self
92 .active_outline()
93 .document
94 .paragraph(paragraph)
95 .ok_or(EditError::InvalidRange)?;
96 let math = text.slice(zone.clone())?;
97 let Ok(nodes) = Math::parse(&math) else {
98 return Ok(false);
99 };
100 let Some(linear) = Math::linear_paragraph(&nodes, &math.spans()[0].format) else {
101 return Ok(false);
102 };
103 let end = zone.start + linear.utf16_offset(linear.text().len())?;
104 self.replace_zone(engine, paragraph, zone, linear, end)
105 }
106
107 /// Alt+=: the selected text becomes an equation, the equation at the caret builds up, or
108 /// what is typed next at the caret starts one.
109 pub fn insert_equation(&mut self, engine: &mut TextEngine) -> Result<(), EditorError> {
110 let outline = self.active_outline();
111 let [anchor, focus] = outline.selection.positions;
112 let (start, end) = (anchor.min(focus), anchor.max(focus));
113 if start == end {
114 if self.caret_zone().is_some() {
115 self.build_equation(engine)?;
116 return Ok(());
117 }
118 let format = Math::format(&self.typing_format(start)?);
119 self.pending = Some((outline.id, start, format));
120 return Ok(());
121 }
122 if start.paragraph != end.paragraph {
123 return Err(EditError::UnsupportedContent.into());
124 }
125 let text = outline
126 .document
127 .paragraph(start.paragraph)
128 .ok_or(EditError::InvalidRange)?;
129 let selected = text.slice(start.offset..end.offset)?;
130 let linear = selected.project()?.text().text().replace('\n', " ");
131 let base = format_after(text, start.offset)
132 .cloned()
133 .unwrap_or_default();
134 let math = Math::paragraph(&Math::from_linear(&linear), &base);
135 let caret = start.offset + math.utf16_offset(math.text().len())?;
136 self.replace_zone(
137 engine,
138 start.paragraph,
139 start.offset..end.offset,
140 math,
141 caret,
142 )?;
143 Ok(())
144 }
145
146 /// Builds the equation up after the space typed at `typed` ended part of it, as the
147 /// equation editor builds as it is typed. Only a caret at the end of the equation, or
148 /// only closing its objects before that end, builds; the caret stays as deep in the built
149 /// objects.
150 pub(super) fn build_typed_equation(
151 &mut self,
152 engine: &mut TextEngine,
153 typed: TextPosition,
154 ) -> Result<(), EditorError> {
155 let text = self
156 .active_outline()
157 .document
158 .paragraph(typed.paragraph)
159 .ok_or(EditError::InvalidRange)?;
160 if format_after(text, typed.offset).is_none_or(|format| format.math != Some(true)) {
161 return Ok(());
162 }
163 let Some(zone) = zone(text, typed.offset) else {
164 return Ok(());
165 };
166 let caret = typed.offset + 1;
167 let after = &text.text()[text.byte_offset(caret)?..text.byte_offset(zone.end)?];
168 if !after.chars().all(|c| c == OBJECT_END) {
169 return Ok(());
170 }
171 let depth = after.chars().count() as u32;
172 self.rebuild(engine, typed.paragraph, zone, Some(depth))?;
173 Ok(())
174 }
175
176 /// Right at the end of an equation ending its paragraph leaves it: what is typed next is
177 /// text again.
178 pub(super) fn leave_equation(&mut self) -> Result<bool, EditError> {
179 let outline = self.active_outline();
180 let [anchor, focus] = outline.selection.positions;
181 let Some(text) = outline.document.paragraph(focus.paragraph) else {
182 return Ok(false);
183 };
184 let end = text.utf16_offset(text.text().len())?;
185 let math = self.typing_format(focus)?.math == Some(true);
186 if anchor != focus || focus.offset != end || !math {
187 return Ok(false);
188 }
189 self.pending = Some((outline.id, focus, text_after(text)));
190 Ok(true)
191 }
192
193 /// Rebuilds math run `zone` of paragraph `paragraph` from its linear text. With `depth`,
194 /// the caret goes that many objects before the end of the built run, and into an
195 /// argument left empty there; without, to the run's end.
196 fn rebuild(
197 &mut self,
198 engine: &mut TextEngine,
199 paragraph: usize,
200 zone: Range<u32>,
201 depth: Option<u32>,
202 ) -> Result<bool, EditorError> {
203 let text = self
204 .active_outline()
205 .document
206 .paragraph(paragraph)
207 .ok_or(EditError::InvalidRange)?;
208 let math = text.slice(zone.clone())?;
209 let Some(linear) = Math::parse(&math)
210 .ok()
211 .and_then(|nodes| Math::linear(&nodes))
212 else {
213 return Ok(false);
214 };
215 let nodes = if depth.is_some() {
216 Math::typed(&linear)
217 } else {
218 Math::from_linear(&linear)
219 };
220 let built = Math::paragraph(&nodes, &math.spans()[0].format);
221 if built == math {
222 return Ok(false);
223 }
224 let units: Vec<char> = built.text().chars().collect();
225 let mut caret = units.len() - depth.unwrap_or(0).min(units.len() as u32) as usize;
226 if depth.is_some()
227 && caret >= 2
228 && units[caret - 1] == OBJECT_END
229 && matches!(units[caret - 2], ARGUMENT_SEPARATOR | OBJECT_START)
230 {
231 caret -= 1;
232 }
233 let caret = zone.start
234 + units[..caret]
235 .iter()
236 .map(|c| c.len_utf16() as u32)
237 .sum::<u32>();
238 self.replace_zone(engine, paragraph, zone, built, caret)
239 }
240
241 /// Enter inside an equation, as OneNote 2010's equation editor takes it
242 /// (`corpus/math-edit/native-enter`): in the equation's own row a line break the
243 /// paragraph keeps, in an object's argument a new row of it, the argument becoming an
244 /// equation array where it is not one. False where the caret is not inside an equation.
245 pub(super) fn break_equation(&mut self, engine: &mut TextEngine) -> Result<bool, EditorError> {
246 let caret = self.active_outline().selection.positions[1];
247 let Some((paragraph, zone)) = self.caret_zone() else {
248 return Ok(false);
249 };
250 let text = self
251 .active_outline()
252 .document
253 .paragraph(paragraph)
254 .ok_or(EditError::InvalidRange)?;
255 let units: Vec<char> = text.slice(zone.clone())?.text().chars().collect();
256 let at = text.slice(zone.start..caret.offset)?.text().chars().count();
257 if at == 0 || at == units.len() || !units.contains(&OBJECT_START) {
258 return Ok(false);
259 }
260 // The argument around the caret: from the control before it at its depth to the one
261 // after.
262 let mut depth = 0;
263 let start = units[..at].iter().rposition(|&c| {
264 match c {
265 OBJECT_END => depth += 1,
266 OBJECT_START if depth > 0 => depth -= 1,
267 OBJECT_START | ARGUMENT_SEPARATOR if depth == 0 => return true,
268 _ => {}
269 }
270 false
271 });
272 let Some(start) = start else {
273 // The equation's own row breaks its line, outside the math.
274 let mut updated = text.clone();
275 let break_format = text_after(text);
276 updated.apply(onestore::page::text::Edit {
277 range: caret.offset..caret.offset,
278 replacement: Paragraph::new("\r".into(), break_format),
279 })?;
280 let after = TextPosition {
281 paragraph,
282 offset: caret.offset + 1,
283 };
284 self.rewrite(engine, paragraph, updated, [after; 2].into())?;
285 return Ok(true);
286 };
287 let mut depth = 0;
288 let end = at
289 + units[at..]
290 .iter()
291 .position(|&c| {
292 match c {
293 OBJECT_START => depth += 1,
294 OBJECT_END if depth > 0 => depth -= 1,
295 OBJECT_END | ARGUMENT_SEPARATOR if depth == 0 => return true,
296 _ => {}
297 }
298 false
299 })
300 .ok_or(EditError::InvalidStructure)?;
301 // The object the argument belongs to opens at the start of its first argument.
302 let mut depth = 0;
303 let opening = units[..=start]
304 .iter()
305 .rposition(|&c| {
306 match c {
307 OBJECT_END => depth += 1,
308 OBJECT_START if depth > 0 => depth -= 1,
309 OBJECT_START => return true,
310 _ => {}
311 }
312 false
313 })
314 .ok_or(EditError::InvalidStructure)?;
315 let math = text.slice(zone.clone())?;
316 let offset =
317 |index: usize| -> u32 { units[..index].iter().map(|c| c.len_utf16() as u32).sum() };
318 let kind = math
319 .format_at(offset(opening) + 1)?
320 .math_object
321 .as_ref()
322 .map(|object| object.kind);
323 let format = math.format_at(offset(at))?.clone();
324 let control = |c: char, kind: u32| {
325 let mut format = format.clone();
326 format.math_object = Some(onestore::document::MathObject {
327 kind,
328 arguments: None,
329 columns: Some(1),
330 symbols: vec!['█'],
331 });
332 (c.to_string(), format)
333 };
334 let piece = |range: Range<usize>| -> Result<Paragraph, EditError> {
335 math.slice(offset(range.start)..offset(range.end))
336 };
337 let mut parts = vec![piece(0..start + 1)?];
338 if kind != Some(15) {
339 parts.push(Paragraph::from_runs([control(OBJECT_START, 15)]));
340 }
341 parts.push(piece(start + 1..at)?);
342 parts.push(Paragraph::from_runs([control(ARGUMENT_SEPARATOR, 15)]));
343 parts.push(piece(at..end)?);
344 if kind != Some(15) {
345 parts.push(Paragraph::from_runs([control(OBJECT_END, 15)]));
346 }
347 parts.push(piece(end..units.len())?);
348 let mut broken = parts.remove(0);
349 for part in parts {
350 broken.append(part)?;
351 }
352 let Some(linear) = Math::parse(&broken)
353 .ok()
354 .and_then(|nodes| Math::linear(&nodes))
355 else {
356 return Ok(false);
357 };
358 let built = Math::paragraph(&Math::from_linear(&linear), &math.spans()[0].format);
359 // The caret starts the new row: after as many characters other than spaces as came
360 // before it.
361 let before = broken
362 .text()
363 .chars()
364 .take(at + if kind == Some(15) { 1 } else { 2 });
365 let count = before.filter(|c| *c != ' ').count();
366 let mut seen = 0;
367 let mut caret = zone.start;
368 for c in built.text().chars() {
369 if seen == count {
370 break;
371 }
372 seen += usize::from(c != ' ');
373 caret += c.len_utf16() as u32;
374 }
375 self.replace_zone(engine, paragraph, zone, built, caret)
376 }
377
378 fn replace_zone(
379 &mut self,
380 engine: &mut TextEngine,
381 paragraph: usize,
382 zone: Range<u32>,
383 math: Paragraph,
384 caret: u32,
385 ) -> Result<bool, EditorError> {
386 let mut text = self
387 .active_outline()
388 .document
389 .paragraph(paragraph)
390 .ok_or(EditError::InvalidRange)?
391 .clone();
392 text.apply(onestore::page::text::Edit {
393 range: zone,
394 replacement: math,
395 })?;
396 let caret = TextPosition {
397 paragraph,
398 offset: caret,
399 };
400 self.rewrite(engine, paragraph, text, [caret; 2].into())?;
401 Ok(true)
402 }
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408 use onestore::document::MathObject;
409 use onestore::page::PageObject;
410
411 type Runs = Vec<(String, Option<MathObject>, Option<bool>)>;
412
413 fn runs(text: &Paragraph) -> Runs {
414 let mut start = 0;
415 text.spans()
416 .iter()
417 .map(|span| {
418 let run = text.text()[start..span.end].to_owned();
419 start = span.end;
420 (run, span.format.math_object.clone(), span.format.italic)
421 })
422 .collect()
423 }
424
425 fn equations(bytes: &[u8]) -> Vec<Runs> {
426 let store = onestore::Store::parse(bytes).unwrap();
427 let index = onestore::RevisionIndex::parse(&store).unwrap();
428 let document = onestore::document::Document::parse(&index).unwrap();
429 let (space, _) = document.pages().unwrap()[0];
430 Page::from_space(&document, space)
431 .unwrap()
432 .objects
433 .iter()
434 .filter_map(|object| match object {
435 PageObject::Outline(outline) => Some(outline),
436 _ => None,
437 })
438 .flat_map(|outline| &outline.paragraphs)
439 .filter_map(|paragraph| paragraph.text())
440 .filter(|text| Math::is_equation(&text.text))
441 .map(|text| {
442 // Text typed after an equation, outside it, is not the editor's.
443 let mut runs = runs(&text.text);
444 runs.retain(|(_, object, _)| object.is_some());
445 runs
446 })
447 .collect()
448 }
449
450 /// Alt+= and typing, each space its own key, store what OneNote 2010's equation editor
451 /// stored for the same keys (`tools/native_math.py`), building up as they are typed.
452 #[test]
453 fn typed_equations_build_up_as_the_equation_editor_stores_them() {
454 let sessions: [(&[u8], &[Option<&str>]); 4] = [
455 (
456 include_bytes!("../../../../corpus/math-edit/native-editor/notebook/links.one"),
457 &[
458 Some("a_1+b_2"),
459 Some("x_i^2"),
460 Some("(a+b)"),
461 Some("\\int_0^1 x dx"),
462 Some("\\sum_(i=1)^n i"),
463 ],
464 ),
465 (
466 include_bytes!("../../../../corpus/math-edit/native-editor-2/notebook/links.one"),
467 &[
468 Some("\\sqrt x+1"),
469 Some("\\cbrt(x)"),
470 Some("\\sqrt(n&x)"),
471 Some("a/b"),
472 Some("\\lim_(x\\to 0) f(x)"),
473 Some("\\prod_(k=1)^n k"),
474 Some("[a+b]"),
475 Some("\\overline(x)"),
476 Some("x\\hat"),
477 ],
478 ),
479 (
480 include_bytes!("../../../../corpus/math-edit/native-editor-3/notebook/links.one"),
481 &[
482 Some("\\matrix(1&2@3&4)"),
483 Some("\\eqarray(x&=1@y&=2)"),
484 Some("x\\above 2"),
485 Some("x\\below 2"),
486 Some("\\box(x)"),
487 Some("\\rect(x)"),
488 None,
489 Some("\\iint x dx dy"),
490 Some("f(x)/(x^2+1)"),
491 Some("\\sum^n x"),
492 ],
493 ),
494 (
495 include_bytes!("../../../../corpus/math-edit/native-editor-4/notebook/links.one"),
496 &[
497 Some("a_1+b_2"),
498 // The editor was still starting this one and kept its placeholder.
499 None,
500 Some("\\sum_(i=1)^n i"),
501 Some("\\int_0^1 x dx"),
502 Some("\\sqrt x+1"),
503 Some("\\cbrt(x)"),
504 Some("\\sqrt(n&x)"),
505 Some("a/b"),
506 Some("\\prod_(k=1)^n k"),
507 Some("[a+b]"),
508 Some("\\overline(x)"),
509 Some("x\\hat"),
510 Some("\\matrix(1&2@3&4)"),
511 Some("\\eqarray(x&=1@y&=2)"),
512 Some("x\\above 2"),
513 Some("x\\below 2"),
514 Some("\\box(x)"),
515 Some("\\rect(x)"),
516 Some("f(x)/(x^2+1)"),
517 Some("\\sum^n x"),
518 Some("e^(x+1)"),
519 Some("\\alpha+\\beta"),
520 Some("(a+b)/(c+d)"),
521 Some("x^2+y^2=z^2"),
522 // A function applied to a limit, an object this reading does not build.
523 None,
524 Some("{a+b}"),
525 ],
526 ),
527 ];
528 let mut engine = TextEngine::default();
529 for (bytes, typed) in sessions {
530 let native = equations(bytes);
531 assert_eq!(native.len(), typed.len());
532 for (native, typed) in native.iter().zip(typed) {
533 let Some(typed) = typed else {
534 continue;
535 };
536 let mut editor = CanvasEditor::new(
537 &mut engine,
538 TextDocument::new(vec![Paragraph::new(String::new(), Format::default())])
539 .unwrap(),
540 400.0,
541 )
542 .unwrap();
543 editor.insert_equation(&mut engine).unwrap();
544 for (index, word) in format!("{typed} ").split(' ').enumerate() {
545 if index > 0 {
546 editor.insert(&mut engine, " ").unwrap();
547 }
548 if !word.is_empty() {
549 editor.insert(&mut engine, word).unwrap();
550 }
551 }
552 let text = editor.active_outline().document.paragraph(0).unwrap();
553 assert_eq!(&runs(text), native, "{typed}");
554 }
555 }
556 }
557}
crates/canvas/src/editor/evidence.rs+17-9
...@@ -1,9 +1,9 @@...@@ -1,9 +1,9 @@
1//! OneNote 2010's Enter, Backspace, Delete, Tab and paste replayed against the page XML it1//! OneNote 2010's Enter, Backspace, Delete, Tab and paste replayed against the page XML it
2//! reported after each keystroke (`evidence/structural-edits`): each outline is built from, and2//! reported after each keystroke: each outline is built from, and compared with, the
3//! compared with, the one-line-per-paragraph summaries in `summaries/`, whose `L` counts3//! one-line-per-paragraph summaries in `corpus/structural-probe/summaries/`, whose `L` counts
4//! paragraph nesting.4//! paragraph nesting.
55
6use super::format::{NoteTag, list_definition};6use super::format::{ListStyle, NoteTag, list_definition};
7use super::*;7use super::*;
8use onestore::document::Tag;8use onestore::document::Tag;
9use onestore::page::ParagraphContent;9use onestore::page::ParagraphContent;
...@@ -11,7 +11,7 @@ use onestore::page::text::new_id;...@@ -11,7 +11,7 @@ use onestore::page::text::new_id;
1111
12fn summary(name: &str) -> String {12fn summary(name: &str) -> String {
13 std::fs::read_to_string(format!(13 std::fs::read_to_string(format!(
14 "{}/../../evidence/structural-edits/summaries/{name}.txt",14 "{}/../../corpus/structural-probe/summaries/{name}.txt",
15 env!("CARGO_MANIFEST_DIR")15 env!("CARGO_MANIFEST_DIR")
16 ))16 ))
17 .unwrap()17 .unwrap()
...@@ -76,7 +76,14 @@ fn open(engine: &mut TextEngine, summary: &str) -> CanvasEditor {...@@ -76,7 +76,14 @@ fn open(engine: &mut TextEngine, summary: &str) -> CanvasEditor {
76 .find(|parent| parent.level + 1 == level)76 .find(|parent| parent.level + 1 == level)
77 .map(|parent| parent.id);77 .map(|parent| parent.id);
78 if !list.is_empty() {78 if !list.is_empty() {
79 let definition = list_definition(list.starts_with("num"), &format);79 let definition = list_definition(
80 if list.starts_with("num") {
81 ListStyle::NUMBER
82 } else {
83 ListStyle::BULLET
84 },
85 &format,
86 );
80 node.lists = vec![define(format!("list {}", nodes.len()), definition)];87 node.lists = vec![define(format!("list {}", nodes.len()), definition)];
81 }88 }
82 for tag in tags.split(',').filter(|tag| !tag.is_empty()) {89 for tag in tags.split(',').filter(|tag| !tag.is_empty()) {
...@@ -162,9 +169,10 @@ fn render(editor: &CanvasEditor) -> String {...@@ -162,9 +169,10 @@ fn render(editor: &CanvasEditor) -> String {
162 .paragraphs169 .paragraphs
163 .iter()170 .iter()
164 .find(|paragraph| paragraph.id == node.id)171 .find(|paragraph| paragraph.id == node.id)
165 .and_then(|paragraph| paragraph.number)172 .and_then(|paragraph| paragraph.number.as_ref())
166 .unwrap()173 .unwrap()
167 .0;174 .0
175 .number;
168 let numeral = crate::outline::numeral(rest.next(), number).unwrap();176 let numeral = crate::outline::numeral(rest.next(), number).unwrap();
169 format!("num:{numeral}{}", rest.as_str())177 format!("num:{numeral}{}", rest.as_str())
170 }178 }
...@@ -274,7 +282,7 @@ fn replay(...@@ -274,7 +282,7 @@ fn replay(
274 layout282 layout
275 .paragraphs283 .paragraphs
276 .iter()284 .iter()
277 .map(|paragraph| paragraph.number)285 .map(|paragraph| paragraph.number.clone())
278 .collect::<Vec<_>>()286 .collect::<Vec<_>>()
279 };287 };
280 assert_eq!(numbers(&outline.shaped), numbers(&fresh), "{after}");288 assert_eq!(numbers(&outline.shaped), numbers(&fresh), "{after}");
...@@ -715,7 +723,7 @@ fn structural_edits_on_native_pages_survive_the_page_writer() {...@@ -715,7 +723,7 @@ fn structural_edits_on_native_pages_survive_the_page_writer() {
715 use onestore::{RevisionIndex, Store, document::Document};723 use onestore::{RevisionIndex, Store, document::Document};
716 let section = std::fs::read(concat!(724 let section = std::fs::read(concat!(
717 env!("CARGO_MANIFEST_DIR"),725 env!("CARGO_MANIFEST_DIR"),
718 "/../../evidence/structural-edits/probe-section/probe.one"726 "/../../corpus/structural-probe/probe.one"
719 ))727 ))
720 .unwrap();728 .unwrap();
721 let page = |bytes: &[u8], title: &str| {729 let page = |bytes: &[u8], title: &str| {
crates/canvas/src/editor/format.rs+497-118
...@@ -23,12 +23,17 @@ pub enum Formatting {...@@ -23,12 +23,17 @@ pub enum Formatting {
23 Highlight(Option<u32>),23 Highlight(Option<u32>),
24 /// Returns text to its paragraph style.24 /// Returns text to its paragraph style.
25 Clear,25 Clear,
26 /// Toggles OneNote's default bullet or number, as Ctrl+. and Ctrl+/ do.
26 Bullets,27 Bullets,
27 Numbering,28 Numbering,
29 /// Applies a style from OneNote's bullet or numbering library; `None` removes lists.
30 List(Option<ListStyle>),
28 Indent,31 Indent,
29 Outdent,32 Outdent,
30 Align(Alignment),33 Align(Alignment),
31 Tag(NoteTag),34 Tag(NoteTag),
35 /// Removes every note tag from the selected paragraphs.
36 RemoveTags,
32 /// Checks the selected paragraphs' check boxes, or clears them when all are checked.37 /// Checks the selected paragraphs' check boxes, or clears them when all are checked.
33 Check,38 Check,
34}39}
...@@ -52,8 +57,108 @@ pub enum Alignment {...@@ -52,8 +57,108 @@ pub enum Alignment {
52 Right,57 Right,
53}58}
5459
60/// A style of OneNote 2010's bullet or numbering library, by its place in the gallery.
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62pub enum ListStyle {
63 Bullet(usize),
64 Number(usize),
65}
66
67/// OneNote 2010's bullet library in gallery order, as it stores each: font, glyph and
68/// ListMSAAIndex. Ctrl+. applies the third.
69pub const BULLET_LIBRARY: [(&str, &str, u16); 34] = [
70 ("Wingdings", "l", 3),
71 ("Symbol", "\u{b7}", 1),
72 ("Calibri", "\u{2022}", 1),
73 ("Courier New", "\u{25cb}", 4),
74 ("Wingdings 2", "\u{9d}", 6),
75 ("Wingdings 2", "\u{9c}", 5),
76 ("Wingdings", "\u{b2}", 13),
77 ("Tahoma", "\u{25ca}", 14),
78 ("Wingdings", "w", 15),
79 ("Wingdings 2", "\u{ae}", 34),
80 ("Wingdings", "v", 16),
81 ("Arial", "\u{25aa}", 7),
82 ("Verdana", "\u{25ab}", 8),
83 ("Wingdings", "\u{a7}", 9),
84 ("Arial", "\u{25a1}", 10),
85 ("Wingdings 3", "}", 11),
86 ("Arial", "\u{25ba}", 12),
87 ("Arial", "\u{2192}", 21),
88 ("Symbol", "\u{de}", 22),
89 ("Arial", ">", 20),
90 ("Wingdings", "\u{d8}", 23),
91 ("Arial", "*", 24),
92 ("Wingdings", "\u{ad}", 17),
93 ("Wingdings", "\u{ae}", 19),
94 ("Wingdings", "\u{af}", 18),
95 ("Arial", "-", 25),
96 ("Arial", "\u{2013}", 26),
97 ("Arial", "\u{2014}", 27),
98 ("Wingdings", "J", 28),
99 ("Wingdings", "K", 29),
100 ("Wingdings", "L", 30),
101 ("Wingdings", "\u{fc}", 31),
102 ("Wingdings", "(", 32),
103 ("Wingdings", "*", 33),
104];
105
106/// OneNote 2010's numbering library in gallery order, as NumberListFormat stores each: the
107/// number's sequence follows U+FFFD. Ctrl+/ applies the first.
108pub const NUMBER_LIBRARY: [&str; 19] = [
109 "\u{fffd}\u{0}.",
110 "\u{fffd}\u{5}.",
111 "\u{fffd}\u{4}.",
112 "\u{fffd}\u{3}.",
113 "\u{fffd}\u{2}.",
114 "\u{fffd}\u{1}.",
115 "\u{fffd}\u{0})",
116 "\u{fffd}\u{4})",
117 "\u{fffd}\u{3})",
118 "\u{fffd}\u{2})",
119 "\u{fffd}\u{1})",
120 "(\u{fffd}\u{0})",
121 "(\u{fffd}\u{3})",
122 "(\u{fffd}\u{4})",
123 "(\u{fffd}\u{2})",
124 "(\u{fffd}\u{1})",
125 "\u{fffd}\u{6}.",
126 "\u{fffd}\u{7}.",
127 "\u{fffd}\u{0}-",
128];
129
130impl ListStyle {
131 pub(super) const BULLET: Self = Self::Bullet(2);
132 pub(super) const NUMBER: Self = Self::Number(0);
133
134 /// The library style a stored list has, if it is one.
135 fn of(kind: &Kind) -> Option<Self> {
136 let Kind::List {
137 font,
138 format: Some(format),
139 bullet,
140 ..
141 } = kind
142 else {
143 return None;
144 };
145 match bullet {
146 Some(bullet) => BULLET_LIBRARY
147 .iter()
148 .position(|entry| {
149 (Some(entry.0), entry.1, entry.2) == (font.as_deref(), format.as_str(), *bullet)
150 })
151 .map(Self::Bullet),
152 None => NUMBER_LIBRARY
153 .iter()
154 .position(|known| known == format)
155 .map(Self::Number),
156 }
157 }
158}
159
55/// OneNote 2010's default tags, Ctrl+1 to Ctrl+9, as it stores their definitions160/// OneNote 2010's default tags, Ctrl+1 to Ctrl+9, as it stores their definitions
56/// (`evidence/structural-edits/tags/tags.one`); declared in that order, their action types.161/// (`corpus/structural-probe/tags.one`); declared in that order, their action types.
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]162#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub enum NoteTag {163pub enum NoteTag {
59 ToDo,164 ToDo,
...@@ -68,7 +173,7 @@ pub enum NoteTag {...@@ -68,7 +173,7 @@ pub enum NoteTag {
68}173}
69174
70/// What the selection shows on the toolbar.175/// What the selection shows on the toolbar.
71#[derive(Clone, Debug, PartialEq)]176#[derive(Clone, Debug, Default, PartialEq)]
72pub struct FormatState {177pub struct FormatState {
73 /// Attributes every selected character has.178 /// Attributes every selected character has.
74 pub toggles: Vec<Toggle>,179 pub toggles: Vec<Toggle>,
...@@ -80,6 +185,8 @@ pub struct FormatState {...@@ -80,6 +185,8 @@ pub struct FormatState {
80 /// Whether every selected paragraph is bulleted or numbered.185 /// Whether every selected paragraph is bulleted or numbered.
81 pub bullets: bool,186 pub bullets: bool,
82 pub numbering: bool,187 pub numbering: bool,
188 /// The library style every selected paragraph's list shares.
189 pub list: Option<ListStyle>,
83 /// Tags every selected paragraph has.190 /// Tags every selected paragraph has.
84 pub tags: Vec<NoteTag>,191 pub tags: Vec<NoteTag>,
85}192}
...@@ -118,7 +225,7 @@ impl Toggle {...@@ -118,7 +225,7 @@ impl Toggle {
118}225}
119226
120impl NoteTag {227impl NoteTag {
121 pub(super) const ALL: [Self; 9] = [228 pub(crate) const ALL: [Self; 9] = [
122 Self::ToDo,229 Self::ToDo,
123 Self::Important,230 Self::Important,
124 Self::Question,231 Self::Question,
...@@ -130,21 +237,36 @@ impl NoteTag {...@@ -130,21 +237,36 @@ impl NoteTag {
130 Self::PhoneNumber,237 Self::PhoneNumber,
131 ];238 ];
132239
240 /// The tag's name, as OneNote stores and lists it.
241 pub const fn label(self) -> &'static str {
242 match self {
243 Self::ToDo => "To Do",
244 Self::Important => "Important",
245 Self::Question => "Question",
246 Self::RememberForLater => "Remember for later",
247 Self::Definition => "Definition",
248 Self::Highlight => "Highlight",
249 Self::Contact => "Contact",
250 Self::Address => "Address",
251 Self::PhoneNumber => "Phone number",
252 }
253 }
254
133 pub(super) fn definition(self) -> Definition {255 pub(super) fn definition(self) -> Definition {
134 let (label, shape, highlight) = match self {256 let (shape, highlight) = match self {
135 Self::ToDo => ("To Do", CHECKBOX, None),257 Self::ToDo => (CHECKBOX, None),
136 Self::Important => ("Important", 13, None),258 Self::Important => (13, None),
137 Self::Question => ("Question", 15, None),259 Self::Question => (15, None),
138 Self::RememberForLater => ("Remember for later", 0, Some(0x0000_ffff)),260 Self::RememberForLater => (0, Some(0x0000_ffff)),
139 Self::Definition => ("Definition", 0, Some(0x0000_ff00)),261 Self::Definition => (0, Some(0x0000_ff00)),
140 Self::Highlight => ("Highlight", 136, None),262 Self::Highlight => (136, None),
141 Self::Contact => ("Contact", 118, None),263 Self::Contact => (118, None),
142 Self::Address => ("Address", 23, None),264 Self::Address => (23, None),
143 Self::PhoneNumber => ("Phone number", 18, None),265 Self::PhoneNumber => (18, None),
144 };266 };
145 Definition {267 Definition {
146 kind: Kind::TagDefinition {268 kind: Kind::TagDefinition {
147 label: Some(label.into()),269 label: Some(self.label().into()),
148 action_type: Some(self as u16),270 action_type: Some(self as u16),
149 shape: Some(shape),271 shape: Some(shape),
150 // The highlighting tags also set black text.272 // The highlighting tags also set black text.
...@@ -161,7 +283,7 @@ type Ends = [(ExGuid, usize); 2];...@@ -161,7 +283,7 @@ type Ends = [(ExGuid, usize); 2];
161283
162/// The selection's ends and the container range holding both; a selection ending at a284/// The selection's ends and the container range holding both; a selection ending at a
163/// paragraph's start ends with the paragraph before.285/// paragraph's start ends with the paragraph before.
164fn selected(286pub(super) fn selected(
165 document: &TextDocument,287 document: &TextDocument,
166 selection: Selection,288 selection: Selection,
167) -> Result<(Option<ExGuid>, Range<usize>, Ends), EditError> {289) -> Result<(Option<ExGuid>, Range<usize>, Ends), EditError> {
...@@ -224,7 +346,7 @@ fn covered(...@@ -224,7 +346,7 @@ fn covered(
224 })346 })
225}347}
226348
227fn leaves_mut(nodes: &mut [PageParagraph], change: &mut impl FnMut(&mut PageParagraph)) {349pub(super) fn leaves_mut(nodes: &mut [PageParagraph], change: &mut impl FnMut(&mut PageParagraph)) {
228 for node in nodes {350 for node in nodes {
229 match &mut node.content {351 match &mut node.content {
230 ParagraphContent::Text(_) => change(node),352 ParagraphContent::Text(_) => change(node),
...@@ -239,7 +361,11 @@ fn leaves_mut(nodes: &mut [PageParagraph], change: &mut impl FnMut(&mut PagePara...@@ -239,7 +361,11 @@ fn leaves_mut(nodes: &mut [PageParagraph], change: &mut impl FnMut(&mut PagePara
239}361}
240362
241/// `text` with `change` applied to the formats of bytes `range`, or to its only format when empty.363/// `text` with `change` applied to the formats of bytes `range`, or to its only format when empty.
242fn restyle(text: &Paragraph, range: Range<usize>, change: impl Fn(&mut Format)) -> Paragraph {364pub(super) fn restyle(
365 text: &Paragraph,
366 range: Range<usize>,
367 change: impl Fn(&mut Format),
368) -> Paragraph {
243 if text.text().is_empty() {369 if text.text().is_empty() {
244 let mut format = text.spans()[0].format.clone();370 let mut format = text.spans()[0].format.clone();
245 change(&mut format);371 change(&mut format);
...@@ -316,7 +442,8 @@ fn tags(node: &PageParagraph) -> impl Iterator<Item = &Tag> {...@@ -316,7 +442,8 @@ fn tags(node: &PageParagraph) -> impl Iterator<Item = &Tag> {
316442
317impl CanvasEditor {443impl CanvasEditor {
318 /// The format text typed at `position` takes: the caret's pending format, else the text's444 /// The format text typed at `position` takes: the caret's pending format, else the text's
319 /// before it.445 /// before it. Text after a link's label or field code is plain, as OneNote types it, and
446 /// math typed into an equation is a run between its objects until it is built up.
320 pub(super) fn typing_format(&self, position: TextPosition) -> Result<Format, EditError> {447 pub(super) fn typing_format(&self, position: TextPosition) -> Result<Format, EditError> {
321 if let Some((id, at, format)) = &self.pending448 if let Some((id, at, format)) = &self.pending
322 && *id == self.active_outline().id449 && *id == self.active_outline().id
...@@ -324,13 +451,23 @@ impl CanvasEditor {...@@ -324,13 +451,23 @@ impl CanvasEditor {
324 {451 {
325 return Ok(format.clone());452 return Ok(format.clone());
326 }453 }
327 Ok(self454 let text = self
328 .active_outline()455 .active_outline()
329 .document456 .document
330 .paragraph(position.paragraph)457 .paragraph(position.paragraph)
331 .ok_or(EditError::InvalidRange)?458 .ok_or(EditError::InvalidRange)?;
332 .format_at(position.offset)?459 let mut format = text.format_at(position.offset)?.clone();
333 .clone())460 if format.hidden == Some(true) && format.hyperlink == Some(true)
461 || super::link::links(text, position.paragraph)
462 .iter()
463 .any(|link| link.code.is_some() && link.label.end == position.offset)
464 {
465 super::link::unlinked(&mut format);
466 }
467 if format.math == Some(true) {
468 format = onestore::page::Math::format(&format);
469 }
470 Ok(format)
334 }471 }
335472
336 fn list(&self, node: &PageParagraph) -> Option<Formatting> {473 fn list(&self, node: &PageParagraph) -> Option<Formatting> {
...@@ -349,6 +486,10 @@ impl CanvasEditor {...@@ -349,6 +486,10 @@ impl CanvasEditor {
349 }486 }
350 }487 }
351488
489 fn list_style(&self, node: &PageParagraph) -> Option<ListStyle> {
490 ListStyle::of(&self.definitions.get(node.lists.last()?)?.kind)
491 }
492
352 fn tag_kind(&self, tag: &Tag) -> Option<&Kind<'static>> {493 fn tag_kind(&self, tag: &Tag) -> Option<&Kind<'static>> {
353 Some(&self.definitions.get(tag.definition.as_ref()?)?.kind)494 Some(&self.definitions.get(tag.definition.as_ref()?)?.kind)
354 }495 }
...@@ -395,6 +536,7 @@ impl CanvasEditor {...@@ -395,6 +536,7 @@ impl CanvasEditor {
395 })),536 })),
396 bullets: list(Formatting::Bullets),537 bullets: list(Formatting::Bullets),
397 numbering: list(Formatting::Numbering),538 numbering: list(Formatting::Numbering),
539 list: common(paragraphs.iter().map(|node| self.list_style(node))),
398 tags: NoteTag::ALL540 tags: NoteTag::ALL
399 .into_iter()541 .into_iter()
400 .filter(|tag| {542 .filter(|tag| {
...@@ -429,7 +571,9 @@ impl CanvasEditor {...@@ -429,7 +571,9 @@ impl CanvasEditor {
429 Formatting::Align(_)571 Formatting::Align(_)
430 | Formatting::Bullets572 | Formatting::Bullets
431 | Formatting::Numbering573 | Formatting::Numbering
574 | Formatting::List(_)
432 | Formatting::Tag(_)575 | Formatting::Tag(_)
576 | Formatting::RemoveTags
433 | Formatting::Check577 | Formatting::Check
434 if title =>578 if title =>
435 {579 {
...@@ -455,13 +599,27 @@ impl CanvasEditor {...@@ -455,13 +599,27 @@ impl CanvasEditor {
455 }599 }
456 });600 });
457 }601 }
458 Formatting::Bullets | Formatting::Numbering => {602 Formatting::Bullets | Formatting::Numbering | Formatting::List(_) => {
459 let remove = covered(&replacement, ends)603 let style = match command {
460 .all(|(node, _)| self.list(node).as_ref() == Some(&command));604 Formatting::Bullets => Some(ListStyle::BULLET),
605 Formatting::Numbering => Some(ListStyle::NUMBER),
606 Formatting::List(style) => style,
607 _ => unreachable!(),
608 };
609 // A toggle leaves paragraphs already of its kind as they are, however styled.
610 let has = |node: &PageParagraph| match command {
611 Formatting::List(_) => self.list_style(node) == style,
612 _ => self.list(node).as_ref() == Some(&command),
613 };
614 let remove = style.is_none()
615 || !matches!(command, Formatting::List(_))
616 && covered(&replacement, ends).all(|(node, _)| has(node));
461 // A list applied after a plain sibling nests under it, as Tab would617 // A list applied after a plain sibling nests under it, as Tab would
462 // (`evidence/structural-edits/xml/pb-1.xml`); removing it leaves it there.618 // (`evidence/structural-edits/xml/pb-1.xml`); removing it, or restyling a
619 // list, leaves it where it is, as OneNote 2010 does.
463 let nodes = outline.document.container(container)?;620 let nodes = outline.document.container(container)?;
464 if !remove621 if !remove
622 && nodes[range.start].lists.is_empty()
465 && crate::document::previous_sibling(nodes, range.start, &BTreeSet::new())623 && crate::document::previous_sibling(nodes, range.start, &BTreeSet::new())
466 .is_some_and(|sibling| nodes[sibling].lists.is_empty())624 .is_some_and(|sibling| nodes[sibling].lists.is_empty())
467 && let Some(edit) =625 && let Some(edit) =
...@@ -472,13 +630,10 @@ impl CanvasEditor {...@@ -472,13 +630,10 @@ impl CanvasEditor {
472 }630 }
473 let wanted = covered(&replacement, ends)631 let wanted = covered(&replacement, ends)
474 .map(|(node, _)| node)632 .map(|(node, _)| node)
475 .filter(|node| !remove && self.list(node).as_ref() != Some(&command))633 .filter(|node| !remove && !has(node))
476 .map(|node| {634 .filter_map(|node| {
477 let format = &node.text().unwrap().text.spans()[0].format;635 let format = &node.text().unwrap().text.spans()[0].format;
478 (636 Some((node.id, list_definition(style?, format)))
479 node.id,
480 list_definition(command == Formatting::Numbering, format),
481 )
482 })637 })
483 .collect::<Vec<_>>();638 .collect::<Vec<_>>();
484 // A list definition belongs to one paragraph, as OneNote stores it.639 // A list definition belongs to one paragraph, as OneNote stores it.
...@@ -560,6 +715,12 @@ impl CanvasEditor {...@@ -560,6 +715,12 @@ impl CanvasEditor {
560 }715 }
561 });716 });
562 }717 }
718 Formatting::RemoveTags => leaves_mut(&mut replacement, &mut |node| {
719 if ranges.remove(&node.id).is_some() {
720 node.tags.clear();
721 node.text_mut().unwrap().tags.clear();
722 }
723 }),
563 Formatting::Check => self.check(&mut replacement, ranges, ends),724 Formatting::Check => self.check(&mut replacement, ranges, ends),
564 Formatting::Toggle(_)725 Formatting::Toggle(_)
565 | Formatting::Font(_)726 | Formatting::Font(_)
...@@ -567,10 +728,20 @@ impl CanvasEditor {...@@ -567,10 +728,20 @@ impl CanvasEditor {
567 | Formatting::Color(_)728 | Formatting::Color(_)
568 | Formatting::Highlight(_)729 | Formatting::Highlight(_)
569 | Formatting::Clear => {730 | Formatting::Clear => {
731 let caret = (ends[0] == ends[1])
732 .then(|| {
733 let [anchor, focus] = selection.positions;
734 let caret = anchor.min(focus);
735 Ok::<_, EditError>((caret, self.typing_format(caret)?))
736 })
737 .transpose()?;
570 let on = match command {738 let on = match command {
571 Formatting::Toggle(toggle) => !character_formats(covered(&replacement, ends))739 Formatting::Toggle(toggle) => !match &caret {
572 .iter()740 Some((_, format)) => vec![format],
573 .all(|format| toggle.get(format) == Some(true)),741 None => character_formats(covered(&replacement, ends)),
742 }
743 .iter()
744 .all(|format| toggle.get(format) == Some(true)),
574 _ => true,745 _ => true,
575 };746 };
576 let change = |base: &Format, format: &mut Format| match &command {747 let change = |base: &Format, format: &mut Format| match &command {
...@@ -606,10 +777,7 @@ impl CanvasEditor {...@@ -606,10 +777,7 @@ impl CanvasEditor {
606 }777 }
607 };778 };
608 let bases = bases(&replacement)?;779 let bases = bases(&replacement)?;
609 if ends[0] == ends[1] {780 if let Some((caret, mut format)) = caret {
610 let [anchor, focus] = selection.positions;
611 let caret = anchor.min(focus);
612 let mut format = self.typing_format(caret)?;
613 change(&bases[&ends[0].0], &mut format);781 change(&bases[&ends[0].0], &mut format);
614 self.pending = Some((id, caret, format));782 self.pending = Some((id, caret, format));
615 return Ok(());783 return Ok(());
...@@ -717,79 +885,47 @@ impl CanvasEditor {...@@ -717,79 +885,47 @@ impl CanvasEditor {
717 }885 }
718}886}
719887
720/// The list a Tab (`deeper`) or Shift+Tab gives a paragraph with a default list, as OneNote888/// The list a Tab (`deeper`) or Shift+Tab gives a paragraph whose list is one OneNote 2010
721/// 2010 steps • to ○ and 1. to a. (`evidence/structural-edits/xml/c6-bullet-tab.xml`,889/// steps through as Tab nests it: from Ctrl+. through nine bullets, and from Ctrl+/ through
722/// `c8-tab-1.xml`), then to ■ as `corpus/private` nests bullets, and to i.; outdenting stops at890/// eight number formats, each as its gallery stores it, starting over after the last
723/// the first style.891/// (`evidence/toolbar-17/tab-lists.txt`). Outdenting stops at the first.
724pub(super) fn nested_list(definition: &Definition, deeper: bool) -> Option<Definition> {892pub(super) fn nested_list(definition: &Definition, deeper: bool) -> Option<Definition> {
725 const BULLETS: [(&str, &str, u16); 3] = [893 const BULLETS: [usize; 9] = [2, 3, 13, 14, 9, 7, 15, 26, 8];
726 ("Calibri", "\u{2022}", 1),894 const NUMBERS: [usize; 8] = [0, 2, 4, 6, 7, 9, 16, 17];
727 ("Courier New", "\u{25cb}", 4),895 let Kind::List { restart, .. } = &definition.kind else {
728 ("Wingdings", "\u{a7}", 7),
729 ];
730 const SEQUENCES: [char; 3] = ['\0', '\u{4}', '\u{2}'];
731 let Kind::List {
732 font,
733 format: Some(format),
734 restart,
735 bullet,
736 } = &definition.kind
737 else {
738 return None;896 return None;
739 };897 };
740 let step = |index: usize| {898 let current = ListStyle::of(&definition.kind)?;
741 if deeper {899 let (chain, style): (&[usize], fn(usize) -> ListStyle) = match current {
742 Some((index + 1) % 3)900 ListStyle::Bullet(_) => (&BULLETS, ListStyle::Bullet),
743 } else {901 ListStyle::Number(_) => (&NUMBERS, ListStyle::Number),
744 index.checked_sub(1)
745 }
746 };902 };
747 let kind = match bullet {903 let at = chain.iter().position(|place| style(*place) == current)?;
748 Some(_) => {904 let at = if deeper {
749 let index = BULLETS.iter().position(|(name, glyph, index)| {905 (at + 1) % chain.len()
750 (font.as_deref(), format.as_str(), *bullet) == (Some(*name), *glyph, Some(*index))906 } else {
751 })?;907 at.checked_sub(1)?
752 let (name, glyph, index) = BULLETS[step(index)?];
753 Kind::List {
754 font: Some(name.into()),
755 format: Some(glyph.into()),
756 restart: *restart,
757 bullet: Some(index),
758 }
759 }
760 None => {
761 let (prefix, rest) = format.split_once('\u{fffd}')?;
762 let mut rest = rest.chars();
763 let sequence = rest.next()?;
764 let index = SEQUENCES.iter().position(|known| *known == sequence)?;
765 Kind::List {
766 font: font.clone(),
767 format: Some(format!(
768 "{prefix}\u{fffd}{}{}",
769 SEQUENCES[step(index)?],
770 rest.as_str()
771 )),
772 restart: *restart,
773 bullet: None,
774 }
775 }
776 };908 };
909 let mut kind = list_definition(style(chain[at]), &Format::default()).kind;
910 if let Kind::List { restart: value, .. } = &mut kind {
911 *value = *restart;
912 }
777 Some(Definition {913 Some(Definition {
778 kind,914 kind,
779 format: definition.format.clone(),915 format: definition.format.clone(),
780 })916 })
781}917}
782918
783/// The list OneNote 2010 gives a paragraph with `format`: its Ctrl+. bullet919/// The list OneNote 2010 gives a paragraph with `format` in library `style`, as its gallery
784/// (`corpus/paragraph-edit/reconciliation/keyboard`), or the `##.` arabic numbering it stores920/// and its Ctrl+. bullet store it (`corpus/paragraph-edit/reconciliation/keyboard`); a number
785/// from its COM interface (`corpus/outline-edit/tree`).921/// also takes the text's font and language (`corpus/outline-edit/tree`).
786pub(super) fn list_definition(numbering: bool, format: &Format) -> Definition {922pub(super) fn list_definition(style: ListStyle, format: &Format) -> Definition {
787 let font_size = Some(format.font_size.unwrap_or(11.0));923 let font_size = Some(format.font_size.unwrap_or(11.0));
788 if numbering {924 match style {
789 Definition {925 ListStyle::Number(index) => Definition {
790 kind: Kind::List {926 kind: Kind::List {
791 font: None,927 font: None,
792 format: Some("\u{fffd}\u{0}.".into()),928 format: Some(NUMBER_LIBRARY[index].into()),
793 restart: None,929 restart: None,
794 bullet: None,930 bullet: None,
795 },931 },
...@@ -802,20 +938,22 @@ pub(super) fn list_definition(numbering: bool, format: &Format) -> Definition {...@@ -802,20 +938,22 @@ pub(super) fn list_definition(numbering: bool, format: &Format) -> Definition {
802 language: format.language,938 language: format.language,
803 ..Format::default()939 ..Format::default()
804 },940 },
805 }941 },
806 } else {942 ListStyle::Bullet(index) => {
807 Definition {943 let (font, glyph, bullet) = BULLET_LIBRARY[index];
808 kind: Kind::List {944 Definition {
809 font: Some("Calibri".into()),945 kind: Kind::List {
810 format: Some("\u{2022}".into()),946 font: Some(font.into()),
811 restart: None,947 format: Some(glyph.into()),
812 bullet: Some(1),948 restart: None,
813 },949 bullet: Some(bullet),
814 format: Format {950 },
815 font_size,951 format: Format {
816 color: Some(AUTOMATIC),952 font_size,
817 ..Format::default()953 color: Some(AUTOMATIC),
818 },954 ..Format::default()
955 },
956 }
819 }957 }
820 }958 }
821}959}
...@@ -930,6 +1068,44 @@ mod tests {...@@ -930,6 +1068,44 @@ mod tests {
930 assert_eq!(bold(&editor)[1], [run("ne", Some(true)), run("xt", None)]);1068 assert_eq!(bold(&editor)[1], [run("ne", Some(true)), run("xt", None)]);
931 }1069 }
9321070
1071 #[test]
1072 fn a_second_toggle_at_a_caret_turns_the_typing_format_back_off() {
1073 for toggle in Toggle::ALL {
1074 for text in ["", "ab"] {
1075 let mut engine = TextEngine::default();
1076 let mut editor = plain(&mut engine, &[text]);
1077 let caret = at(0, text.len() as u32);
1078 editor.select([caret; 2].into()).unwrap();
1079 let press = |editor: &mut CanvasEditor, engine: &mut TextEngine| {
1080 editor.format(engine, Formatting::Toggle(toggle)).unwrap();
1081 editor.format_state().unwrap().toggles
1082 };
1083 assert_eq!(press(&mut editor, &mut engine), [toggle], "{toggle:?}");
1084 assert_eq!(press(&mut editor, &mut engine), [], "{toggle:?}");
1085 editor.insert(&mut engine, "x").unwrap();
1086 assert_eq!(
1087 runs(&editor, |format| toggle.get(format))[0],
1088 [run(&format!("{text}x"), None)]
1089 );
1090 }
1091 let mut engine = TextEngine::default();
1092 let mut on = calibri();
1093 *toggle.slot(&mut on) = Some(true);
1094 let mut editor = editor(&mut engine, vec![Paragraph::new("ab".into(), on)]);
1095 editor.select([at(0, 1); 2].into()).unwrap();
1096 editor
1097 .format(&mut engine, Formatting::Toggle(toggle))
1098 .unwrap();
1099 assert_eq!(editor.format_state().unwrap().toggles, [], "{toggle:?}");
1100 editor.insert(&mut engine, "x").unwrap();
1101 assert_eq!(
1102 runs(&editor, |format| toggle.get(format) == Some(true))[0],
1103 [("a".into(), true), ("x".into(), false), ("b".into(), true)],
1104 "{toggle:?}"
1105 );
1106 }
1107 }
1108
933 #[test]1109 #[test]
934 fn a_caret_formats_the_text_typed_next_and_scripts_exclude_each_other() {1110 fn a_caret_formats_the_text_typed_next_and_scripts_exclude_each_other() {
935 let mut engine = TextEngine::default();1111 let mut engine = TextEngine::default();
...@@ -1112,7 +1288,7 @@ mod tests {...@@ -1112,7 +1288,7 @@ mod tests {
1112 let markers = paragraph.markers.iter().map(|(marker, origin)| {1288 let markers = paragraph.markers.iter().map(|(marker, origin)| {
1113 (*origin, marker.lines().next().unwrap().0.metrics().advance)1289 (*origin, marker.lines().next().unwrap().0.metrics().advance)
1114 });1290 });
1115 (paragraph.number, markers.collect::<Vec<_>>())1291 (paragraph.number.clone(), markers.collect::<Vec<_>>())
1116 })1292 })
1117 .collect::<Vec<_>>()1293 .collect::<Vec<_>>()
1118 };1294 };
...@@ -1121,7 +1297,7 @@ mod tests {...@@ -1121,7 +1297,7 @@ mod tests {
1121 .shaped1297 .shaped
1122 .paragraphs1298 .paragraphs
1123 .iter()1299 .iter()
1124 .map(|paragraph| paragraph.number.map(|(number, _)| number))1300 .map(|paragraph| paragraph.number.as_ref().map(|(count, _)| count.number))
1125 .collect()1301 .collect()
1126 }1302 }
11271303
...@@ -1139,7 +1315,7 @@ mod tests {...@@ -1139,7 +1315,7 @@ mod tests {
1139 let bullet = editor.active_outline().document.nodes()[0].lists[0];1315 let bullet = editor.active_outline().document.nodes()[0].lists[0];
1140 assert_eq!(1316 assert_eq!(
1141 editor.definitions[&bullet],1317 editor.definitions[&bullet],
1142 list_definition(false, &calibri())1318 list_definition(ListStyle::BULLET, &calibri())
1143 );1319 );
1144 editor.format(&mut engine, Formatting::Numbering).unwrap();1320 editor.format(&mut engine, Formatting::Numbering).unwrap();
1145 let state = editor.format_state().unwrap();1321 let state = editor.format_state().unwrap();
...@@ -1197,6 +1373,136 @@ mod tests {...@@ -1197,6 +1373,136 @@ mod tests {
1197 assert_eq!(numbers(&mut engine, &editor), [None; 4]);1373 assert_eq!(numbers(&mut engine, &editor), [None; 4]);
1198 }1374 }
11991375
1376 #[test]
1377 fn library_styles_replace_any_list_and_none_removes_them() {
1378 let mut engine = TextEngine::default();
1379 let mut editor = plain(&mut engine, &["one", "two", "three"]);
1380 let original = editor.active_outline().document.clone();
1381 let all = Selection::from([at(0, 0), at(2, 5)]);
1382 editor.select(all).unwrap();
1383 editor.format(&mut engine, Formatting::Bullets).unwrap();
1384 assert_eq!(editor.format_state().unwrap().list, Some(ListStyle::BULLET));
1385 let pick = |style| Formatting::List(Some(style));
1386 editor
1387 .format(&mut engine, pick(ListStyle::Bullet(0)))
1388 .unwrap();
1389 let state = editor.format_state().unwrap();
1390 assert!(state.bullets && !state.numbering);
1391 assert_eq!(state.list, Some(ListStyle::Bullet(0)));
1392 let first = editor.active_outline().document.nodes()[0].lists[0];
1393 assert_eq!(
1394 editor.definitions[&first],
1395 list_definition(ListStyle::Bullet(0), &calibri())
1396 );
1397 // Picking the style again leaves the lists as they are.
1398 let stored = editor.active_outline().document.clone();
1399 editor
1400 .format(&mut engine, pick(ListStyle::Bullet(0)))
1401 .unwrap();
1402 assert_eq!(editor.active_outline().document, stored);
1403 // A number format replaces the bullets; "First." lays out as any number does.
1404 editor
1405 .format(&mut engine, pick(ListStyle::Number(17)))
1406 .unwrap();
1407 let state = editor.format_state().unwrap();
1408 assert!(state.numbering && !state.bullets);
1409 assert_eq!(state.list, Some(ListStyle::Number(17)));
1410 assert_eq!(numbers(&mut engine, &editor), [Some(1), Some(2), Some(3)]);
1411 // Paragraphs of different styles share none.
1412 editor.select([at(1, 0); 2].into()).unwrap();
1413 editor
1414 .format(&mut engine, pick(ListStyle::Number(0)))
1415 .unwrap();
1416 editor.select(all).unwrap();
1417 let state = editor.format_state().unwrap();
1418 assert!(state.numbering && state.list.is_none());
1419 editor.format(&mut engine, Formatting::List(None)).unwrap();
1420 let state = editor.format_state().unwrap();
1421 assert!(!state.bullets && !state.numbering && state.list.is_none());
1422 assert_eq!(numbers(&mut engine, &editor), [None; 3]);
1423 for _ in 0..6 {
1424 editor.undo(&mut engine).unwrap();
1425 }
1426 assert_eq!(editor.active_outline().document, original);
1427 }
1428
1429 #[test]
1430 fn restyling_a_list_after_a_plain_sibling_leaves_it_where_it_is() {
1431 let mut engine = TextEngine::default();
1432 let mut editor = plain(&mut engine, &["head", "item"]);
1433 let levels = |editor: &CanvasEditor| {
1434 editor
1435 .active_outline()
1436 .document
1437 .nodes()
1438 .iter()
1439 .map(|node| node.level)
1440 .collect::<Vec<_>>()
1441 };
1442 editor.select([at(1, 0); 2].into()).unwrap();
1443 editor.format(&mut engine, Formatting::Numbering).unwrap();
1444 assert_eq!(levels(&editor), [1, 2]);
1445 editor.format(&mut engine, Formatting::Outdent).unwrap();
1446 assert_eq!(levels(&editor), [1, 1]);
1447 editor.format(&mut engine, Formatting::Bullets).unwrap();
1448 editor
1449 .format(&mut engine, Formatting::List(Some(ListStyle::Bullet(0))))
1450 .unwrap();
1451 assert_eq!(levels(&editor), [1, 1]);
1452 assert_eq!(
1453 editor.format_state().unwrap().list,
1454 Some(ListStyle::Bullet(0))
1455 );
1456 }
1457
1458 #[test]
1459 fn tab_steps_through_onenotes_nested_bullets_and_numbers() {
1460 let chain = |start: ListStyle, steps: usize| {
1461 let mut definition = list_definition(start, &calibri());
1462 let mut styles = vec![ListStyle::of(&definition.kind).unwrap()];
1463 for _ in 0..steps {
1464 definition = nested_list(&definition, true).unwrap();
1465 styles.push(ListStyle::of(&definition.kind).unwrap());
1466 }
1467 (styles, definition)
1468 };
1469 let (bullets, deepest) = chain(ListStyle::BULLET, 9);
1470 assert_eq!(
1471 bullets,
1472 [2, 3, 13, 14, 9, 7, 15, 26, 8, 2].map(ListStyle::Bullet)
1473 );
1474 // Wingdings § is ListMSAAIndex 9 here, as the gallery stores it.
1475 assert_eq!(BULLET_LIBRARY[13], ("Wingdings", "\u{a7}", 9));
1476 assert_eq!(
1477 deepest.format,
1478 list_definition(ListStyle::BULLET, &calibri()).format
1479 );
1480 let (numbers, _) = chain(ListStyle::NUMBER, 8);
1481 assert_eq!(
1482 numbers,
1483 [0, 2, 4, 6, 7, 9, 16, 17, 0].map(ListStyle::Number)
1484 );
1485 let second = nested_list(&list_definition(ListStyle::NUMBER, &calibri()), true).unwrap();
1486 assert_eq!(
1487 ListStyle::of(&nested_list(&second, false).unwrap().kind),
1488 Some(ListStyle::NUMBER)
1489 );
1490 assert!(nested_list(&list_definition(ListStyle::BULLET, &calibri()), false).is_none());
1491 // A style off both paths keeps its list.
1492 assert!(nested_list(&list_definition(ListStyle::Bullet(33), &calibri()), true).is_none());
1493 }
1494
1495 #[test]
1496 fn every_library_style_reads_back_as_itself() {
1497 let styles = (0..BULLET_LIBRARY.len())
1498 .map(ListStyle::Bullet)
1499 .chain((0..NUMBER_LIBRARY.len()).map(ListStyle::Number));
1500 for style in styles {
1501 let definition = list_definition(style, &calibri());
1502 assert_eq!(ListStyle::of(&definition.kind), Some(style));
1503 }
1504 }
1505
1200 #[test]1506 #[test]
1201 fn numbering_follows_stored_sequences_restarts_and_nesting() {1507 fn numbering_follows_stored_sequences_restarts_and_nesting() {
1202 let mut engine = TextEngine::default();1508 let mut engine = TextEngine::default();
...@@ -1207,10 +1513,10 @@ mod tests {...@@ -1207,10 +1513,10 @@ mod tests {
1207 ('\u{1}', None, 1),1513 ('\u{1}', None, 1),
1208 ('\u{4}', None, 2),1514 ('\u{4}', None, 2),
1209 ('\u{1}', Some(9), 1),1515 ('\u{1}', Some(9), 1),
1210 ('\u{0}', None, 1),1516 ('\u{1}', None, 1),
1211 ]) {1517 ]) {
1212 let id = new_id().unwrap();1518 let id = new_id().unwrap();
1213 let mut definition = list_definition(true, &calibri());1519 let mut definition = list_definition(ListStyle::NUMBER, &calibri());
1214 let Kind::List {1520 let Kind::List {
1215 format,1521 format,
1216 restart: value,1522 restart: value,
...@@ -1237,6 +1543,78 @@ mod tests {...@@ -1237,6 +1543,78 @@ mod tests {
1237 );1543 );
1238 }1544 }
12391545
1546 /// OneNote 2010's numbers for siblings and children in the formats and levels given, as
1547 /// its COM interface reports them (`evidence/toolbar-17/restart.txt`); `None` is a plain
1548 /// paragraph, `Some("•")` a bullet.
1549 #[test]
1550 fn numbers_restart_where_the_format_changes() {
1551 let numbered = |paragraphs: &[(Option<&str>, u32)]| {
1552 let mut engine = TextEngine::default();
1553 let texts = vec!["x"; paragraphs.len()];
1554 let mut editor = plain(&mut engine, &texts);
1555 let mut nodes = editor.active_outline().document.nodes().to_vec();
1556 for (node, (list, level)) in nodes.iter_mut().zip(paragraphs) {
1557 node.level = *level;
1558 let style = match list {
1559 None => continue,
1560 Some("•") => ListStyle::BULLET,
1561 Some(_) => ListStyle::NUMBER,
1562 };
1563 let mut definition = list_definition(style, &calibri());
1564 if let (Some(value), Kind::List { format, .. }) = (list, &mut definition.kind)
1565 && style == ListStyle::NUMBER
1566 {
1567 *format = Some((*value).into());
1568 }
1569 let id = new_id().unwrap();
1570 editor.definitions.insert(id, definition);
1571 node.lists = vec![id];
1572 }
1573 let outline = Outline {
1574 paragraphs: nodes,
1575 ..editor.active_outline().snapshot()
1576 };
1577 let editor =
1578 CanvasEditor::from_outlines(&mut engine, vec![outline], editor.definitions)
1579 .unwrap();
1580 numbers(&mut engine, &editor)
1581 };
1582 let [dot, paren, letter] = ["\u{fffd}\u{0}.", "\u{fffd}\u{0})", "\u{fffd}\u{4}."];
1583 // Another format starts again, and so does the first format after it.
1584 assert_eq!(
1585 numbered(&[
1586 (Some(dot), 1),
1587 (Some(dot), 1),
1588 (Some(paren), 1),
1589 (Some(dot), 1),
1590 (Some(paren), 1),
1591 ]),
1592 [Some(1), Some(2), Some(1), Some(1), Some(1)]
1593 );
1594 // Plain and bulleted siblings leave the count alone.
1595 assert_eq!(
1596 numbered(&[(Some(dot), 1), (None, 1), (Some("•"), 1), (Some(dot), 1)]),
1597 [Some(1), None, None, Some(2)]
1598 );
1599 // So does another sequence; children count apart from their parents, afresh under each.
1600 assert_eq!(
1601 numbered(&[(Some(dot), 1), (Some(letter), 1), (Some(dot), 1)]),
1602 [Some(1), Some(1), Some(1)]
1603 );
1604 assert_eq!(
1605 numbered(&[
1606 (Some(dot), 1),
1607 (Some(dot), 2),
1608 (Some(dot), 2),
1609 (Some(dot), 1),
1610 (Some(dot), 2),
1611 (None, 1),
1612 (Some(dot), 1),
1613 ]),
1614 [Some(1), Some(1), Some(2), Some(2), Some(1), None, Some(3)]
1615 );
1616 }
1617
1240 fn text_tags(editor: &CanvasEditor, paragraph: usize) -> Vec<(String, u16, bool)> {1618 fn text_tags(editor: &CanvasEditor, paragraph: usize) -> Vec<(String, u16, bool)> {
1241 let (_, _, node) = editor.active_outline().document.leaf(paragraph).unwrap();1619 let (_, _, node) = editor.active_outline().document.leaf(paragraph).unwrap();
1242 tags(node)1620 tags(node)
...@@ -1389,7 +1767,7 @@ mod tests {...@@ -1389,7 +1767,7 @@ mod tests {
1389 #[test]1767 #[test]
1390 fn the_nine_default_tags_are_onenotes_stored_definitions_and_all_draw() {1768 fn the_nine_default_tags_are_onenotes_stored_definitions_and_all_draw() {
1391 use onestore::{RevisionIndex, Store, document::Document};1769 use onestore::{RevisionIndex, Store, document::Document};
1392 let bytes = include_bytes!("../../../../evidence/structural-edits/tags/tags.one");1770 let bytes = include_bytes!("../../../../corpus/structural-probe/tags.one");
1393 let store = Store::parse(bytes).unwrap();1771 let store = Store::parse(bytes).unwrap();
1394 let index = RevisionIndex::parse(&store).unwrap();1772 let index = RevisionIndex::parse(&store).unwrap();
1395 let document = Document::parse(&index).unwrap();1773 let document = Document::parse(&index).unwrap();
...@@ -1561,6 +1939,7 @@ mod tests {...@@ -1561,6 +1939,7 @@ mod tests {
1561 alignment: Some(Alignment::Center),1939 alignment: Some(Alignment::Center),
1562 bullets: false,1940 bullets: false,
1563 numbering: true,1941 numbering: true,
1942 list: Some(ListStyle::NUMBER),
1564 tags: vec![NoteTag::ToDo, NoteTag::Question],1943 tags: vec![NoteTag::ToDo, NoteTag::Question],
1565 }1944 }
1566 );1945 );
crates/canvas/src/editor/link.rs created+681
...@@ -0,0 +1,681 @@
1//! Hyperlinks as OneNote 2010 edits them (`corpus/link-edit/native-typed`): the Link dialog
2//! stores the address as typed in a hidden `HYPERLINK` field code before the label, a URL
3//! typed and ended by a space or Enter becomes a link of its own text, typing after a label
4//! is plain text, and Remove Link leaves the label as plain text.
5
6use super::format::{leaves_mut, restyle, selected};
7use super::*;
8
9/// A link in a paragraph, in UTF-16 offsets of its stored text.
10#[derive(Clone, Debug, PartialEq, Eq)]
11pub struct Link {
12 pub paragraph: usize,
13 /// The hidden field code naming the address, before the label; none where the label is
14 /// its own address.
15 pub code: Option<Range<u32>>,
16 pub label: Range<u32>,
17 pub target: String,
18}
19
20impl Link {
21 fn range(&self) -> Range<u32> {
22 self.code
23 .as_ref()
24 .map_or(self.label.start, |code| code.start)..self.label.end
25 }
26}
27
28/// The links in `text`, the text of paragraph `paragraph`.
29pub(crate) fn links(text: &Paragraph, paragraph: usize) -> Vec<Link> {
30 let mut found: Vec<Link> = Vec::new();
31 let mut code: Option<(Range<u32>, String)> = None;
32 let (mut byte, mut unit) = (0, 0);
33 for span in text.spans() {
34 let fragment = &text.text()[byte..span.end];
35 let range = unit..unit + fragment.encode_utf16().count() as u32;
36 (byte, unit) = (span.end, range.end);
37 let format = &span.format;
38 if format.hyperlink != Some(true) {
39 code = None;
40 continue;
41 }
42 if format.hidden == Some(true) {
43 code = fragment
44 .strip_prefix("\u{fddf}HYPERLINK \"")
45 .and_then(|target| target.strip_suffix('"'))
46 .map(|target| (range, target.to_owned()));
47 continue;
48 }
49 let labelled = format.hyperlink_label == Some(true);
50 match (code.take(), found.last_mut()) {
51 (Some((code, target)), _) if code.end == range.start => found.push(Link {
52 paragraph,
53 code: Some(code),
54 label: range,
55 target,
56 }),
57 // A label runs on across format changes; a bare link is its own text.
58 (_, Some(last)) if last.label.end == range.start && last.code.is_some() == labelled => {
59 last.label.end = range.end;
60 }
61 _ => found.push(Link {
62 paragraph,
63 code: None,
64 label: range,
65 target: String::new(),
66 }),
67 }
68 }
69 for link in &mut found {
70 if link.code.is_none() {
71 let start = text.byte_offset(link.label.start).unwrap_or(0);
72 let end = text.byte_offset(link.label.end).unwrap_or(start);
73 link.target = text.text()[start..end].to_owned();
74 }
75 }
76 found
77}
78
79/// Where a typed URL lies in `word`, a run of text ended by a space or Enter, as OneNote
80/// links it: from a scheme it knows (or `www.`, or a share's `\\`) preceded by punctuation
81/// only, to before any trailing punctuation or unmatched closing bracket.
82pub(crate) fn typed_url(word: &str) -> Option<Range<usize>> {
83 const SCHEMES: [&str; 9] = [
84 "http://", "https://", "ftp://", "file://", "mailto:", "news:", "onenote:", "www.", "\\\\",
85 ];
86 let lower = word.to_ascii_lowercase();
87 let (start, scheme) = SCHEMES
88 .iter()
89 .filter_map(|scheme| Some((lower.find(scheme)?, scheme.len())))
90 .min()?;
91 if word[..start].chars().any(char::is_alphanumeric) {
92 return None;
93 }
94 let mut end = word.len();
95 while let Some(last) = word[start..end].chars().last() {
96 let count = |c: char| word[start..end].matches(c).count();
97 let trailing = match last {
98 '.' | ',' | ';' | ':' | '!' | '?' | '\'' | '"' => true,
99 ')' => count('(') < count(')'),
100 ']' => count('[') < count(']'),
101 '}' => count('{') < count('}'),
102 _ => false,
103 };
104 if !trailing {
105 break;
106 }
107 end -= last.len_utf8();
108 }
109 (end > start + scheme).then_some(start..end)
110}
111
112/// The URLs written as text in `text`, as byte ranges: OneNote links them when it opens a
113/// page, so they show and open as links without being stored as ones.
114pub fn shown_urls(text: &Paragraph) -> Vec<Range<usize>> {
115 let plain =
116 |format: &Format| ![format.hyperlink, format.hidden, format.math].contains(&Some(true));
117 let mut urls = Vec::new();
118 let mut start = 0;
119 for span in text.spans() {
120 if !plain(&span.format) {
121 start = span.end;
122 continue;
123 }
124 let run = &text.text()[start..span.end];
125 let mut at = 0;
126 for word in run.split(char::is_whitespace) {
127 // Most words hold none of what a URL starts with.
128 let candidate = word.contains([':', '\\'])
129 || word
130 .as_bytes()
131 .windows(4)
132 .any(|w| w.eq_ignore_ascii_case(b"www."));
133 if candidate && let Some(url) = typed_url(word) {
134 urls.push(start + at + url.start..start + at + url.end);
135 }
136 at += word.len()
137 + run[at + word.len()..]
138 .chars()
139 .next()
140 .map_or(0, char::len_utf8);
141 }
142 start = span.end;
143 }
144 urls
145}
146
147/// The format of the character at UTF-16 `offset`.
148pub(crate) fn format_after(text: &Paragraph, offset: u32) -> Option<&Format> {
149 let byte = text.byte_offset(offset).ok()?;
150 let length = text.text()[byte..].chars().next()?.len_utf16() as u32;
151 text.format_at(offset + length).ok()
152}
153
154/// Text that follows a link rather than extending it.
155pub(crate) fn unlinked(format: &mut Format) {
156 format.hidden = format.hidden.map(|_| false);
157 format.hyperlink = format.hyperlink.map(|_| false);
158 format.hyperlink_label = None;
159}
160
161/// A field code naming `address`, which cannot hold its quotes or line breaks.
162fn field_code(address: &str) -> String {
163 let address: String = address
164 .trim()
165 .chars()
166 .filter(|c| !c.is_control() && *c != '\u{fddf}')
167 .map(|c| {
168 if c == '"' {
169 "%22".to_owned()
170 } else {
171 c.to_string()
172 }
173 })
174 .collect();
175 format!("\u{fddf}HYPERLINK \"{address}\"")
176}
177
178impl CanvasEditor {
179 /// The link in the active outline at `position`, its ends included.
180 pub fn link_at(&self, position: TextPosition) -> Option<Link> {
181 let text = self
182 .active_outline()
183 .document
184 .paragraph(position.paragraph)?;
185 links(text, position.paragraph).into_iter().find(|link| {
186 link.range().contains(&position.offset) || link.label.end == position.offset
187 })
188 }
189
190 /// The address of the link drawn under document point `point` of outline `id`, which a
191 /// click opens as OneNote's does.
192 pub fn link_under(&self, id: ExGuid, [x, y]: [f32; 2]) -> Option<String> {
193 let outline = self.visible_outlines().find(|outline| outline.id == id)?;
194 let index = outline.paragraph_at(x, y).ok()?;
195 let paragraph = &outline.shaped.paragraphs[index];
196 let point = [x - paragraph.origin[0], y - paragraph.origin[1]];
197 let cursor = paragraph.text.hit_test(point[0], point[1]);
198 let edge = paragraph.text.caret(cursor, 0.0).x0 as f32;
199 let visible = paragraph.projection.text();
200 let byte = cursor.index();
201 // The character under the point lies on the side of the nearest boundary it is on.
202 let at = if point[0] < edge {
203 visible.text()[..byte].char_indices().next_back()?.0
204 } else {
205 visible.text()[byte..].chars().next()?;
206 byte
207 };
208 let (line, _) = paragraph
209 .text
210 .lines()
211 .find(|(_, bounds)| bounds.top <= point[1] && point[1] < bounds.top + bounds.height)?;
212 if point[0] > line.metrics().advance {
213 return None;
214 }
215 let source = paragraph
216 .projection
217 .source_offset(
218 visible.utf16_offset(at).ok()?,
219 onestore::page::text::Affinity::Downstream,
220 )
221 .ok()?;
222 let paragraph = outline.source_index(index);
223 links(outline.document.paragraph(paragraph)?, paragraph)
224 .into_iter()
225 .find(|link| link.label.contains(&source))
226 .map(|link| link.target)
227 .or_else(|| {
228 shown_urls(visible)
229 .into_iter()
230 .find(|url| url.contains(&at))
231 .map(|url| visible.text()[url].to_owned())
232 })
233 }
234
235 /// What the Link dialog links, as OneNote 2010 picks it: the selection within one
236 /// paragraph, else the link or word at the caret, else nothing at the caret. With the
237 /// text it shows and the address it holds.
238 fn link_range(&self) -> Result<(usize, Range<u32>, String, String), EditError> {
239 let [anchor, focus] = self.active_outline().selection.positions;
240 let (start, end) = (anchor.min(focus), anchor.max(focus));
241 if start.paragraph != end.paragraph {
242 return Err(EditError::UnsupportedContent);
243 }
244 let text = self
245 .active_outline()
246 .document
247 .paragraph(start.paragraph)
248 .ok_or(EditError::InvalidRange)?;
249 let shown = |range: Range<u32>| -> Result<String, EditError> {
250 Ok(text.slice(range)?.project()?.text().text().to_owned())
251 };
252 if start != end {
253 let mut range = start.offset..end.offset;
254 // Links the selection touches are relinked whole.
255 for link in links(text, start.paragraph) {
256 let whole = link.range();
257 if whole.start < range.end && range.start < whole.end {
258 range = range.start.min(whole.start)..range.end.max(whole.end);
259 }
260 }
261 let address = self
262 .link_at(start)
263 .filter(|link| link.range().start <= range.start && range.end <= link.label.end)
264 .map(|link| link.target)
265 .unwrap_or_default();
266 return Ok((start.paragraph, range.clone(), shown(range)?, address));
267 }
268 if let Some(link) = self.link_at(start) {
269 let label = shown(link.label.clone())?;
270 return Ok((start.paragraph, link.range(), label, link.target));
271 }
272 // The word at the caret, within plain visible text.
273 let byte = text.byte_offset(start.offset)?;
274 let plain = |at: usize, c: char| {
275 !c.is_whitespace()
276 && text
277 .utf16_offset(at)
278 .ok()
279 .and_then(|at| format_after(text, at))
280 .is_some_and(|format| {
281 ![format.hidden, format.hyperlink, format.math].contains(&Some(true))
282 })
283 };
284 let from = text.text()[..byte]
285 .char_indices()
286 .rev()
287 .take_while(|&(at, c)| plain(at, c))
288 .last()
289 .map_or(byte, |(at, _)| at);
290 let to = text.text()[byte..]
291 .char_indices()
292 .take_while(|&(at, c)| plain(byte + at, c))
293 .last()
294 .map_or(byte, |(at, c)| byte + at + c.len_utf8());
295 let range = text.utf16_offset(from)?..text.utf16_offset(to)?;
296 Ok((start.paragraph, range.clone(), shown(range)?, String::new()))
297 }
298
299 /// The text and address the Link dialog opens with.
300 pub fn link_prefill(&self) -> (String, String) {
301 self.link_range()
302 .map(|(_, _, text, address)| (text, address))
303 .unwrap_or_default()
304 }
305
306 /// OK in the Link dialog: what [`Self::link_prefill`] picked becomes `text`, or the
307 /// address where `text` is empty, linked to `address`, and the caret follows it.
308 pub fn set_link(
309 &mut self,
310 engine: &mut TextEngine,
311 text: &str,
312 address: &str,
313 ) -> Result<(), EditorError> {
314 if address.trim().is_empty() {
315 return Ok(());
316 }
317 let (paragraph, range, _, _) = self.link_range()?;
318 let current = self
319 .active_outline()
320 .document
321 .paragraph(paragraph)
322 .ok_or(EditError::InvalidRange)?;
323 let label: String = if text.is_empty() {
324 address.trim()
325 } else {
326 text
327 }
328 .chars()
329 .map(|c| if c.is_control() { ' ' } else { c })
330 .collect();
331 let mut base = match format_after(current, range.start).filter(|_| !range.is_empty()) {
332 Some(format) => format.clone(),
333 None => self.typing_format(TextPosition {
334 paragraph,
335 offset: range.start,
336 })?,
337 };
338 unlinked(&mut base);
339 base.hidden = Some(false);
340 let mut code = base.clone();
341 code.hidden = Some(true);
342 code.hyperlink = Some(true);
343 code.hyperlink_label = Some(true);
344 let mut shown = base;
345 shown.hyperlink = Some(true);
346 shown.hyperlink_label = Some(true);
347 let replacement = Paragraph::from_runs([(field_code(address), code), (label, shown)]);
348 let length = replacement.utf16_offset(replacement.text().len())?;
349 let mut updated = current.clone();
350 updated.apply(onestore::page::text::Edit {
351 range: range.clone(),
352 replacement,
353 })?;
354 let caret = TextPosition {
355 paragraph,
356 offset: range.start + length,
357 };
358 self.rewrite(engine, paragraph, updated, [caret; 2].into())
359 }
360
361 /// Remove Link: the link at the caret keeps its label as plain text.
362 pub fn remove_link(&mut self, engine: &mut TextEngine) -> Result<bool, EditorError> {
363 let [anchor, focus] = self.active_outline().selection.positions;
364 let Some(link) = self.link_at(anchor.min(focus)) else {
365 return Ok(false);
366 };
367 let text = self
368 .active_outline()
369 .document
370 .paragraph(link.paragraph)
371 .ok_or(EditError::InvalidRange)?;
372 let start = text.byte_offset(link.label.start)?;
373 let end = text.byte_offset(link.label.end)?;
374 let mut updated = restyle(text, start..end, unlinked);
375 let mut label = link.label.clone();
376 if let Some(code) = &link.code {
377 updated.apply(onestore::page::text::Edit {
378 range: code.clone(),
379 replacement: Paragraph::new(String::new(), Format::default()),
380 })?;
381 label = label.start - code.len() as u32..label.end - code.len() as u32;
382 }
383 let at = |offset| TextPosition {
384 paragraph: link.paragraph,
385 offset,
386 };
387 self.rewrite(
388 engine,
389 link.paragraph,
390 updated,
391 [at(label.start), at(label.end)].into(),
392 )?;
393 Ok(true)
394 }
395
396 /// Select Link: the label of the link at the caret.
397 pub fn select_link(&mut self) -> Result<bool, EditError> {
398 let [anchor, focus] = self.active_outline().selection.positions;
399 let Some(link) = self.link_at(anchor.min(focus)) else {
400 return Ok(false);
401 };
402 let at = |offset| TextPosition {
403 paragraph: link.paragraph,
404 offset,
405 };
406 self.select([at(link.label.start), at(link.label.end)].into())?;
407 Ok(true)
408 }
409
410 /// Links the URL typed before `end` once a space or Enter ends it, as its own undo step.
411 pub(super) fn link_typed_url(
412 &mut self,
413 engine: &mut TextEngine,
414 end: TextPosition,
415 ) -> Result<(), EditorError> {
416 let Some(text) = self.active_outline().document.paragraph(end.paragraph) else {
417 return Ok(());
418 };
419 let byte = text.byte_offset(end.offset)?;
420 let from = text.text()[..byte]
421 .char_indices()
422 .rev()
423 .take_while(|&(_, c)| !c.is_whitespace())
424 .last()
425 .map_or(byte, |(at, _)| at);
426 let Some(url) = typed_url(&text.text()[from..byte]) else {
427 return Ok(());
428 };
429 let url = from + url.start..from + url.end;
430 // Only plain text links; a link, field code or equation there stays as it is.
431 let mut offset = text.utf16_offset(url.start)?;
432 for c in text.text()[url.clone()].chars() {
433 let format = format_after(text, offset).ok_or(EditError::InvalidRange)?;
434 if [format.hyperlink, format.hidden, format.math].contains(&Some(true)) {
435 return Ok(());
436 }
437 offset += c.len_utf16() as u32;
438 }
439 let updated = restyle(text, url, |format| format.hyperlink = Some(true));
440 let selection = self.active_outline().selection;
441 self.rewrite(engine, end.paragraph, updated, selection)
442 }
443
444 /// Moves a caret between a field code and its label to before the code, so what is
445 /// typed there stays outside the link.
446 pub(super) fn leave_link_code(&mut self) -> Result<(), EditError> {
447 let selection = self.active_outline().selection;
448 let [anchor, focus] = selection.positions;
449 if anchor != focus {
450 return Ok(());
451 }
452 let Some(link) = self.link_at(focus) else {
453 return Ok(());
454 };
455 if let Some(code) = link.code.filter(|code| code.end == focus.offset) {
456 let at = TextPosition {
457 paragraph: focus.paragraph,
458 offset: code.start,
459 };
460 self.active_outline_mut().selection = [at; 2].into();
461 }
462 Ok(())
463 }
464
465 /// Replaces the text of paragraph `paragraph` of the active outline as one undo step
466 /// ending at `selection`.
467 pub(super) fn rewrite(
468 &mut self,
469 engine: &mut TextEngine,
470 paragraph: usize,
471 text: Paragraph,
472 selection: Selection,
473 ) -> Result<(), EditorError> {
474 let document = &self.active_outline().document;
475 let at = TextPosition {
476 paragraph,
477 offset: 0,
478 };
479 let (container, range, [(id, _), _]) = selected(document, [at; 2].into())?;
480 let mut replacement = document.container(container)?[range.clone()].to_vec();
481 leaves_mut(&mut replacement, &mut |node| {
482 if node.id == id {
483 node.text_mut().unwrap().text = text.clone();
484 }
485 });
486 self.commit(
487 engine,
488 DocumentEdit {
489 columns: BTreeMap::new(),
490 container,
491 range,
492 replacement,
493 },
494 selection,
495 )
496 }
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502 use onestore::page::PageObject;
503
504 /// A paragraph's text as runs of (text, hyperlink, label, hidden), runs with the same
505 /// flags merged.
506 fn flags(text: &Paragraph) -> Vec<(String, bool, bool, bool)> {
507 let mut runs: Vec<(String, bool, bool, bool)> = Vec::new();
508 let mut start = 0;
509 for span in text.spans() {
510 let format = &span.format;
511 let run = (
512 text.text()[start..span.end].to_owned(),
513 format.hyperlink == Some(true),
514 format.hyperlink_label == Some(true),
515 format.hidden == Some(true),
516 );
517 start = span.end;
518 match runs.last_mut() {
519 Some(last) if (last.1, last.2, last.3) == (run.1, run.2, run.3) => {
520 last.0.push_str(&run.0);
521 }
522 _ => runs.push(run),
523 }
524 }
525 runs
526 }
527
528 fn native_paragraphs() -> Vec<Paragraph> {
529 let bytes = include_bytes!("../../../../corpus/link-edit/native-typed/notebook/links.one");
530 let store = onestore::Store::parse(bytes).unwrap();
531 let index = onestore::RevisionIndex::parse(&store).unwrap();
532 let document = onestore::document::Document::parse(&index).unwrap();
533 let (space, _) = document.pages().unwrap()[0];
534 let page = Page::from_space(&document, space).unwrap();
535 page.objects
536 .iter()
537 .filter_map(|object| match object {
538 PageObject::Outline(outline) if !outline.title => Some(outline),
539 _ => None,
540 })
541 .flat_map(|outline| &outline.paragraphs)
542 .filter_map(|paragraph| paragraph.text().map(|text| text.text.clone()))
543 .skip(1)
544 .collect()
545 }
546
547 /// Types `text`, each space on its own as a key does.
548 fn typed(editor: &mut CanvasEditor, engine: &mut TextEngine, text: &str) {
549 for (index, word) in text.split(' ').enumerate() {
550 if index > 0 {
551 editor.insert(engine, " ").unwrap();
552 }
553 if !word.is_empty() {
554 editor.insert(engine, word).unwrap();
555 }
556 }
557 }
558
559 fn at(paragraph: usize, offset: u32) -> Selection {
560 [TextPosition { paragraph, offset }; 2].into()
561 }
562
563 /// The editor replays what `tools/native_links.py` typed into OneNote 2010 and stores
564 /// the same links: typed URLs, the Link dialog on a word, on a selection and on nothing,
565 /// typing after a link, and Remove Link.
566 #[test]
567 fn link_editing_stores_what_onenote_stored() {
568 let native = native_paragraphs();
569 let mut engine = TextEngine::default();
570 let mut editor = CanvasEditor::new(
571 &mut engine,
572 TextDocument::new(vec![Paragraph::new(String::new(), Format::default())]).unwrap(),
573 400.0,
574 )
575 .unwrap();
576 let engine = &mut engine;
577 for url in [
578 "www.example.com",
579 "http://example.org/a?b=1",
580 "https://a.example/p.",
581 "(http://b.example/q)",
582 "ftp://c.example/f",
583 "mailto:me@example.com",
584 "me@example.com",
585 "example.com",
586 "onenote:#Page&section-id={x}",
587 "file://c:/windows",
588 "\\\\server\\share\\f",
589 "news:comp.lang",
590 "www.d.example,",
591 "HTTP://E.EXAMPLE/UP",
592 "https://f.example/a_b-c~d%20e#frag",
593 ] {
594 // OneNote capitalized the first letter of each sentence as it was typed.
595 let after = if url.ends_with('.') { "Z" } else { "z" };
596 typed(&mut editor, engine, &format!("T {url} {after}"));
597 editor.enter(engine, false).unwrap();
598 }
599 typed(&mut editor, engine, "See http://g.example/end");
600 editor.enter(engine, false).unwrap();
601 typed(&mut editor, engine, "Zplain words here");
602 let paragraph = editor.selection().positions[0].paragraph;
603 editor.select(at(paragraph, 13)).unwrap();
604 assert_eq!(editor.link_prefill(), ("here".into(), String::new()));
605 editor.set_link(engine, "here", "example.net/x").unwrap();
606 typed(&mut editor, engine, "Q");
607 editor.enter(engine, false).unwrap();
608 typed(&mut editor, engine, "Select me please");
609 let paragraph = editor.selection().positions[0].paragraph;
610 editor
611 .select(
612 [
613 TextPosition {
614 paragraph,
615 offset: 7,
616 },
617 TextPosition {
618 paragraph,
619 offset: 16,
620 },
621 ]
622 .into(),
623 )
624 .unwrap();
625 assert_eq!(editor.link_prefill(), ("me please".into(), String::new()));
626 editor
627 .set_link(engine, "me please", "https://example.com/selected")
628 .unwrap();
629 editor.enter(engine, false).unwrap();
630 assert_eq!(editor.link_prefill(), (String::new(), String::new()));
631 editor
632 .set_link(engine, "", "https://example.com/only")
633 .unwrap();
634 typed(&mut editor, engine, " tail");
635 editor.enter(engine, false).unwrap();
636 typed(&mut editor, engine, "Gone ftp://h.example/f z");
637 let paragraph = editor.selection().positions[0].paragraph;
638 editor.select(at(paragraph, 8)).unwrap();
639 assert!(editor.remove_link(engine).unwrap());
640 let written: Vec<_> = editor
641 .active_outline()
642 .document
643 .text_nodes()
644 .map(|node| flags(&node.text().unwrap().text))
645 .collect();
646 let expected: Vec<_> = native.iter().map(flags).collect();
647 assert_eq!(written, expected);
648 }
649
650 /// Every URL OneNote 2010 linked or left as typed in `corpus/link-edit/native-typed`.
651 #[test]
652 fn typed_urls_link_as_onenote_links_them() {
653 for (word, linked) in [
654 ("www.example.com", Some("www.example.com")),
655 ("http://example.org/a?b=1", Some("http://example.org/a?b=1")),
656 ("https://a.example/p.", Some("https://a.example/p")),
657 ("(http://b.example/q)", Some("http://b.example/q")),
658 ("ftp://c.example/f", Some("ftp://c.example/f")),
659 ("mailto:me@example.com", Some("mailto:me@example.com")),
660 ("me@example.com", None),
661 ("example.com", None),
662 (
663 "onenote:#Page&section-id={x}",
664 Some("onenote:#Page&section-id={x}"),
665 ),
666 ("file://c:/windows", Some("file://c:/windows")),
667 ("\\\\server\\share\\f", Some("\\\\server\\share\\f")),
668 ("news:comp.lang", Some("news:comp.lang")),
669 ("www.d.example,", Some("www.d.example")),
670 ("HTTP://E.EXAMPLE/UP", Some("HTTP://E.EXAMPLE/UP")),
671 (
672 "https://f.example/a_b-c~d%20e#frag",
673 Some("https://f.example/a_b-c~d%20e#frag"),
674 ),
675 ("wwWw.example.com", None),
676 ("http://", None),
677 ] {
678 assert_eq!(typed_url(word).map(|range| &word[range]), linked, "{word}");
679 }
680 }
681}
crates/canvas/src/editor/ops.rs+56
...@@ -596,6 +596,62 @@ mod tests {...@@ -596,6 +596,62 @@ mod tests {
596 assert_eq!((*target, range.clone()), (text(0), 1..4));596 assert_eq!((*target, range.clone()), (text(0), 1..4));
597 }597 }
598598
599 /// Alt+Shift+Up and Down swap a paragraph, children and all, with its sibling as moves,
600 /// keeping the caret on the moved text, and stop at the first and last sibling.
601 #[test]
602 fn moving_paragraphs_swaps_sibling_subtrees_as_move_ops() {
603 let mut engine = TextEngine::default();
604 let lines = ["A", "a", "B"].map(|line| Paragraph::new(line.into(), Format::default()));
605 let document = crate::document::TextDocument::new(lines.to_vec()).unwrap();
606 let mut editor = CanvasEditor::new(&mut engine, document, 400.0).unwrap();
607 editor
608 .select(
609 [TextPosition {
610 paragraph: 1,
611 offset: 0,
612 }; 2]
613 .into(),
614 )
615 .unwrap();
616 editor.indent(&mut engine, false).unwrap();
617 editor.take_ops().unwrap();
618 let texts = |editor: &CanvasEditor| {
619 editor.outlines()[0]
620 .document()
621 .nodes()
622 .iter()
623 .map(|node| (node.text().unwrap().text.text().to_owned(), node.level))
624 .collect::<Vec<_>>()
625 };
626 let caret = TextPosition {
627 paragraph: 2,
628 offset: 1,
629 };
630 editor.select([caret; 2].into()).unwrap();
631 assert!(!editor.move_paragraphs(&mut engine, false).unwrap());
632 assert!(editor.move_paragraphs(&mut engine, true).unwrap());
633 let moved = [("B".into(), 1), ("A".into(), 1), ("a".into(), 2)];
634 assert_eq!(texts(&editor), moved);
635 assert_eq!(
636 editor.selection().positions,
637 [TextPosition {
638 paragraph: 0,
639 offset: 1
640 }; 2]
641 );
642 let ops = editor.take_ops().unwrap();
643 assert!(
644 !ops.is_empty() && ops.iter().all(|op| matches!(op, PageOp::Move { .. })),
645 "{ops:?}"
646 );
647 assert!(!editor.move_paragraphs(&mut engine, true).unwrap());
648 assert!(editor.move_paragraphs(&mut engine, false).unwrap());
649 assert_eq!(texts(&editor)[2], ("B".into(), 1));
650 assert_eq!(editor.selection().positions[0].paragraph, 2);
651 editor.undo(&mut engine).unwrap();
652 assert_eq!(texts(&editor), moved);
653 }
654
599 /// A change from elsewhere reaches only what it changed: a refresh whose stored page is655 /// A change from elsewhere reaches only what it changed: a refresh whose stored page is
600 /// what the editor's ops left (though storage normalized it) changes nothing, history656 /// what the editor's ops left (though storage normalized it) changes nothing, history
601 /// included; one that changes an outline drops that outline's history and keeps the657 /// included; one that changes an outline drops that outline's history and keeps the
crates/canvas/src/editor/table.rs+10-1
...@@ -273,8 +273,17 @@ impl CanvasEditor {...@@ -273,8 +273,17 @@ impl CanvasEditor {
273 if self.active_outline().title {273 if self.active_outline().title {
274 return self.leave_title(engine);274 return self.leave_title(engine);
275 }275 }
276 let [anchor, focus] = self.active_outline().selection.positions;
277 if anchor == focus {
278 self.link_typed_url(engine, focus)?;
279 // Enter at a label's start splits before its field code.
280 self.leave_link_code()?;
281 if self.break_equation(engine)? {
282 return Ok(());
283 }
284 }
285 let [anchor, focus] = self.active_outline().selection.positions;
276 let outline = self.active_outline();286 let outline = self.active_outline();
277 let [anchor, focus] = outline.selection.positions;
278 let (cell, local, source) = outline287 let (cell, local, source) = outline
279 .document288 .document
280 .leaf(focus.paragraph)289 .leaf(focus.paragraph)
crates/canvas/src/gpu/mod.rs+15-1
...@@ -33,6 +33,7 @@ impl Viewport {...@@ -33,6 +33,7 @@ impl Viewport {
33 origin: self.origin,33 origin: self.origin,
34 clip: None,34 clip: None,
35 backdrop: None,35 backdrop: None,
36 motion: None,
36 primitives,37 primitives,
37 }38 }
38 }39 }
...@@ -151,6 +152,8 @@ const EXCLAMATION: &str = include_str!("../../assets/tags/exclamation.svg");...@@ -151,6 +152,8 @@ const EXCLAMATION: &str = include_str!("../../assets/tags/exclamation.svg");
151const RED_SQUARE: &str = include_str!("../../assets/tags/red-square.svg");152const RED_SQUARE: &str = include_str!("../../assets/tags/red-square.svg");
152const YELLOW_SQUARE: &str = include_str!("../../assets/tags/yellow-square.svg");153const YELLOW_SQUARE: &str = include_str!("../../assets/tags/yellow-square.svg");
153const BLUE_SQUARE: &str = include_str!("../../assets/tags/blue-square.svg");154const BLUE_SQUARE: &str = include_str!("../../assets/tags/blue-square.svg");
155const FLAG: &str = include_str!("../../assets/tags/flag.svg");
156const TAG: &str = include_str!("../../assets/tags/tag.svg");
154157
155/// The tag's artwork, drawn in order.158/// The tag's artwork, drawn in order.
156pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] {159pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] {
...@@ -168,6 +171,8 @@ pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] {...@@ -168,6 +171,8 @@ pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] {
168 TagIcon::RedSquare => &[RED_SQUARE],171 TagIcon::RedSquare => &[RED_SQUARE],
169 TagIcon::YellowSquare => &[YELLOW_SQUARE],172 TagIcon::YellowSquare => &[YELLOW_SQUARE],
170 TagIcon::BlueSquare => &[BLUE_SQUARE],173 TagIcon::BlueSquare => &[BLUE_SQUARE],
174 TagIcon::Flag => &[FLAG],
175 TagIcon::Other => &[TAG],
171 }176 }
172}177}
173178
...@@ -190,7 +195,13 @@ mod tests {...@@ -190,7 +195,13 @@ mod tests {
190 #[test]195 #[test]
191 fn page_colours_colour_the_paper() {196 fn page_colours_colour_the_paper() {
192 let teal = 0x00f2f9d4;197 let teal = 0x00f2f9d4;
193 assert_eq!(Paper::WHITE.colored(Some(teal)).color.map(|c| (c * 1000.0).round()), colorref(teal).map(|c| (c * 1000.0).round()));198 assert_eq!(
199 Paper::WHITE
200 .colored(Some(teal))
201 .color
202 .map(|c| (c * 1000.0).round()),
203 colorref(teal).map(|c| (c * 1000.0).round())
204 );
194 let dark = Paper {205 let dark = Paper {
195 color: draw::srgb(0x1f, 0x20, 0x22),206 color: draw::srgb(0x1f, 0x20, 0x22),
196 ink: draw::srgb(0xe6, 0xe6, 0xe6),207 ink: draw::srgb(0xe6, 0xe6, 0xe6),
...@@ -290,6 +301,8 @@ mod tests {...@@ -290,6 +301,8 @@ mod tests {
290 TagIcon::RedSquare,301 TagIcon::RedSquare,
291 TagIcon::YellowSquare,302 TagIcon::YellowSquare,
292 TagIcon::BlueSquare,303 TagIcon::BlueSquare,
304 TagIcon::Flag,
305 TagIcon::Other,
293 ];306 ];
294 let text = TextEngine::default()307 let text = TextEngine::default()
295 .layout(308 .layout(
...@@ -322,6 +335,7 @@ mod tests {...@@ -322,6 +335,7 @@ mod tests {
322 origin: [2.0 + 16.0 * index as f32, 2.0],335 origin: [2.0 + 16.0 * index as f32, 2.0],
323 size: crate::outline::ParagraphTag::SIZE,336 size: crate::outline::ParagraphTag::SIZE,
324 tint: [1.0; 4],337 tint: [1.0; 4],
338 palette: draw::Palette::default(),
325 })339 })
326 .collect();340 .collect();
327 primitives.push(Primitive::Text {341 primitives.push(Primitive::Text {
crates/canvas/src/gpu/page.rs+91-72
...@@ -838,7 +838,7 @@ impl crate::outline::OutlineLayout {...@@ -838,7 +838,7 @@ impl crate::outline::OutlineLayout {
838 paragraph.origin[1] - OVERHANG,838 paragraph.origin[1] - OVERHANG,
839 paragraph.origin[1] + paragraph.text.height() + OVERHANG,839 paragraph.origin[1] + paragraph.text.height() + OVERHANG,
840 ];840 ];
841 paragraph.math.is_some() || (end >= rows[0] && start <= rows[1])841 end >= rows[0] && start <= rows[1]
842 })842 })
843 }843 }
844844
...@@ -925,50 +925,50 @@ impl crate::outline::OutlineLayout {...@@ -925,50 +925,50 @@ impl crate::outline::OutlineLayout {
925 bottom + origin[1],925 bottom + origin[1],
926 ]926 ]
927 });927 });
928 match &paragraph.math {928 primitives.push(Primitive::Text {
929 Some(math) => {929 clip,
930 for item in &math.items {930 text: &paragraph.text,
931 primitives.push(match item {931 origin: [x, y],
932 crate::math::MathItem::Text { layout, origin } => Primitive::Text {932 ink,
933 clip,933 });
934 text: layout,934 for (index, [left, baseline]) in paragraph.text.spaces() {
935 origin: [x + origin[0], y + origin[1]],935 let math = &paragraph.math[index];
936 ink,936 let [x, y] = [x + left, y + baseline - math.baseline];
937 },937 for item in &math.items {
938 // As pen strokes, so hairline rules keep a device pixel.938 primitives.push(match item {
939 crate::math::MathItem::Rule([x0, y0, x1, y1]) => {939 crate::math::MathItem::Text { layout, origin } => Primitive::Text {
940 let width = (x1 - x0).min(y1 - y0);940 clip,
941 let [from, to] = if x1 - x0 >= y1 - y0 {941 text: layout,
942 let middle = (y0 + y1) / 2.0;942 origin: [x + origin[0], y + origin[1]],
943 [[x0 + width / 2.0, middle], [x1 - width / 2.0, middle]]943 ink,
944 } else {944 },
945 let middle = (x0 + x1) / 2.0;945 // As pen strokes, so hairline rules keep a device pixel.
946 [[middle, y0 + width / 2.0], [middle, y1 - width / 2.0]]946 crate::math::MathItem::Rule([x0, y0, x1, y1]) => {
947 };947 let width = (x1 - x0).min(y1 - y0);
948 Primitive::Segment {948 let [from, to] = if x1 - x0 >= y1 - y0 {
949 from: [x + from[0], y + from[1]],949 let middle = (y0 + y1) / 2.0;
950 to: [x + to[0], y + to[1]],950 [[x0 + width / 2.0, middle], [x1 - width / 2.0, middle]]
951 width,951 } else {
952 round: false,952 let middle = (x0 + x1) / 2.0;
953 color: math.color.map_or(ink, colorref),953 [[middle, y0 + width / 2.0], [middle, y1 - width / 2.0]]
954 }954 };
955 }955 Primitive::Segment {
956 crate::math::MathItem::Stroke { from, to, width } => Primitive::Segment {
957 from: [x + from[0], y + from[1]],956 from: [x + from[0], y + from[1]],
958 to: [x + to[0], y + to[1]],957 to: [x + to[0], y + to[1]],
959 width: *width,958 width,
960 round: true,959 round: false,
961 color: math.color.map_or(ink, colorref),960 color: math.color.map_or(ink, colorref),
962 },961 }
963 });962 }
964 }963 crate::math::MathItem::Stroke { from, to, width } => Primitive::Segment {
964 from: [x + from[0], y + from[1]],
965 to: [x + to[0], y + to[1]],
966 width: *width,
967 round: true,
968 color: math.color.map_or(ink, colorref),
969 },
970 });
965 }971 }
966 None => primitives.push(Primitive::Text {
967 clip,
968 text: &paragraph.text,
969 origin: [x, y],
970 ink,
971 }),
972 }972 }
973 for (layout, marker) in &paragraph.markers {973 for (layout, marker) in &paragraph.markers {
974 primitives.push(Primitive::Text {974 primitives.push(Primitive::Text {
...@@ -987,6 +987,7 @@ impl crate::outline::OutlineLayout {...@@ -987,6 +987,7 @@ impl crate::outline::OutlineLayout {
987 ],987 ],
988 size: tag.size,988 size: tag.size,
989 tint: [1.0, 1.0, 1.0, if tag.disabled { 0.45 } else { 1.0 }],989 tint: [1.0, 1.0, 1.0, if tag.disabled { 0.45 } else { 1.0 }],
990 palette: draw::Palette::default(),
990 });991 });
991 }992 }
992 }993 }
...@@ -1621,15 +1622,15 @@ mod tests {...@@ -1621,15 +1622,15 @@ mod tests {
1621 }1622 }
16221623
1623 #[test]1624 #[test]
1624 fn unsupported_tags_and_titles_leave_supported_outlines_editable() {1625 fn unsupported_outlines_and_titles_leave_supported_outlines_editable() {
1625 use crate::document::TextDocument;1626 use crate::document::TextDocument;
1626 use onestore::document::{Kind, Tag};1627 use onestore::document::{Kind, Tag};
1627 use onestore::page::{Definition, Outline, Title};1628 use onestore::page::{Definition, Outline, Title};
1628 for title in [false, true] {1629 for title in [false, true] {
1629 let definition = ExGuid {1630 let [tag, list] = [9, 10].map(|byte| ExGuid {
1630 guid: [9; 16],1631 guid: [byte; 16],
1631 n: 1,1632 n: 1,
1632 };1633 });
1633 let make_outline = |text: &str| {1634 let make_outline = |text: &str| {
1634 let document =1635 let document =
1635 TextDocument::new(vec![Paragraph::new(text.into(), Default::default())])1636 TextDocument::new(vec![Paragraph::new(text.into(), Default::default())])
...@@ -1650,18 +1651,8 @@ mod tests {...@@ -1650,18 +1651,8 @@ mod tests {
1650 unsupported: Vec::new(),1651 unsupported: Vec::new(),
1651 }1652 }
1652 };1653 };
1653 let mut tagged = make_outline("Tagged");1654 let mut numbered = make_outline("Numbered");
1654 tagged.paragraphs[0].tags.push(Tag {1655 numbered.paragraphs[0].lists.push(list);
1655 definition: Some(definition),
1656 status: 1,
1657 action_type: None,
1658 created: None,
1659 completed: None,
1660 start: None,
1661 due: None,
1662 task_id: None,
1663 extra_set: 0,
1664 });
1665 let unsupported = if title {1656 let unsupported = if title {
1666 PageObject::Title(Title {1657 PageObject::Title(Title {
1667 date: None,1658 date: None,
...@@ -1671,31 +1662,59 @@ mod tests {...@@ -1671,31 +1662,59 @@ mod tests {
1671 y: Some(20.0),1662 y: Some(20.0),
1672 ..Default::default()1663 ..Default::default()
1673 },1664 },
1674 outlines: vec![tagged],1665 outlines: vec![numbered],
1675 })1666 })
1676 } else {1667 } else {
1677 PageObject::Outline(tagged)1668 PageObject::Outline(numbered)
1678 };1669 };
1670 // A tag symbol without artwork draws as a plain tag and stays editable.
1671 let mut editable = make_outline("Editable");
1672 editable.paragraphs[0].tags.push(Tag {
1673 definition: Some(tag),
1674 status: 1,
1675 action_type: None,
1676 created: None,
1677 completed: None,
1678 start: None,
1679 due: None,
1680 task_id: None,
1681 extra_set: 0,
1682 });
1679 let page = Page {1683 let page = Page {
1680 identity: None,1684 identity: None,
1681 created: None,1685 created: None,
1682 title: String::new(),1686 title: String::new(),
1683 margin_origin: [36.0, 14.0],1687 margin_origin: [36.0, 14.0],
1684 color: None,1688 color: None,
1685 objects: vec![unsupported, PageObject::Outline(make_outline("Editable"))],1689 objects: vec![unsupported, PageObject::Outline(editable)],
1686 definitions: BTreeMap::from([(1690 definitions: BTreeMap::from([
1687 definition,1691 (
1688 Definition {1692 tag,
1689 kind: Kind::TagDefinition {1693 Definition {
1690 shape: Some(999),1694 kind: Kind::TagDefinition {
1691 label: Some("Unknown icon".into()),1695 shape: Some(999),
1692 color: None,1696 label: Some("Unknown icon".into()),
1693 highlight: None,1697 color: None,
1694 action_type: None,1698 highlight: None,
1699 action_type: None,
1700 },
1701 format: Default::default(),
1695 },1702 },
1696 format: Default::default(),1703 ),
1697 },1704 (
1698 )]),1705 // A numbering sequence OneNote 2010 does not offer.
1706 list,
1707 Definition {
1708 kind: Kind::List {
1709 font: None,
1710 format: Some("\u{fffd}\u{63}.".into()),
1711 restart: None,
1712 bullet: None,
1713 },
1714 format: Default::default(),
1715 },
1716 ),
1717 ]),
1699 };1718 };
1700 let mut engine = TextEngine::default();1719 let mut engine = TextEngine::default();
1701 let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap();1720 let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap();
crates/canvas/src/gpu/page/background.rs+4-1
...@@ -551,7 +551,10 @@ pub fn template_art(...@@ -551,7 +551,10 @@ pub fn template_art(
551 [view.width() * 0.75, view.height() * 0.75]551 [view.width() * 0.75, view.height() * 0.75]
552 });552 });
553 let pixels = size.map(|side| (side * density).round().max(1.0) as u32);553 let pixels = size.map(|side| (side * density).round().max(1.0) as u32);
554 Some((rasterize(template.svg, dark(paper).then_some(paper), pixels), size))554 Some((
555 rasterize(template.svg, dark(paper).then_some(paper), pixels),
556 size,
557 ))
555}558}
556559
557/// Template `name`'s art as Snowbound stores it for a page: in the format and pixel size of560/// Template `name`'s art as Snowbound stores it for a page: in the format and pixel size of
crates/canvas/src/interaction/accessibility.rs+2
...@@ -608,6 +608,8 @@ fn runs_of(shaped: &ParagraphLayout, breaks: bool) -> Result<Vec<(Node, Vec<u32>...@@ -608,6 +608,8 @@ fn runs_of(shaped: &ParagraphLayout, breaks: bool) -> Result<Vec<(Node, Vec<u32>
608 TagIcon::RedSquare => "Project A",608 TagIcon::RedSquare => "Project A",
609 TagIcon::YellowSquare => "Project B",609 TagIcon::YellowSquare => "Project B",
610 TagIcon::BlueSquare => "Project C",610 TagIcon::BlueSquare => "Project C",
611 TagIcon::Flag => "Outlook task",
612 TagIcon::Other => "Tag",
611 };613 };
612 let label = if tag.label.is_empty() {614 let label = if tag.label.is_empty() {
613 fallback615 fallback
crates/canvas/src/interaction/mod.rs+221-126
...@@ -53,8 +53,20 @@ pub enum Request {...@@ -53,8 +53,20 @@ pub enum Request {
53 Copy(String),53 Copy(String),
54 /// Read the clipboard and hand its text to `PageView::commit_text`.54 /// Read the clipboard and hand its text to `PageView::commit_text`.
55 Paste,55 Paste,
56 /// Show the platform's character picker.56 /// Open a link's address, as a click on a link does.
57 CharacterPalette,57 OpenLink(String),
58}
59
60/// What a secondary press lands on, which its context menu offers commands for.
61#[derive(Debug, Default, PartialEq)]
62pub struct Context {
63 /// The address of the link at the caret.
64 pub link: Option<String>,
65 pub equation: bool,
66 /// Whether text is selected, which Cut and Copy take.
67 pub selected: bool,
68 /// The paragraph at the caret, which Copy Link to Paragraph names.
69 pub paragraph: Option<onestore::ExGuid>,
58}70}
5971
60/// What an event did: `changed` means the page or selection changed (the host saves,72/// What an event did: `changed` means the page or selection changed (the host saves,
...@@ -167,10 +179,7 @@ impl ObjectFocus {...@@ -167,10 +179,7 @@ impl ObjectFocus {
167}179}
168180
169fn read_only_shortcut(key: &Key, modifiers: Modifiers) -> bool {181fn read_only_shortcut(key: &Key, modifiers: Modifiers) -> bool {
170 key == &Key::Named(NamedKey::Escape)182 key == &Key::Named(NamedKey::Escape) || (modifiers.control && key == &Key::Named(NamedKey::Tab))
171 || (modifiers.control && key == &Key::Named(NamedKey::Tab))
172 || (modifiers.command
173 && matches!(key, Key::Character(value) if matches!(value.as_str(), "+" | "=" | "-" | "0") || (modifiers.shift && value.eq_ignore_ascii_case("n"))))
174}183}
175184
176/// Where a page was left: its scroll and the focused outline's selection.185/// Where a page was left: its scroll and the focused outline's selection.
...@@ -750,6 +759,11 @@ impl PageView {...@@ -750,6 +759,11 @@ impl PageView {
750 (Some(Drag::Resize { .. }), _) | (None, Some(Hit::Resize { .. })) => Cursor::EwResize,759 (Some(Drag::Resize { .. }), _) | (None, Some(Hit::Resize { .. })) => Cursor::EwResize,
751 (Some(Drag::Outline { .. }), _) | (None, Some(Hit::Handle { .. })) => Cursor::Move,760 (Some(Drag::Outline { .. }), _) | (None, Some(Hit::Handle { .. })) => Cursor::Move,
752 (None, Some(Hit::Date(_))) => Cursor::Pointer,761 (None, Some(Hit::Date(_))) => Cursor::Pointer,
762 (None, Some(Hit::Text { id, point }))
763 if self.editor.link_under(id, point).is_some() =>
764 {
765 Cursor::Pointer
766 }
753 (None, Some(Hit::ReadOnly(_) | Hit::Check { .. })) => Cursor::Default,767 (None, Some(Hit::ReadOnly(_) | Hit::Check { .. })) => Cursor::Default,
754 _ => Cursor::Text,768 _ => Cursor::Text,
755 }769 }
...@@ -902,6 +916,17 @@ impl PageView {...@@ -902,6 +916,17 @@ impl PageView {
902 grab,916 grab,
903 });917 });
904 }918 }
919 Some(Hit::Text { id, point })
920 if unit == SelectionUnit::Grapheme
921 && !self.modifiers.shift
922 && let Some(address) = self.editor.link_under(id, point) =>
923 {
924 self.drag = None;
925 return Ok(Response {
926 request: Some(Request::OpenLink(address)),
927 ..Response::default()
928 });
929 }
905 Some(Hit::Text { id, point }) => {930 Some(Hit::Text { id, point }) => {
906 let extend = self.modifiers.shift931 let extend = self.modifiers.shift
907 && self.object_focus.is_none()932 && self.object_focus.is_none()
...@@ -1050,6 +1075,118 @@ impl PageView {...@@ -1050,6 +1075,118 @@ impl PageView {
1050 self.edited()1075 self.edited()
1051 }1076 }
10521077
1078 /// Copy, or Cut with `cut`: the selected text goes to the clipboard.
1079 pub fn copy(&mut self, cut: bool) -> Result<Response> {
1080 let [anchor, focus] = self.editor.selection().positions;
1081 let selected = self
1082 .editor
1083 .active_outline()
1084 .document()
1085 .slice(anchor.min(focus)..anchor.max(focus))?;
1086 let text = selected
1087 .iter()
1088 .map(|paragraph| {
1089 paragraph
1090 .project()
1091 .map(|projection| projection.text().text().to_owned())
1092 })
1093 .collect::<std::result::Result<Vec<_>, _>>()?
1094 .join("\n");
1095 if text.is_empty() {
1096 return Ok(Response::default());
1097 }
1098 if cut {
1099 self.editor.insert(&mut self.engine, "")?;
1100 }
1101 Ok(Response {
1102 request: Some(Request::Copy(text)),
1103 ..self.edited()?
1104 })
1105 }
1106
1107 /// A secondary press at the pointer: text there takes the caret unless it lies in the
1108 /// selection, as OneNote's context menu acts where it opens. `None` off text.
1109 pub fn context(&mut self) -> Result<Option<(Response, Context)>> {
1110 let point = self.viewport.document_point(self.pointer);
1111 let Some(Hit::Text { id, point }) = self.hit_test(point) else {
1112 return Ok(None);
1113 };
1114 if self.object_focus.is_some() || !self.accepts_text() {
1115 return Ok(None);
1116 }
1117 let outline = self.editor.active_outline().id;
1118 let [anchor, focus] = self.editor.selection().positions;
1119 self.editor.focus_outline(id)?;
1120 let at = self
1121 .editor
1122 .selection_at(point[0], point[1], SelectionUnit::Grapheme)?;
1123 let inside = outline == id
1124 && anchor.min(focus) <= at.positions[0]
1125 && at.positions[0] <= anchor.max(focus);
1126 if !inside {
1127 self.editor.select(at)?;
1128 }
1129 let [anchor, focus] = self.editor.selection().positions;
1130 let context = Context {
1131 link: self
1132 .editor
1133 .link_at(anchor.min(focus))
1134 .map(|link| link.target),
1135 equation: self.editor.in_equation(),
1136 selected: anchor != focus,
1137 paragraph: self
1138 .editor
1139 .active_outline()
1140 .document()
1141 .leaf(anchor.min(focus).paragraph)
1142 .map(|(_, _, node)| node.id),
1143 };
1144 Ok(Some((self.changed()?, context)))
1145 }
1146
1147 /// The text and address the Link dialog opens with for the selection.
1148 pub fn link_prefill(&self) -> (String, String) {
1149 self.editor.link_prefill()
1150 }
1151
1152 /// OK in the Link dialog: links what [`Self::link_prefill`] picked, shown as `text`.
1153 pub fn set_link(&mut self, text: &str, address: &str) -> Result<Response> {
1154 if !self.accepts_text() {
1155 return Ok(Response::default());
1156 }
1157 self.editor.set_link(&mut self.engine, text, address)?;
1158 self.edited()
1159 }
1160
1161 /// Remove Link, or Select Link with `select`, on the link at the caret.
1162 pub fn unlink(&mut self, select: bool) -> Result<Response> {
1163 if select {
1164 self.editor.select_link()?;
1165 } else {
1166 self.editor.remove_link(&mut self.engine)?;
1167 }
1168 self.edited()
1169 }
1170
1171 /// Alt+= and the toolbar's Equation: see [`CanvasEditor::insert_equation`].
1172 pub fn insert_equation(&mut self) -> Result<Response> {
1173 if !self.accepts_text() || self.editor.marked_range().is_some() {
1174 return Ok(Response::default());
1175 }
1176 self.editor.insert_equation(&mut self.engine)?;
1177 self.edited()
1178 }
1179
1180 /// Professional, or Linear with `linear`, on the equation at the caret.
1181 pub fn switch_equation(&mut self, linear: bool) -> Result<Response> {
1182 if linear {
1183 self.editor.linear_equation(&mut self.engine)?;
1184 } else {
1185 self.editor.build_equation(&mut self.engine)?;
1186 }
1187 self.edited()
1188 }
1189
1053 /// Undoes the last edit, or redoes the last undone one.1190 /// Undoes the last edit, or redoes the last undone one.
1054 pub fn undo(&mut self, redo: bool) -> Result<Response> {1191 pub fn undo(&mut self, redo: bool) -> Result<Response> {
1055 if redo {1192 if redo {
...@@ -1065,6 +1202,12 @@ impl PageView {...@@ -1065,6 +1202,12 @@ impl PageView {
1065 self.edited()1202 self.edited()
1066 }1203 }
10671204
1205 /// Select All, widening the selection a unit at a time as OneNote 2010's Ctrl+A does.
1206 pub fn widen_selection(&mut self) -> Result<Response> {
1207 self.editor.widen_selection()?;
1208 self.edited()
1209 }
1210
1068 /// The date or time chosen after `Request::EditDate`.1211 /// The date or time chosen after `Request::EditDate`.
1069 pub fn change_date(&mut self, timestamp: u64, text: [String; 2]) -> Result<Response> {1212 pub fn change_date(&mut self, timestamp: u64, text: [String; 2]) -> Result<Response> {
1070 self.editor.change_date(&mut self.engine, timestamp, text)?;1213 self.editor.change_date(&mut self.engine, timestamp, text)?;
...@@ -1113,11 +1256,8 @@ impl PageView {...@@ -1113,11 +1256,8 @@ impl PageView {
1113 self.set_object_focus(None);1256 self.set_object_focus(None);
1114 return self.edited();1257 return self.edited();
1115 }1258 }
1116 if let Some(focus) = self.object_focus {1259 if self.object_focus.is_some() {
1117 let undo = matches!(focus, ObjectFocus::Image(_))1260 if !read_only_shortcut(key, self.modifiers) {
1118 && command
1119 && matches!(key, Key::Character(value) if value.eq_ignore_ascii_case("z"));
1120 if !undo && !read_only_shortcut(key, self.modifiers) {
1121 return Ok(Response::default());1261 return Ok(Response::default());
1122 }1262 }
1123 if key == &Key::Named(NamedKey::Escape) {1263 if key == &Key::Named(NamedKey::Escape) {
...@@ -1125,12 +1265,6 @@ impl PageView {...@@ -1125,12 +1265,6 @@ impl PageView {
1125 return self.edited();1265 return self.edited();
1126 }1266 }
1127 }1267 }
1128 if command && control && key == &Key::Named(NamedKey::Space) {
1129 return Ok(Response {
1130 request: Some(Request::CharacterPalette),
1131 ..Response::default()
1132 });
1133 }
1134 if matches!(1268 if matches!(
1135 self.drag,1269 self.drag,
1136 Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. })1270 Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. })
...@@ -1140,6 +1274,12 @@ impl PageView {...@@ -1140,6 +1274,12 @@ impl PageView {
1140 return self.changed();1274 return self.changed();
1141 }1275 }
1142 }1276 }
1277 if let Some(Command::MoveParagraphs { up }) = Command::from_key(key, self.modifiers)
1278 && self.editor.marked_range().is_none()
1279 {
1280 self.editor.move_paragraphs(&mut self.engine, up)?;
1281 return self.edited();
1282 }
1143 if command && option {1283 if command && option {
1144 let delta = match key {1284 let delta = match key {
1145 Key::Named(NamedKey::ArrowLeft) => Some([-1.0, 0.0]),1285 Key::Named(NamedKey::ArrowLeft) => Some([-1.0, 0.0]),
...@@ -1195,124 +1335,75 @@ impl PageView {...@@ -1195,124 +1335,75 @@ impl PageView {
1195 }1335 }
1196 return self.edited();1336 return self.edited();
1197 }1337 }
1198 let mut request = None;1338 // The host runs the shortcut modifier's chords from its command table.
1199 if command && let Key::Character(key) = key {1339 if command && let Key::Character(_) = key {
1200 match key.to_lowercase().as_str() {1340 return Ok(Response::default());
1201 "n" if shift => {1341 }
1202 let position = if let Some(focus) = self.object_focus {1342 let chord = Command::from_key(key, self.modifiers);
1203 let rect = self.object_rect(focus);1343 if let Some(Command::Move(movement)) = chord {
1204 [rect[2] + 24.0, rect[1]]1344 self.editor
1205 } else {1345 .move_selection(&mut self.engine, movement, shift)?;
1206 let bounds = self.editor.active_outline().bounds();1346 } else if let Some(Command::ScrollPage { up }) = chord {
1207 [bounds.x1 as f32 + 24.0, bounds.y0 as f32]1347 // AppKit keeps ten points of the last page in view.
1208 };1348 let page = (self.viewport.size[1] as f32 - 10.0 * self.display_scale).max(0.0);
1209 self.editor.place_caret(1349 self.viewport.origin[1] += if up { page } else { -page };
1210 &mut self.engine,1350 return self.moved();
1211 snap_to_grid(position, self.editor.margin_origin()),1351 } else if let Some(Command::MovePage { up }) = chord {
1212 DEFAULT_OUTLINE_WIDTH,1352 self.move_page(up, shift)?;
1213 )?;1353 } else if self.editor.marked_range().is_none() {
1214 self.set_object_focus(None);1354 match (chord, key) {
1355 (Some(Command::DeleteTo(movement)), _) => {
1356 self.editor.delete_to(&mut self.engine, movement)?;
1215 }1357 }
1216 "a" => self.editor.select_all()?,1358 (Some(Command::Delete { backward }), _) => {
1217 "z" => return self.undo(shift),1359 self.editor.delete(&mut self.engine, backward)?;
1218 "y" if edit::Platform::CURRENT == edit::Platform::Windows => {
1219 return self.undo(true);
1220 }1360 }
1221 "c" | "x" => {1361 (Some(Command::Kill), _) => {
1222 let [anchor, focus] = self.editor.selection().positions;1362 if !self.editor.delete_to(&mut self.engine, Movement::LineEnd)? {
1223 let selected = self1363 self.editor.delete(&mut self.engine, false)?;
1224 .editor
1225 .active_outline()
1226 .document()
1227 .slice(anchor.min(focus)..anchor.max(focus))?;
1228 let text = selected
1229 .iter()
1230 .map(|paragraph| {
1231 paragraph
1232 .project()
1233 .map(|projection| projection.text().text().to_owned())
1234 })
1235 .collect::<std::result::Result<Vec<_>, _>>()?
1236 .join("\n");
1237 if !text.is_empty() {
1238 if key.eq_ignore_ascii_case("x") {
1239 self.editor.insert(&mut self.engine, "")?;
1240 }
1241 request = Some(Request::Copy(text));
1242 }1364 }
1243 }1365 }
1244 "v" => request = Some(Request::Paste),1366 (_, Key::Named(end @ (NamedKey::Home | NamedKey::End))) => {
1245 "+" | "=" => {1367 let limits = self.scroll();
1246 self.zoom_about(1.1, self.pointer);1368 self.viewport.origin[1] = -if *end == NamedKey::Home {
1369 limits.min[1]
1370 } else {
1371 limits.max[1]
1372 };
1247 return self.moved();1373 return self.moved();
1248 }1374 }
1249 "-" => {1375 (_, Key::Named(NamedKey::Enter)) => {
1250 self.zoom_about(1.0 / 1.1, self.pointer);1376 // Enter inside a link follows it, as OneNote's does.
1251 return self.moved();1377 let [anchor, focus] = self.editor.selection().positions;
1378 if let Some(link) = self.editor.link_at(focus).filter(|link| {
1379 anchor == focus
1380 && !shift
1381 && link.label.start < focus.offset
1382 && focus.offset < link.label.end
1383 }) {
1384 return Ok(Response {
1385 request: Some(Request::OpenLink(link.target)),
1386 ..Response::default()
1387 });
1388 }
1389 self.editor.enter(&mut self.engine, shift)?;
1252 }1390 }
1253 "0" => {1391 (_, Key::Named(NamedKey::Tab)) => {
1254 self.zoom_about(1.0 / self.zoom(), self.pointer);1392 self.editor.tab(&mut self.engine, shift)?;
1255 return self.moved();
1256 }1393 }
1257 _ => return Ok(Response::default()),1394 _ if !command && !control => {
1258 }1395 if let Some(text) =
1259 } else {1396 text.filter(|text| !text.is_empty() && !text.chars().any(char::is_control))
1260 let chord = Command::from_key(key, self.modifiers);1397 {
1261 if let Some(Command::Move(movement)) = chord {1398 self.editor.insert(&mut self.engine, text)?;
1262 self.editor
1263 .move_selection(&mut self.engine, movement, shift)?;
1264 } else if let Some(Command::ScrollPage { up }) = chord {
1265 // AppKit keeps ten points of the last page in view.
1266 let page = self.viewport.size[1] as f32 - 10.0 * self.display_scale;
1267 self.viewport.origin[1] += if up { page } else { -page };
1268 return self.moved();
1269 } else if let Some(Command::MovePage { up }) = chord {
1270 self.move_page(up, shift)?;
1271 } else if self.editor.marked_range().is_none() {
1272 match (chord, key) {
1273 (Some(Command::DeleteTo(movement)), _) => {
1274 self.editor.delete_to(&mut self.engine, movement)?;
1275 }1399 }
1276 (Some(Command::Delete { backward }), _) => {
1277 self.editor.delete(&mut self.engine, backward)?;
1278 }
1279 (Some(Command::Kill), _) => {
1280 if !self.editor.delete_to(&mut self.engine, Movement::LineEnd)? {
1281 self.editor.delete(&mut self.engine, false)?;
1282 }
1283 }
1284 (_, Key::Named(end @ (NamedKey::Home | NamedKey::End))) => {
1285 let limits = self.scroll();
1286 self.viewport.origin[1] = -if *end == NamedKey::Home {
1287 limits.min[1]
1288 } else {
1289 limits.max[1]
1290 };
1291 return self.moved();
1292 }
1293 (_, Key::Named(NamedKey::Enter)) => {
1294 self.editor.enter(&mut self.engine, shift)?;
1295 }
1296 (_, Key::Named(NamedKey::Tab)) => {
1297 self.editor.tab(&mut self.engine, shift)?;
1298 }
1299 _ if !command && !control => {
1300 if let Some(text) = text
1301 .filter(|text| !text.is_empty() && !text.chars().any(char::is_control))
1302 {
1303 self.editor.insert(&mut self.engine, text)?;
1304 }
1305 }
1306 _ => {}
1307 }1400 }
1308 } else if key == &Key::Named(NamedKey::Escape) {1401 _ => {}
1309 self.editor.cancel_composition(&mut self.engine)?;
1310 }1402 }
1403 } else if key == &Key::Named(NamedKey::Escape) {
1404 self.editor.cancel_composition(&mut self.engine)?;
1311 }1405 }
1312 Ok(Response {1406 self.edited()
1313 request,
1314 ..self.edited()?
1315 })
1316 }1407 }
1317}1408}
13181409
...@@ -1526,7 +1617,9 @@ fn page_primitives<'a>(...@@ -1526,7 +1617,9 @@ fn page_primitives<'a>(
1526 Some(PointerFeedback::Move(id, origin)) if id == outline.id => origin,1617 Some(PointerFeedback::Move(id, origin)) if id == outline.id => origin,
1527 _ => outline.origin(),1618 _ => outline.origin(),
1528 };1619 };
1529 if (paint.chrome && object_focus.is_none() && outline.id == editor.active_outline().id)1620 // OneNote frames the title whether or not it is focused or hovered.
1621 if outline.title
1622 || (paint.chrome && object_focus.is_none() && outline.id == editor.active_outline().id)
1530 || matches!(preview, Some(PointerFeedback::Hover(id) | PointerFeedback::Move(id, _)) if id == outline.id)1623 || matches!(preview, Some(PointerFeedback::Hover(id) | PointerFeedback::Move(id, _)) if id == outline.id)
1531 || matches!(preview, Some(PointerFeedback::Resize(resized)) if resized.id == outline.id)1624 || matches!(preview, Some(PointerFeedback::Resize(resized)) if resized.id == outline.id)
1532 {1625 {
...@@ -1860,8 +1953,10 @@ fn append_outline_chrome(...@@ -1860,8 +1953,10 @@ fn append_outline_chrome(
1860 if outline.title {1953 if outline.title {
1861 primitives.push(Primitive::RoundedRect {1954 primitives.push(Primitive::RoundedRect {
1862 rect: [left, top, right, bottom],1955 rect: [left, top, right, bottom],
1863 radius: [6.0 * pixel, (bottom - top) * 0.5],1956 radius: [4.5, (bottom - top) * 0.5],
1864 stroke: Some(Stroke::Dashed(pixel)),1957 // A page pixel at 100% zoom, so it scales when zoomed in but stays a screen pixel
1958 // when zoomed out.
1959 stroke: Some(Stroke::Dashed(pixel.max(0.75))),
1865 color: paper.shade(crate::gpu::colorref(0x007f7f7f)),1960 color: paper.shade(crate::gpu::colorref(0x007f7f7f)),
1866 });1961 });
1867 return;1962 return;
crates/canvas/src/interaction/tests.rs+76-44
...@@ -171,7 +171,7 @@ fn title_chrome_selects_text_and_exposes_a_named_editable_field() {...@@ -171,7 +171,7 @@ fn title_chrome_selects_text_and_exposes_a_named_editable_field() {
171 })171 })
172 .collect::<Vec<_>>();172 .collect::<Vec<_>>();
173 assert_eq!(borders, [Some(Stroke::Dashed(1.0))]);173 assert_eq!(borders, [Some(Stroke::Dashed(1.0))]);
174 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])));174 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])));
175 let mut access = accessibility::Accessibility::default();175 let mut access = accessibility::Accessibility::default();
176 let update = access176 let update = access
177 .update(177 .update(
...@@ -192,6 +192,49 @@ fn title_chrome_selects_text_and_exposes_a_named_editable_field() {...@@ -192,6 +192,49 @@ fn title_chrome_selects_text_and_exposes_a_named_editable_field() {
192 && node.label() == Some("Page title")));192 && node.label() == Some("Page title")));
193}193}
194194
195#[test]
196fn title_frame_shows_unfocused_and_its_pen_scales_only_when_zoomed_in() {
197 let mut engine = TextEngine::default();
198 let mut outline = |text: &str, origin, title| {
199 let mut source = TextOutline::new(
200 &mut engine,
201 TextDocument::new(vec![Paragraph::new(text.into(), Format::default())]).unwrap(),
202 468.0,
203 origin,
204 )
205 .unwrap()
206 .snapshot();
207 source.title = title;
208 TextOutline::from_outline(&mut engine, &source, &Default::default()).unwrap()
209 };
210 let title = outline("Title", [36.0, 14.4], true);
211 let body = outline("Body", [36.0, 120.0], false);
212 let body_id = body.id;
213 let mut editor =
214 CanvasEditor::from_text_outlines(vec![title, body], Default::default(), None).unwrap();
215 editor.focus_outline(body_id).unwrap();
216 // OneNote 2010: a 4 px dash period at 100% and below, 4 px times the zoom above it.
217 for (zoom, pen) in [(0.5, 1.5), (1.0, 0.75), (2.0, 0.75), (3.0, 0.75)] {
218 let scale = zoom * 96.0 / 72.0;
219 let primitives =
220 page_primitives(&editor, None, None, None, paint(false, scale, 1.0)).unwrap();
221 let dashed = primitives
222 .iter()
223 .filter_map(|primitive| match primitive {
224 Primitive::RoundedRect {
225 stroke: Some(Stroke::Dashed(width)),
226 ..
227 } => Some(*width),
228 _ => None,
229 })
230 .collect::<Vec<_>>();
231 assert!(
232 matches!(dashed[..], [width] if (width - pen).abs() < 1e-5),
233 "zoom {zoom}: {dashed:?}"
234 );
235 }
236}
237
195#[test]238#[test]
196fn provisional_lines_share_one_drawn_hit_tested_and_accessible_outline() {239fn provisional_lines_share_one_drawn_hit_tested_and_accessible_outline() {
197 let mut engine = TextEngine::default();240 let mut engine = TextEngine::default();
...@@ -1339,45 +1382,9 @@ fn read_only_focus_accepts_navigation_without_edit_shortcuts() {...@@ -1339,45 +1382,9 @@ fn read_only_focus_accepts_navigation_without_edit_shortcuts() {
1339 ..Modifiers::default()1382 ..Modifiers::default()
1340 },1383 },
1341 ),1384 ),
1342 (
1343 Key::Character("N".into()),
1344 Modifiers {
1345 command: true,
1346 shift: true,
1347 ..Modifiers::default()
1348 },
1349 ),
1350 (
1351 Key::Character("+".into()),
1352 Modifiers {
1353 command: true,
1354 ..Modifiers::default()
1355 },
1356 ),
1357 (
1358 Key::Character("0".into()),
1359 Modifiers {
1360 command: true,
1361 ..Modifiers::default()
1362 },
1363 ),
1364 (
1365 Key::Character("-".into()),
1366 Modifiers {
1367 command: true,
1368 ..Modifiers::default()
1369 },
1370 ),
1371 ] {1385 ] {
1372 assert!(read_only_shortcut(&key, modifiers));1386 assert!(read_only_shortcut(&key, modifiers));
1373 }1387 }
1374 assert!(!read_only_shortcut(
1375 &Key::Character("n".into()),
1376 Modifiers {
1377 command: true,
1378 ..Modifiers::default()
1379 }
1380 ));
1381 assert!(!read_only_shortcut(1388 assert!(!read_only_shortcut(
1382 &Key::Named(NamedKey::Tab),1389 &Key::Named(NamedKey::Tab),
1383 Modifiers::default()1390 Modifiers::default()
...@@ -1611,7 +1618,8 @@ fn events_route_through_the_view_as_the_host_delivers_them() {...@@ -1611,7 +1618,8 @@ fn events_route_through_the_view_as_the_host_delivers_them() {
1611 .starts_with('X')1618 .starts_with('X')
1612 );1619 );
16131620
1614 // Command-C asks the host to copy; Command-V asks it to paste, then commits its text.1621 // Copy asks the host to copy; the host's paste commits its text. The shortcut
1622 // modifier's chords are the host's, so the page takes none of them as typing.
1615 let _ = view1623 let _ = view
1616 .modifiers_changed(Modifiers {1624 .modifiers_changed(Modifiers {
1617 shift: true,1625 shift: true,
...@@ -1619,6 +1627,10 @@ fn events_route_through_the_view_as_the_host_delivers_them() {...@@ -1619,6 +1627,10 @@ fn events_route_through_the_view_as_the_host_delivers_them() {
1619 })1627 })
1620 .unwrap();1628 .unwrap();
1621 let _ = view.key(&Key::Named(NamedKey::ArrowRight), None).unwrap();1629 let _ = view.key(&Key::Named(NamedKey::ArrowRight), None).unwrap();
1630 assert_eq!(
1631 view.copy(false).unwrap().request,
1632 Some(Request::Copy("B".into()))
1633 );
1622 let _ = view1634 let _ = view
1623 .modifiers_changed(Modifiers {1635 .modifiers_changed(Modifiers {
1624 command: true,1636 command: true,
...@@ -1626,18 +1638,38 @@ fn events_route_through_the_view_as_the_host_delivers_them() {...@@ -1626,18 +1638,38 @@ fn events_route_through_the_view_as_the_host_delivers_them() {
1626 })1638 })
1627 .unwrap();1639 .unwrap();
1628 assert_eq!(1640 assert_eq!(
1629 view.key(&Key::Character("c".into()), None).unwrap().request,1641 view.key(&Key::Character("v".into()), Some("v")).unwrap(),
1630 Some(Request::Copy("B".into()))1642 Response::default()
1631 );
1632 assert_eq!(
1633 view.key(&Key::Character("v".into()), None).unwrap().request,
1634 Some(Request::Paste)
1635 );1643 );
1636 assert!(view.commit_text("line\nbreak".into()).unwrap().changed);1644 assert!(view.commit_text("line\nbreak".into()).unwrap().changed);
1637 assert_eq!(1645 assert_eq!(
1638 view.editor.active_outline().document().paragraphs().count(),1646 view.editor.active_outline().document().paragraphs().count(),
1639 31647 3
1640 );1648 );
1649
1650 // Select All selects the caret's paragraph, then the whole outline.
1651 let caret = view.editor.selection().positions[1].paragraph;
1652 let select_all = |view: &mut PageView| {
1653 let _ = view.widen_selection().unwrap();
1654 view.editor
1655 .selection()
1656 .positions
1657 .map(|position| (position.paragraph, position.offset))
1658 };
1659 let last = view
1660 .editor
1661 .active_outline()
1662 .document()
1663 .paragraphs()
1664 .last()
1665 .unwrap();
1666 let whole = [(0, 0), (2, last.utf16_offset(last.text().len()).unwrap())];
1667 let paragraph = select_all(&mut view);
1668 assert!(
1669 paragraph[0] == (caret, 0) && paragraph != whole,
1670 "{paragraph:?}"
1671 );
1672 assert_eq!(select_all(&mut view), whole);
1641 let _ = view.modifiers_changed(Modifiers::default()).unwrap();1673 let _ = view.modifiers_changed(Modifiers::default()).unwrap();
16421674
1643 // A click on the picture selects it, stops text input, and Delete removes it.1675 // A click on the picture selects it, stops text input, and Delete removes it.
crates/canvas/src/layout.rs+65-2
...@@ -62,6 +62,18 @@ pub struct TextLayout {...@@ -62,6 +62,18 @@ pub struct TextLayout {
62 id: u64,62 id: u64,
63 pub(crate) shaped: Arc<Layout<TextBrush>>,63 pub(crate) shaped: Arc<Layout<TextBrush>>,
64 lines: Vec<LineBox>,64 lines: Vec<LineBox>,
65 /// Where each inline space lies: its index in the spaces laid out, its x, and its line.
66 spaces: Vec<(usize, f32, usize)>,
67}
68
69/// Room kept in a line for something drawn inline, such as an equation, before the text at
70/// byte `index`; the line grows to its ascent and descent.
71#[derive(Clone, Copy, Debug, PartialEq)]
72pub struct InlineSpace {
73 pub index: usize,
74 pub width: f32,
75 pub ascent: f32,
76 pub descent: f32,
65}77}
6678
67#[derive(Clone, Copy, Debug, PartialEq, Eq)]79#[derive(Clone, Copy, Debug, PartialEq, Eq)]
...@@ -255,6 +267,7 @@ impl TextEngine {...@@ -255,6 +267,7 @@ impl TextEngine {
255 &mut self,267 &mut self,
256 paragraph: &Paragraph,268 paragraph: &Paragraph,
257 width: f32,269 width: f32,
270 spaces: &[InlineSpace],
258 ) -> Result<Layout<TextBrush>, LayoutError> {271 ) -> Result<Layout<TextBrush>, LayoutError> {
259 if !width.is_finite() || width <= 0.0 {272 if !width.is_finite() || width <= 0.0 {
260 return Err(LayoutError::InvalidWidth);273 return Err(LayoutError::InvalidWidth);
...@@ -335,6 +348,16 @@ impl TextEngine {...@@ -335,6 +348,16 @@ impl TextEngine {
335 }348 }
336 start = span.end;349 start = span.end;
337 }350 }
351 for (id, space) in spaces.iter().enumerate() {
352 builder.push_inline_box(parley::InlineBox {
353 id: id as u64,
354 kind: parley::InlineBoxKind::InFlow,
355 index: space.index,
356 width: space.width,
357 height: space.ascent + space.descent,
358 baseline: Some(space.ascent),
359 });
360 }
338 let mut shaped = builder.build(text);361 let mut shaped = builder.build(text);
339 shaped.break_all_lines(Some(width));362 shaped.break_all_lines(Some(width));
340 let alignment = match paragraph.spans()[0].format.alignment {363 let alignment = match paragraph.spans()[0].format.alignment {
...@@ -362,6 +385,19 @@ impl TextEngine {...@@ -362,6 +385,19 @@ impl TextEngine {
362 f32::from(hhea.ascender().to_i16()) * scale,385 f32::from(hhea.ascender().to_i16()) * scale,
363 -f32::from(hhea.descender().to_i16()) * scale,386 -f32::from(hhea.descender().to_i16()) * scale,
364 )387 )
388 } else if let (Ok(head), Ok(os2)) = (font.head(), font.os2())
389 && head.units_per_em() != 0
390 && font
391 .table_data(skrifa::raw::types::Tag::new(b"MATH"))
392 .is_some()
393 {
394 // A math font's Windows extents reach its tallest operators; OneNote sets linear
395 // math in lines of text height.
396 let scale = run.font_size() / f32::from(head.units_per_em());
397 (
398 f32::from(os2.s_typo_ascender()) * scale,
399 -f32::from(os2.s_typo_descender()) * scale,
400 )
365 } else if let (Ok(head), Ok(os2)) = (font.head(), font.os2())401 } else if let (Ok(head), Ok(os2)) = (font.head(), font.os2())
366 && head.units_per_em() != 0402 && head.units_per_em() != 0
367 && (os2.us_win_ascent() != 0 || os2.us_win_descent() != 0)403 && (os2.us_win_ascent() != 0 || os2.us_win_descent() != 0)
...@@ -378,7 +414,18 @@ impl TextEngine {...@@ -378,7 +414,18 @@ impl TextEngine {
378 }414 }
379415
380 pub fn layout(&mut self, paragraph: &Paragraph, width: f32) -> Result<TextLayout, LayoutError> {416 pub fn layout(&mut self, paragraph: &Paragraph, width: f32) -> Result<TextLayout, LayoutError> {
381 let shaped = self.shape(paragraph, width)?;417 self.layout_with(paragraph, width, &[])
418 }
419
420 /// Lays out `paragraph` keeping `spaces` inline, for what draws in them.
421 pub fn layout_with(
422 &mut self,
423 paragraph: &Paragraph,
424 width: f32,
425 spaces: &[InlineSpace],
426 ) -> Result<TextLayout, LayoutError> {
427 let shaped = self.shape(paragraph, width, spaces)?;
428 let mut placed = Vec::new();
382 let text = paragraph.text();429 let text = paragraph.text();
383 let mut lines = Vec::with_capacity(shaped.len());430 let mut lines = Vec::with_capacity(shaped.len());
384 let mut top = 0.0_f64;431 let mut top = 0.0_f64;
...@@ -417,7 +464,7 @@ impl TextEngine {...@@ -417,7 +464,7 @@ impl TextEngine {
417 .unwrap_or_else(|| paragraph.spans().last().unwrap())464 .unwrap_or_else(|| paragraph.spans().last().unwrap())
418 .format;465 .format;
419 let sample =466 let sample =
420 self.shape(&Paragraph::new("Mg".into(), format.clone()), f32::MAX)?;467 self.shape(&Paragraph::new("Mg".into(), format.clone()), f32::MAX, &[])?;
421 sample.lines().flat_map(|line| line.runs()).try_fold(468 sample.lines().flat_map(|line| line.runs()).try_fold(
422 (0.0_f32, 0.0_f32),469 (0.0_f32, 0.0_f32),
423 |(a, d), run| {470 |(a, d), run| {
...@@ -434,6 +481,14 @@ impl TextEngine {...@@ -434,6 +481,14 @@ impl TextEngine {
434 ascent = ascent.max(a);481 ascent = ascent.max(a);
435 descent = descent.max(d);482 descent = descent.max(d);
436 }483 }
484 for item in line.items() {
485 if let PositionedLayoutItem::InlineBox(inline) = item {
486 let space = &spaces[inline.id as usize];
487 ascent = ascent.max(space.ascent);
488 descent = descent.max(space.descent);
489 placed.push((inline.id as usize, inline.x, lines.len()));
490 }
491 }
437 let height = ascent + descent;492 let height = ascent + descent;
438 if height <= 0.0 || !height.is_finite() {493 if height <= 0.0 || !height.is_finite() {
439 return Err(LayoutError::InvalidFontMetrics);494 return Err(LayoutError::InvalidFontMetrics);
...@@ -460,6 +515,7 @@ impl TextEngine {...@@ -460,6 +515,7 @@ impl TextEngine {
460 id,515 id,
461 shaped: Arc::new(shaped),516 shaped: Arc::new(shaped),
462 lines,517 lines,
518 spaces: placed,
463 })519 })
464 }520 }
465}521}
...@@ -491,6 +547,13 @@ impl TextLayout {...@@ -491,6 +547,13 @@ impl TextLayout {
491 Ok(())547 Ok(())
492 }548 }
493549
550 /// Each inline space laid out, by its index, with its x and its line's baseline.
551 pub fn spaces(&self) -> impl Iterator<Item = (usize, [f32; 2])> + '_ {
552 self.spaces
553 .iter()
554 .map(|&(id, x, line)| (id, [x, self.lines[line].baseline]))
555 }
556
494 pub fn lines(&self) -> impl Iterator<Item = (parley::Line<'_, TextBrush>, &LineBox)> {557 pub fn lines(&self) -> impl Iterator<Item = (parley::Line<'_, TextBrush>, &LineBox)> {
495 self.shaped.lines().zip(&self.lines)558 self.shaped.lines().zip(&self.lines)
496 }559 }
crates/canvas/src/math.rs+22-2
...@@ -590,6 +590,24 @@ fn ink(layout: &TextLayout) -> [f32; 2] {...@@ -590,6 +590,24 @@ fn ink(layout: &TextLayout) -> [f32; 2] {
590 extent.map(|v| if v.is_finite() { v } else { 0.0 })590 extent.map(|v| if v.is_finite() { v } else { 0.0 })
591}591}
592592
593/// The byte ranges of `paragraph`'s runs of math holding objects, which draw in two
594/// dimensions; math without objects is text.
595pub(crate) fn built(paragraph: &Paragraph) -> Vec<std::ops::Range<usize>> {
596 let mut zones: Vec<std::ops::Range<usize>> = Vec::new();
597 let mut start = 0;
598 for span in paragraph.spans() {
599 if span.format.math == Some(true) {
600 match zones.last_mut() {
601 Some(zone) if zone.end == start => zone.end = span.end,
602 _ => zones.push(start..span.end),
603 }
604 }
605 start = span.end;
606 }
607 zones.retain(|zone| paragraph.text()[zone.clone()].contains('\u{fdd0}'));
608 zones
609}
610
593/// Lays out an equation paragraph at the font size and colour of its first run.611/// Lays out an equation paragraph at the font size and colour of its first run.
594pub fn layout(engine: &mut TextEngine, paragraph: &Paragraph) -> Result<MathLayout, LayoutError> {612pub fn layout(engine: &mut TextEngine, paragraph: &Paragraph) -> Result<MathLayout, LayoutError> {
595 let nodes = Math::parse(paragraph).map_err(|_| LayoutError::UnsupportedContent)?;613 let nodes = Math::parse(paragraph).map_err(|_| LayoutError::UnsupportedContent)?;
...@@ -612,8 +630,10 @@ pub fn layout(engine: &mut TextEngine, paragraph: &Paragraph) -> Result<MathLayo...@@ -612,8 +630,10 @@ pub fn layout(engine: &mut TextEngine, paragraph: &Paragraph) -> Result<MathLayo
612 .next()630 .next()
613 .ok_or(LayoutError::InvalidFontMetrics)?;631 .ok_or(LayoutError::InvalidFontMetrics)?;
614 let mut body = Context { engine, color }.row(&nodes, size)?;632 let mut body = Context { engine, color }.row(&nodes, size)?;
615 body.ascent = body.ascent.max(line.baseline);633 // Boxes follow ink; lines with taller math keep a gap from the lines beside them.
616 body.descent = body.descent.max(line.height - line.baseline);634 let gap = 0.15 * size;
635 body.ascent = (body.ascent + gap).max(line.baseline);
636 body.descent = (body.descent + gap).max(line.height - line.baseline);
617 Ok(MathLayout {637 Ok(MathLayout {
618 size: [body.width, body.height()],638 size: [body.width, body.height()],
619 color,639 color,
crates/canvas/src/outline.rs+431-113
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1use crate::{1use crate::{
2 document::{DocumentEdit, edited_nodes, leaves},2 document::{DocumentEdit, edited_nodes, leaves},
3 layout::{LayoutError, TextEngine, TextLayout},3 layout::{InlineSpace, LayoutError, TextEngine, TextLayout},
4};4};
5use onestore::page::text::{Paragraph, TextProjection};5use onestore::page::text::{Paragraph, TextProjection};
6use onestore::page::{Definition, Outline, PageParagraph, ParagraphContent, Table, Title};6use onestore::page::{Definition, Outline, PageParagraph, ParagraphContent, Table, Title};
...@@ -11,6 +11,7 @@ use onestore::{...@@ -11,6 +11,7 @@ use onestore::{
11use std::{11use std::{
12 collections::{BTreeMap, BTreeSet},12 collections::{BTreeMap, BTreeSet},
13 ops::Range,13 ops::Range,
14 sync::Arc,
14};15};
1516
16pub(crate) const TITLE_WIDTH: f32 = 468.0;17pub(crate) const TITLE_WIDTH: f32 = 468.0;
...@@ -43,7 +44,7 @@ struct Block {...@@ -43,7 +44,7 @@ struct Block {
43 /// A table's vertical coordinates before its offset.44 /// A table's vertical coordinates before its offset.
44 rel: Vec<f32>,45 rel: Vec<f32>,
45 /// The number the next numbered sibling at the node's level continues from.46 /// The number the next numbered sibling at the node's level continues from.
46 count: Option<u32>,47 count: Option<Count>,
47}48}
4849
49/// A layout of root nodes `range` once an edit applies, and how the nodes after them move.50/// A layout of root nodes `range` once an edit applies, and how the nodes after them move.
...@@ -196,13 +197,13 @@ pub struct ParagraphLayout {...@@ -196,13 +197,13 @@ pub struct ParagraphLayout {
196 pub markers: Vec<(TextLayout, [f32; 2])>,197 pub markers: Vec<(TextLayout, [f32; 2])>,
197 /// A numbered paragraph's number, and whether its list restarts the count instead of198 /// A numbered paragraph's number, and whether its list restarts the count instead of
198 /// continuing from the previous sibling's.199 /// continuing from the previous sibling's.
199 pub(crate) number: Option<(u32, bool)>,200 pub(crate) number: Option<(Count, bool)>,
200 pub tags: Vec<ParagraphTag>,201 pub tags: Vec<ParagraphTag>,
201 /// Its siblings' group, whose list markers its tags clear: the parent paragraph, or the202 /// Its siblings' group, whose list markers its tags clear: the parent paragraph, or the
202 /// table cell holding a cell's top-level paragraph.203 /// table cell holding a cell's top-level paragraph.
203 pub(crate) parent: Option<ExGuid>,204 pub(crate) parent: Option<ExGuid>,
204 /// An equation draws in two dimensions in place of its linear text.205 /// The equations drawn in two dimensions in spaces of the text, in order.
205 pub math: Option<crate::math::MathLayout>,206 pub math: Vec<crate::math::MathLayout>,
206 /// A highlight of the whole paragraph (its own `highlight`), COLORREF: a band across its207 /// A highlight of the whole paragraph (its own `highlight`), COLORREF: a band across its
207 /// outline behind its lines, as OneNote marks a conflict page's conflicting changes.208 /// outline behind its lines, as OneNote marks a conflict page's conflicting changes.
208 pub band: Option<u32>,209 pub band: Option<u32>,
...@@ -210,7 +211,9 @@ pub struct ParagraphLayout {...@@ -210,7 +211,9 @@ pub struct ParagraphLayout {
210211
211#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]212#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
212pub enum TagIcon {213pub enum TagIcon {
213 CheckBox { checked: bool },214 CheckBox {
215 checked: bool,
216 },
214 Star,217 Star,
215 Question,218 Question,
216 Highlight,219 Highlight,
...@@ -222,6 +225,10 @@ pub enum TagIcon {...@@ -222,6 +225,10 @@ pub enum TagIcon {
222 RedSquare,225 RedSquare,
223 YellowSquare,226 YellowSquare,
224 BlueSquare,227 BlueSquare,
228 /// An Outlook task flag.
229 Flag,
230 /// A tag symbol without artwork of its own.
231 Other,
225}232}
226233
227#[derive(Clone, Debug, PartialEq)]234#[derive(Clone, Debug, PartialEq)]
...@@ -270,6 +277,8 @@ impl ParagraphTag {...@@ -270,6 +277,8 @@ impl ParagraphTag {
270277
271/// How much further than its list spacing OneNote 2010 sets a marker's advance from its text.278/// How much further than its list spacing OneNote 2010 sets a marker's advance from its text.
272const MARKER_OFFSET: f32 = 3.9;279const MARKER_OFFSET: f32 = 3.9;
280/// Points an equation's linear text lays out at, holding the caret without showing.
281const HELD: f32 = 0.01;
273282
274/// A one-run paragraph standing in for an object's caption, so it lays out through the same283/// A one-run paragraph standing in for an object's caption, so it lays out through the same
275/// shaping (and caching) as the outline's text.284/// shaping (and caching) as the outline's text.
...@@ -327,20 +336,38 @@ pub(crate) fn indentation(level: u32, indents: &[f32], width: f32) -> Result<f32...@@ -327,20 +336,38 @@ pub(crate) fn indentation(level: u32, indents: &[f32], width: f32) -> Result<f32
327 Ok(indent)336 Ok(indent)
328}337}
329338
330/// The number after a previous sibling's, or the first when the sibling is not numbered.339/// A numbered paragraph's number and its NumberListFormat.
331fn next(previous: Option<u32>) -> u32 {340#[derive(Clone, Debug, PartialEq)]
332 previous.map_or(1, |number| number.saturating_add(1))341pub(crate) struct Count {
342 pub(crate) number: u32,
343 format: Arc<str>,
344}
345
346/// The number after a previous sibling's in the same `format`, or the first. A number in
347/// another format starts again at 1 even where one in this format came before it; siblings
348/// without a number leave the count alone (`evidence/toolbar-17/restart.txt`).
349fn next(previous: Option<&Count>, format: &str) -> u32 {
350 previous
351 .filter(|count| &*count.format == format)
352 .map_or(1, |count| count.number.saturating_add(1))
333}353}
334354
335/// The number a paragraph laid out as `number` takes once it follows `previous`.355/// The number a paragraph laid out as `number` takes once it follows `previous`.
336fn follows(number: Option<(u32, bool)>, previous: Option<u32>) -> Option<u32> {356fn follows(number: Option<&(Count, bool)>, previous: Option<&Count>) -> Option<Count> {
337 number.map(|(number, restart)| if restart { number } else { next(previous) })357 number.map(|(count, restart)| Count {
358 number: if *restart {
359 count.number
360 } else {
361 next(previous, &count.format)
362 },
363 format: count.format.clone(),
364 })
338}365}
339366
340/// What the numbered sibling after `node`, numbered `number`, continues from: siblings without367/// What the numbered sibling after `node`, numbered `number`, continues from: siblings without
341/// a number leave the count alone, as do empty numbered ones after another number368/// a number leave the count alone, as do empty numbered ones after another number
342/// (`evidence/structural-edits/xml/c8-bs-1.xml`, `c8-enter-empty-1.xml`, `c2s-num-1.xml`).369/// (`evidence/structural-edits/xml/c8-bs-1.xml`, `c8-enter-empty-1.xml`, `c2s-num-1.xml`).
343fn tally(node: &PageParagraph, number: Option<u32>, previous: Option<u32>) -> Option<u32> {370fn tally(node: &PageParagraph, number: Option<Count>, previous: Option<Count>) -> Option<Count> {
344 number371 number
345 .filter(|_| {372 .filter(|_| {
346 previous.is_none() || node.text().is_some_and(|text| !text.text.text().is_empty())373 previous.is_none() || node.text().is_some_and(|text| !text.text.text().is_empty())
...@@ -350,7 +377,7 @@ fn tally(node: &PageParagraph, number: Option<u32>, previous: Option<u32>) -> Op...@@ -350,7 +377,7 @@ fn tally(node: &PageParagraph, number: Option<u32>, previous: Option<u32>) -> Op
350377
351/// Removes deeper paragraphs' entries from a flow's `(level, count)` stack of the latest378/// Removes deeper paragraphs' entries from a flow's `(level, count)` stack of the latest
352/// paragraph at each level, and the previous sibling's at `level`, returning that one's count.379/// paragraph at each level, and the previous sibling's at `level`, returning that one's count.
353fn sibling(siblings: &mut Vec<(u32, Option<u32>)>, level: u32) -> Option<u32> {380fn sibling(siblings: &mut Vec<(u32, Option<Count>)>, level: u32) -> Option<Count> {
354 while siblings.last().is_some_and(|(deeper, _)| *deeper > level) {381 while siblings.last().is_some_and(|(deeper, _)| *deeper > level) {
355 siblings.pop();382 siblings.pop();
356 }383 }
...@@ -360,8 +387,56 @@ fn sibling(siblings: &mut Vec<(u32, Option<u32>)>, level: u32) -> Option<u32> {...@@ -360,8 +387,56 @@ fn sibling(siblings: &mut Vec<(u32, Option<u32>)>, level: u32) -> Option<u32> {
360 }387 }
361}388}
362389
390/// `text` in `font` as a Unicode font shows it. Windows' Symbol font is symbol-encoded, so
391/// its bytes map through the Adobe Symbol encoding (a bullet is `U+00B7` there); Wingdings'
392/// map into the private use area their symbol cmaps cover, which also keeps a shaper from
393/// dropping `U+00AD` as a soft hyphen.
394pub fn symbol_text(font: &str, text: &str) -> String {
395 /// The Adobe Symbol encoding from 0x20 and from 0xA0, after Unicode's `SYMBOL.TXT`.
396 const SYMBOL: [&str; 2] = [
397 " !∀#∃%&∋()∗+,−./0123456789:;<=>?≅ΑΒΧΔΕΦΓΗΙϑΚΛΜΝΟΠΘΡΣΤΥςΩΞΨΖ[∴]⊥_‾αβχδεφγηιϕκλμνοπθρστυϖωξψζ{|}∼",
398 "€ϒ′≤⁄∞ƒ♣♦♥♠↔←↑→↓°±″≥×∝∂•÷≠≡≈…⏐⎯↵ℵℑℜ℘⊗⊕∅∩∪⊃⊇⊄⊂⊆∈∉∠∇®©™∏√⋅¬∧∨⇔⇐⇑⇒⇓◊〈®©™∑⎛⎜⎝⎡⎢⎣⎧⎨⎩⎪\u{f8ff}〉∫⌠⎮⌡⎞⎟⎠⎤⎥⎦⎫⎬⎭",
399 ];
400 if font.eq_ignore_ascii_case("Symbol") {
401 text.chars()
402 .map(|c| {
403 let (range, from) = match u32::from(c) {
404 code @ 0x20..0x7f => (SYMBOL[0], code - 0x20),
405 code @ 0xa0..0xff => (SYMBOL[1], code - 0xa0),
406 _ => return c,
407 };
408 range.chars().nth(from as usize).unwrap_or(c)
409 })
410 .collect()
411 } else if font.to_ascii_lowercase().starts_with("wingdings") {
412 text.chars()
413 .map(|c| match u32::from(c) {
414 code @ 0x20..0x100 => char::from_u32(0xf000 + code).unwrap_or(c),
415 _ => c,
416 })
417 .collect()
418 } else {
419 text.to_owned()
420 }
421}
422
423/// The marker NumberListFormat `format` shows for `number`.
424pub fn numbered(format: &str, number: u32) -> Result<String, LayoutError> {
425 let (prefix, rest) = format
426 .split_once('\u{fffd}')
427 .ok_or(LayoutError::InvalidList)?;
428 let mut rest = rest.chars();
429 Ok(format!(
430 "{prefix}{}{}",
431 numeral(rest.next(), number)?,
432 rest.as_str()
433 ))
434}
435
363/// `number` in a list numbering sequence: 0 arabic, 1 and 2 upper and lower roman, 3 and 4436/// `number` in a list numbering sequence: 0 arabic, 1 and 2 upper and lower roman, 3 and 4
364/// upper and lower letters, as OneNote 2010's `numberSequence`.437/// upper and lower letters, 5 ordinal, 6 and 7 cardinal and ordinal words, 22 arabic of two
438/// digits at least, as OneNote 2010's `numberSequence` spells them in English
439/// (`evidence/toolbar-17/sequences.txt`).
365pub(crate) fn numeral(sequence: Option<char>, number: u32) -> Result<String, LayoutError> {440pub(crate) fn numeral(sequence: Option<char>, number: u32) -> Result<String, LayoutError> {
366 let roman = |number: u32| {441 let roman = |number: u32| {
367 const DIGITS: [(u32, &str); 13] = [442 const DIGITS: [(u32, &str); 13] = [
...@@ -379,7 +454,9 @@ pub(crate) fn numeral(sequence: Option<char>, number: u32) -> Result<String, Lay...@@ -379,7 +454,9 @@ pub(crate) fn numeral(sequence: Option<char>, number: u32) -> Result<String, Lay
379 (4, "IV"),454 (4, "IV"),
380 (1, "I"),455 (1, "I"),
381 ];456 ];
382 if !(1..4000).contains(&number) {457 // Past 3999 the thousands run on as Ms; a million, far past what was observed,
458 // would lay out a thousand of them.
459 if !(1..1_000_000).contains(&number) {
383 return Err(LayoutError::UnsupportedContent);460 return Err(LayoutError::UnsupportedContent);
384 }461 }
385 let mut rest = number;462 let mut rest = number;
...@@ -392,18 +469,114 @@ pub(crate) fn numeral(sequence: Option<char>, number: u32) -> Result<String, Lay...@@ -392,18 +469,114 @@ pub(crate) fn numeral(sequence: Option<char>, number: u32) -> Result<String, Lay
392 }469 }
393 Ok(text)470 Ok(text)
394 };471 };
472 // Past Z a letter repeats, AA to ZZ, up to thirty times, then starts over at A.
395 let letter = |number: u32| {473 let letter = |number: u32| {
396 (1..=26)474 let index = number
397 .contains(&number)475 .checked_sub(1)
398 .then(|| char::from(b'A' + (number - 1) as u8).to_string())476 .ok_or(LayoutError::UnsupportedContent)?;
399 .ok_or(LayoutError::UnsupportedContent)477 let letter = char::from(b'A' + (index % 26) as u8);
478 Ok(std::iter::repeat_n(letter, (index / 26 % 30) as usize + 1).collect::<String>())
400 };479 };
401 match sequence.map(u32::from) {480 match sequence.map(u32::from) {
402 Some(0) => Ok(number.to_string()),481 Some(0) => Ok(number.to_string()),
482 Some(22) => Ok(format!("{number:02}")),
403 Some(1) => roman(number),483 Some(1) => roman(number),
404 Some(2) => roman(number).map(|text| text.to_lowercase()),484 Some(2) => roman(number).map(|text| text.to_lowercase()),
405 Some(3) => letter(number),485 Some(3) => letter(number),
406 Some(4) => letter(number).map(|text| text.to_lowercase()),486 Some(4) => letter(number).map(|text| text.to_lowercase()),
487 Some(5) => {
488 let suffix = match (number % 10, number % 100) {
489 (_, 11..=13) => "th",
490 (1, _) => "st",
491 (2, _) => "nd",
492 (3, _) => "rd",
493 _ => "th",
494 };
495 Ok(format!("{number}{suffix}"))
496 }
497 Some(sequence @ (6 | 7)) => {
498 let mut words = cardinal(number)?;
499 if sequence == 7 {
500 // The last word turns ordinal: one to first, twenty to twentieth.
501 let at = words.rfind([' ', '-']).map_or(0, |at| at + 1);
502 let last = &words[at..];
503 let ordinal = match last {
504 "one" => "first".to_owned(),
505 "two" => "second".to_owned(),
506 "three" => "third".to_owned(),
507 "five" => "fifth".to_owned(),
508 "eight" => "eighth".to_owned(),
509 "nine" => "ninth".to_owned(),
510 "twelve" => "twelfth".to_owned(),
511 _ if last.ends_with('y') => format!("{}ieth", &last[..last.len() - 1]),
512 _ => format!("{last}th"),
513 };
514 words.replace_range(at.., &ordinal);
515 }
516 let mut chars = words.chars();
517 let first = chars.next().unwrap_or_default();
518 Ok(first.to_uppercase().chain(chars).collect())
519 }
520 _ => Err(LayoutError::UnsupportedContent),
521 }
522}
523
524/// `number` in lowercase English words, "one hundred twenty-one", below a million.
525fn cardinal(number: u32) -> Result<String, LayoutError> {
526 const SMALL: [&str; 20] = [
527 "zero",
528 "one",
529 "two",
530 "three",
531 "four",
532 "five",
533 "six",
534 "seven",
535 "eight",
536 "nine",
537 "ten",
538 "eleven",
539 "twelve",
540 "thirteen",
541 "fourteen",
542 "fifteen",
543 "sixteen",
544 "seventeen",
545 "eighteen",
546 "nineteen",
547 ];
548 const TENS: [&str; 10] = [
549 "", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety",
550 ];
551 fn below_thousand(number: u32) -> String {
552 let (hundreds, rest) = (number / 100, number % 100);
553 let rest = match rest {
554 0 => None,
555 1..20 => Some(SMALL[rest as usize].to_owned()),
556 _ if rest % 10 == 0 => Some(TENS[(rest / 10) as usize].to_owned()),
557 _ => Some(format!(
558 "{}-{}",
559 TENS[(rest / 10) as usize],
560 SMALL[(rest % 10) as usize]
561 )),
562 };
563 match (hundreds, rest) {
564 (0, rest) => rest.unwrap_or_default(),
565 (hundreds, None) => format!("{} hundred", SMALL[hundreds as usize]),
566 (hundreds, Some(rest)) => format!("{} hundred {rest}", SMALL[hundreds as usize]),
567 }
568 }
569 match number {
570 1..1000 => Ok(below_thousand(number)),
571 1000..1_000_000 => {
572 let (thousands, rest) = (number / 1000, number % 1000);
573 let head = format!("{} thousand", below_thousand(thousands));
574 Ok(if rest == 0 {
575 head
576 } else {
577 format!("{head} {}", below_thousand(rest))
578 })
579 }
407 _ => Err(LayoutError::UnsupportedContent),580 _ => Err(LayoutError::UnsupportedContent),
408 }581 }
409}582}
...@@ -438,9 +611,6 @@ impl ParagraphLayout {...@@ -438,9 +611,6 @@ impl ParagraphLayout {
438 }611 }
439612
440 fn size(&self) -> [f32; 2] {613 fn size(&self) -> [f32; 2] {
441 if let Some(math) = &self.math {
442 return [self.origin[0] + math.size[0], math.size[1]];
443 }
444 [614 [
445 self.origin[0] + self.text.shaped.width(),615 self.origin[0] + self.text.shaped.width(),
446 self.markers616 self.markers
...@@ -454,7 +624,7 @@ impl ParagraphLayout {...@@ -454,7 +624,7 @@ impl ParagraphLayout {
454 pub(crate) fn shape(624 pub(crate) fn shape(
455 engine: &mut TextEngine,625 engine: &mut TextEngine,
456 paragraph: &PageParagraph,626 paragraph: &PageParagraph,
457 previous: Option<u32>,627 previous: Option<&Count>,
458 width: f32,628 width: f32,
459 indents: &[f32],629 indents: &[f32],
460 definitions: &BTreeMap<ExGuid, Definition>,630 definitions: &BTreeMap<ExGuid, Definition>,
...@@ -474,53 +644,111 @@ impl ParagraphLayout {...@@ -474,53 +644,111 @@ impl ParagraphLayout {
474 return Err(LayoutError::InvalidSpacing);644 return Err(LayoutError::InvalidSpacing);
475 }645 }
476 // Stored newest first; OneNote lists and paints tags oldest first.646 // Stored newest first; OneNote lists and paints tags oldest first.
477 let mut tag_definitions = paragraph647 // An Outlook task tag carries no definition; a tag whose definition is missing shows
648 // nothing, as OneNote draws no icon for it.
649 let mut tag_definitions: Vec<_> = paragraph
478 .tags650 .tags
479 .iter()651 .iter()
480 .chain(&source.tags)652 .chain(&source.tags)
481 .map(653 .filter_map(|tag| {
482 |tag| match tag.definition.as_ref().and_then(|id| definitions.get(id)) {654 if tag.status & 4 != 0 {
483 Some(Definition {655 return Some((tag, None, None, None, None));
484 kind:656 }
485 Kind::TagDefinition {657 match &definitions.get(tag.definition.as_ref()?)?.kind {
486 shape,658 Kind::TagDefinition {
487 label,659 shape,
488 color,660 label,
489 highlight,661 color,
490 ..662 highlight,
491 },
492 ..663 ..
493 }) if tag.status & 4 == 0 => Ok((tag, *shape, label, *color, *highlight)),664 } => Some((tag, Some(*shape), label.as_deref(), *color, *highlight)),
494 _ => Err(LayoutError::UnsupportedContent),665 _ => None,
495 },666 }
496 )667 })
497 .collect::<Result<Vec<_>, _>>()?;668 .collect();
498 tag_definitions.reverse();669 tag_definitions.reverse();
499 // The newest tag that sets a colour wins.670 // The newest tag that sets a colour wins.
500 let color = tag_definitions.iter().rev().find_map(|tag| tag.3);671 let color = tag_definitions.iter().rev().find_map(|tag| tag.3);
501 let highlight = tag_definitions.iter().rev().find_map(|tag| tag.4);672 let highlight = tag_definitions.iter().rev().find_map(|tag| tag.4);
502 let equation = onestore::page::Math::is_equation(&source.text);673 // Equations with objects draw in two dimensions in a space kept in their line; their
503 let mut text = if color.is_none() && highlight.is_none() && !equation {674 // linear text stays in the text, too small to see, to hold the caret.
504 engine.layout(projection.text(), width - indent)?675 let visible = projection.text();
676 let mut equations = Vec::new();
677 for zone in crate::math::built(visible) {
678 let units = |byte| {
679 visible
680 .utf16_offset(byte)
681 .map_err(|_| LayoutError::InvalidSourceRange)
682 };
683 let math = visible
684 .slice(units(zone.start)?..units(zone.end)?)
685 .map_err(|_| LayoutError::InvalidSourceRange)?;
686 // An equation that does not parse shows its linear text.
687 if let Ok(math) = crate::math::layout(engine, &math) {
688 equations.push((zone, math));
689 }
690 }
691 let spaces: Vec<_> = equations
692 .iter()
693 .map(|(zone, math)| InlineSpace {
694 index: zone.start,
695 width: math.size[0],
696 ascent: math.baseline,
697 descent: math.size[1] - math.baseline,
698 })
699 .collect();
700 // URL text shows as a link, as OneNote links it on opening a page.
701 let mut marks: Vec<(Range<usize>, bool)> = crate::editor::shown_urls(visible)
702 .into_iter()
703 .map(|url| (url, false))
704 .chain(equations.iter().map(|(zone, _)| (zone.clone(), true)))
705 .collect();
706 marks.sort_by_key(|(range, _)| range.start);
707 let mut text = if color.is_none() && highlight.is_none() && marks.is_empty() {
708 engine.layout(visible, width - indent)?
505 } else {709 } else {
506 let visible = projection.text();
507 let mut start = 0;710 let mut start = 0;
508 let runs = visible.spans().iter().map(|span| {711 let runs = visible.spans().iter().flat_map(|span| {
509 let mut format = span.format.clone();712 let mut format = span.format.clone();
510 format.color = color.or(format.color);713 format.color = color.or(format.color);
511 format.highlight = highlight.or(format.highlight);714 format.highlight = highlight.or(format.highlight);
512 // An equation draws in two dimensions; its linear text only holds the caret,715 let mut runs = Vec::new();
513 // which the body font sizes like a line of text.716 let mut from = start;
514 if equation {717 for (range, equation) in &marks {
515 format.font = None;718 let (a, b) = (
719 range.start.clamp(from, span.end),
720 range.end.clamp(from, span.end),
721 );
722 if a < b {
723 runs.push((visible.text()[from..a].to_owned(), format.clone()));
724 let marked = if *equation {
725 Format {
726 font_size: Some(HELD),
727 superscript: None,
728 subscript: None,
729 ..format.clone()
730 }
731 } else {
732 Format {
733 hyperlink: Some(true),
734 ..format.clone()
735 }
736 };
737 runs.push((visible.text()[a..b].to_owned(), marked));
738 from = b;
739 }
516 }740 }
517 let run = (visible.text()[start..span.end].to_owned(), format);741 runs.push((visible.text()[from..span.end].to_owned(), format));
518 start = span.end;742 start = span.end;
519 run743 runs
520 });744 });
521 engine.layout(745 engine.layout_with(
522 &Paragraph::from_runs(runs.collect::<Vec<_>>()),746 &Paragraph::from_runs(
747 runs.filter(|(text, _)| !text.is_empty())
748 .collect::<Vec<_>>(),
749 ),
523 width - indent,750 width - indent,
751 &spaces,
524 )?752 )?
525 };753 };
526 let mut markers = Vec::new();754 let mut markers = Vec::new();
...@@ -538,34 +766,39 @@ impl ParagraphLayout {...@@ -538,34 +766,39 @@ impl ParagraphLayout {
538 return Err(LayoutError::InvalidList);766 return Err(LayoutError::InvalidList);
539 };767 };
540 let mut color = definition.format.color;768 let mut color = definition.format.color;
541 let value = match value.split_once('\u{fffd}') {769 let value = if value.contains('\u{fffd}') {
542 Some((prefix, rest)) => {770 // An empty numbered paragraph shows its number as a gray placeholder.
543 // An empty numbered paragraph shows its number as a grey placeholder.771 if source.text.text().is_empty() {
544 if source.text.text().is_empty() {772 color = Some(PLACEHOLDER);
545 color = Some(PLACEHOLDER);
546 }
547 let current = restart.unwrap_or(next(previous));
548 number = Some((current, restart.is_some()));
549 let mut rest = rest.chars();
550 format!(
551 "{prefix}{}{}",
552 numeral(rest.next(), current)?,
553 rest.as_str()
554 )
555 }773 }
556 None => value.clone(),774 let current = restart.unwrap_or(next(previous, value));
775 let format = value.as_str().into();
776 number = Some((
777 Count {
778 number: current,
779 format,
780 },
781 restart.is_some(),
782 ));
783 numbered(value, current)?
784 } else {
785 value.clone()
557 };786 };
558 if value.contains('\u{fffd}') {787 if value.contains('\u{fffd}') {
559 return Err(LayoutError::UnsupportedContent);788 return Err(LayoutError::UnsupportedContent);
560 }789 }
790 // Without a font of its own a marker takes its text's, as a number does.
791 let font = font
792 .clone()
793 .or_else(|| definition.format.font.clone())
794 .or_else(|| format.font.clone());
795 let value = font
796 .as_deref()
797 .map_or(value.clone(), |font| symbol_text(font, &value));
561 let marker = Paragraph::new(798 let marker = Paragraph::new(
562 value,799 value,
563 Format {800 Format {
564 // Without a font of its own a marker takes its text's, as a number does.801 font,
565 font: font
566 .clone()
567 .or_else(|| definition.format.font.clone())
568 .or_else(|| format.font.clone()),
569 font_size: definition.format.font_size.or(format.font_size),802 font_size: definition.format.font_size.or(format.font_size),
570 color,803 color,
571 ..Format::default()804 ..Format::default()
...@@ -589,22 +822,24 @@ impl ParagraphLayout {...@@ -589,22 +822,24 @@ impl ParagraphLayout {
589 for tag in &tag_definitions {822 for tag in &tag_definitions {
590 let (tag, shape, label) = (tag.0, tag.1, tag.2);823 let (tag, shape, label) = (tag.0, tag.1, tag.2);
591 let icon = match shape {824 let icon = match shape {
592 Some(0) => continue,825 None => TagIcon::Flag,
593 Some(3) => TagIcon::CheckBox {826 Some(Some(0) | None) => continue,
827 Some(Some(3)) => TagIcon::CheckBox {
594 checked: tag.status & 1 != 0,828 checked: tag.status & 1 != 0,
595 },829 },
596 Some(13) => TagIcon::Star,830 Some(Some(13)) => TagIcon::Star,
597 Some(15) => TagIcon::Question,831 Some(Some(15)) => TagIcon::Question,
598 Some(17) => TagIcon::Exclamation,832 Some(Some(17)) => TagIcon::Exclamation,
599 Some(18) => TagIcon::Phone,833 Some(Some(18)) => TagIcon::Phone,
600 Some(23) => TagIcon::Address,834 Some(Some(23)) => TagIcon::Address,
601 Some(118) => TagIcon::Contact,835 Some(Some(118)) => TagIcon::Contact,
602 Some(136) => TagIcon::Highlight,836 Some(Some(136)) => TagIcon::Highlight,
603 Some(100) => TagIcon::RedSquare,837 Some(Some(100)) => TagIcon::RedSquare,
604 Some(101) => TagIcon::YellowSquare,838 Some(Some(101)) => TagIcon::YellowSquare,
605 Some(102) => TagIcon::BlueSquare,839 Some(Some(102)) => TagIcon::BlueSquare,
606 Some(121) => TagIcon::Music,840 Some(Some(121)) => TagIcon::Music,
607 _ => return Err(LayoutError::UnsupportedContent),841 // A symbol without artwork here shows as a plain tag.
842 Some(_) => TagIcon::Other,
608 };843 };
609 let size = format.font_size.unwrap_or(11.0);844 let size = format.font_size.unwrap_or(11.0);
610 let side = ParagraphTag::side(size);845 let side = ParagraphTag::side(size);
...@@ -612,13 +847,11 @@ impl ParagraphLayout {...@@ -612,13 +847,11 @@ impl ParagraphLayout {
612 icon,847 icon,
613 origin: [0.0, ParagraphTag::top(baseline, size, side)],848 origin: [0.0, ParagraphTag::top(baseline, size, side)],
614 size: side,849 size: side,
615 label: label.clone().unwrap_or_default(),850 label: label.unwrap_or_default().to_owned(),
616 disabled: tag.status & 2 != 0,851 disabled: tag.status & 2 != 0,
617 });852 });
618 }853 }
619 let math = equation854 let math = equations.into_iter().map(|(_, math)| math).collect();
620 .then(|| crate::math::layout(engine, &source.text))
621 .transpose()?;
622 let mut result = Self {855 let mut result = Self {
623 id: paragraph.id,856 id: paragraph.id,
624 origin: [indent, 0.0],857 origin: [indent, 0.0],
...@@ -732,7 +965,7 @@ impl OutlineLayout {...@@ -732,7 +965,7 @@ impl OutlineLayout {
732 edit: Option<&DocumentEdit>,965 edit: Option<&DocumentEdit>,
733 shape: &mut impl FnMut(966 shape: &mut impl FnMut(
734 &PageParagraph,967 &PageParagraph,
735 Option<u32>,968 Option<&Count>,
736 f32,969 f32,
737 &[f32],970 &[f32],
738 ) -> Result<ParagraphLayout, LayoutError>,971 ) -> Result<ParagraphLayout, LayoutError>,
...@@ -779,10 +1012,10 @@ impl OutlineLayout {...@@ -779,10 +1012,10 @@ impl OutlineLayout {
779 edit: Option<&DocumentEdit>,1012 edit: Option<&DocumentEdit>,
780 mut hiding: BTreeSet<ExGuid>,1013 mut hiding: BTreeSet<ExGuid>,
781 mut state: (f64, Option<f32>),1014 mut state: (f64, Option<f32>),
782 siblings: &mut Vec<(u32, Option<u32>)>,1015 siblings: &mut Vec<(u32, Option<Count>)>,
783 shape: &mut impl FnMut(1016 shape: &mut impl FnMut(
784 &PageParagraph,1017 &PageParagraph,
785 Option<u32>,1018 Option<&Count>,
786 f32,1019 f32,
787 &[f32],1020 &[f32],
788 ) -> Result<ParagraphLayout, LayoutError>,1021 ) -> Result<ParagraphLayout, LayoutError>,
...@@ -820,8 +1053,8 @@ impl OutlineLayout {...@@ -820,8 +1053,8 @@ impl OutlineLayout {
820 if !hidden {1053 if !hidden {
821 let (space, height, flow, extent) = match &node.content {1054 let (space, height, flow, extent) = match &node.content {
822 ParagraphContent::Text(_) => {1055 ParagraphContent::Text(_) => {
823 let mut paragraph = shape(node, previous, width, indents)?;1056 let mut paragraph = shape(node, previous.as_ref(), width, indents)?;
824 number = paragraph.number.map(|(number, _)| number);1057 number = paragraph.number.clone().map(|(count, _)| count);
825 let space = spacing(&paragraph.projection.text().spans()[0].format)?;1058 let space = spacing(&paragraph.projection.text().spans()[0].format)?;
826 paragraph.origin[1] = top(&mut state, space);1059 paragraph.origin[1] = top(&mut state, space);
827 let size = paragraph.size();1060 let size = paragraph.size();
...@@ -951,7 +1184,7 @@ impl OutlineLayout {...@@ -951,7 +1184,7 @@ impl OutlineLayout {
951 }1184 }
952 }1185 }
953 block.count = tally(node, number, previous);1186 block.count = tally(node, number, previous);
954 siblings.push((node.level, block.count));1187 siblings.push((node.level, block.count.clone()));
955 block.state = state;1188 block.state = state;
956 if depth == 0 {1189 if depth == 0 {
957 result.blocks.push(block);1190 result.blocks.push(block);
...@@ -963,10 +1196,10 @@ impl OutlineLayout {...@@ -963,10 +1196,10 @@ impl OutlineLayout {
963 }1196 }
9641197
965 /// Root node `root`'s number, when it is numbered text on show.1198 /// Root node `root`'s number, when it is numbered text on show.
966 fn number(&self, nodes: &[PageParagraph], root: usize) -> Option<(u32, bool)> {1199 fn number(&self, nodes: &[PageParagraph], root: usize) -> Option<&(Count, bool)> {
967 let block = &self.blocks[root];1200 let block = &self.blocks[root];
968 (block.metrics.is_some() && nodes[root].text().is_some())1201 (block.metrics.is_some() && nodes[root].text().is_some())
969 .then(|| self.paragraphs[block.first[0]].number)1202 .then(|| self.paragraphs[block.first[0]].number.as_ref())
970 .flatten()1203 .flatten()
971 }1204 }
9721205
...@@ -996,7 +1229,7 @@ impl OutlineLayout {...@@ -996,7 +1229,7 @@ impl OutlineLayout {
996 fixed_width: bool,1229 fixed_width: bool,
997 shape: &mut impl FnMut(1230 shape: &mut impl FnMut(
998 &PageParagraph,1231 &PageParagraph,
999 Option<u32>,1232 Option<&Count>,
1000 f32,1233 f32,
1001 &[f32],1234 &[f32],
1002 ) -> Result<ParagraphLayout, LayoutError>,1235 ) -> Result<ParagraphLayout, LayoutError>,
...@@ -1047,7 +1280,7 @@ impl OutlineLayout {...@@ -1047,7 +1280,7 @@ impl OutlineLayout {
1047 for root in (0..range.start).rev() {1280 for root in (0..range.start).rev() {
1048 if nodes[root].level < level {1281 if nodes[root].level < level {
1049 level = nodes[root].level;1282 level = nodes[root].level;
1050 siblings.push((level, self.blocks[root].count));1283 siblings.push((level, self.blocks[root].count.clone()));
1051 if level <= 1 {1284 if level <= 1 {
1052 break;1285 break;
1053 }1286 }
...@@ -1067,7 +1300,8 @@ impl OutlineLayout {...@@ -1067,7 +1300,8 @@ impl OutlineLayout {
1067 let indent = indentation(node.level, indents, width)?;1300 let indent = indentation(node.level, indents, width)?;
1068 if let Some((source, cached)) = cached.get(&node.id)1301 if let Some((source, cached)) = cached.get(&node.id)
1069 && *source == node1302 && *source == node
1070 && follows(cached.number, previous) == cached.number.map(|(n, _)| n)1303 && follows(cached.number.as_ref(), previous)
1304 == cached.number.clone().map(|(count, _)| count)
1071 && cached.text.shaped.layout_max_advance() == width - indent1305 && cached.text.shaped.layout_max_advance() == width - indent
1072 && cached1306 && cached
1073 .markers1307 .markers
...@@ -1104,9 +1338,11 @@ impl OutlineLayout {...@@ -1104,9 +1338,11 @@ impl OutlineLayout {
1104 for root in range.end..nodes.len() {1338 for root in range.end..nodes.len() {
1105 let previous = sibling(&mut siblings, nodes[root].level);1339 let previous = sibling(&mut siblings, nodes[root].level);
1106 let number = self.number(nodes, root);1340 let number = self.number(nodes, root);
1107 let now = follows(number, previous);1341 let now = follows(number, previous.as_ref());
1108 let counted = tally(&nodes[root], now, previous);1342 let counted = tally(&nodes[root], now.clone(), previous);
1109 if now != number.map(|(number, _)| number) || counted != self.blocks[root].count {1343 if now.as_ref() != number.map(|(count, _)| count)
1344 || counted != self.blocks[root].count
1345 {
1110 renumbered = root + 1;1346 renumbered = root + 1;
1111 }1347 }
1112 siblings.push((nodes[root].level, counted));1348 siblings.push((nodes[root].level, counted));
...@@ -1275,7 +1511,7 @@ impl OutlineLayout {...@@ -1275,7 +1511,7 @@ impl OutlineLayout {
1275 edit: Option<&DocumentEdit>,1511 edit: Option<&DocumentEdit>,
1276 shape: &mut impl FnMut(1512 shape: &mut impl FnMut(
1277 &PageParagraph,1513 &PageParagraph,
1278 Option<u32>,1514 Option<&Count>,
1279 f32,1515 f32,
1280 &[f32],1516 &[f32],
1281 ) -> Result<ParagraphLayout, LayoutError>,1517 ) -> Result<ParagraphLayout, LayoutError>,
...@@ -1885,17 +2121,26 @@ mod tests {...@@ -1885,17 +2121,26 @@ mod tests {
1885 unreachable!()2121 unreachable!()
1886 };2122 };
1887 *shape = Some(999);2123 *shape = Some(999);
1888 assert!(matches!(2124 let mut shaped = |node: &PageParagraph, definitions| {
1889 ParagraphLayout::shape(2125 ParagraphLayout::shape(
1890 &mut engine,2126 &mut engine,
1891 &node,2127 node,
1892 None,2128 None,
1893 120.0,2129 120.0,
1894 &[18.0, 0.0, 27.0],2130 &[18.0, 0.0, 27.0],
1895 &definitions2131 definitions,
1896 ),2132 )
1897 Err(LayoutError::UnsupportedContent)2133 .unwrap()
1898 ));2134 };
2135 // A symbol without artwork shows as a plain tag, an Outlook task as its flag, and a
2136 // tag whose definition is missing as nothing.
2137 assert_eq!(shaped(&node, &definitions).tags[0].icon, TagIcon::Other);
2138 let mut task = node.clone();
2139 task.text_mut().unwrap().tags[0].status |= 4;
2140 assert_eq!(shaped(&task, &definitions).tags[0].icon, TagIcon::Flag);
2141 let mut untagged = definitions.clone();
2142 untagged.remove(&id);
2143 assert!(shaped(&node, &untagged).tags.is_empty());
1899 }2144 }
19002145
1901 /// OneNote 2010 at 400% (Calibri bullets and numbers from 8 to 24 pt, default list2146 /// OneNote 2010 at 400% (Calibri bullets and numbers from 8 to 24 pt, default list
...@@ -2475,6 +2720,17 @@ mod tests {...@@ -2475,6 +2720,17 @@ mod tests {
2475 assert_eq!(layout.size[0], 110.0);2720 assert_eq!(layout.size[0], 110.0);
2476 }2721 }
24772722
2723 #[test]
2724 fn symbol_fonts_show_what_windows_draws() {
2725 // OneNote's bullet library stores these (`BULLET_LIBRARY`).
2726 assert_eq!(symbol_text("Symbol", "\u{b7}"), "\u{2022}");
2727 assert_eq!(symbol_text("Symbol", "\u{de}"), "\u{21d2}");
2728 assert_eq!(symbol_text("Symbol", "ap\u{f0}"), "\u{3b1}\u{3c0}\u{f8ff}");
2729 assert_eq!(symbol_text("Wingdings", "l\u{ad}"), "\u{f06c}\u{f0ad}");
2730 assert_eq!(symbol_text("Wingdings 2", "\u{9d}"), "\u{f09d}");
2731 assert_eq!(symbol_text("Calibri", "\u{b7}"), "\u{b7}");
2732 }
2733
2478 #[test]2734 #[test]
2479 fn numerals_follow_onenote_number_sequences() {2735 fn numerals_follow_onenote_number_sequences() {
2480 assert_eq!(numeral(Some('\u{0}'), 12).unwrap(), "12");2736 assert_eq!(numeral(Some('\u{0}'), 12).unwrap(), "12");
...@@ -2482,8 +2738,70 @@ mod tests {...@@ -2482,8 +2738,70 @@ mod tests {
2482 assert_eq!(numeral(Some('\u{2}'), 4).unwrap(), "iv");2738 assert_eq!(numeral(Some('\u{2}'), 4).unwrap(), "iv");
2483 assert_eq!(numeral(Some('\u{3}'), 3).unwrap(), "C");2739 assert_eq!(numeral(Some('\u{3}'), 3).unwrap(), "C");
2484 assert_eq!(numeral(Some('\u{4}'), 26).unwrap(), "z");2740 assert_eq!(numeral(Some('\u{4}'), 26).unwrap(), "z");
2485 assert!(numeral(Some('\u{4}'), 27).is_err());
2486 assert!(numeral(Some('\u{1}'), 0).is_err());2741 assert!(numeral(Some('\u{1}'), 0).is_err());
2742 // As OneNote 2010 renders them through COM: letters to 800, Roman numerals to 4010.
2743 assert_eq!(numeral(Some('\u{4}'), 27).unwrap(), "aa");
2744 assert_eq!(numeral(Some('\u{3}'), 105).unwrap(), "AAAAA");
2745 assert_eq!(numeral(Some('\u{3}'), 780).unwrap(), "Z".repeat(30));
2746 assert_eq!(numeral(Some('\u{3}'), 781).unwrap(), "A");
2747 assert_eq!(numeral(Some('\u{3}'), 800).unwrap(), "T");
2748 assert_eq!(numeral(Some('\u{1}'), 3999).unwrap(), "MMMCMXCIX");
2749 assert_eq!(numeral(Some('\u{1}'), 4009).unwrap(), "MMMMIX");
2750 assert_eq!(numeral(Some('\u{2}'), 4000).unwrap(), "mmmm");
2751 assert_eq!(numeral(Some('\u{16}'), 7).unwrap(), "07");
2752 assert_eq!(numeral(Some('\u{16}'), 106).unwrap(), "106");
2487 assert!(numeral(Some('\u{9}'), 1).is_err());2753 assert!(numeral(Some('\u{9}'), 1).is_err());
2754 // As OneNote 2010 renders them through COM, 1 to 130.
2755 let words = |sequence, numbers: &[u32]| {
2756 numbers
2757 .iter()
2758 .map(|number| numeral(Some(sequence), *number).unwrap())
2759 .collect::<Vec<_>>()
2760 };
2761 assert_eq!(
2762 words(
2763 '\u{5}',
2764 &[1, 2, 3, 4, 11, 12, 13, 21, 22, 23, 101, 111, 112, 121]
2765 ),
2766 [
2767 "1st", "2nd", "3rd", "4th", "11th", "12th", "13th", "21st", "22nd", "23rd",
2768 "101st", "111th", "112th", "121st"
2769 ]
2770 );
2771 assert_eq!(
2772 words('\u{6}', &[1, 12, 20, 21, 99, 100, 101, 130]),
2773 [
2774 "One",
2775 "Twelve",
2776 "Twenty",
2777 "Twenty-one",
2778 "Ninety-nine",
2779 "One hundred",
2780 "One hundred one",
2781 "One hundred thirty"
2782 ]
2783 );
2784 assert_eq!(
2785 words(
2786 '\u{7}',
2787 &[1, 2, 3, 5, 8, 9, 12, 20, 21, 40, 100, 101, 112, 130]
2788 ),
2789 [
2790 "First",
2791 "Second",
2792 "Third",
2793 "Fifth",
2794 "Eighth",
2795 "Ninth",
2796 "Twelfth",
2797 "Twentieth",
2798 "Twenty-first",
2799 "Fortieth",
2800 "One hundredth",
2801 "One hundred first",
2802 "One hundred twelfth",
2803 "One hundred thirtieth"
2804 ]
2805 );
2488 }2806 }
2489}2807}
crates/canvas/tests/links_equations.rs created+537
...@@ -0,0 +1,537 @@
1//! Links and equations made as a user makes them, through the editor's typing, Link
2//! dialog, Remove Link, Alt+=, Linear and Professional, save as the ops the editor recorded
3//! and read back as the page it saved. `CANVAS_LINKS_EQUATIONS_EXPORT` names a new directory
4//! receiving the section for a cold OneNote reopen, with the links and MathML its read must
5//! show (`corpus/link-edit/editor`).
6
7use canvas::{document::TextPosition, editor::CanvasEditor, layout::TextEngine};
8use onestore::{
9 Arena, ExGuid, RevisionIndex, Section, Store,
10 document::Document,
11 page::{
12 Math, Page, PageObject,
13 link::{LinkTarget, internal_link},
14 },
15};
16use std::path::Path;
17
18const BASE_PATH: &str = r"C:\one-tests\runs\capture\notebook\links.one";
19
20fn pages(section: &[u8]) -> Vec<(ExGuid, Page)> {
21 let store = Store::parse(section).unwrap();
22 let index = RevisionIndex::parse(&store).unwrap();
23 let document = Document::parse(&index).unwrap();
24 document
25 .pages()
26 .unwrap()
27 .into_iter()
28 .map(|(space, _)| (space, Page::from_space(&document, space).unwrap()))
29 .collect()
30}
31
32/// Types `text`, each space on its own as a key does.
33fn typed(editor: &mut CanvasEditor, engine: &mut TextEngine, text: &str) {
34 for (index, word) in text.split(' ').enumerate() {
35 if index > 0 {
36 editor.insert(engine, " ").unwrap();
37 }
38 if !word.is_empty() {
39 editor.insert(engine, word).unwrap();
40 }
41 }
42}
43
44fn caret(editor: &CanvasEditor) -> TextPosition {
45 editor.selection().positions[1]
46}
47
48/// End, then Enter, returning the new paragraph.
49fn line(editor: &mut CanvasEditor, engine: &mut TextEngine) -> usize {
50 let paragraph = caret(editor).paragraph;
51 let text = editor
52 .active_outline()
53 .document()
54 .paragraphs()
55 .nth(paragraph)
56 .unwrap();
57 let offset = text.utf16_offset(text.text().len()).unwrap();
58 editor
59 .select([TextPosition { paragraph, offset }; 2].into())
60 .unwrap();
61 editor.enter(engine, false).unwrap();
62 caret(editor).paragraph
63}
64
65fn select(editor: &mut CanvasEditor, paragraph: usize, range: std::ops::Range<u32>) {
66 editor
67 .select(
68 [
69 TextPosition {
70 paragraph,
71 offset: range.start,
72 },
73 TextPosition {
74 paragraph,
75 offset: range.end,
76 },
77 ]
78 .into(),
79 )
80 .unwrap();
81}
82
83#[test]
84fn links_and_equations_save_as_the_editor_shows_them() {
85 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
86 let source = std::fs::read(root.join("corpus/link-edit/internal/candidate/links.one")).unwrap();
87 let section_id = Store::parse(&source).unwrap().header.file_id;
88 let all = pages(&source);
89 let (space, page) = all
90 .iter()
91 .find(|(_, page)| page.title.starts_with("Linking page"))
92 .cloned()
93 .unwrap();
94 let target = all
95 .iter()
96 .map(|(_, page)| page)
97 .find(|page| page.title == "Link target")
98 .unwrap();
99 let paragraph_id = page
100 .objects
101 .iter()
102 .find_map(|object| match object {
103 PageObject::Outline(outline) if !outline.title => Some(outline.paragraphs[0].id),
104 _ => None,
105 })
106 .unwrap();
107 let mut engine = TextEngine::default();
108 let engine = &mut engine;
109 let mut editor = CanvasEditor::from_page(page.clone(), engine).unwrap();
110 let body = editor
111 .outlines()
112 .iter()
113 .find(|outline| !outline.title)
114 .unwrap()
115 .id;
116 editor.focus_outline(body).unwrap();
117 let end = editor
118 .active_outline()
119 .document()
120 .paragraphs()
121 .next()
122 .unwrap();
123 let end = end.utf16_offset(end.text().len()).unwrap();
124 editor
125 .select(
126 [TextPosition {
127 paragraph: 0,
128 offset: end,
129 }; 2]
130 .into(),
131 )
132 .unwrap();
133 let mut hrefs = vec![internal_link(
134 section_id,
135 BASE_PATH,
136 LinkTarget::Page {
137 identity: target.identity.unwrap(),
138 title: "Link target",
139 },
140 )];
141 // Typed URLs ended by a space and by Enter.
142 line(&mut editor, engine);
143 typed(
144 &mut editor,
145 engine,
146 "Visit www.example.com or (https://example.org/a?b=1). ",
147 );
148 hrefs.extend([
149 "http://www.example.com".into(),
150 "https://example.org/a?b=1".into(),
151 ]);
152 line(&mut editor, engine);
153 typed(&mut editor, engine, "Ends with http://example.net/end");
154 hrefs.push("http://example.net/end".into());
155 // The Link dialog on a selection, on the word at the caret and on nothing.
156 let at = line(&mut editor, engine);
157 typed(&mut editor, engine, "Read the Rust book today");
158 select(&mut editor, at, 9..18);
159 assert_eq!(editor.link_prefill(), ("Rust book".into(), String::new()));
160 editor
161 .set_link(engine, "Rust book", "https://doc.rust-lang.org/book/")
162 .unwrap();
163 hrefs.push("https://doc.rust-lang.org/book/".into());
164 let at = line(&mut editor, engine);
165 typed(&mut editor, engine, "A word here");
166 select(&mut editor, at, 3..3);
167 editor.set_link(engine, "word", "example.net/x").unwrap();
168 hrefs.push("http://example.net/x".into());
169 line(&mut editor, engine);
170 editor
171 .set_link(engine, "", "https://example.com/only")
172 .unwrap();
173 typed(&mut editor, engine, " tail");
174 hrefs.push("https://example.com/only".into());
175 // A link to the other page and one to this page's first paragraph, as picked or pasted.
176 line(&mut editor, engine);
177 typed(&mut editor, engine, "See ");
178 editor
179 .set_link(engine, "the target page", &hrefs[0].clone())
180 .unwrap();
181 hrefs.push(hrefs[0].clone());
182 line(&mut editor, engine);
183 let paragraph_link = internal_link(
184 section_id,
185 BASE_PATH,
186 LinkTarget::Object {
187 identity: page.identity.unwrap(),
188 title: "Linking page",
189 object: paragraph_id,
190 },
191 );
192 editor
193 .set_link(engine, "Back to the top", &paragraph_link)
194 .unwrap();
195 hrefs.push(paragraph_link);
196 // Remove Link.
197 let at = line(&mut editor, engine);
198 typed(&mut editor, engine, "Gone ftp://h.example/f z");
199 select(&mut editor, at, 8..8);
200 assert!(editor.remove_link(engine).unwrap());
201 // Equations typed after Alt+=, one shown in its linear form, one after text.
202 for linear in [
203 "a_1+b_2",
204 "\\int_0^1 x dx",
205 "(a+b)/(c+d)",
206 "\\sqrt x+1",
207 "\\sum_(i=1)^n i",
208 "\\matrix(1&2@3&4)",
209 "e^(x+1)",
210 ] {
211 line(&mut editor, engine);
212 editor.insert_equation(engine).unwrap();
213 typed(&mut editor, engine, &format!("{linear} "));
214 }
215 assert!(editor.linear_equation(engine).unwrap());
216 let at = line(&mut editor, engine);
217 typed(&mut editor, engine, "Area ");
218 editor.insert_equation(engine).unwrap();
219 typed(&mut editor, engine, "\\pi r^2 ");
220 // Linear, then Professional, rebuilds an equation as it was.
221 let built = editor
222 .active_outline()
223 .document()
224 .paragraphs()
225 .nth(at)
226 .unwrap()
227 .clone();
228 select(&mut editor, at, 7..7);
229 assert!(editor.linear_equation(engine).unwrap());
230 assert!(editor.build_equation(engine).unwrap());
231 assert_eq!(
232 editor
233 .active_outline()
234 .document()
235 .paragraphs()
236 .nth(at)
237 .unwrap(),
238 &built
239 );
240 // Enter at an equation's start, inside its row, inside a fraction's argument, and at a
241 // link's start.
242 let paragraph = |editor: &CanvasEditor, at: usize| {
243 editor
244 .active_outline()
245 .document()
246 .paragraphs()
247 .nth(at)
248 .unwrap()
249 .clone()
250 };
251 let at = line(&mut editor, engine);
252 typed(&mut editor, engine, "Split ");
253 editor.insert_equation(engine).unwrap();
254 typed(&mut editor, engine, "y^2+1 ");
255 select(&mut editor, at, 6..6);
256 editor.enter(engine, false).unwrap();
257 let text = paragraph(&editor, at + 1);
258 let one = text.utf16_offset(text.text().find('1').unwrap()).unwrap();
259 select(&mut editor, at + 1, one..one);
260 editor.enter(engine, false).unwrap();
261 let at = line(&mut editor, engine);
262 editor.insert_equation(engine).unwrap();
263 typed(&mut editor, engine, "(a+b)/(c+d) ");
264 let text = paragraph(&editor, at);
265 let plus = text.utf16_offset(text.text().rfind('+').unwrap()).unwrap();
266 select(&mut editor, at, plus..plus);
267 editor.enter(engine, false).unwrap();
268 let at = line(&mut editor, engine);
269 typed(&mut editor, engine, "Moved ");
270 editor
271 .set_link(engine, "down", "https://example.com/moved")
272 .unwrap();
273 hrefs.push("https://example.com/moved".into());
274 let text = paragraph(&editor, at);
275 let label = text
276 .utf16_offset(text.text().find("down").unwrap())
277 .unwrap();
278 select(&mut editor, at, label..label);
279 editor.enter(engine, false).unwrap();
280
281 let after = editor.page().unwrap();
282 let arena = Arena::default();
283 let mut stored = Section::open(&arena, source.clone()).unwrap();
284 let edit = onestore::op::Edit {
285 at: 133_000_000_000_000_000,
286 ops: editor
287 .take_ops()
288 .unwrap()
289 .into_iter()
290 .map(|op| onestore::op::Op::Page { space, op })
291 .collect(),
292 };
293 stored.apply("Snowbound", &edit).unwrap();
294 stored.seal().unwrap();
295 let saved = stored.image();
296 let (_, reread) = pages(&saved)
297 .into_iter()
298 .find(|(id, _)| *id == space)
299 .unwrap();
300 let body = |page: &Page| -> Vec<onestore::page::PageParagraph> {
301 page.objects
302 .iter()
303 .filter_map(|object| match object {
304 PageObject::Outline(outline) if !outline.title => Some(outline),
305 _ => None,
306 })
307 .flat_map(|outline| outline.paragraphs.clone())
308 .collect()
309 };
310 for (stored, shown) in body(&reread).iter().zip(body(&after)) {
311 assert_eq!(stored, &shown);
312 }
313 assert_eq!(
314 Page {
315 title: after.title.clone(),
316 ..reread.clone()
317 },
318 after
319 );
320
321 let paragraphs: Vec<_> = reread
322 .objects
323 .iter()
324 .filter_map(|object| match object {
325 PageObject::Outline(outline) if !outline.title => Some(outline),
326 _ => None,
327 })
328 .flat_map(|outline| &outline.paragraphs)
329 .filter_map(|paragraph| paragraph.text())
330 .collect();
331 let stored_hrefs: Vec<String> = paragraphs
332 .iter()
333 .flat_map(|text| {
334 text.text
335 .spans()
336 .iter()
337 .scan(0, |start, span| {
338 let run = &text.text.text()[*start..span.end];
339 *start = span.end;
340 Some((run, span.format.clone()))
341 })
342 .filter(|(_, format)| format.hyperlink == Some(true) && format.hidden != Some(true))
343 .map(|(run, _)| run.to_owned())
344 .collect::<Vec<_>>()
345 })
346 .collect();
347 assert_eq!(stored_hrefs.len(), hrefs.len(), "{stored_hrefs:?}");
348 // OneNote links URL text again when it opens a page, after its own Remove Link too
349 // (`corpus/link-edit/native-typed`, cold read).
350 let moved = hrefs.len() - 1;
351 hrefs.insert(moved, "ftp://h.example/f".into());
352 // The MathML OneNote exports for each run of math, an equation of its own.
353 let mut mathml = Vec::new();
354 for text in &paragraphs {
355 let mut start = 0;
356 let mut zone = Vec::new();
357 for span in text.text.spans() {
358 let run = text.text.text()[start..span.end].to_owned();
359 start = span.end;
360 if span.format.math == Some(true) {
361 zone.push((run, span.format.clone()));
362 }
363 let ends = span.format.math != Some(true) || start == text.text.text().len();
364 if ends && !zone.is_empty() {
365 let math = onestore::page::Paragraph::from_runs(std::mem::take(&mut zone));
366 mathml.push(Math::mathml(&Math::parse(&math).unwrap()));
367 }
368 }
369 }
370 assert_eq!(mathml.len(), 11);
371 if let Some(directory) = std::env::var_os("CANVAS_LINKS_EQUATIONS_EXPORT") {
372 let directory = Path::new(&directory);
373 std::fs::create_dir(directory).unwrap();
374 std::fs::write(directory.join("links.one"), &saved).unwrap();
375 std::fs::copy(
376 root.join("corpus/link-edit/internal/candidate/Open Notebook.onetoc2"),
377 directory.join("Open Notebook.onetoc2"),
378 )
379 .unwrap();
380 std::fs::write(
381 directory.join("expected.json"),
382 serde_json::to_string_pretty(&serde_json::json!({
383 "hrefs": hrefs,
384 "mathml": mathml,
385 }))
386 .unwrap(),
387 )
388 .unwrap();
389 }
390}
391
392/// A paragraph's runs as (text, hidden, hyperlink, math, italic, run data), with a run
393/// between objects stored either with or without run data.
394type Runs = Vec<(String, bool, bool, bool, bool, Option<(u32, Option<u32>)>)>;
395
396fn runs(text: &onestore::page::Paragraph) -> Runs {
397 let mut start = 0;
398 text.spans()
399 .iter()
400 .map(|span| {
401 let run = text.text()[start..span.end].to_owned();
402 start = span.end;
403 let format = &span.format;
404 let object = format
405 .math_object
406 .as_ref()
407 .map(|object| (object.kind, object.arguments))
408 .filter(|(kind, _)| *kind != 0x9000_0000);
409 (
410 run,
411 format.hidden == Some(true),
412 format.hyperlink == Some(true),
413 format.math == Some(true),
414 format.italic == Some(true),
415 object,
416 )
417 })
418 .collect()
419}
420
421/// Enter at an equation's start, inside its row and inside a fraction's argument, and at a
422/// link's start, stores what OneNote 2010 stored for the same keys (`tools/native_enter.py`,
423/// `corpus/math-edit/native-enter`).
424#[test]
425fn enter_at_and_inside_equations_and_links_stores_what_onenote_stored() {
426 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
427 let native =
428 std::fs::read(root.join("corpus/math-edit/native-enter/notebook/links.one")).unwrap();
429 let (_, page) = pages(&native).remove(0);
430 let native: Vec<Runs> = page
431 .objects
432 .iter()
433 .filter_map(|object| match object {
434 PageObject::Outline(outline) if !outline.title => Some(outline),
435 _ => None,
436 })
437 .flat_map(|outline| &outline.paragraphs)
438 .filter_map(|paragraph| paragraph.text())
439 .skip(1)
440 .map(|text| runs(&text.text))
441 .collect();
442 let mut engine = TextEngine::default();
443 let engine = &mut engine;
444 let mut editor = CanvasEditor::new(
445 engine,
446 canvas::document::TextDocument::new(vec![onestore::page::Paragraph::new(
447 String::new(),
448 onestore::document::Format {
449 font: Some("Calibri".into()),
450 ..Default::default()
451 },
452 )])
453 .unwrap(),
454 400.0,
455 )
456 .unwrap();
457 let text = |editor: &CanvasEditor, paragraph: usize| {
458 editor
459 .active_outline()
460 .document()
461 .paragraphs()
462 .nth(paragraph)
463 .unwrap()
464 .clone()
465 };
466 let before = |editor: &CanvasEditor, paragraph: usize, needle: &str| {
467 let text = text(editor, paragraph);
468 let at = text.text().find(needle).unwrap();
469 text.utf16_offset(at).unwrap()
470 };
471 typed(&mut editor, engine, "Before ");
472 editor.insert_equation(engine).unwrap();
473 typed(&mut editor, engine, "y^2+1 ");
474 select(&mut editor, 0, 7..7);
475 editor.enter(engine, false).unwrap();
476 let at = before(&editor, 1, "1");
477 select(&mut editor, 1, at..at);
478 editor.enter(engine, false).unwrap();
479 line(&mut editor, engine);
480 editor.insert_equation(engine).unwrap();
481 typed(&mut editor, engine, "(a+b)/(c+d) ");
482 let at = before(&editor, 2, "+𝑑");
483 select(&mut editor, 2, at..at);
484 editor.enter(engine, false).unwrap();
485 let paragraph = line(&mut editor, engine);
486 typed(&mut editor, engine, "Select me please");
487 select(&mut editor, paragraph, 7..16);
488 editor
489 .set_link(engine, "me please", "https://example.com/selected")
490 .unwrap();
491 let start = before(&editor, paragraph, "me please");
492 select(&mut editor, paragraph, start..start);
493 editor.enter(engine, false).unwrap();
494 let written: Vec<Runs> = editor
495 .active_outline()
496 .document()
497 .paragraphs()
498 .map(runs)
499 .collect();
500 assert_eq!(written, native);
501}
502
503/// An equation among text draws in its line, which grows to hold it, and URL text shows as a
504/// link without being stored as one.
505#[test]
506fn equations_draw_inline_and_url_text_shows_as_a_link() {
507 let mut engine = TextEngine::default();
508 let engine = &mut engine;
509 let mut editor = CanvasEditor::new(
510 engine,
511 canvas::document::TextDocument::new(vec![onestore::page::Paragraph::new(
512 String::new(),
513 Default::default(),
514 )])
515 .unwrap(),
516 400.0,
517 )
518 .unwrap();
519 typed(&mut editor, engine, "Area ");
520 editor.insert_equation(engine).unwrap();
521 typed(&mut editor, engine, "(a+b)/(c+d) ");
522 let outline = editor.active_outline();
523 let layout = outline.paragraph_layout(0).unwrap();
524 assert_eq!(layout.math.len(), 1);
525 let spaces: Vec<_> = layout.text.spaces().collect();
526 let [(0, [x, baseline])] = spaces[..] else {
527 panic!("{spaces:?}")
528 };
529 let (_, line) = layout.text.lines().next().unwrap();
530 assert!(x > 20.0, "the equation follows the text");
531 assert!(line.height >= layout.math[0].size[1]);
532 assert!(baseline - line.top >= layout.math[0].baseline);
533
534 let url = onestore::page::Paragraph::new("Gone ftp://h.example/f z".into(), Default::default());
535 assert_eq!(canvas::editor::shown_urls(&url), vec![5..22]);
536 assert!(url.spans()[0].format.hyperlink.is_none());
537}
crates/canvas/tests/ops_differential.rs+39-2
...@@ -22,7 +22,7 @@ use std::{collections::BTreeMap, path::Path};...@@ -22,7 +22,7 @@ use std::{collections::BTreeMap, path::Path};
22#[path = "../../onestore/tests/support/sweep.rs"]22#[path = "../../onestore/tests/support/sweep.rs"]
23mod sweep;23mod sweep;
2424
25const SECTIONS: [&str; 15] = [25const SECTIONS: [&str; 18] = [
26 "corpus/outline-edit/before/notebook/synthetic.one",26 "corpus/outline-edit/before/notebook/synthetic.one",
27 "corpus/paragraph-edit/before/notebook/synthetic.one",27 "corpus/paragraph-edit/before/notebook/synthetic.one",
28 "corpus/outline-edit/tree/before/notebook/synthetic.one",28 "corpus/outline-edit/tree/before/notebook/synthetic.one",
...@@ -36,6 +36,9 @@ const SECTIONS: [&str; 15] = [...@@ -36,6 +36,9 @@ const SECTIONS: [&str; 15] = [
36 "corpus/ink-edit/drawing/cold/notebook/ink.one",36 "corpus/ink-edit/drawing/cold/notebook/ink.one",
37 "corpus/math-edit/native-editor/notebook/links.one",37 "corpus/math-edit/native-editor/notebook/links.one",
38 "corpus/link-edit/native-links/notebook/links.one",38 "corpus/link-edit/native-links/notebook/links.one",
39 "corpus/link-edit/native-typed/notebook/links.one",
40 "corpus/math-edit/native-editor-3/notebook/links.one",
41 "corpus/math-edit/native-enter/notebook/links.one",
39 "corpus/paragraph-format/cold/notebook/synthetic.one",42 "corpus/paragraph-format/cold/notebook/synthetic.one",
40 "corpus/canvas/baseline-anchors.one",43 "corpus/canvas/baseline-anchors.one",
41];44];
...@@ -82,11 +85,17 @@ enum Kind {...@@ -82,11 +85,17 @@ enum Kind {
82 PlaceImage,85 PlaceImage,
83 RemoveImage,86 RemoveImage,
84 Date,87 Date,
88 Link,
89 Unlink,
90 TypedUrl,
91 Equation,
92 Linear,
93 Professional,
85 Undo,94 Undo,
86 Redo,95 Redo,
87}96}
8897
89const KINDS: [Kind; 42] = [98const KINDS: [Kind; 48] = [
90 Kind::Type,99 Kind::Type,
91 Kind::TypeEnd,100 Kind::TypeEnd,
92 Kind::Compose,101 Kind::Compose,
...@@ -127,6 +136,12 @@ const KINDS: [Kind; 42] = [...@@ -127,6 +136,12 @@ const KINDS: [Kind; 42] = [
127 Kind::PlaceImage,136 Kind::PlaceImage,
128 Kind::RemoveImage,137 Kind::RemoveImage,
129 Kind::Date,138 Kind::Date,
139 Kind::Link,
140 Kind::Unlink,
141 Kind::TypedUrl,
142 Kind::Equation,
143 Kind::Linear,
144 Kind::Professional,
130 Kind::Undo,145 Kind::Undo,
131 Kind::Redo,146 Kind::Redo,
132];147];
...@@ -372,6 +387,28 @@ fn perform(...@@ -372,6 +387,28 @@ fn perform(
372 )387 )
373 .unwrap_or(false)388 .unwrap_or(false)
374 }389 }
390 Kind::Link => {
391 range(editor, random)
392 && editor
393 .set_link(engine, "linked", "https://example.invalid/ops")
394 .is_ok()
395 }
396 Kind::Unlink => place(editor, middle) && editor.remove_link(engine).unwrap_or(false),
397 Kind::TypedUrl => {
398 place(editor, middle)
399 && editor.insert(engine, " www.example.invalid").is_ok()
400 && editor.insert(engine, " ").is_ok()
401 }
402 Kind::Equation => {
403 place(editor, middle)
404 && editor.insert_equation(engine).is_ok()
405 && editor.insert(engine, "x^2").is_ok()
406 && editor.insert(engine, " ").is_ok()
407 }
408 Kind::Linear => place(editor, middle) && editor.linear_equation(engine).unwrap_or(false),
409 Kind::Professional => {
410 place(editor, middle) && editor.build_equation(engine).unwrap_or(false)
411 }
375 Kind::Undo => editor.undo(engine).unwrap_or(false),412 Kind::Undo => editor.undo(engine).unwrap_or(false),
376 Kind::Redo => editor.redo(engine).unwrap_or(false),413 Kind::Redo => editor.redo(engine).unwrap_or(false),
377 }414 }
crates/canvas/tests/structural_roundtrip.rs+2-2
...@@ -384,7 +384,7 @@ fn sweep(...@@ -384,7 +384,7 @@ fn sweep(
384#[test]384#[test]
385fn structural_edits_reread_as_saved() {385fn structural_edits_reread_as_saved() {
386 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");386 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
387 let mut paths = vec![root.join("evidence/structural-edits/probe-section/probe.one")];387 let mut paths = vec![root.join("corpus/structural-probe/probe.one")];
388 if let Some(extra) = std::env::var_os("CANVAS_SWEEP_SECTIONS") {388 if let Some(extra) = std::env::var_os("CANVAS_SWEEP_SECTIONS") {
389 paths.extend(std::env::split_paths(&extra));389 paths.extend(std::env::split_paths(&extra));
390 }390 }
...@@ -522,7 +522,7 @@ fn structural_edits_write_what_onenote_reads() {...@@ -522,7 +522,7 @@ fn structural_edits_write_what_onenote_reads() {
522 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");522 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
523 let read = |path: &str| std::fs::read(root.join(path)).unwrap();523 let read = |path: &str| std::fs::read(root.join(path)).unwrap();
524 let mut engine = TextEngine::default();524 let mut engine = TextEngine::default();
525 let mut probe = read("evidence/structural-edits/probe-section/probe.one");525 let mut probe = read("corpus/structural-probe/probe.one");
526 for (title, keys) in [526 for (title, keys) in [
527 ("c6-plain", vec![(Before("Above"), Press::Tab)]),527 ("c6-plain", vec![(Before("Above"), Press::Tab)]),
528 ("c6-first", vec![(Before("Target text"), Press::ShiftTab)]),528 ("c6-first", vec![(Before("Target text"), Press::ShiftTab)]),
crates/draw/src/edit.rs+37-2
...@@ -87,6 +87,9 @@ pub enum Command {...@@ -87,6 +87,9 @@ pub enum Command {
87 ScrollPage { up: bool },87 ScrollPage { up: bool },
88 /// Moves the caret a page and the view with it, extending the selection with Shift.88 /// Moves the caret a page and the view with it, extending the selection with Shift.
89 MovePage { up: bool },89 MovePage { up: bool },
90 /// Moves the selected paragraphs past their sibling above or below: OneNote's Alt+Shift+Up
91 /// and Down, Option-Command on macOS as OneNote for Mac binds them.
92 MoveParagraphs { up: bool },
90}93}
9194
92impl Command {95impl Command {
...@@ -285,6 +288,13 @@ fn mac_command(key: &Key, modifiers: Modifiers) -> Option<Command> {...@@ -285,6 +288,13 @@ fn mac_command(key: &Key, modifiers: Modifiers) -> Option<Command> {
285 }))288 }))
286 };289 };
287 match key {290 match key {
291 Key::Named(arrow @ (NamedKey::ArrowUp | NamedKey::ArrowDown))
292 if command && option && !shift && !control =>
293 {
294 Some(Command::MoveParagraphs {
295 up: *arrow == NamedKey::ArrowUp,
296 })
297 }
288 Key::Named(NamedKey::ArrowLeft) => {298 Key::Named(NamedKey::ArrowLeft) => {
289 pick(Movement::Left, Movement::WordLeft, Movement::LineStart)299 pick(Movement::Left, Movement::WordLeft, Movement::LineStart)
290 }300 }
...@@ -341,10 +351,18 @@ fn mac_command(key: &Key, modifiers: Modifiers) -> Option<Command> {...@@ -341,10 +351,18 @@ fn mac_command(key: &Key, modifiers: Modifiers) -> Option<Command> {
341/// and End reaches the document's ends; Control with Up and Down moves by paragraphs.351/// and End reaches the document's ends; Control with Up and Down moves by paragraphs.
342fn pc_command(key: &Key, modifiers: Modifiers) -> Option<Command> {352fn pc_command(key: &Key, modifiers: Modifiers) -> Option<Command> {
343 let Modifiers {353 let Modifiers {
344 control, option, ..354 shift,
355 control,
356 option,
357 ..
345 } = modifiers;358 } = modifiers;
346 if option {359 if option {
347 return None;360 return match key {
361 _ if !shift || control => None,
362 Key::Named(NamedKey::ArrowUp) => Some(Command::MoveParagraphs { up: true }),
363 Key::Named(NamedKey::ArrowDown) => Some(Command::MoveParagraphs { up: false }),
364 _ => None,
365 };
348 }366 }
349 let pick =367 let pick =
350 |plain, with_control| Some(Command::Move(if control { with_control } else { plain }));368 |plain, with_control| Some(Command::Move(if control { with_control } else { plain }));
...@@ -643,6 +661,16 @@ mod tests {...@@ -643,6 +661,16 @@ mod tests {
643 None,661 None,
644 ),662 ),
645 (Key::Character("a".into()), Modifiers::default(), None),663 (Key::Character("a".into()), Modifiers::default(), None),
664 (
665 named(NamedKey::ArrowDown),
666 with(|m| (m.command, m.option) = (true, true)),
667 Some(Command::MoveParagraphs { up: false }),
668 ),
669 (
670 named(NamedKey::ArrowUp),
671 with(|m| (m.shift, m.option) = (true, true)),
672 Some(Command::Move(Movement::ParagraphStart)),
673 ),
646 ];674 ];
647 for (key, modifiers, command) in cases {675 for (key, modifiers, command) in cases {
648 assert_eq!(676 assert_eq!(
...@@ -666,8 +694,15 @@ mod tests {...@@ -666,8 +694,15 @@ mod tests {
666 option: true,694 option: true,
667 ..Modifiers::default()695 ..Modifiers::default()
668 };696 };
697 let alt_shift = Modifiers { shift: true, ..alt };
669 let named = |named| Key::Named(named);698 let named = |named| Key::Named(named);
670 let cases = [699 let cases = [
700 (
701 named(NamedKey::ArrowUp),
702 alt_shift,
703 Some(Command::MoveParagraphs { up: true }),
704 ),
705 (named(NamedKey::ArrowUp), alt, None),
671 (706 (
672 named(NamedKey::ArrowLeft),707 named(NamedKey::ArrowLeft),
673 control,708 control,
crates/draw/src/render.rs+229-22
...@@ -1,7 +1,11 @@...@@ -1,7 +1,11 @@
1mod icon;1mod icon;
2
3pub use icon::Palette;
2mod text;4mod text;
5mod translucent;
36
4pub use text::{Decoration, Glyph, GlyphRun, Glyphs, paint_parley_run};7pub use text::{Decoration, Glyph, GlyphRun, Glyphs, paint_parley_run};
8pub use translucent::Translucent;
59
6use bytemuck::{Pod, Zeroable};10use bytemuck::{Pod, Zeroable};
7use image::ImageDecoder;11use image::ImageDecoder;
...@@ -150,10 +154,20 @@ struct CachedImage {...@@ -150,10 +154,20 @@ struct CachedImage {
150154
151struct Batch {155struct Batch {
152 vertices: Range<u32>,156 vertices: Range<u32>,
153 image: Option<u64>,157 blend: Blend,
154 scissor: [u32; 4],158 scissor: [u32; 4],
155}159}
156160
161/// How a batch meets what lies beneath it.
162#[derive(Clone, Copy, PartialEq)]
163enum Blend {
164 Over,
165 /// A premultiplied picture over it.
166 Image(u64),
167 /// Clearing it by the batch's coverage.
168 Erase,
169}
170
157#[repr(C)]171#[repr(C)]
158#[derive(Clone, Copy, Pod, Zeroable)]172#[derive(Clone, Copy, Pod, Zeroable)]
159struct Vertex {173struct Vertex {
...@@ -181,6 +195,9 @@ enum AtlasKey {...@@ -181,6 +195,9 @@ enum AtlasKey {
181 sources: &'static [&'static str],195 sources: &'static [&'static str],
182 size: u32,196 size: u32,
183 phase: [u8; 2],197 phase: [u8; 2],
198 /// The bits of the colour `currentColor` paints.
199 ink: [u32; 3],
200 palette: [Option<[u32; 3]>; 3],
184 },201 },
185 Path {202 Path {
186 data: String,203 data: String,
...@@ -213,9 +230,47 @@ pub struct Layer<'a> {...@@ -213,9 +230,47 @@ pub struct Layer<'a> {
213 /// The colour behind the layer: on a dark one, text in dark colours of its own is230 /// The colour behind the layer: on a dark one, text in dark colours of its own is
214 /// lifted to stay legible against what lies behind it, as OneNote's dark page does.231 /// lifted to stay legible against what lies behind it, as OneNote's dark page does.
215 pub backdrop: Option<[f32; 4]>,232 pub backdrop: Option<[f32; 4]>,
233 pub motion: Option<Motion>,
216 pub primitives: &'a [Primitive<'a>],234 pub primitives: &'a [Primitive<'a>],
217}235}
218236
237/// A layer part of the way through appearing: faded, and leaned back in perspective.
238#[derive(Clone, Copy, Debug, PartialEq)]
239pub struct Motion {
240 pub opacity: f32,
241 /// Radians the layer turns about the horizontal line through `pivot`, its part below
242 /// the line leaning away from the viewer.
243 pub tilt: f32,
244 /// Device point the layer turns about.
245 pub pivot: [f32; 2],
246}
247
248impl Motion {
249 fn apply(&self, vertex: &mut Vertex, size: [u32; 2], blend: Blend) {
250 match blend {
251 Blend::Image(_) => vertex.color = vertex.color.map(|v| v * self.opacity),
252 Blend::Over | Blend::Erase => vertex.color[3] *= self.opacity,
253 }
254 if self.tilt == 0.0 {
255 return;
256 }
257 let [width, height] = size.map(|v| v as f32);
258 // Seen from twice the target's height away, so a small turn reads as depth.
259 let distance = 2.0 * height;
260 let [x, y] = [
261 (vertex.position[0] + 1.0) * width / 2.0,
262 (1.0 - vertex.position[1]) * height / 2.0,
263 ];
264 let below = y - self.pivot[1];
265 let near = distance / (distance + below * self.tilt.sin());
266 let [x, y] = [
267 self.pivot[0] + (x - self.pivot[0]) * near,
268 self.pivot[1] + below * self.tilt.cos() * near,
269 ];
270 vertex.position = [x * 2.0 / width - 1.0, 1.0 - y * 2.0 / height];
271 }
272}
273
219/// A layer's transform onto the target.274/// A layer's transform onto the target.
220#[derive(Clone, Copy)]275#[derive(Clone, Copy)]
221struct Space {276struct Space {
...@@ -223,6 +278,7 @@ struct Space {...@@ -223,6 +278,7 @@ struct Space {
223 scale: f32,278 scale: f32,
224 origin: [f32; 2],279 origin: [f32; 2],
225 backdrop: Option<[f32; 4]>,280 backdrop: Option<[f32; 4]>,
281 motion: Option<Motion>,
226}282}
227283
228impl Space {284impl Space {
...@@ -271,6 +327,9 @@ pub enum PathStyle {...@@ -271,6 +327,9 @@ pub enum PathStyle {
271 Stroke(f32),327 Stroke(f32),
272 /// Filled and blurred this far, as a soft shadow.328 /// Filled and blurred this far, as a soft shadow.
273 Shadow(f32),329 Shadow(f32),
330 /// Filled, clearing what lies beneath towards transparency by the colours' opacity, so
331 /// whatever the system shows behind the window shows through.
332 Erase,
274}333}
275334
276#[derive(Clone, Copy, Debug, PartialEq)]335#[derive(Clone, Copy, Debug, PartialEq)]
...@@ -291,12 +350,14 @@ pub enum Primitive<'a> {...@@ -291,12 +350,14 @@ pub enum Primitive<'a> {
291 ink: [f32; 4],350 ink: [f32; 4],
292 },351 },
293 /// 16×16 SVG artwork painted `size` units square, later sources over earlier ones.352 /// 16×16 SVG artwork painted `size` units square, later sources over earlier ones.
294 /// `tint` multiplies it; `currentColor` paints white, so it takes the tint's colour.353 /// `currentColor` paints in `tint`'s colour, slots take `palette`'s, other colours keep
354 /// theirs, and `tint`'s opacity fades the whole icon.
295 Icon {355 Icon {
296 sources: &'static [&'static str],356 sources: &'static [&'static str],
297 origin: [f32; 2],357 origin: [f32; 2],
298 size: f32,358 size: f32,
299 tint: [f32; 4],359 tint: [f32; 4],
360 palette: Palette,
300 },361 },
301 /// SVG path data in layer units from `origin`, shaded from `colors[0]` at the top of362 /// SVG path data in layer units from `origin`, shaded from `colors[0]` at the top of
302 /// what it paints to `colors[1]` at the bottom. Each distinct path is rasterized once363 /// what it paints to `colors[1]` at the bottom. Each distinct path is rasterized once
...@@ -378,6 +439,7 @@ pub struct Renderer {...@@ -378,6 +439,7 @@ pub struct Renderer {
378 pub queue: wgpu::Queue,439 pub queue: wgpu::Queue,
379 pipeline: wgpu::RenderPipeline,440 pipeline: wgpu::RenderPipeline,
380 image_pipeline: wgpu::RenderPipeline,441 image_pipeline: wgpu::RenderPipeline,
442 erase_pipeline: wgpu::RenderPipeline,
381 atlas: wgpu::Texture,443 atlas: wgpu::Texture,
382 bind_group: wgpu::BindGroup,444 bind_group: wgpu::BindGroup,
383 image_sampler: wgpu::Sampler,445 image_sampler: wgpu::Sampler,
...@@ -451,8 +513,21 @@ impl Renderer {...@@ -451,8 +513,21 @@ impl Renderer {
451 cache: None,513 cache: None,
452 })514 })
453 };515 };
454 let pipeline = make_pipeline(wgpu::BlendState::ALPHA_BLENDING);516 // Coverage accumulates in alpha, leaving premultiplied colour over a transparent clear.
517 let pipeline = make_pipeline(wgpu::BlendState {
518 color: wgpu::BlendState::ALPHA_BLENDING.color,
519 alpha: wgpu::BlendComponent::OVER,
520 });
455 let image_pipeline = make_pipeline(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING);521 let image_pipeline = make_pipeline(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING);
522 let erase = wgpu::BlendComponent {
523 src_factor: wgpu::BlendFactor::Zero,
524 dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
525 operation: wgpu::BlendOperation::Add,
526 };
527 let erase_pipeline = make_pipeline(wgpu::BlendState {
528 color: erase,
529 alpha: erase,
530 });
456 let atlas = device.create_texture(&wgpu::TextureDescriptor {531 let atlas = device.create_texture(&wgpu::TextureDescriptor {
457 label: Some("Glyph atlas"),532 label: Some("Glyph atlas"),
458 size: wgpu::Extent3d {533 size: wgpu::Extent3d {
...@@ -518,6 +593,7 @@ impl Renderer {...@@ -518,6 +593,7 @@ impl Renderer {
518 queue,593 queue,
519 pipeline,594 pipeline,
520 image_pipeline,595 image_pipeline,
596 erase_pipeline,
521 atlas,597 atlas,
522 bind_group,598 bind_group,
523 image_sampler,599 image_sampler,
...@@ -601,6 +677,7 @@ impl Renderer {...@@ -601,6 +677,7 @@ impl Renderer {
601 scale: layer.scale,677 scale: layer.scale,
602 origin: layer.origin,678 origin: layer.origin,
603 backdrop: layer.backdrop,679 backdrop: layer.backdrop,
680 motion: layer.motion,
604 };681 };
605 for primitive in layer.primitives {682 for primitive in layer.primitives {
606 if let Primitive::Image { image, rect } = primitive683 if let Primitive::Image { image, rect } = primitive
...@@ -647,15 +724,12 @@ impl Renderer {...@@ -647,15 +724,12 @@ impl Renderer {
647 for batch in &self.batches {724 for batch in &self.batches {
648 let [x, y, width, height] = batch.scissor;725 let [x, y, width, height] = batch.scissor;
649 pass.set_scissor_rect(x, y, width, height);726 pass.set_scissor_rect(x, y, width, height);
650 pass.set_pipeline(if batch.image.is_some() {727 let (pipeline, binding) = match batch.blend {
651 &self.image_pipeline728 Blend::Over => (&self.pipeline, &self.bind_group),
652 } else {729 Blend::Image(id) => (&self.image_pipeline, &self.images[&id].binding),
653 &self.pipeline730 Blend::Erase => (&self.erase_pipeline, &self.bind_group),
654 });731 };
655 let binding = batch732 pass.set_pipeline(pipeline);
656 .image
657 .map(|id| &self.images[&id].binding)
658 .unwrap_or(&self.bind_group);
659 pass.set_bind_group(0, binding, &[]);733 pass.set_bind_group(0, binding, &[]);
660 pass.draw(batch.vertices.clone(), 0..1);734 pass.draw(batch.vertices.clone(), 0..1);
661 }735 }
...@@ -676,6 +750,7 @@ impl Renderer {...@@ -676,6 +750,7 @@ impl Renderer {
676 scale: layer.scale,750 scale: layer.scale,
677 origin: layer.origin,751 origin: layer.origin,
678 backdrop: layer.backdrop,752 backdrop: layer.backdrop,
753 motion: layer.motion,
679 };754 };
680 let bounds = match layer.clip {755 let bounds = match layer.clip {
681 Some(clip) => space.scissor(clip),756 Some(clip) => space.scissor(clip),
...@@ -699,7 +774,7 @@ impl Renderer {...@@ -699,7 +774,7 @@ impl Renderer {
699 active_images: &HashSet<u64>,774 active_images: &HashSet<u64>,
700 ) -> Result<(), RenderError> {775 ) -> Result<(), RenderError> {
701 let start = self.vertices.len() as u32;776 let start = self.vertices.len() as u32;
702 let mut image_id = None;777 let mut blend = Blend::Over;
703 let mut scissor = bounds;778 let mut scissor = bounds;
704 match primitive {779 match primitive {
705 Primitive::Icon {780 Primitive::Icon {
...@@ -707,19 +782,25 @@ impl Renderer {...@@ -707,19 +782,25 @@ impl Renderer {
707 origin,782 origin,
708 size,783 size,
709 tint,784 tint,
785 palette,
710 } => {786 } => {
711 let origin = [787 let origin = [
712 space.origin[0] + origin[0] * space.scale,788 space.origin[0] + origin[0] * space.scale,
713 space.origin[1] + origin[1] * space.scale,789 space.origin[1] + origin[1] * space.scale,
714 ];790 ];
715 self.icon(Space { origin, ..space }, sources, *size, *tint)?;791 self.icon(Space { origin, ..space }, sources, *size, *tint, palette)?;
716 }792 }
717 Primitive::Path {793 Primitive::Path {
718 data,794 data,
719 origin,795 origin,
720 style,796 style,
721 colors,797 colors,
722 } => self.path(space, data, *origin, *style, *colors)?,798 } => {
799 if *style == PathStyle::Erase {
800 blend = Blend::Erase;
801 }
802 self.path(space, data, *origin, *style, *colors)?;
803 }
723 Primitive::Text {804 Primitive::Text {
724 text,805 text,
725 origin,806 origin,
...@@ -779,22 +860,27 @@ impl Renderer {...@@ -779,22 +860,27 @@ impl Renderer {
779 Primitive::Image { image, rect } => {860 Primitive::Image { image, rect } => {
780 if let Some(rect) = space.visible_rect(*rect)? {861 if let Some(rect) = space.visible_rect(*rect)? {
781 self.image(image, active_images)?;862 self.image(image, active_images)?;
782 image_id = Some(image.id());863 blend = Blend::Image(image.id());
783 self.quad(space, rect, [0.0, 0.0, 1.0, 1.0], [1.0; 4])?;864 self.quad(space, rect, [0.0, 0.0, 1.0, 1.0], [1.0; 4])?;
784 }865 }
785 }866 }
786 }867 }
787 let end = self.vertices.len() as u32;868 let end = self.vertices.len() as u32;
869 if let Some(motion) = space.motion {
870 for vertex in &mut self.vertices[start as usize..] {
871 motion.apply(vertex, space.size, blend);
872 }
873 }
788 if start != end {874 if start != end {
789 if let Some(last) = self.batches.last_mut()875 if let Some(last) = self.batches.last_mut()
790 && last.image == image_id876 && last.blend == blend
791 && last.scissor == scissor877 && last.scissor == scissor
792 {878 {
793 last.vertices.end = end;879 last.vertices.end = end;
794 } else {880 } else {
795 self.batches.push(Batch {881 self.batches.push(Batch {
796 vertices: start..end,882 vertices: start..end,
797 image: image_id,883 blend,
798 scissor,884 scissor,
799 });885 });
800 }886 }
...@@ -1073,6 +1159,7 @@ impl Renderer {...@@ -1073,6 +1159,7 @@ impl Renderer {
1073 sources: &'static [&'static str],1159 sources: &'static [&'static str],
1074 size: f32,1160 size: f32,
1075 tint: [f32; 4],1161 tint: [f32; 4],
1162 palette: &Palette,
1076 ) -> Result<(), RenderError> {1163 ) -> Result<(), RenderError> {
1077 let size = size * space.scale;1164 let size = size * space.scale;
1078 let [x, y] = space.origin.map(|v| (v * 4.0).round() * 0.25);1165 let [x, y] = space.origin.map(|v| (v * 4.0).round() * 0.25);
...@@ -1090,10 +1177,13 @@ impl Renderer {...@@ -1090,10 +1177,13 @@ impl Renderer {
1090 return Err(RenderError::AtlasFull);1177 return Err(RenderError::AtlasFull);
1091 }1178 }
1092 let phase = [((x - x.floor()) * 4.0) as u8, ((y - y.floor()) * 4.0) as u8];1179 let phase = [((x - x.floor()) * 4.0) as u8, ((y - y.floor()) * 4.0) as u8];
1180 let ink = [tint[0], tint[1], tint[2]];
1093 let key = AtlasKey::Icon {1181 let key = AtlasKey::Icon {
1094 sources,1182 sources,
1095 size: size.to_bits(),1183 size: size.to_bits(),
1096 phase,1184 phase,
1185 ink: ink.map(f32::to_bits),
1186 palette: palette.bits(),
1097 };1187 };
1098 let glyph = if let Some(Some(glyph)) = self.glyphs.get(&key) {1188 let glyph = if let Some(Some(glyph)) = self.glyphs.get(&key) {
1099 *glyph1189 *glyph
...@@ -1101,7 +1191,7 @@ impl Renderer {...@@ -1101,7 +1191,7 @@ impl Renderer {
1101 if self.glyphs.len() >= MAX_GLYPHS {1191 if self.glyphs.len() >= MAX_GLYPHS {
1102 return Err(RenderError::AtlasFull);1192 return Err(RenderError::AtlasFull);
1103 }1193 }
1104 let glyph = self.upload(icon::rasterize(sources, size, phase))?;1194 let glyph = self.upload(icon::rasterize(sources, size, phase, ink, palette))?;
1105 self.glyphs.insert(key, Some(glyph));1195 self.glyphs.insert(key, Some(glyph));
1106 glyph1196 glyph
1107 };1197 };
...@@ -1122,7 +1212,7 @@ impl Renderer {...@@ -1122,7 +1212,7 @@ impl Renderer {
1122 (glyph.x + glyph.width) as f32 / atlas,1212 (glyph.x + glyph.width) as f32 / atlas,
1123 (glyph.y + glyph.height) as f32 / atlas,1213 (glyph.y + glyph.height) as f32 / atlas,
1124 ],1214 ],
1125 tint,1215 [1.0, 1.0, 1.0, tint[3]],
1126 )1216 )
1127 }1217 }
11281218
...@@ -1197,7 +1287,7 @@ impl Renderer {...@@ -1197,7 +1287,7 @@ impl Renderer {
1197 let [x, y] = [0, 1]1287 let [x, y] = [0, 1]
1198 .map(|axis| ((space.origin[axis] + origin[axis] * space.scale) * 4.0).round() * 0.25);1288 .map(|axis| ((space.origin[axis] + origin[axis] * space.scale) * 4.0).round() * 0.25);
1199 let (kind, width) = match style {1289 let (kind, width) = match style {
1200 PathStyle::Fill => (0, 0.0),1290 PathStyle::Fill | PathStyle::Erase => (0, 0.0),
1201 PathStyle::Stroke(width) => (1, width),1291 PathStyle::Stroke(width) => (1, width),
1202 PathStyle::Shadow(width) => (2, width),1292 PathStyle::Shadow(width) => (2, width),
1203 };1293 };
...@@ -1729,6 +1819,10 @@ mod tests {...@@ -1729,6 +1819,10 @@ mod tests {
17291819
1730 impl Target {1820 impl Target {
1731 fn new(device: &wgpu::Device) -> Self {1821 fn new(device: &wgpu::Device) -> Self {
1822 Self::with_format(device, wgpu::TextureFormat::Rgba8UnormSrgb)
1823 }
1824
1825 fn with_format(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
1732 Self {1826 Self {
1733 texture: device.create_texture(&wgpu::TextureDescriptor {1827 texture: device.create_texture(&wgpu::TextureDescriptor {
1734 label: Some("Draw readback test"),1828 label: Some("Draw readback test"),
...@@ -1740,7 +1834,7 @@ mod tests {...@@ -1740,7 +1834,7 @@ mod tests {
1740 mip_level_count: 1,1834 mip_level_count: 1,
1741 sample_count: 1,1835 sample_count: 1,
1742 dimension: wgpu::TextureDimension::D2,1836 dimension: wgpu::TextureDimension::D2,
1743 format: wgpu::TextureFormat::Rgba8UnormSrgb,1837 format,
1744 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,1838 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
1745 view_formats: &[],1839 view_formats: &[],
1746 }),1840 }),
...@@ -1809,6 +1903,7 @@ mod tests {...@@ -1809,6 +1903,7 @@ mod tests {
1809 origin: [24.0; 2],1903 origin: [24.0; 2],
1810 clip: None,1904 clip: None,
1811 backdrop: None,1905 backdrop: None,
1906 motion: None,
1812 primitives,1907 primitives,
1813 }]1908 }]
1814 }1909 }
...@@ -1895,6 +1990,7 @@ mod tests {...@@ -1895,6 +1990,7 @@ mod tests {
1895 origin: [128.125 + 20.0 * index as f32, 80.25],1990 origin: [128.125 + 20.0 * index as f32, 80.25],
1896 size: 12.0,1991 size: 12.0,
1897 tint: [1.0; 4],1992 tint: [1.0; 4],
1993 palette: Palette::default(),
1898 });1994 });
1899 }1995 }
1900 primitives.extend((0..9).map(|step| Primitive::Text {1996 primitives.extend((0..9).map(|step| Primitive::Text {
...@@ -1998,6 +2094,7 @@ mod tests {...@@ -1998,6 +2094,7 @@ mod tests {
1998 origin: [10000.0; 2],2094 origin: [10000.0; 2],
1999 size: 12.0,2095 size: 12.0,
2000 tint: [1.0; 4],2096 tint: [1.0; 4],
2097 palette: Palette::default(),
2001 }]),2098 }]),
2002 )2099 )
2003 .unwrap();2100 .unwrap();
...@@ -2210,6 +2307,7 @@ mod tests {...@@ -2210,6 +2307,7 @@ mod tests {
2210 origin: [0.0; 2],2307 origin: [0.0; 2],
2211 clip: None,2308 clip: None,
2212 backdrop: None,2309 backdrop: None,
2310 motion: None,
2213 primitives: &primitives,2311 primitives: &primitives,
2214 }],2312 }],
2215 )2313 )
...@@ -2246,6 +2344,112 @@ mod tests {...@@ -2246,6 +2344,112 @@ mod tests {
2246 assert_eq!(beyond, [255; 4]);2344 assert_eq!(beyond, [255; 4]);
2247 }2345 }
22482346
2347 #[test]
2348 #[ignore = "requires a native GPU adapter"]
2349 fn transparent_targets_hold_premultiplied_coverage_and_erase() {
2350 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
2351 let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
2352 let (device, queue) =
2353 pollster::block_on(adapter.request_device(&Default::default())).unwrap();
2354 let target = Target::new(&device);
2355 let mut renderer = Renderer::new(device, queue, wgpu::TextureFormat::Rgba8UnormSrgb);
2356 let primitives = [
2357 Primitive::Rect {
2358 rect: [0.0, 0.0, 10.0, 10.0],
2359 color: [1.0, 1.0, 1.0, 0.5],
2360 },
2361 Primitive::Rect {
2362 rect: [20.0, 0.0, 40.0, 10.0],
2363 color: [0.0, 0.0, 1.0, 1.0],
2364 },
2365 Primitive::Path {
2366 data: "M0 0H10V10H0Z",
2367 origin: [30.0, 0.0],
2368 style: PathStyle::Erase,
2369 colors: [[0.0, 0.0, 0.0, 1.0]; 2],
2370 },
2371 ];
2372 renderer
2373 .draw(
2374 &target.texture.create_view(&Default::default()),
2375 [512, 256],
2376 [0.0; 4],
2377 &[Layer {
2378 scale: 1.0,
2379 origin: [0.0; 2],
2380 clip: None,
2381 backdrop: None,
2382 motion: None,
2383 primitives: &primitives,
2384 }],
2385 )
2386 .unwrap();
2387 let capture = target.capture(&renderer);
2388 let pixel = |x: usize, y: usize| &capture[(y * 512 + x) * 4..(y * 512 + x) * 4 + 4];
2389 assert_eq!(pixel(5, 5)[3], 128, "half coverage is half opaque");
2390 assert_eq!(pixel(5, 5)[0], 188, "over premultiplied colour");
2391 assert_eq!(pixel(25, 5), [0, 0, 255, 255]);
2392 assert_eq!(pixel(35, 5), [0; 4], "erased back to transparency");
2393 assert_eq!(pixel(50, 5), [0; 4]);
2394 }
2395
2396 #[test]
2397 #[ignore = "requires a native GPU adapter"]
2398 fn translucent_windows_take_colour_premultiplied_in_srgb() {
2399 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
2400 let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
2401 let (device, queue) =
2402 pollster::block_on(adapter.request_device(&Default::default())).unwrap();
2403 let format = wgpu::TextureFormat::Rgba8UnormSrgb;
2404 let mut renderer = Renderer::new(device.clone(), queue.clone(), format);
2405 let mut translucent = Translucent::new(&device, format, wgpu::TextureFormat::Rgba8Unorm);
2406 let primitives = [
2407 Primitive::Rect {
2408 rect: [0.0, 0.0, 10.0, 10.0],
2409 color: [1.0, 1.0, 1.0, 0.2],
2410 },
2411 Primitive::Rect {
2412 rect: [20.0, 0.0, 30.0, 10.0],
2413 color: [0.2, 0.2, 0.2, 1.0],
2414 },
2415 ];
2416 let frame = translucent.target(&device, [512, 256]);
2417 renderer
2418 .draw(
2419 &frame,
2420 [512, 256],
2421 [0.0; 4],
2422 &[Layer {
2423 scale: 1.0,
2424 origin: [0.0; 2],
2425 clip: None,
2426 backdrop: None,
2427 motion: None,
2428 primitives: &primitives,
2429 }],
2430 )
2431 .unwrap();
2432 let window = Target::with_format(&device, wgpu::TextureFormat::Rgba8Unorm);
2433 translucent.present(
2434 &device,
2435 &queue,
2436 &window.texture.create_view(&Default::default()),
2437 );
2438 let capture = window.capture(&renderer);
2439 let pixel = |x: usize, y: usize| &capture[(y * 512 + x) * 4..(y * 512 + x) * 4 + 4];
2440 assert_eq!(
2441 pixel(5, 5),
2442 [51, 51, 51, 51],
2443 "white at a fifth, premultiplied in sRGB"
2444 );
2445 assert_eq!(
2446 pixel(25, 5),
2447 [124, 124, 124, 255],
2448 "opaque colours encode as ever"
2449 );
2450 assert_eq!(pixel(50, 5), [0; 4]);
2451 }
2452
2249 #[test]2453 #[test]
2250 #[ignore = "requires a native GPU adapter"]2454 #[ignore = "requires a native GPU adapter"]
2251 fn layers_transform_and_clip_independently() {2455 fn layers_transform_and_clip_independently() {
...@@ -2272,6 +2476,7 @@ mod tests {...@@ -2272,6 +2476,7 @@ mod tests {
2272 origin: [0.0; 2],2476 origin: [0.0; 2],
2273 clip: Some([100.0, 50.0, 200.5, 80.0]),2477 clip: Some([100.0, 50.0, 200.5, 80.0]),
2274 backdrop: None,2478 backdrop: None,
2479 motion: None,
2275 primitives: &fill,2480 primitives: &fill,
2276 },2481 },
2277 Layer {2482 Layer {
...@@ -2279,6 +2484,7 @@ mod tests {...@@ -2279,6 +2484,7 @@ mod tests {
2279 origin: [300.0, 10.0],2484 origin: [300.0, 10.0],
2280 clip: None,2485 clip: None,
2281 backdrop: None,2486 backdrop: None,
2487 motion: None,
2282 primitives: &chrome,2488 primitives: &chrome,
2283 },2489 },
2284 ],2490 ],
...@@ -2306,6 +2512,7 @@ mod tests {...@@ -2306,6 +2512,7 @@ mod tests {
2306 origin: [0.0; 2],2512 origin: [0.0; 2],
2307 clip: None,2513 clip: None,
2308 backdrop: None,2514 backdrop: None,
2515 motion: None,
2309 primitives: &fill,2516 primitives: &fill,
2310 }]2517 }]
2311 ),2518 ),
crates/draw/src/render/icon.rs+469-34
...@@ -1,27 +1,62 @@...@@ -1,27 +1,62 @@
1use swash::{1use swash::{
2 scale::image::{Content, Image},2 scale::image::{Content, Image},
3 zeno::{Cap, Fill, Join, Mask, Placement, Stroke, Style, Transform, Vector},3 zeno::{
4 self, Cap, Fill, Join, Mask, PathBuilder, Placement, Point, Stroke, Style, Transform,
5 Vector,
6 },
4};7};
58
9/// Colours for an icon's slots, linear RGB: paths of `class="accent"`, `"highlight"` or
10/// `"badge"` are recoloured to their slot's colour; an empty slot keeps the art's colours.
11#[derive(Clone, Copy, Debug, Default, PartialEq)]
12pub struct Palette {
13 pub accent: Option<[f32; 3]>,
14 pub highlight: Option<[f32; 3]>,
15 pub badge: Option<[f32; 3]>,
16}
17
18impl Palette {
19 fn slot(&self, class: &str) -> Option<[f32; 3]> {
20 match class {
21 "accent" => self.accent,
22 "highlight" => self.highlight,
23 "badge" => self.badge,
24 _ => None,
25 }
26 }
27
28 /// The palette as hashable bits, for caching icons per distinct palette.
29 pub(crate) fn bits(&self) -> [Option<[u32; 3]>; 3] {
30 [self.accent, self.highlight, self.badge]
31 .map(|slot| slot.map(|color| color.map(f32::to_bits)))
32 }
33}
34
6/// Rasterizes 16×16 SVG sources over each other at `size` device pixels, offset by35/// Rasterizes 16×16 SVG sources over each other at `size` device pixels, offset by
7/// `phase` quarter pixels. Paths are filled and stroked with `#rrggbb`, two-stop vertical36/// `phase` quarter pixels. Paths are filled and stroked with `#rrggbb`, `currentColor`,
8/// gradients or `currentColor`, which paints white; strokes have round caps and joins.37/// which paints the linear `ink`, or two-stop `linearGradient`s with SVG's defaults
9pub(crate) fn rasterize(sources: &[&str], size: f32, phase: [u8; 2]) -> Image {38/// (`objectBoundingBox` unless `userSpaceOnUse`, `x1`..`y2`, stop offsets, pad spread; no
39/// `gradientTransform`). `fill-opacity` and `stroke-opacity` apply, and strokes have round
40/// caps and joins. A slot's paths keep the shading among their colours: the hue turn,
41/// saturation scale and lightness shift that carry the mean of the slot's colours in a
42/// source onto the palette's colour apply to each of them.
43pub(crate) fn rasterize(
44 sources: &[&str],
45 size: f32,
46 phase: [u8; 2],
47 ink: [f32; 3],
48 palette: &Palette,
49) -> Image {
10 let side = (size + 1.0).ceil() as u32;50 let side = (size + 1.0).ceil() as u32;
11 let mut pixels = vec![[0.0_f32; 4]; (side * side) as usize];51 let mut pixels = vec![[0.0_f32; 4]; (side * side) as usize];
52 let offset = phase.map(|quarters| f32::from(quarters) * 0.25);
12 for source in sources {53 for source in sources {
13 let svg = roxmltree::Document::parse(source).expect("Bundled icon SVG must be valid");54 let svg = roxmltree::Document::parse(source).expect("Bundled icon SVG must be valid");
14 assert_eq!(svg.root_element().attribute("viewBox"), Some("0 0 16 16"));55 assert_eq!(svg.root_element().attribute("viewBox"), Some("0 0 16 16"));
15 let paint = |value: &str| -> Option<[[f32; 4]; 2]> {56 let paint = |value: &str| -> Option<Paint> {
16 let hex = |value: &str| {
17 let [_, r, g, b] = u32::from_str_radix(value.strip_prefix('#').unwrap(), 16)
18 .unwrap()
19 .to_be_bytes();
20 super::srgb(r, g, b)
21 };
22 match value {57 match value {
23 "none" => None,58 "none" => None,
24 "currentColor" => Some([[1.0; 4]; 2]),59 "currentColor" => Some(Paint::Flat([ink[0], ink[1], ink[2], 1.0])),
25 _ => Some(match value.strip_prefix("url(#") {60 _ => Some(match value.strip_prefix("url(#") {
26 Some(id) => {61 Some(id) => {
27 let id = id.strip_suffix(')').unwrap();62 let id = id.strip_suffix(')').unwrap();
...@@ -29,18 +64,38 @@ pub(crate) fn rasterize(sources: &[&str], size: f32, phase: [u8; 2]) -> Image {...@@ -29,18 +64,38 @@ pub(crate) fn rasterize(sources: &[&str], size: f32, phase: [u8; 2]) -> Image {
29 .descendants()64 .descendants()
30 .find(|node| node.attribute("id") == Some(id))65 .find(|node| node.attribute("id") == Some(id))
31 .unwrap();66 .unwrap();
32 let stops: Vec<_> = gradient67 Paint::linear(gradient)
33 .children()
34 .filter(|node| node.has_tag_name("stop"))
35 .map(|node| hex(node.attribute("stop-color").unwrap()))
36 .collect();
37 stops.try_into().expect("Icon gradients have two stops")
38 }68 }
39 None => [hex(value); 2],69 None => Paint::Flat(hex(value)),
40 }),70 }),
41 }71 }
42 };72 };
43 for path in svg.descendants().filter(|node| node.has_tag_name("path")) {73 let paths = || svg.descendants().filter(|node| node.has_tag_name("path"));
74 let shifts: Vec<_> = ["accent", "highlight", "badge"]
75 .into_iter()
76 .filter_map(|class| {
77 let target = palette.slot(class)?;
78 let colors: Vec<_> = paths()
79 .filter(|path| path.attribute("class") == Some(class))
80 .flat_map(|path| {
81 [
82 path.attribute("fill").unwrap_or("#000000"),
83 path.attribute("stroke").unwrap_or("none"),
84 ]
85 })
86 .filter(|value| *value != "currentColor")
87 .filter_map(paint)
88 .flat_map(|paint| paint.colors())
89 .collect();
90 Some((class, Shift::new(&colors, target)?))
91 })
92 .collect();
93 for path in paths() {
94 let data = path.attribute("d").unwrap();
95 let shift = shifts
96 .iter()
97 .find(|(class, _)| path.attribute("class") == Some(*class))
98 .map(|(_, shift)| shift);
44 let fill = match path.attribute("fill-rule") {99 let fill = match path.attribute("fill-rule") {
45 Some("evenodd") => Fill::EvenOdd,100 Some("evenodd") => Fill::EvenOdd,
46 _ => Fill::NonZero,101 _ => Fill::NonZero,
...@@ -48,13 +103,19 @@ pub(crate) fn rasterize(sources: &[&str], size: f32, phase: [u8; 2]) -> Image {...@@ -48,13 +103,19 @@ pub(crate) fn rasterize(sources: &[&str], size: f32, phase: [u8; 2]) -> Image {
48 let pen = path103 let pen = path
49 .attribute("stroke-width")104 .attribute("stroke-width")
50 .map_or(1.0, |width| width.parse().unwrap());105 .map_or(1.0, |width| width.parse().unwrap());
106 let opacity = |name| {
107 path.attribute(name)
108 .map_or(1.0, |value| value.parse().unwrap())
109 };
51 let layers = [110 let layers = [
52 (111 (
53 path.attribute("fill").unwrap_or("#000000"),112 path.attribute("fill").unwrap_or("#000000"),
113 opacity("fill-opacity"),
54 Style::from(fill),114 Style::from(fill),
55 ),115 ),
56 (116 (
57 path.attribute("stroke").unwrap_or("none"),117 path.attribute("stroke").unwrap_or("none"),
118 opacity("stroke-opacity"),
58 Style::from(Stroke {119 Style::from(Stroke {
59 start_cap: Cap::Round,120 start_cap: Cap::Round,
60 end_cap: Cap::Round,121 end_cap: Cap::Round,
...@@ -63,22 +124,32 @@ pub(crate) fn rasterize(sources: &[&str], size: f32, phase: [u8; 2]) -> Image {...@@ -63,22 +124,32 @@ pub(crate) fn rasterize(sources: &[&str], size: f32, phase: [u8; 2]) -> Image {
63 }),124 }),
64 ),125 ),
65 ];126 ];
66 for (value, style) in layers {127 for (value, opacity, style) in layers {
67 let Some([top, bottom]) = paint(value) else {128 let Some(mut paint) = paint(value) else {
68 continue;129 continue;
69 };130 };
70 let (mask, _) = Mask::new(path.attribute("d").unwrap())131 if let Some(shift) = shift.filter(|_| value != "currentColor") {
132 paint.recolor(|color| shift.apply(color));
133 }
134 let color = paint.shader(data);
135 let (mask, _) = Mask::new(data)
71 .style(style)136 .style(style)
72 .transform(Some(Transform::scale(size / 16.0, size / 16.0)))137 .transform(Some(Transform::scale(size / 16.0, size / 16.0)))
73 .render_offset(Vector::new(phase[0] as f32 * 0.25, phase[1] as f32 * 0.25))138 .render_offset(Vector::new(offset[0], offset[1]))
74 .size(side, side)139 .size(side, side)
75 .render();140 .render();
76 for (index, (pixel, coverage)) in pixels.iter_mut().zip(mask).enumerate() {141 for (index, (pixel, coverage)) in pixels.iter_mut().zip(mask).enumerate() {
77 let alpha = f32::from(coverage) / 255.0;142 if coverage == 0 {
78 let y = ((index / side as usize) as f32 / size).min(1.0);143 continue;
144 }
145 let alpha = f32::from(coverage) / 255.0 * opacity;
146 // The pixel's centre in icon units.
147 let [x, y] = [index % side as usize, index / side as usize]
148 .map(|device| device as f32 + 0.5)
149 .map(|device| device * 16.0 / size);
150 let color = color([x - offset[0] * 16.0 / size, y - offset[1] * 16.0 / size]);
79 for channel in 0..3 {151 for channel in 0..3 {
80 let color = top[channel] + (bottom[channel] - top[channel]) * y;152 pixel[channel] = color[channel] * alpha + pixel[channel] * (1.0 - alpha);
81 pixel[channel] = color * alpha + pixel[channel] * (1.0 - alpha);
82 }153 }
83 pixel[3] = alpha + pixel[3] * (1.0 - alpha);154 pixel[3] = alpha + pixel[3] * (1.0 - alpha);
84 }155 }
...@@ -114,6 +185,270 @@ pub(crate) fn rasterize(sources: &[&str], size: f32, phase: [u8; 2]) -> Image {...@@ -114,6 +185,270 @@ pub(crate) fn rasterize(sources: &[&str], size: f32, phase: [u8; 2]) -> Image {
114 }185 }
115}186}
116187
188fn hex(value: &str) -> [f32; 4] {
189 let [_, r, g, b] = u32::from_str_radix(value.strip_prefix('#').unwrap(), 16)
190 .unwrap()
191 .to_be_bytes();
192 super::srgb(r, g, b)
193}
194
195/// A number or a percentage of `whole`.
196fn number(value: &str, whole: f32) -> f32 {
197 match value.strip_suffix('%') {
198 Some(percent) => percent.parse::<f32>().unwrap() / 100.0 * whole,
199 None => value.parse().unwrap(),
200 }
201}
202
203enum Paint {
204 Flat([f32; 4]),
205 Linear {
206 /// Colour and offset of each stop.
207 stops: [([f32; 4], f32); 2],
208 /// Where offsets 0 and 1 lie, in icon units or fractions of the shape's box.
209 line: [[f32; 2]; 2],
210 bounding: bool,
211 },
212}
213
214impl Paint {
215 fn colors(&self) -> Vec<[f32; 4]> {
216 match self {
217 Self::Flat(color) => vec![*color],
218 Self::Linear { stops, .. } => stops.iter().map(|(color, _)| *color).collect(),
219 }
220 }
221
222 fn recolor(&mut self, change: impl Fn([f32; 4]) -> [f32; 4]) {
223 match self {
224 Self::Flat(color) => *color = change(*color),
225 Self::Linear { stops, .. } => {
226 for (color, _) in stops {
227 *color = change(*color);
228 }
229 }
230 }
231 }
232
233 fn linear(gradient: roxmltree::Node) -> Self {
234 assert!(
235 gradient.attribute("gradientTransform").is_none(),
236 "Icon gradients have no gradientTransform"
237 );
238 let stops: Vec<_> = gradient
239 .children()
240 .filter(|node| node.has_tag_name("stop"))
241 .map(|node| {
242 (
243 hex(node.attribute("stop-color").unwrap()),
244 node.attribute("offset")
245 .map_or(0.0, |offset| number(offset, 1.0)),
246 )
247 })
248 .collect();
249 let bounding = gradient.attribute("gradientUnits") != Some("userSpaceOnUse");
250 // Percentages are of the shape's box or of the icon.
251 let whole = if bounding { 1.0 } else { 16.0 };
252 let at = |name, default| {
253 gradient
254 .attribute(name)
255 .map_or(default * whole, |value| number(value, whole))
256 };
257 Self::Linear {
258 stops: stops.try_into().expect("Icon gradients have two stops"),
259 line: [
260 [at("x1", 0.0), at("y1", 0.0)],
261 [at("x2", 1.0), at("y2", 0.0)],
262 ],
263 bounding,
264 }
265 }
266
267 /// The colour at each point of `data` in icon units.
268 fn shader(&self, data: &str) -> impl Fn([f32; 2]) -> [f32; 4] {
269 let (stops, [from, to], frame) = match *self {
270 Self::Flat(color) => ([(color, 0.0), (color, 1.0)], [[0.0; 2], [1.0, 0.0]], None),
271 Self::Linear {
272 stops,
273 line,
274 bounding,
275 } => (stops, line, bounding.then(|| bounds(data))),
276 };
277 let direction = [to[0] - from[0], to[1] - from[1]];
278 let length = direction[0] * direction[0] + direction[1] * direction[1];
279 move |point| {
280 let [x, y] = match frame {
281 // A flat box has no extent to spread across on that axis.
282 Some([min, max]) => std::array::from_fn(|axis| {
283 let extent = max[axis] - min[axis];
284 if extent > 0.0 {
285 (point[axis] - min[axis]) / extent
286 } else {
287 0.0
288 }
289 }),
290 None => point,
291 };
292 // A gradient of no length paints its last stop.
293 let along = if length > 0.0 {
294 ((x - from[0]) * direction[0] + (y - from[1]) * direction[1]) / length
295 } else {
296 1.0
297 };
298 let [(first, start), (last, end)] = stops;
299 let t = if end > start {
300 ((along - start) / (end - start)).clamp(0.0, 1.0)
301 } else {
302 f32::from(along >= start)
303 };
304 std::array::from_fn(|channel| first[channel] + (last[channel] - first[channel]) * t)
305 }
306 }
307}
308
309/// A turn of hue, scale of saturation and shift of lightness in sRGB's HSL.
310struct Shift([f32; 3]);
311
312impl Shift {
313 /// The shift carrying the mean of linear `colors` onto linear `target`.
314 fn new(colors: &[[f32; 4]], target: [f32; 3]) -> Option<Self> {
315 let count = colors.len() as f32;
316 let mean: [f32; 3] = std::array::from_fn(|channel| {
317 colors
318 .iter()
319 .map(|color| encode(color[channel]))
320 .sum::<f32>()
321 / count
322 });
323 let [from, to] = [mean, target.map(encode)].map(hsl);
324 (count > 0.0).then(|| {
325 Self([
326 to[0] - from[0],
327 if from[1] > 0.0 { to[1] / from[1] } else { 0.0 },
328 to[2] - from[2],
329 ])
330 })
331 }
332
333 fn apply(&self, color: [f32; 4]) -> [f32; 4] {
334 let [hue, saturation, lightness] = hsl([color[0], color[1], color[2]].map(encode));
335 let [turn, scale, lift] = self.0;
336 let [r, g, b] = rgb([
337 (hue + turn).rem_euclid(360.0),
338 (saturation * scale).clamp(0.0, 1.0),
339 (lightness + lift).clamp(0.0, 1.0),
340 ])
341 .map(decode);
342 [r, g, b, color[3]]
343 }
344}
345
346fn encode(linear: f32) -> f32 {
347 if linear <= 0.003_130_8 {
348 linear * 12.92
349 } else {
350 1.055 * linear.powf(1.0 / 2.4) - 0.055
351 }
352}
353
354fn decode(encoded: f32) -> f32 {
355 if encoded <= 0.040_45 {
356 encoded / 12.92
357 } else {
358 ((encoded + 0.055) / 1.055).powf(2.4)
359 }
360}
361
362/// Hue in degrees, saturation and lightness of an encoded sRGB colour.
363fn hsl([r, g, b]: [f32; 3]) -> [f32; 3] {
364 let [max, min] = [r.max(g).max(b), r.min(g).min(b)];
365 let lightness = (max + min) / 2.0;
366 let range = max - min;
367 if range == 0.0 {
368 return [0.0, 0.0, lightness];
369 }
370 let saturation = range / (1.0 - (2.0 * lightness - 1.0).abs());
371 let sector = if max == r {
372 (g - b) / range
373 } else if max == g {
374 (b - r) / range + 2.0
375 } else {
376 (r - g) / range + 4.0
377 };
378 [(sector * 60.0).rem_euclid(360.0), saturation, lightness]
379}
380
381fn rgb([hue, saturation, lightness]: [f32; 3]) -> [f32; 3] {
382 let chroma = (1.0 - (2.0 * lightness - 1.0).abs()) * saturation;
383 [0.0, 8.0, 4.0].map(|offset: f32| {
384 let k = (offset + hue / 30.0).rem_euclid(12.0);
385 lightness - chroma / 2.0 * (k - 3.0).min(9.0 - k).clamp(-1.0, 1.0)
386 })
387}
388
389/// The tight box around a path's geometry, curves included, as SVG's bounding box is.
390fn bounds(data: &str) -> [[f32; 2]; 2] {
391 struct Sampled {
392 current: Point,
393 min: [f32; 2],
394 max: [f32; 2],
395 }
396 impl Sampled {
397 fn add(&mut self, point: Point) {
398 self.min = [self.min[0].min(point.x), self.min[1].min(point.y)];
399 self.max = [self.max[0].max(point.x), self.max[1].max(point.y)];
400 }
401 }
402 impl PathBuilder for Sampled {
403 fn current_point(&self) -> Point {
404 self.current
405 }
406 fn move_to(&mut self, to: impl Into<Point>) -> &mut Self {
407 self.current = to.into();
408 self.add(self.current);
409 self
410 }
411 fn line_to(&mut self, to: impl Into<Point>) -> &mut Self {
412 self.move_to(to)
413 }
414 fn quad_to(&mut self, control: impl Into<Point>, to: impl Into<Point>) -> &mut Self {
415 let [start, control, to] = [self.current, control.into(), to.into()];
416 let lift = |end: Point| end + (control - end) * (2.0 / 3.0);
417 self.curve_to(lift(start), lift(to), to)
418 }
419 fn curve_to(
420 &mut self,
421 first: impl Into<Point>,
422 second: impl Into<Point>,
423 to: impl Into<Point>,
424 ) -> &mut Self {
425 let [start, first, second, to] = [self.current, first.into(), second.into(), to.into()];
426 for step in 1..=32 {
427 let t = step as f32 / 32.0;
428 let u = 1.0 - t;
429 self.add(
430 start * (u * u * u)
431 + first * (3.0 * u * u * t)
432 + second * (3.0 * u * t * t)
433 + to * (t * t * t),
434 );
435 }
436 self.current = to;
437 self
438 }
439 fn close(&mut self) -> &mut Self {
440 self
441 }
442 }
443 let mut sampled = Sampled {
444 current: Point::ZERO,
445 min: [f32::INFINITY; 2],
446 max: [f32::NEG_INFINITY; 2],
447 };
448 zeno::apply(data, Fill::NonZero, None, &mut sampled);
449 [sampled.min, sampled.max]
450}
451
117#[cfg(test)]452#[cfg(test)]
118mod tests {453mod tests {
119 use super::*;454 use super::*;
...@@ -121,23 +456,123 @@ mod tests {...@@ -121,23 +456,123 @@ mod tests {
121 const BOX: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><linearGradient id="a" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#8090a0"/></linearGradient></defs><path fill="url(#a)" d="M2 2h12v12H2z"/></svg>"##;456 const BOX: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><linearGradient id="a" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#8090a0"/></linearGradient></defs><path fill="url(#a)" d="M2 2h12v12H2z"/></svg>"##;
122 const MARK: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><linearGradient id="b" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ff2000"/><stop offset="1" stop-color="#a00000"/></linearGradient></defs><path fill="url(#b)" d="M4 8l3 3 5-7-1-1-4 5-2-2z"/></svg>"##;457 const MARK: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><linearGradient id="b" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ff2000"/><stop offset="1" stop-color="#a00000"/></linearGradient></defs><path fill="url(#b)" d="M4 8l3 3 5-7-1-1-4 5-2-2z"/></svg>"##;
123458
459 fn gradient(attributes: &str, stops: &str) -> String {
460 format!(
461 r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><linearGradient id="g" {attributes}>{stops}</linearGradient></defs><path fill="url(#g)" d="M0 4H16V12H0Z"/></svg>"##
462 )
463 }
464
465 const BLACK_WHITE: &str =
466 r##"<stop offset="0" stop-color="#000000"/><stop offset="1" stop-color="#ffffff"/>"##;
467
468 /// The grey painted at a pixel of `source` rasterized at 16 pixels, and the grey a
469 /// black-to-white ramp paints at fraction `t`.
470 fn grey(source: &str, x: usize, y: usize) -> u8 {
471 rasterize(&[source], 16.0, [0, 0], [1.0; 3], &Palette::default()).data[(y * 17 + x) * 4]
472 }
473
474 fn ramp(t: f32) -> u8 {
475 super::super::srgb_byte(t)
476 }
477
478 #[test]
479 fn gradients_span_the_shape_unless_in_user_space() {
480 let vertical = gradient(r#"x2="0" y2="1""#, BLACK_WHITE);
481 // Pixel centres 4.5 and 11.5 lie 0.5 inside the shape's 8-unit box.
482 assert_eq!(grey(&vertical, 8, 4), ramp(0.5 / 8.0));
483 assert_eq!(grey(&vertical, 8, 11), ramp(7.5 / 8.0));
484 let user = gradient(
485 r#"gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="0" y2="16""#,
486 BLACK_WHITE,
487 );
488 assert_eq!(grey(&user, 8, 4), ramp(4.5 / 16.0));
489 assert_eq!(grey(&user, 8, 11), ramp(11.5 / 16.0));
490 }
491
492 #[test]
493 fn gradients_default_to_horizontal_and_pad_past_their_stops() {
494 let across = gradient("", BLACK_WHITE);
495 assert_eq!(grey(&across, 3, 4), ramp(3.5 / 16.0));
496 assert_eq!(grey(&across, 3, 11), ramp(3.5 / 16.0));
497 let inner = gradient(
498 r#"x2="0" y2="1""#,
499 r##"<stop offset="25%" stop-color="#000000"/><stop offset="75%" stop-color="#ffffff"/>"##,
500 );
501 assert_eq!(grey(&inner, 8, 4), 0);
502 assert_eq!(grey(&inner, 8, 11), 255);
503 assert_eq!(grey(&inner, 8, 7), ramp((3.5 / 8.0 - 0.25) / 0.5));
504 }
505
124 #[test]506 #[test]
125 fn current_color_strokes_paint_white_coverage() {507 fn bounds_follow_curves_not_their_control_points() {
126 const LINE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M2 8H14" fill="none" stroke="currentColor" stroke-width="2"/></svg>"##;508 let [min, max] = bounds("M2 8A6 6 0 0 1 14 8Q8 14 2 8Z");
127 let image = rasterize(&[LINE], 16.0, [0, 0]);509 assert!((min[1] - 2.0).abs() < 0.01, "{min:?}");
510 assert!((max[1] - 11.0).abs() < 0.01, "{max:?}");
511 assert!((min[0] - 2.0).abs() < 0.01 && (max[0] - 14.0).abs() < 0.01);
512 }
513
514 #[test]
515 fn slots_take_the_palette_and_keep_their_shading() {
516 // Two blues of one hue in the accent slot, a red outside it.
517 const ART: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path class="accent" d="M0 0H16V4H0Z" fill="#80a0ff"/><path class="accent" d="M0 4H16V8H0Z" fill="#0040e0"/><path d="M0 8H16V12H0Z" fill="#ff0000"/><path class="accent" d="M0 12H16V16H0Z" fill="currentColor"/></svg>"##;
518 let pixel = |palette: &Palette, y: usize| {
519 let image = rasterize(&[ART], 16.0, [0, 0], [0.0; 3], palette);
520 let at = (y * 17 + 8) * 4;
521 let [r, g, b] = [0, 1, 2].map(|channel| f32::from(image.data[at + channel]) / 255.0);
522 hsl([r, g, b])
523 };
524 let plain = Palette::default();
525 let green = Palette {
526 accent: Some([0.0, 1.0, 0.0].map(decode)),
527 ..plain
528 };
529 let [light, dark] = [2, 6].map(|y| pixel(&green, y));
530 // Both turn green, the lighter above the darker, centred on the palette's colour.
531 for [hue, ..] in [light, dark] {
532 assert!((hue - 120.0).abs() < 2.0, "{hue}");
533 }
534 assert!(light[2] > dark[2]);
535 assert!(((light[2] + dark[2]) / 2.0 - 0.5).abs() < 0.02);
536 assert_eq!(pixel(&green, 10), pixel(&plain, 10));
537 assert_eq!(pixel(&green, 14), [0.0, 0.0, 0.0]);
538 assert!((pixel(&plain, 2)[0] - hsl([0.5, 0.627, 1.0])[0]).abs() < 2.0);
539 }
540
541 #[test]
542 fn current_color_paints_the_ink_and_fixed_colours_keep_theirs() {
543 const LINE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M2 8H14" fill="none" stroke="currentColor" stroke-width="2"/><path d="M2 12H14" fill="none" stroke="#ff0000" stroke-width="2"/></svg>"##;
544 let ink = super::super::srgb(0x20, 0x60, 0xa0);
545 let image = rasterize(
546 &[LINE],
547 16.0,
548 [0, 0],
549 [ink[0], ink[1], ink[2]],
550 &Palette::default(),
551 );
128 let pixel = |x: usize, y: usize| &image.data[(y * 17 + x) * 4..][..4];552 let pixel = |x: usize, y: usize| &image.data[(y * 17 + x) * 4..][..4];
129 assert_eq!(pixel(8, 7), [255, 255, 255, 255]);553 assert_eq!(pixel(8, 7), [0x20, 0x60, 0xa0, 255]);
554 assert_eq!(pixel(8, 11), [255, 0, 0, 255]);
130 assert_eq!(pixel(8, 2)[3], 0);555 assert_eq!(pixel(8, 2)[3], 0);
131 // Round caps reach past the path's ends.556 // Round caps reach past the path's ends.
132 assert!(pixel(1, 7)[3] > 0);557 assert!(pixel(1, 7)[3] > 0);
133 }558 }
134559
560 #[test]
561 fn opacity_scales_each_paint_on_its_own() {
562 const HALF: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M2 2H14V14H2Z" fill="#ffffff" fill-opacity="0.5" stroke="#000000" stroke-width="2" stroke-opacity="0.25"/></svg>"##;
563 let image = rasterize(&[HALF], 16.0, [0, 0], [1.0; 3], &Palette::default());
564 let alpha = |x: usize, y: usize| image.data[(y * 17 + x) * 4 + 3];
565 assert_eq!(alpha(8, 8), 128);
566 // The stroke's outer half covers only what the fill leaves.
567 assert_eq!(alpha(1, 8), 64);
568 }
569
135 #[test]570 #[test]
136 fn icons_keep_transparency_and_overlays_across_scales() {571 fn icons_keep_transparency_and_overlays_across_scales() {
137 for size in [8.0, 16.0, 32.0, 57.5] {572 for size in [8.0, 16.0, 32.0, 57.5] {
138 for phase in [[0, 0], [1, 2], [3, 3]] {573 for phase in [[0, 0], [1, 2], [3, 3]] {
139 let plain = rasterize(&[BOX], size, phase);574 let plain = rasterize(&[BOX], size, phase, [1.0; 3], &Palette::default());
140 let marked = rasterize(&[BOX, MARK], size, phase);575 let marked = rasterize(&[BOX, MARK], size, phase, [1.0; 3], &Palette::default());
141 for image in [&plain, &marked] {576 for image in [&plain, &marked] {
142 assert_eq!(577 assert_eq!(
143 image.data.len(),578 image.data.len(),
crates/draw/src/render/translucent.rs created+110
...@@ -0,0 +1,110 @@
1/// Carries frames to a window whose compositor blends premultiplied sRGB, as Core Animation's
2/// transparent layers do. The renderer blends in linear light into a texture of its own; on
3/// the way to the window each pixel is premultiplied again in sRGB, so a translucent colour
4/// over whatever shows through the window looks as it would over an opaque one.
5pub struct Translucent {
6 frame: Option<wgpu::Texture>,
7 format: wgpu::TextureFormat,
8 pipeline: wgpu::RenderPipeline,
9}
10
11impl Translucent {
12 /// Frames the renderer draws in `format` for a window view in `window`, which stores its
13 /// bytes as given.
14 pub fn new(
15 device: &wgpu::Device,
16 format: wgpu::TextureFormat,
17 window: wgpu::TextureFormat,
18 ) -> Self {
19 let shader = device.create_shader_module(wgpu::include_wgsl!("translucent.wgsl"));
20 let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
21 label: Some("Translucent window"),
22 layout: None,
23 vertex: wgpu::VertexState {
24 module: &shader,
25 entry_point: Some("vertex"),
26 compilation_options: Default::default(),
27 buffers: &[],
28 },
29 fragment: Some(wgpu::FragmentState {
30 module: &shader,
31 entry_point: Some("fragment"),
32 compilation_options: Default::default(),
33 targets: &[Some(window.into())],
34 }),
35 primitive: Default::default(),
36 depth_stencil: None,
37 multisample: Default::default(),
38 multiview_mask: None,
39 cache: None,
40 });
41 Self {
42 frame: None,
43 format,
44 pipeline,
45 }
46 }
47
48 /// The view to draw a frame `size` pixels large into.
49 pub fn target(&mut self, device: &wgpu::Device, size: [u32; 2]) -> wgpu::TextureView {
50 let extent = wgpu::Extent3d {
51 width: size[0],
52 height: size[1],
53 depth_or_array_layers: 1,
54 };
55 let frame = match &self.frame {
56 Some(frame) if frame.size() == extent => frame,
57 _ => self
58 .frame
59 .insert(device.create_texture(&wgpu::TextureDescriptor {
60 label: Some("Translucent frame"),
61 size: extent,
62 mip_level_count: 1,
63 sample_count: 1,
64 dimension: wgpu::TextureDimension::D2,
65 format: self.format,
66 usage: wgpu::TextureUsages::RENDER_ATTACHMENT
67 | wgpu::TextureUsages::TEXTURE_BINDING,
68 view_formats: &[],
69 })),
70 };
71 frame.create_view(&Default::default())
72 }
73
74 /// Copies the frame last drawn into `target` to `window`.
75 pub fn present(&self, device: &wgpu::Device, queue: &wgpu::Queue, window: &wgpu::TextureView) {
76 let Some(frame) = &self.frame else {
77 return;
78 };
79 let binding = device.create_bind_group(&wgpu::BindGroupDescriptor {
80 label: Some("Translucent frame"),
81 layout: &self.pipeline.get_bind_group_layout(0),
82 entries: &[wgpu::BindGroupEntry {
83 binding: 0,
84 resource: wgpu::BindingResource::TextureView(
85 &frame.create_view(&Default::default()),
86 ),
87 }],
88 });
89 let mut encoder = device.create_command_encoder(&Default::default());
90 {
91 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
92 label: Some("Translucent window"),
93 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
94 view: window,
95 depth_slice: None,
96 resolve_target: None,
97 ops: wgpu::Operations {
98 load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
99 store: wgpu::StoreOp::Store,
100 },
101 })],
102 ..Default::default()
103 });
104 pass.set_pipeline(&self.pipeline);
105 pass.set_bind_group(0, &binding, &[]);
106 pass.draw(0..3, 0..1);
107 }
108 queue.submit([encoder.finish()]);
109 }
110}
crates/draw/src/render/translucent.wgsl created+21
...@@ -0,0 +1,21 @@
1@group(0) @binding(0) var frame: texture_2d<f32>;
2
3@vertex
4fn vertex(@builtin(vertex_index) index: u32) -> @builtin(position) vec4<f32> {
5 let corner = vec2<f32>(f32((index << 1u) & 2u), f32(index & 2u));
6 return vec4<f32>(corner * 2.0 - 1.0, 0.0, 1.0);
7}
8
9fn encode(linear: vec3<f32>) -> vec3<f32> {
10 return select(1.055 * pow(linear, vec3<f32>(1.0 / 2.4)) - 0.055, linear * 12.92,
11 linear <= vec3<f32>(0.0031308));
12}
13
14@fragment
15fn fragment(@builtin(position) at: vec4<f32>) -> @location(0) vec4<f32> {
16 let pixel = textureLoad(frame, vec2<i32>(at.xy), 0);
17 if pixel.a == 0.0 {
18 return vec4<f32>(0.0);
19 }
20 return vec4<f32>(encode(pixel.rgb / pixel.a) * pixel.a, pixel.a);
21}
crates/snowbound/Cargo.toml+3-1
...@@ -24,7 +24,7 @@ accesskit_winit = { version = "0.34.0", default-features = false, features = ["r...@@ -24,7 +24,7 @@ accesskit_winit = { version = "0.34.0", default-features = false, features = ["r
24[target.'cfg(target_os = "macos")'.dependencies]24[target.'cfg(target_os = "macos")'.dependencies]
25libc = "0.2"25libc = "0.2"
26objc2 = "0.5.2"26objc2 = "0.5.2"
27objc2-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"] }27objc2-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"] }
28objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSGeometry", "NSLocale", "NSPathUtilities", "NSTimeZone", "NSURL", "NSArray"] }28objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSGeometry", "NSLocale", "NSPathUtilities", "NSTimeZone", "NSURL", "NSArray"] }
2929
30[target.'cfg(target_os = "linux")'.dependencies]30[target.'cfg(target_os = "linux")'.dependencies]
...@@ -34,3 +34,5 @@ smithay-clipboard = "0.7.3"...@@ -34,3 +34,5 @@ smithay-clipboard = "0.7.3"
34fontique = { workspace = true, features = ["fontconfig-dlopen"] }34fontique = { workspace = true, features = ["fontconfig-dlopen"] }
35libc = "0.2"35libc = "0.2"
36zbus = { version = "5.19", default-features = false, features = ["async-io", "blocking-api"] }36zbus = { version = "5.19", default-features = false, features = ["async-io", "blocking-api"] }
37# The colours of winit's own window frame, which the toolbar continues.
38sctk-adwaita = { version = "0.10.1", default-features = false }
crates/snowbound/assets/icons/align-center.svg+1-1
...@@ -1,3 +1,3 @@...@@ -1,3 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M2.5 3.5H13.5M4.5 6.5H11.5M2.5 9.5H13.5M4.5 12.5H11.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>2 <path d="M2 3H14V4H2ZM4 6H12V7H4ZM2 9H14V10H2ZM4 12H12V13H4Z" fill="currentColor"/>
3</svg>3</svg>
crates/snowbound/assets/icons/align-left.svg+1-1
...@@ -1,3 +1,3 @@...@@ -1,3 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M2.5 3.5H13.5M2.5 6.5H9.5M2.5 9.5H13.5M2.5 12.5H9.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>2 <path d="M2 3H14V4H2ZM2 6H10V7H2ZM2 9H14V10H2ZM2 12H10V13H2Z" fill="currentColor"/>
3</svg>3</svg>
crates/snowbound/assets/icons/align-right.svg+1-1
...@@ -1,3 +1,3 @@...@@ -1,3 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M2.5 3.5H13.5M6.5 6.5H13.5M2.5 9.5H13.5M6.5 12.5H13.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>2 <path d="M2 3H14V4H2ZM6 6H14V7H6ZM2 9H14V10H2ZM6 12H14V13H6Z" fill="currentColor"/>
3</svg>3</svg>
crates/snowbound/assets/icons/attachment.svg+6-1
...@@ -1,3 +1,8 @@...@@ -1,3 +1,8 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M11.89 8.15L8 12.04A2.5 2.5 0 0 1 4.46 8.5L9.59 3.37A1.5 1.5 0 0 1 11.71 5.49L7.29 9.91" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>2 <defs>
3 <linearGradient id="g0" gradientUnits="userSpaceOnUse" x2="0" y1="1.354" y2="13.995"><stop offset="0" stop-color="#d3d9e0"/><stop offset="1" stop-color="#7b8796"/></linearGradient>
4 </defs>
5 <path d="M12.531 7.902L9.781 12.665A3.5 3.5 30 0 1 3.719 9.165L7.094 3.319A1.75 1.75 30 0 1 10.125 5.069L7.25 10.049" fill="none" stroke="#000000" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.16"/>
6 <path d="M12.531 7.152L9.781 11.915A3.5 3.5 30 0 1 3.719 8.415L7.094 2.569A1.75 1.75 30 0 1 10.125 4.319L7.25 9.299" fill="none" stroke="#5a6676" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
7 <path d="M12.531 7.152L9.781 11.915A3.5 3.5 30 0 1 3.719 8.415L7.094 2.569A1.75 1.75 30 0 1 10.125 4.319L7.25 9.299" fill="none" stroke="url(#g0)" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round"/>
3</svg>8</svg>
crates/snowbound/assets/icons/back.svg created+3
...@@ -0,0 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M13 8H3.5M7.5 3.5L3 8L7.5 12.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
3</svg>
crates/snowbound/assets/icons/bold.svg+1-1
...@@ -1,3 +1,3 @@...@@ -1,3 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M4.5 8H8.5A2.5 2.5 0 0 0 8.5 3H4.5V13H9.25A2.5 2.5 0 0 0 9.25 8H8.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>2 <path d="M3 2H8.6C10.9 2 12.2 3.2 12.2 5.1C12.2 6.3 11.5 7.2 10.5 7.6C11.9 8 12.8 9.1 12.8 10.6C12.8 12.7 11.3 14 8.9 14H3ZM5.6 4.2V6.9H8.2C9.2 6.9 9.7 6.4 9.7 5.5C9.7 4.7 9.2 4.2 8.2 4.2ZM5.6 8.9V11.8H8.5C9.6 11.8 10.2 11.3 10.2 10.3C10.2 9.4 9.6 8.9 8.5 8.9Z" fill="currentColor" fill-rule="evenodd"/>
3</svg>3</svg>
crates/snowbound/assets/icons/bullets.svg+12-4
...@@ -1,6 +1,14 @@...@@ -1,6 +1,14 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M7 4.5H13.5M7 8.5H13.5M7 12.5H13.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>2 <defs>
3 <path d="M2 4.5A1.25 1.25 0 1 0 4.5 4.5A1.25 1.25 0 1 0 2 4.5Z" fill="currentColor"/>3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#79c2ff"/><stop offset="1" stop-color="#1f7cf0"/></linearGradient>
4 <path d="M2 8.5A1.25 1.25 0 1 0 4.5 8.5A1.25 1.25 0 1 0 2 8.5Z" fill="currentColor"/>4 <linearGradient id="g1" x2="0" y2="1"><stop offset="0" stop-color="#79c2ff"/><stop offset="1" stop-color="#1f7cf0"/></linearGradient>
5 <path d="M2 12.5A1.25 1.25 0 1 0 4.5 12.5A1.25 1.25 0 1 0 2 12.5Z" fill="currentColor"/>5 <linearGradient id="g2" x2="0" y2="1"><stop offset="0" stop-color="#79c2ff"/><stop offset="1" stop-color="#1f7cf0"/></linearGradient>
6 </defs>
7 <path d="M1.9 3.25A1.6 1.6 0 1 1 5.1 3.25A1.6 1.6 0 1 1 1.9 3.25Z" fill="#000000" fill-opacity="0.16"/>
8 <path class="accent" d="M1.9 2.5A1.6 1.6 0 1 1 5.1 2.5A1.6 1.6 0 1 1 1.9 2.5Z" fill="url(#g0)" stroke="#1560c4" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
9 <path d="M1.9 8.25A1.6 1.6 0 1 1 5.1 8.25A1.6 1.6 0 1 1 1.9 8.25Z" fill="#000000" fill-opacity="0.16"/>
10 <path class="accent" d="M1.9 7.5A1.6 1.6 0 1 1 5.1 7.5A1.6 1.6 0 1 1 1.9 7.5Z" fill="url(#g1)" stroke="#1560c4" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
11 <path d="M1.9 13.25A1.6 1.6 0 1 1 5.1 13.25A1.6 1.6 0 1 1 1.9 13.25Z" fill="#000000" fill-opacity="0.16"/>
12 <path class="accent" d="M1.9 12.5A1.6 1.6 0 1 1 5.1 12.5A1.6 1.6 0 1 1 1.9 12.5Z" fill="url(#g2)" stroke="#1560c4" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
13 <path d="M7 2H15V3H7ZM7 7H15V8H7ZM7 12H15V13H7Z" fill="currentColor"/>
6</svg>14</svg>
crates/snowbound/assets/icons/calendar.svg+14-2
...@@ -1,4 +1,16 @@...@@ -1,4 +1,16 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M4 3.5H12A1.5 1.5 0 0 1 13.5 5V12A1.5 1.5 0 0 1 12 13.5H4A1.5 1.5 0 0 1 2.5 12V5A1.5 1.5 0 0 1 4 3.5ZM2.5 6.5H13.5M5.5 1.5V4.5M10.5 1.5V4.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>2 <defs>
3 <path d="M9.5 9H11A0.5 0.5 0 0 1 11.5 9.5V11A0.5 0.5 0 0 1 11 11.5H9.5A0.5 0.5 0 0 1 9 11V9.5A0.5 0.5 0 0 1 9.5 9Z" fill="currentColor"/>3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#e3e9f0"/></linearGradient>
4 <linearGradient id="g1" x2="0" y2="1"><stop offset="0" stop-color="#ff8f80"/><stop offset="1" stop-color="#f03b30"/></linearGradient>
5 <linearGradient id="g2" gradientUnits="userSpaceOnUse" x2="0" y1="0.9" y2="4.6"><stop offset="0" stop-color="#6a7482"/><stop offset="1" stop-color="#3b4350"/></linearGradient>
6 </defs>
7 <path d="M2.5 3.25L13.5 3.25A1 1 0 0 1 14.5 4.25L14.5 14.25A1 1 0 0 1 13.5 15.25L2.5 15.25A1 1 0 0 1 1.5 14.25L1.5 4.25A1 1 0 0 1 2.5 3.25Z" fill="#000000" fill-opacity="0.16"/>
8 <path d="M2.5 2.5H13.5A1 1 0 0 1 14.5 3.5V13.5A1 1 0 0 1 13.5 14.5H2.5A1 1 0 0 1 1.5 13.5V3.5A1 1 0 0 1 2.5 2.5Z" fill="url(#g0)" stroke="#98a4b3" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
9 <path d="M1.5 7.25L1.5 4.25A1 1 0 0 1 2.5 3.25L13.5 3.25A1 1 0 0 1 14.5 4.25L14.5 7.25Z" fill="#000000" fill-opacity="0.16"/>
10 <path d="M1.5 6.5V3.5A1 1 0 0 1 2.5 2.5H13.5A1 1 0 0 1 14.5 3.5V6.5Z" fill="url(#g1)" stroke="#c42418" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
11 <path d="M4 8H6V10H4ZM7 8H9V10H7ZM10 8H12V10H10ZM4 11H6V13H4ZM7 11H9V13H7Z" fill="#b7c5d6"/>
12 <path d="M10 11H12V13H10Z" fill="#f03b30"/>
13 <path d="M4.5 2.25L4.5 4.75M11.5 2.25L11.5 4.75" fill="none" stroke="#000000" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.16"/>
14 <path d="M4.5 1.5V4M11.5 1.5V4" fill="none" stroke="#2b323b" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/>
15 <path d="M4.5 1.5V4M11.5 1.5V4" fill="none" stroke="url(#g2)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
4</svg>16</svg>
crates/snowbound/assets/icons/clear-formatting.svg+9-1
...@@ -1,3 +1,11 @@...@@ -1,3 +1,11 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M2.5 3.5H10.5M6.5 3.5V13.5M10 10L13.5 13.5M13.5 10L10 13.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#ffb8c9"/><stop offset="1" stop-color="#ff5c85"/></linearGradient>
4 <linearGradient id="g1" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#c3cdd9"/></linearGradient>
5 </defs>
6 <path d="M4.152 5.92L6.168 5.92L9.192 13.84L7.248 13.84L6.672 12.112L3.648 12.112L3.072 13.84L1.128 13.84ZM4.152 10.6L6.168 10.6L5.16 7.648Z" fill="currentColor" fill-rule="evenodd"/>
7 <path d="M12.629 2.807L14.927 4.735A0.8 0.8 40 0 1 15.026 5.862L12.84 8.467A0.8 0.8 40 0 1 11.713 8.565L9.415 6.637A0.8 0.8 40 0 1 9.317 5.51L11.502 2.905A0.8 0.8 40 0 1 12.629 2.807Z" fill="#000000" fill-opacity="0.16"/>
8 <path d="M12.629 2.057L14.927 3.985A0.8 0.8 40 0 1 15.026 5.112L12.84 7.717A0.8 0.8 40 0 1 11.713 7.815L9.415 5.887A0.8 0.8 40 0 1 9.317 4.76L11.502 2.155A0.8 0.8 40 0 1 12.629 2.057Z" fill="url(#g0)" stroke="#d93d66" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
9 <path d="M9.737 6.254L12.035 8.182A0.8 0.8 40 0 1 12.133 9.309L8.984 13.063A0.8 0.8 40 0 1 7.857 13.161L5.558 11.233A0.8 0.8 40 0 1 5.46 10.106L8.609 6.352A0.8 0.8 40 0 1 9.737 6.254Z" fill="#000000" fill-opacity="0.16"/>
10 <path d="M9.737 5.504L12.035 7.432A0.8 0.8 40 0 1 12.133 8.559L8.984 12.313A0.8 0.8 40 0 1 7.857 12.411L5.558 10.483A0.8 0.8 40 0 1 5.46 9.356L8.609 5.602A0.8 0.8 40 0 1 9.737 5.504Z" fill="url(#g1)" stroke="#8e9aab" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
3</svg>11</svg>
crates/snowbound/assets/icons/clock.svg+10-1
...@@ -1,3 +1,12 @@...@@ -1,3 +1,12 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M1.75 8A6.25 6.25 0 1 0 14.25 8A6.25 6.25 0 1 0 1.75 8ZM8 4.5V8L10.5 9.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#d3d9e0"/><stop offset="1" stop-color="#7b8796"/></linearGradient>
4 <linearGradient id="g1" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#e3e9f0"/></linearGradient>
5 </defs>
6 <path d="M1.5 8.75A6.5 6.5 0 1 1 14.5 8.75A6.5 6.5 0 1 1 1.5 8.75Z" fill="#000000" fill-opacity="0.16"/>
7 <path d="M1.5 8A6.5 6.5 0 1 1 14.5 8A6.5 6.5 0 1 1 1.5 8Z" fill="url(#g0)" stroke="#5a6676" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
8 <path d="M3 8A5 5 0 1 1 13 8A5 5 0 1 1 3 8Z" fill="url(#g1)"/>
9 <path d="M8 3.9V4.6M12.1 8H11.4M8 12.1V11.4M3.9 8H4.6" fill="none" stroke="#b7c5d6" stroke-width="0.9" stroke-linecap="round" stroke-linejoin="round"/>
10 <path d="M8 4.9V8H10.6" fill="none" stroke="#3b4350" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
11 <path d="M8 8L6.2 10.4" fill="none" stroke="#f03b30" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
3</svg>12</svg>
crates/snowbound/assets/icons/copy.svg created+18
...@@ -0,0 +1,18 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#e3e9f0"/></linearGradient>
4 <linearGradient id="g1" x2="0" y2="1"><stop offset="0" stop-color="#f1f4f8"/><stop offset="1" stop-color="#c9d3df"/></linearGradient>
5 <linearGradient id="g2" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#e3e9f0"/></linearGradient>
6 <linearGradient id="g3" x2="0" y2="1"><stop offset="0" stop-color="#f1f4f8"/><stop offset="1" stop-color="#c9d3df"/></linearGradient>
7 </defs>
8 <path d="M1.5 2.25L7 2.25L9.5 4.75L9.5 12.25L1.5 12.25Z" fill="#000000" fill-opacity="0.16"/>
9 <path d="M1.5 1.5L7 1.5L9.5 4L9.5 11.5L1.5 11.5Z" fill="url(#g0)" stroke="#98a4b3" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
10 <path d="M7 2.25L7 4.75L9.5 4.75Z" fill="#000000" fill-opacity="0.16"/>
11 <path d="M7 1.5L7 4L9.5 4Z" fill="url(#g1)" stroke="#98a4b3" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
12 <path class="accent" d="M3 5.5H7M3 7.5H7" fill="none" stroke="#8cc0f5" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"/>
13 <path d="M6.5 5.25L12 5.25L14.5 7.75L14.5 15.25L6.5 15.25Z" fill="#000000" fill-opacity="0.16"/>
14 <path d="M6.5 4.5L12 4.5L14.5 7L14.5 14.5L6.5 14.5Z" fill="url(#g2)" stroke="#98a4b3" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
15 <path d="M12 5.25L12 7.75L14.5 7.75Z" fill="#000000" fill-opacity="0.16"/>
16 <path d="M12 4.5L12 7L14.5 7Z" fill="url(#g3)" stroke="#98a4b3" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
17 <path class="accent" d="M8 8.5H12.5M8 10.5H12.5M8 12.5H11" fill="none" stroke="#8cc0f5" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"/>
18</svg>
crates/snowbound/assets/icons/cut.svg created+16
...@@ -0,0 +1,16 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" gradientUnits="userSpaceOnUse" x2="0" y1="1.05" y2="10.95"><stop offset="0" stop-color="#d3d9e0"/><stop offset="1" stop-color="#7b8796"/></linearGradient>
4 <linearGradient id="g1" gradientUnits="userSpaceOnUse" x2="0" y1="12.1" y2="12.9"><stop offset="0" stop-color="#79c2ff"/><stop offset="1" stop-color="#1f7cf0"/></linearGradient>
5 <linearGradient id="g2" gradientUnits="userSpaceOnUse" x2="0" y1="12.1" y2="12.9"><stop offset="0" stop-color="#79c2ff"/><stop offset="1" stop-color="#1f7cf0"/></linearGradient>
6 </defs>
7 <path d="M5 2.25L10.5 11.25M11 2.25L5.5 11.25" fill="none" stroke="#000000" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.16"/>
8 <path d="M5 1.5L10.5 10.5M11 1.5L5.5 10.5" fill="none" stroke="#5a6676" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
9 <path d="M5 1.5L10.5 10.5M11 1.5L5.5 10.5" fill="none" stroke="url(#g0)" stroke-width="0.9" stroke-linecap="round" stroke-linejoin="round"/>
10 <path d="M2.5 13.25A2 2 0 1 1 6.5 13.25A2 2 0 1 1 2.5 13.25Z" fill="none" stroke="#000000" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.16"/>
11 <path class="accent" d="M2.5 12.5A2 2 0 1 1 6.5 12.5A2 2 0 1 1 2.5 12.5Z" fill="none" stroke="#1560c4" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>
12 <path class="accent" d="M2.5 12.5A2 2 0 1 1 6.5 12.5A2 2 0 1 1 2.5 12.5Z" fill="none" stroke="url(#g1)" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
13 <path d="M9.5 13.25A2 2 0 1 1 13.5 13.25A2 2 0 1 1 9.5 13.25Z" fill="none" stroke="#000000" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.16"/>
14 <path class="accent" d="M9.5 12.5A2 2 0 1 1 13.5 12.5A2 2 0 1 1 9.5 12.5Z" fill="none" stroke="#1560c4" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>
15 <path class="accent" d="M9.5 12.5A2 2 0 1 1 13.5 12.5A2 2 0 1 1 9.5 12.5Z" fill="none" stroke="url(#g2)" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
16</svg>
crates/snowbound/assets/icons/date-time.svg created+21
...@@ -0,0 +1,21 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#e3e9f0"/></linearGradient>
4 <linearGradient id="g1" x2="0" y2="1"><stop offset="0" stop-color="#ff8f80"/><stop offset="1" stop-color="#f03b30"/></linearGradient>
5 <linearGradient id="g2" gradientUnits="userSpaceOnUse" x2="0" y1="1" y2="4"><stop offset="0" stop-color="#6a7482"/><stop offset="1" stop-color="#3b4350"/></linearGradient>
6 <linearGradient id="g3" x2="0" y2="1"><stop offset="0" stop-color="#d3d9e0"/><stop offset="1" stop-color="#7b8796"/></linearGradient>
7 <linearGradient id="g4" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#e3e9f0"/></linearGradient>
8 </defs>
9 <path d="M2 3.25H11A1 1 0 0 1 12 4.25V13.25A1 1 0 0 1 11 14.25H2A1 1 0 0 1 1 13.25V4.25A1 1 0 0 1 2 3.25Z" fill="#000000" fill-opacity="0.16"/>
10 <path d="M2 2.5H11A1 1 0 0 1 12 3.5V12.5A1 1 0 0 1 11 13.5H2A1 1 0 0 1 1 12.5V3.5A1 1 0 0 1 2 2.5Z" fill="url(#g0)" stroke="#98a4b3" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
11 <path d="M1 6.75V4.25A1 1 0 0 1 2 3.25H11A1 1 0 0 1 12 4.25V6.75Z" fill="#000000" fill-opacity="0.16"/>
12 <path d="M1 6V3.5A1 1 0 0 1 2 2.5H11A1 1 0 0 1 12 3.5V6Z" fill="url(#g1)" stroke="#c42418" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
13 <path d="M3 7.5H5V9.5H3ZM6 7.5H8V9.5H6ZM3 10.5H5V12.5H3Z" fill="#b7c5d6"/>
14 <path d="M4 2.25V4.25M9 2.25V4.25" fill="none" stroke="#000000" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.16"/>
15 <path d="M4 1.5V3.5M9 1.5V3.5" fill="none" stroke="#2b323b" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/>
16 <path d="M4 1.5V3.5M9 1.5V3.5" fill="none" stroke="url(#g2)" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"/>
17 <path d="M8 12.25A3.5 3.5 0 1 1 15 12.25A3.5 3.5 0 1 1 8 12.25Z" fill="#000000" fill-opacity="0.16"/>
18 <path d="M8 11.5A3.5 3.5 0 1 1 15 11.5A3.5 3.5 0 1 1 8 11.5Z" fill="url(#g3)" stroke="#5a6676" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
19 <path d="M8.9 11.5A2.6 2.6 0 1 1 14.1 11.5A2.6 2.6 0 1 1 8.9 11.5Z" fill="url(#g4)"/>
20 <path d="M11.5 9.8V11.5H13" fill="none" stroke="#3b4350" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"/>
21</svg>
crates/snowbound/assets/icons/equation.svg+5-1
...@@ -1,3 +1,7 @@...@@ -1,3 +1,7 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M2.5 4H13.5M6 4V12.5M10.5 4V11C10.5 12.25 11.25 12.75 12.75 12.25" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#79c2ff"/><stop offset="1" stop-color="#1f7cf0"/></linearGradient>
4 </defs>
5 <path d="M1.8 5.35C2.2 3.75 3.1 2.75 4.6 2.75L14.2 2.75L14.2 5.15L11.4 5.15L11.4 11.75C11.4 12.45 11.7 12.75 12.2 12.75C12.6 12.75 12.9 12.55 13.2 12.15L14.3 13.35C13.6 14.35 12.6 14.75 11.5 14.75C9.8 14.75 8.8 13.75 8.8 11.95L8.8 5.15L6.8 5.15C6.7 9.15 6 12.25 5 14.75L2.3 14.75C3.5 12.05 4.2 9.15 4.3 5.15L4.1 5.15C3.5 5.15 3.2 5.45 3 6.05Z" fill="#000000" fill-opacity="0.16"/>
6 <path class="accent" d="M1.8 4.6C2.2 3 3.1 2 4.6 2H14.2V4.4H11.4V11C11.4 11.7 11.7 12 12.2 12C12.6 12 12.9 11.8 13.2 11.4L14.3 12.6C13.6 13.6 12.6 14 11.5 14C9.8 14 8.8 13 8.8 11.2V4.4H6.8C6.7 8.4 6 11.5 5 14H2.3C3.5 11.3 4.2 8.4 4.3 4.4H4.1C3.5 4.4 3.2 4.7 3 5.3Z" fill="url(#g0)" stroke="#1560c4" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
3</svg>7</svg>
crates/snowbound/assets/icons/find-tags.svg created+17
...@@ -0,0 +1,17 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#fff590"/><stop offset="1" stop-color="#ffc800"/></linearGradient>
4 <linearGradient id="g1" gradientUnits="userSpaceOnUse" x2="0" y1="11.21" y2="14.75"><stop offset="0" stop-color="#6a7482"/><stop offset="1" stop-color="#3b4350"/></linearGradient>
5 <linearGradient id="g2" gradientUnits="userSpaceOnUse" x2="0" y1="9.65" y2="10.35"><stop offset="0" stop-color="#d3d9e0"/><stop offset="1" stop-color="#7b8796"/></linearGradient>
6 <linearGradient id="g3" x2="0" y2="1"><stop offset="0" stop-color="#eaf6ff"/><stop offset="1" stop-color="#aed8ff"/></linearGradient>
7 </defs>
8 <path d="M1 2.25L7.5 2.25L11 5.75L7.5 9.25L1 9.25ZM2.5 5.75A1 1 0 1 1 4.5 5.75A1 1 0 1 1 2.5 5.75Z" fill="#000000" fill-rule="evenodd" fill-opacity="0.16"/>
9 <path d="M1 1.5L7.5 1.5L11 5L7.5 8.5L1 8.5ZM2.5 5A1 1 0 1 1 4.5 5A1 1 0 1 1 2.5 5Z" fill="url(#g0)" fill-rule="evenodd" stroke="#d9a300" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
10 <path d="M12.16 12.91L13.8 14.55" fill="none" stroke="#000000" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.16"/>
11 <path d="M12.16 12.16L13.8 13.8" fill="none" stroke="#2b323b" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round"/>
12 <path d="M12.16 12.16L13.8 13.8" fill="none" stroke="url(#g1)" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/>
13 <path d="M7 10.75A3 3 0 1 1 13 10.75A3 3 0 1 1 7 10.75Z" fill="none" stroke="#000000" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.16"/>
14 <path d="M7 10A3 3 0 1 1 13 10A3 3 0 1 1 7 10Z" fill="none" stroke="#5a6676" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
15 <path d="M7 10A3 3 0 1 1 13 10A3 3 0 1 1 7 10Z" fill="none" stroke="url(#g2)" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round"/>
16 <path d="M7.5 10A2.5 2.5 0 1 1 12.5 10A2.5 2.5 0 1 1 7.5 10Z" fill="url(#g3)"/>
17</svg>
crates/snowbound/assets/icons/font-color.svg+1-1
...@@ -1,3 +1,3 @@...@@ -1,3 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M3.5 12L8 1.75L12.5 12M5.3 8.25H10.7" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>2 <path d="M6.6 1H9.4L13.6 12H10.9L10.1 9.6H5.9L5.1 12H2.4ZM6.6 7.5H9.4L8 3.4Z" fill="currentColor" fill-rule="evenodd"/>
3</svg>3</svg>
crates/snowbound/assets/icons/format-painter.svg created+14
...@@ -0,0 +1,14 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#f0c98a"/><stop offset="1" stop-color="#c48a3e"/></linearGradient>
4 <linearGradient id="g1" x2="0" y2="1"><stop offset="0" stop-color="#d3d9e0"/><stop offset="1" stop-color="#7b8796"/></linearGradient>
5 <linearGradient id="g2" x2="0" y2="1"><stop offset="0" stop-color="#79c2ff"/><stop offset="1" stop-color="#1f7cf0"/></linearGradient>
6 </defs>
7 <path d="M7.5 9.25H8.5A1 1 0 0 1 9.5 10.25V14.75A1 1 0 0 1 8.5 15.75H7.5A1 1 0 0 1 6.5 14.75V10.25A1 1 0 0 1 7.5 9.25Z" fill="#000000" fill-opacity="0.16"/>
8 <path d="M7.5 8.5H8.5A1 1 0 0 1 9.5 9.5V14A1 1 0 0 1 8.5 15H7.5A1 1 0 0 1 6.5 14V9.5A1 1 0 0 1 7.5 8.5Z" fill="url(#g0)" stroke="#94632a" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
9 <path d="M3.9 6.75H12.1A0.4 0.4 0 0 1 12.5 7.15V8.85A0.4 0.4 0 0 1 12.1 9.25H3.9A0.4 0.4 0 0 1 3.5 8.85V7.15A0.4 0.4 0 0 1 3.9 6.75Z" fill="#000000" fill-opacity="0.16"/>
10 <path d="M3.9 6H12.1A0.4 0.4 0 0 1 12.5 6.4V8.1A0.4 0.4 0 0 1 12.1 8.5H3.9A0.4 0.4 0 0 1 3.5 8.1V6.4A0.4 0.4 0 0 1 3.9 6Z" fill="url(#g1)" stroke="#5a6676" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
11 <path d="M4.5 1.75H11.5A1 1 0 0 1 12.5 2.75V5.75A1 1 0 0 1 11.5 6.75H4.5A1 1 0 0 1 3.5 5.75V2.75A1 1 0 0 1 4.5 1.75Z" fill="#000000" fill-opacity="0.16"/>
12 <path class="accent" d="M4.5 1H11.5A1 1 0 0 1 12.5 2V5A1 1 0 0 1 11.5 6H4.5A1 1 0 0 1 3.5 5V2A1 1 0 0 1 4.5 1Z" fill="url(#g2)" stroke="#1560c4" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
13 <path d="M4.75 2.25V4.75" fill="none" stroke="#ffffff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.85"/>
14</svg>
crates/snowbound/assets/icons/forward.svg created+3
...@@ -0,0 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M3 8H12.5M8.5 3.5L13 8L8.5 12.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
3</svg>
crates/snowbound/assets/icons/full-page-view.svg created+12
...@@ -0,0 +1,12 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#e3e9f0"/></linearGradient>
4 <linearGradient id="g1" x2="0" y2="1"><stop offset="0" stop-color="#f1f4f8"/><stop offset="1" stop-color="#c9d3df"/></linearGradient>
5 </defs>
6 <path d="M4.5 4.25L9.5 4.25L11.5 6.25L11.5 13.25L4.5 13.25Z" fill="#000000" fill-opacity="0.16"/>
7 <path d="M4.5 3.5L9.5 3.5L11.5 5.5L11.5 12.5L4.5 12.5Z" fill="url(#g0)" stroke="#98a4b3" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
8 <path d="M9.5 4.25L9.5 6.25L11.5 6.25Z" fill="#000000" fill-opacity="0.16"/>
9 <path d="M9.5 3.5L9.5 5.5L11.5 5.5Z" fill="url(#g1)" stroke="#98a4b3" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
10 <path class="accent" d="M6 7H9M6 9H9" fill="none" stroke="#8cc0f5" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"/>
11 <path class="accent" d="M1.5 5V1.5H5M11 1.5H14.5V5M14.5 11V14.5H11M5 14.5H1.5V11" fill="none" stroke="#1f6fe0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"/>
12</svg>
crates/snowbound/assets/icons/highlighter.svg+12-1
...@@ -1,3 +1,14 @@...@@ -1,3 +1,14 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M11 1.75L13.75 4.5L8.5 9.75L5.75 7ZM5.75 7L4.75 8L3.5 11.25L4.25 12L7.5 10.75L8.5 9.75" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#fff590"/><stop offset="1" stop-color="#ffc800"/></linearGradient>
4 <linearGradient id="g1" x2="0" y2="1"><stop offset="0" stop-color="#d3d9e0"/><stop offset="1" stop-color="#7b8796"/></linearGradient>
5 <linearGradient id="g2" x2="0" y2="1"><stop offset="0" stop-color="#fff35a"/><stop offset="1" stop-color="#ffbf00"/></linearGradient>
6 </defs>
7 <path d="M11.722 2.081L13.419 3.778A1 1 45 0 1 13.419 5.192L9.035 9.576A1 1 45 0 1 7.621 9.576L5.924 7.879A1 1 45 0 1 5.924 6.465L10.308 2.081A1 1 45 0 1 11.722 2.081Z" fill="#000000" fill-opacity="0.16"/>
8 <path class="highlight" d="M11.722 1.331L13.419 3.028A1 1 45 0 1 13.419 4.442L9.035 8.826A1 1 45 0 1 7.621 8.826L5.924 7.129A1 1 45 0 1 5.924 5.715L10.308 1.331A1 1 45 0 1 11.722 1.331Z" fill="url(#g0)" stroke="#d9a300" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
9 <path d="M5.782 7.172L8.328 9.718A0.4 0.4 45 0 1 8.328 10.283L7.338 11.273A0.4 0.4 45 0 1 6.772 11.273L4.227 8.728A0.4 0.4 45 0 1 4.227 8.162L5.217 7.172A0.4 0.4 45 0 1 5.782 7.172Z" fill="#000000" fill-opacity="0.16"/>
10 <path d="M5.782 6.422L8.328 8.968A0.4 0.4 45 0 1 8.328 9.533L7.338 10.523A0.4 0.4 45 0 1 6.772 10.523L4.227 7.978A0.4 0.4 45 0 1 4.227 7.412L5.217 6.422A0.4 0.4 45 0 1 5.782 6.422Z" fill="url(#g1)" stroke="#5a6676" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
11 <path d="M4.439 8.94L6.56 11.061L5.287 12.334L2.317 11.061Z" fill="#000000" fill-opacity="0.16"/>
12 <path class="highlight" d="M4.439 8.19L6.56 10.311L5.287 11.584L2.317 10.311Z" fill="url(#g2)" stroke="#c99600" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
13 <path d="M11.086 2.109L6.701 6.493" fill="none" stroke="#ffffff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.85"/>
3</svg>14</svg>
crates/snowbound/assets/icons/indent.svg+6-2
...@@ -1,4 +1,8 @@...@@ -1,4 +1,8 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M2.5 3.5H13.5M7.5 6.5H13.5M7.5 9.5H13.5M2.5 12.5H13.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>2 <defs>
3 <path d="M2.5 5.5L5 8L2.5 10.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#79c2ff"/><stop offset="1" stop-color="#1f7cf0"/></linearGradient>
4 </defs>
5 <path d="M2 1H15V2H2ZM7 5H15V6H7ZM7 9H15V10H7ZM2 13H15V14H2Z" fill="currentColor"/>
6 <path d="M1.5 5.75L5 8.25L1.5 10.75Z" fill="#000000" fill-opacity="0.16"/>
7 <path class="accent" d="M1.5 5L5 7.5L1.5 10Z" fill="url(#g0)" stroke="#1560c4" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
4</svg>8</svg>
crates/snowbound/assets/icons/insert-space.svg created+8
...@@ -0,0 +1,8 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#79c2ff"/><stop offset="1" stop-color="#1f7cf0"/></linearGradient>
4 </defs>
5 <path d="M2 1H14V2H2ZM2 14H14V15H2Z" fill="currentColor"/>
6 <path d="M8 4.25L11.5 7.75L9 7.75L9 9.75L11.5 9.75L8 13.25L4.5 9.75L7 9.75L7 7.75L4.5 7.75Z" fill="#000000" fill-opacity="0.16"/>
7 <path class="accent" d="M8 3.5L11.5 7L9 7L9 9L11.5 9L8 12.5L4.5 9L7 9L7 7L4.5 7Z" fill="url(#g0)" stroke="#1560c4" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
8</svg>
crates/snowbound/assets/icons/italic.svg+1-1
...@@ -1,3 +1,3 @@...@@ -1,3 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M7 2.5H12.5M3.5 13.5H9M9.75 2.5L6.25 13.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>2 <path d="M6.2 2L12.4 2L12 3.9L10.3 3.9L8.4 12.1L10.1 12.1L9.7 14L3.5 14L3.9 12.1L5.6 12.1L7.5 3.9L5.8 3.9Z" fill="currentColor"/>
3</svg>3</svg>
crates/snowbound/assets/icons/link.svg+14-1
...@@ -1,3 +1,16 @@...@@ -1,3 +1,16 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M6.5 4.75H5A3.25 3.25 0 0 0 5 11.25H6.5M9.5 4.75H11A3.25 3.25 0 0 1 11 11.25H9.5M5.5 8H10.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>2 <defs>
3 <linearGradient id="g0" gradientUnits="userSpaceOnUse" x2="0" y1="5.174" y2="13.694"><stop offset="0" stop-color="#9cd0ff"/><stop offset="1" stop-color="#2f86f0"/></linearGradient>
4 <linearGradient id="g1" gradientUnits="userSpaceOnUse" x2="0" y1="2.306" y2="10.826"><stop offset="0" stop-color="#9cd0ff"/><stop offset="1" stop-color="#2f86f0"/></linearGradient>
5 <linearGradient id="g2" gradientUnits="userSpaceOnUse" x2="0" y1="6.453" y2="11.431"><stop offset="0" stop-color="#9cd0ff"/><stop offset="1" stop-color="#2f86f0"/></linearGradient>
6 </defs>
7 <path d="M3.003 8.587L5.46 6.866A3 3 -35 0 1 9.638 7.603L9.638 7.603A3 3 -35 0 1 8.902 11.781L6.444 13.502A3 3 -35 0 1 2.266 12.765L2.266 12.765A3 3 -35 0 1 3.003 8.587Z" fill="none" stroke="#000000" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.16"/>
8 <path class="accent" d="M3.003 7.837L5.46 6.116A3 3 -35 0 1 9.638 6.853L9.638 6.853A3 3 -35 0 1 8.902 11.031L6.444 12.752A3 3 -35 0 1 2.266 12.015L2.266 12.015A3 3 -35 0 1 3.003 7.837Z" fill="none" stroke="#1a62c0" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>
9 <path class="accent" d="M3.003 7.837L5.46 6.116A3 3 -35 0 1 9.638 6.853L9.638 6.853A3 3 -35 0 1 8.902 11.031L6.444 12.752A3 3 -35 0 1 2.266 12.015L2.266 12.015A3 3 -35 0 1 3.003 7.837Z" fill="none" stroke="url(#g0)" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
10 <path d="M7.098 5.719L9.556 3.998A3 3 -35 0 1 13.734 4.735L13.734 4.735A3 3 -35 0 1 12.997 8.913L10.54 10.634A3 3 -35 0 1 6.362 9.897L6.362 9.897A3 3 -35 0 1 7.098 5.719Z" fill="none" stroke="#000000" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.16"/>
11 <path class="accent" d="M7.098 4.969L9.556 3.248A3 3 -35 0 1 13.734 3.985L13.734 3.985A3 3 -35 0 1 12.997 8.163L10.54 9.884A3 3 -35 0 1 6.362 9.147L6.362 9.147A3 3 -35 0 1 7.098 4.969Z" fill="none" stroke="#1a62c0" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>
12 <path class="accent" d="M7.098 4.969L9.556 3.248A3 3 -35 0 1 13.734 3.985L13.734 3.985A3 3 -35 0 1 12.997 8.163L10.54 9.884A3 3 -35 0 1 6.362 9.147L6.362 9.147A3 3 -35 0 1 7.098 4.969Z" fill="none" stroke="url(#g1)" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
13 <path d="M9.638 7.603A3 3 -35 0 1 8.902 11.781" fill="none" stroke="#000000" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.16"/>
14 <path class="accent" d="M9.638 6.853A3 3 -35 0 1 8.902 11.031" fill="none" stroke="#1a62c0" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>
15 <path class="accent" d="M9.638 6.853A3 3 -35 0 1 8.902 11.031" fill="none" stroke="url(#g2)" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
3</svg>16</svg>
crates/snowbound/assets/icons/notebook.svg+18-1
...@@ -1,3 +1,20 @@...@@ -1,3 +1,20 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M4.5 1.5H12A1.5 1.5 0 0 1 13.5 3V13A1.5 1.5 0 0 1 12 14.5H4.5A1 1 0 0 1 3.5 13.5V2.5A1 1 0 0 1 4.5 1.5ZM6 1.5V14.5M2.5 4.5H4.5M2.5 8H4.5M2.5 11.5H4.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#ff8f80"/><stop offset="1" stop-color="#f03b30"/></linearGradient>
4 <linearGradient id="g1" x2="0" y2="1"><stop offset="0" stop-color="#86e88a"/><stop offset="1" stop-color="#28b04a"/></linearGradient>
5 <linearGradient id="g2" x2="0" y2="1"><stop offset="0" stop-color="#79c2ff"/><stop offset="1" stop-color="#1f7cf0"/></linearGradient>
6 <linearGradient id="g3" x2="0" y2="1"><stop offset="0" stop-color="#ffdc80"/><stop offset="1" stop-color="#f39c28"/></linearGradient>
7 <linearGradient id="g4" x2="0" y2="1"><stop offset="0" stop-color="#fffdf3"/><stop offset="1" stop-color="#fbeec7"/></linearGradient>
8 </defs>
9 <path d="M12.1 3.75L14.4 3.75A0.6 0.6 0 0 1 15 4.35L15 5.65A0.6 0.6 0 0 1 14.4 6.25L12.1 6.25A0.6 0.6 0 0 1 11.5 5.65L11.5 4.35A0.6 0.6 0 0 1 12.1 3.75Z" fill="#000000" fill-opacity="0.16"/>
10 <path d="M12.1 3H14.4A0.6 0.6 0 0 1 15 3.6V4.9A0.6 0.6 0 0 1 14.4 5.5H12.1A0.6 0.6 0 0 1 11.5 4.9V3.6A0.6 0.6 0 0 1 12.1 3Z" fill="url(#g0)" stroke="#c42418" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
11 <path d="M12.1 7.25L14.4 7.25A0.6 0.6 0 0 1 15 7.85L15 9.15A0.6 0.6 0 0 1 14.4 9.75L12.1 9.75A0.6 0.6 0 0 1 11.5 9.15L11.5 7.85A0.6 0.6 0 0 1 12.1 7.25Z" fill="#000000" fill-opacity="0.16"/>
12 <path d="M12.1 6.5H14.4A0.6 0.6 0 0 1 15 7.1V8.4A0.6 0.6 0 0 1 14.4 9H12.1A0.6 0.6 0 0 1 11.5 8.4V7.1A0.6 0.6 0 0 1 12.1 6.5Z" fill="url(#g1)" stroke="#1b8a36" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
13 <path d="M12.1 10.75L14.4 10.75A0.6 0.6 0 0 1 15 11.35L15 12.65A0.6 0.6 0 0 1 14.4 13.25L12.1 13.25A0.6 0.6 0 0 1 11.5 12.65L11.5 11.35A0.6 0.6 0 0 1 12.1 10.75Z" fill="#000000" fill-opacity="0.16"/>
14 <path class="accent" d="M12.1 10H14.4A0.6 0.6 0 0 1 15 10.6V11.9A0.6 0.6 0 0 1 14.4 12.5H12.1A0.6 0.6 0 0 1 11.5 11.9V10.6A0.6 0.6 0 0 1 12.1 10Z" fill="url(#g2)" stroke="#1560c4" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
15 <path d="M3.5 2.25L11.5 2.25A1 1 0 0 1 12.5 3.25L12.5 14.25A1 1 0 0 1 11.5 15.25L3.5 15.25A1 1 0 0 1 2.5 14.25L2.5 3.25A1 1 0 0 1 3.5 2.25Z" fill="#000000" fill-opacity="0.16"/>
16 <path d="M3.5 1.5H11.5A1 1 0 0 1 12.5 2.5V13.5A1 1 0 0 1 11.5 14.5H3.5A1 1 0 0 1 2.5 13.5V2.5A1 1 0 0 1 3.5 1.5Z" fill="url(#g3)" stroke="#cc7a10" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
17 <path d="M6 4H10A0.5 0.5 0 0 1 10.5 4.5V6.5A0.5 0.5 0 0 1 10 7H6A0.5 0.5 0 0 1 5.5 6.5V4.5A0.5 0.5 0 0 1 6 4Z" fill="url(#g4)"/>
18 <path d="M3.5 13.5V3A0.5 0.5 0 0 1 4 2.5H11" fill="none" stroke="#ffffff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.85"/>
19 <path d="M1.5 3.75H4.5M1.5 6.25H4.5M1.5 8.75H4.5M1.5 11.25H4.5M1.5 13.5H4.5" fill="none" stroke="#3b4350" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
3</svg>20</svg>
crates/snowbound/assets/icons/numbering.svg+2-3
...@@ -1,5 +1,4 @@...@@ -1,5 +1,4 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M7.5 4.5H13.5M7.5 8.5H13.5M7.5 12.5H13.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>2 <path class="accent" d="M2.5 2.5L3.5 1.5V4.5M2.5 4.5H4.5M2.5 6.5H4.5V7.5L2.5 9.5H4.5M2.5 11.5H4.5V14.5H2.5M3.5 12.5H4.5" fill="none" stroke="#1f6fe0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"/>
3 <path d="M2.25 4L3.75 3V7.25" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>3 <path d="M7 2H15V3H7ZM7 7H15V8H7ZM7 12H15V13H7Z" fill="currentColor"/>
4 <path d="M2 11.25C2.2 10.15 4.6 10.05 4.6 11.35C4.6 12.55 2 13.05 2 14.25H4.8" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
5</svg>4</svg>
crates/snowbound/assets/icons/options.svg+6-3
...@@ -1,5 +1,8 @@...@@ -1,5 +1,8 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M8 1.5V3.5M8 12.5V14.5M1.5 8H3.5M12.5 8H14.5M3.4 3.4L4.8 4.8M11.2 11.2L12.6 12.6M3.4 12.6L4.8 11.2M11.2 4.8L12.6 3.4" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round"/>2 <defs>
3 <circle cx="8" cy="8" r="3.5" fill="none" stroke="currentColor" stroke-width="1.25"/>3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#d3d9e0"/><stop offset="1" stop-color="#7b8796"/></linearGradient>
4 <circle cx="8" cy="8" r="1.25" fill="none" stroke="currentColor" stroke-width="1.25"/>4 </defs>
5 <path d="M8.529 3.376L10.09 1.86L11.394 2.4L11.426 4.576L12.174 5.324L14.35 5.356L14.89 6.66L13.374 8.221L13.374 9.279L14.89 10.84L14.35 12.144L12.174 12.176L11.426 12.924L11.394 15.1L10.09 15.64L8.529 14.124L7.471 14.124L5.91 15.64L4.606 15.1L4.574 12.924L3.826 12.176L1.65 12.144L1.11 10.84L2.626 9.279L2.626 8.221L1.11 6.66L1.65 5.356L3.826 5.324L4.574 4.576L4.606 2.4L5.91 1.86L7.471 3.376ZM5.8 8.75A2.2 2.2 0 1 1 10.2 8.75A2.2 2.2 0 1 1 5.8 8.75Z" fill="#000000" fill-rule="evenodd" fill-opacity="0.16"/>
6 <path d="M8.529 2.626L10.09 1.11L11.394 1.65L11.426 3.826L12.174 4.574L14.35 4.606L14.89 5.91L13.374 7.471L13.374 8.529L14.89 10.09L14.35 11.394L12.174 11.426L11.426 12.174L11.394 14.35L10.09 14.89L8.529 13.374L7.471 13.374L5.91 14.89L4.606 14.35L4.574 12.174L3.826 11.426L1.65 11.394L1.11 10.09L2.626 8.529L2.626 7.471L1.11 5.91L1.65 4.606L3.826 4.574L4.574 3.826L4.606 1.65L5.91 1.11L7.471 2.626ZM5.8 8A2.2 2.2 0 1 1 10.2 8A2.2 2.2 0 1 1 5.8 8Z" fill="url(#g0)" fill-rule="evenodd" stroke="#5a6676" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
7 <path d="M4.4 8A3.6 3.6 0 1 1 11.6 8A3.6 3.6 0 1 1 4.4 8Z" fill="none" stroke="#8e99a7" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"/>
5</svg>8</svg>
crates/snowbound/assets/icons/outdent.svg+6-2
...@@ -1,4 +1,8 @@...@@ -1,4 +1,8 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M2.5 3.5H13.5M7.5 6.5H13.5M7.5 9.5H13.5M2.5 12.5H13.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>2 <defs>
3 <path d="M5 5.5L2.5 8L5 10.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#79c2ff"/><stop offset="1" stop-color="#1f7cf0"/></linearGradient>
4 </defs>
5 <path d="M2 1H15V2H2ZM7 5H15V6H7ZM7 9H15V10H7ZM2 13H15V14H2Z" fill="currentColor"/>
6 <path d="M5 5.75L1.5 8.25L5 10.75Z" fill="#000000" fill-opacity="0.16"/>
7 <path class="accent" d="M5 5L1.5 7.5L5 10Z" fill="url(#g0)" stroke="#1560c4" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
4</svg>8</svg>
crates/snowbound/assets/icons/page-color.svg created+15
...@@ -0,0 +1,15 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#e3e9f0"/></linearGradient>
4 <linearGradient id="g1" x2="0" y2="1"><stop offset="0" stop-color="#f1f4f8"/><stop offset="1" stop-color="#c9d3df"/></linearGradient>
5 <linearGradient id="g2" x2="0" y2="1"><stop offset="0" stop-color="#79c2ff"/><stop offset="1" stop-color="#1f7cf0"/></linearGradient>
6 </defs>
7 <path d="M1.5 2.25L7.5 2.25L10.5 5.25L10.5 15.25L1.5 15.25Z" fill="#000000" fill-opacity="0.16"/>
8 <path d="M1.5 1.5L7.5 1.5L10.5 4.5L10.5 14.5L1.5 14.5Z" fill="url(#g0)" stroke="#98a4b3" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
9 <path d="M7.5 2.25L7.5 5.25L10.5 5.25Z" fill="#000000" fill-opacity="0.16"/>
10 <path d="M7.5 1.5L7.5 4.5L10.5 4.5Z" fill="url(#g1)" stroke="#98a4b3" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
11 <path class="accent" d="M3 6H7M3 8H6.5" fill="none" stroke="#8cc0f5" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"/>
12 <path d="M11.5 7.25C11.5 7.25 8.5 10.75 8.5 12.75A3 3 0 0 0 14.5 12.75C14.5 10.75 11.5 7.25 11.5 7.25Z" fill="#000000" fill-opacity="0.16"/>
13 <path class="accent" d="M11.5 6.5C11.5 6.5 8.5 10 8.5 12A3 3 0 0 0 14.5 12C14.5 10 11.5 6.5 11.5 6.5Z" fill="url(#g2)" stroke="#1560c4" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
14 <path d="M10 11.5A1.6 1.6 0 0 0 10.6 13" fill="none" stroke="#ffffff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.85"/>
15</svg>
crates/snowbound/assets/icons/page.svg+10-1
...@@ -1,3 +1,12 @@...@@ -1,3 +1,12 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M4 2.25H9.25L12 5V13.75H4ZM9.25 2.25V5H12" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linejoin="round"/>2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#e3e9f0"/></linearGradient>
4 <linearGradient id="g1" x2="0" y2="1"><stop offset="0" stop-color="#f1f4f8"/><stop offset="1" stop-color="#c9d3df"/></linearGradient>
5 </defs>
6 <path d="M3.5 2.25L10 2.25L13.5 5.75L13.5 15.25L3.5 15.25Z" fill="#000000" fill-opacity="0.16"/>
7 <path d="M3.5 1.5L10 1.5L13.5 5L13.5 14.5L3.5 14.5Z" fill="url(#g0)" stroke="#98a4b3" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
8 <path d="M10 2.25L10 5.75L13.5 5.75Z" fill="#000000" fill-opacity="0.16"/>
9 <path d="M10 1.5L10 5L13.5 5Z" fill="url(#g1)" stroke="#98a4b3" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
10 <path class="accent" d="M5 7.5H12M5 9.5H12M5 11.5H12" fill="none" stroke="#8cc0f5" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"/>
11 <path d="M4.5 13.5V2.5H9.5" fill="none" stroke="#ffffff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.85"/>
3</svg>12</svg>
crates/snowbound/assets/icons/paste.svg created+17
...@@ -0,0 +1,17 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#f0c98a"/><stop offset="1" stop-color="#c48a3e"/></linearGradient>
4 <linearGradient id="g1" x2="0" y2="1"><stop offset="0" stop-color="#d3d9e0"/><stop offset="1" stop-color="#7b8796"/></linearGradient>
5 <linearGradient id="g2" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#e3e9f0"/></linearGradient>
6 <linearGradient id="g3" x2="0" y2="1"><stop offset="0" stop-color="#f1f4f8"/><stop offset="1" stop-color="#c9d3df"/></linearGradient>
7 </defs>
8 <path d="M2.5 3.25H10.5A1 1 0 0 1 11.5 4.25V14.75A1 1 0 0 1 10.5 15.75H2.5A1 1 0 0 1 1.5 14.75V4.25A1 1 0 0 1 2.5 3.25Z" fill="#000000" fill-opacity="0.16"/>
9 <path d="M2.5 2.5H10.5A1 1 0 0 1 11.5 3.5V14A1 1 0 0 1 10.5 15H2.5A1 1 0 0 1 1.5 14V3.5A1 1 0 0 1 2.5 2.5Z" fill="url(#g0)" stroke="#94632a" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
10 <path d="M5 1.75H8A1 1 0 0 1 9 2.75V3.75A1 1 0 0 1 8 4.75H5A1 1 0 0 1 4 3.75V2.75A1 1 0 0 1 5 1.75Z" fill="#000000" fill-opacity="0.16"/>
11 <path d="M5 1H8A1 1 0 0 1 9 2V3A1 1 0 0 1 8 4H5A1 1 0 0 1 4 3V2A1 1 0 0 1 5 1Z" fill="url(#g1)" stroke="#5a6676" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
12 <path d="M6.5 6.75L12 6.75L14.5 9.25L14.5 15.75L6.5 15.75Z" fill="#000000" fill-opacity="0.16"/>
13 <path d="M6.5 6L12 6L14.5 8.5L14.5 15L6.5 15Z" fill="url(#g2)" stroke="#98a4b3" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
14 <path d="M12 6.75L12 9.25L14.5 9.25Z" fill="#000000" fill-opacity="0.16"/>
15 <path d="M12 6L12 8.5L14.5 8.5Z" fill="url(#g3)" stroke="#98a4b3" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
16 <path class="accent" d="M8 9.5H12M8 11.5H12" fill="none" stroke="#8cc0f5" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"/>
17</svg>
crates/snowbound/assets/icons/picture.svg+13-2
...@@ -1,4 +1,15 @@...@@ -1,4 +1,15 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M3 2.5H13A1.5 1.5 0 0 1 14.5 4V12A1.5 1.5 0 0 1 13 13.5H3A1.5 1.5 0 0 1 1.5 12V4A1.5 1.5 0 0 1 3 2.5ZM1.75 12L6 7.5L10.5 12M9 10.5L10.75 8.75L14.25 12.25" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>2 <defs>
3 <path d="M9.5 5.75A1.25 1.25 0 1 0 12 5.75A1.25 1.25 0 1 0 9.5 5.75Z" fill="currentColor"/>3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#e3e9f0"/></linearGradient>
4 <linearGradient id="g1" x2="0" y2="1"><stop offset="0" stop-color="#6fb9ff"/><stop offset="1" stop-color="#dff0ff"/></linearGradient>
5 <linearGradient id="g2" x2="0" y2="1"><stop offset="0" stop-color="#ffe36b"/><stop offset="1" stop-color="#ffa51f"/></linearGradient>
6 <linearGradient id="g3" x2="0" y2="1"><stop offset="0" stop-color="#8fa8cc"/><stop offset="1" stop-color="#4b6993"/></linearGradient>
7 <linearGradient id="g4" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#ffffff"/></linearGradient>
8 </defs>
9 <path d="M2 3.25L14 3.25A0.5 0.5 0 0 1 14.5 3.75L14.5 13.75A0.5 0.5 0 0 1 14 14.25L2 14.25A0.5 0.5 0 0 1 1.5 13.75L1.5 3.75A0.5 0.5 0 0 1 2 3.25Z" fill="#000000" fill-opacity="0.16"/>
10 <path d="M2 2.5H14A0.5 0.5 0 0 1 14.5 3V13A0.5 0.5 0 0 1 14 13.5H2A0.5 0.5 0 0 1 1.5 13V3A0.5 0.5 0 0 1 2 2.5Z" fill="url(#g0)" stroke="#98a4b3" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
11 <path d="M3 4H13V12H3Z" fill="url(#g1)"/>
12 <path d="M9 6.5A1.5 1.5 0 1 1 12 6.5A1.5 1.5 0 1 1 9 6.5Z" fill="url(#g2)"/>
13 <path d="M3 12L3 10.6L6.5 6.2L9 9.2L10.2 8.2L13 11L13 12Z" fill="url(#g3)"/>
14 <path d="M5.3 7.7L6.5 6.2L7.7 7.6L6.9 7.3L6.3 8L5.9 7.4Z" fill="url(#g4)"/>
4</svg>15</svg>
crates/snowbound/assets/icons/plus.svg+7-1
...@@ -1,3 +1,9 @@...@@ -1,3 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M8 2.5V13.5M2.5 8H13.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#86e88a"/><stop offset="1" stop-color="#1f9a3e"/></linearGradient>
4 </defs>
5 <path d="M1.5 8.75A6.5 6.5 0 1 1 14.5 8.75A6.5 6.5 0 1 1 1.5 8.75Z" fill="#000000" fill-opacity="0.16"/>
6 <path class="badge" d="M1.5 8A6.5 6.5 0 1 1 14.5 8A6.5 6.5 0 1 1 1.5 8Z" fill="url(#g0)" stroke="#16762e" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
7 <path d="M3.5 6.5A4.75 4.75 0 0 1 8 2.75" fill="none" stroke="#ffffff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.5"/>
8 <path d="M4 7H12V9H4ZM7 4H9V12H7Z" fill="#ffffff"/>
3</svg>9</svg>
crates/snowbound/assets/icons/record-audio.svg created+13
...@@ -0,0 +1,13 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#d3d9e0"/><stop offset="1" stop-color="#7b8796"/></linearGradient>
4 <linearGradient id="g1" x2="0" y2="1"><stop offset="0" stop-color="#ff8f80"/><stop offset="1" stop-color="#f03b30"/></linearGradient>
5 </defs>
6 <path d="M2.5 7A4.5 4.5 0 0 0 11.5 7M7 11.5V14M4.5 14.5H9.5" fill="none" stroke="#7b8796" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
7 <path d="M7 1.75H7A2.5 2.5 0 0 1 9.5 4.25V8.25A2.5 2.5 0 0 1 7 10.75H7A2.5 2.5 0 0 1 4.5 8.25V4.25A2.5 2.5 0 0 1 7 1.75Z" fill="#000000" fill-opacity="0.16"/>
8 <path d="M7 1H7A2.5 2.5 0 0 1 9.5 3.5V7.5A2.5 2.5 0 0 1 7 10H7A2.5 2.5 0 0 1 4.5 7.5V3.5A2.5 2.5 0 0 1 7 1Z" fill="url(#g0)" stroke="#5a6676" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
9 <path d="M5.75 3.25V6.5" fill="none" stroke="#ffffff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.85"/>
10 <path d="M9 12.25A3 3 0 1 1 15 12.25A3 3 0 1 1 9 12.25Z" fill="#000000" fill-opacity="0.16"/>
11 <path d="M9 11.5A3 3 0 1 1 15 11.5A3 3 0 1 1 9 11.5Z" fill="url(#g1)" stroke="#c42418" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
12 <path d="M10.35 11.2A1.8 1.8 0 0 1 11.85 9.7" fill="none" stroke="#ffffff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.85"/>
13</svg>
crates/snowbound/assets/icons/record-video.svg created+15
...@@ -0,0 +1,15 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#d3d9e0"/><stop offset="1" stop-color="#7b8796"/></linearGradient>
4 <linearGradient id="g1" x2="0" y2="1"><stop offset="0" stop-color="#d3d9e0"/><stop offset="1" stop-color="#7b8796"/></linearGradient>
5 <linearGradient id="g2" x2="0" y2="1"><stop offset="0" stop-color="#ff8f80"/><stop offset="1" stop-color="#f03b30"/></linearGradient>
6 </defs>
7 <path d="M2 2.75H9.5A1 1 0 0 1 10.5 3.75V8.75A1 1 0 0 1 9.5 9.75H2A1 1 0 0 1 1 8.75V3.75A1 1 0 0 1 2 2.75Z" fill="#000000" fill-opacity="0.16"/>
8 <path d="M2 2H9.5A1 1 0 0 1 10.5 3V8A1 1 0 0 1 9.5 9H2A1 1 0 0 1 1 8V3A1 1 0 0 1 2 2Z" fill="url(#g0)" stroke="#5a6676" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
9 <path d="M10.5 4.75L14.5 2.75L14.5 9.75L10.5 7.75Z" fill="#000000" fill-opacity="0.16"/>
10 <path d="M10.5 4L14.5 2L14.5 9L10.5 7Z" fill="url(#g1)" stroke="#5a6676" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
11 <path d="M2.5 3.5V7.5" fill="none" stroke="#ffffff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.85"/>
12 <path d="M9 11.75A3 3 0 1 1 15 11.75A3 3 0 1 1 9 11.75Z" fill="#000000" fill-opacity="0.16"/>
13 <path d="M9 11A3 3 0 1 1 15 11A3 3 0 1 1 9 11Z" fill="url(#g2)" stroke="#c42418" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
14 <path d="M10.35 10.7A1.8 1.8 0 0 1 11.85 9.2" fill="none" stroke="#ffffff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.85"/>
15</svg>
crates/snowbound/assets/icons/redo.svg+6-1
...@@ -1,3 +1,8 @@...@@ -1,3 +1,8 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M10.5 3.5L13.5 6.5L10.5 9.5M13.5 6.5H6.25A3.5 3.5 0 0 0 6.25 13.5H9.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#79c2ff"/><stop offset="1" stop-color="#1f7cf0"/></linearGradient>
4 </defs>
5 <path d="M7.5 4.75A5.5 5.5 180 1 0 8.924 15.563L8.147 12.665A2.5 2.5 180 1 1 7.5 7.75L8.5 7.75L8.5 10.75L14.5 6.25L8.5 1.75L8.5 4.75Z" fill="#000000" fill-opacity="0.16"/>
6 <path class="accent" d="M7.5 4A5.5 5.5 180 1 0 8.924 14.813L8.147 11.915A2.5 2.5 180 1 1 7.5 7L8.5 7L8.5 10L14.5 5.5L8.5 1L8.5 4Z" fill="url(#g0)" stroke="#1560c4" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
7 <path d="M7.5 5A4.5 4.5 180 0 0 3.1 8.7" fill="none" stroke="#ffffff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.85"/>
3</svg>8</svg>
crates/snowbound/assets/icons/rule-lines.svg created+9
...@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#e3e9f0"/></linearGradient>
4 </defs>
5 <path d="M3 2.25H13A0.5 0.5 0 0 1 13.5 2.75V14.75A0.5 0.5 0 0 1 13 15.25H3A0.5 0.5 0 0 1 2.5 14.75V2.75A0.5 0.5 0 0 1 3 2.25Z" fill="#000000" fill-opacity="0.16"/>
6 <path d="M3 1.5H13A0.5 0.5 0 0 1 13.5 2V14A0.5 0.5 0 0 1 13 14.5H3A0.5 0.5 0 0 1 2.5 14V2A0.5 0.5 0 0 1 3 1.5Z" fill="url(#g0)" stroke="#98a4b3" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
7 <path class="accent" d="M3 4H13V5H3ZM3 7H13V8H3ZM3 10H13V11H3ZM3 13H13V14H3Z" fill="#8cc0f5"/>
8 <path d="M5 2H6V14H5Z" fill="#f03b30"/>
9</svg>
crates/snowbound/assets/icons/screen-clipping.svg created+8
...@@ -0,0 +1,8 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#f1f4f8"/></linearGradient>
4 </defs>
5 <path class="accent" d="M1 1H3V2H1ZM1 11H3V12H1ZM1 1H2V3H1ZM11 1H12V3H11ZM4 1H6V2H4ZM4 11H6V12H4ZM1 4H2V6H1ZM11 4H12V6H11ZM7 1H9V2H7ZM7 11H9V12H7ZM1 7H2V9H1ZM11 7H12V9H11ZM10 1H12V2H10ZM10 11H12V12H10ZM1 10H2V12H1ZM11 10H12V12H11Z" fill="#1f6fe0"/>
6 <path d="M8 7.75L8 15.25L9.9 13.45L11.1 16.15L12.5 15.55L11.3 12.85L13.8 12.85Z" fill="#000000" fill-opacity="0.16"/>
7 <path d="M8 7L8 14.5L9.9 12.7L11.1 15.4L12.5 14.8L11.3 12.1L13.8 12.1Z" fill="url(#g0)" stroke="#5a6676" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
8</svg>
crates/snowbound/assets/icons/search.svg+13-1
...@@ -1,3 +1,15 @@...@@ -1,3 +1,15 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M2.25 7A4.75 4.75 0 1 0 11.75 7A4.75 4.75 0 1 0 2.25 7ZM10.5 10.5L13.5 13.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>2 <defs>
3 <linearGradient id="g0" gradientUnits="userSpaceOnUse" x2="0" y1="8.85" y2="14.85"><stop offset="0" stop-color="#6a7482"/><stop offset="1" stop-color="#3b4350"/></linearGradient>
4 <linearGradient id="g1" gradientUnits="userSpaceOnUse" x2="0" y1="1.15" y2="11.85"><stop offset="0" stop-color="#d3d9e0"/><stop offset="1" stop-color="#7b8796"/></linearGradient>
5 <linearGradient id="g2" x2="0" y2="1"><stop offset="0" stop-color="#eaf6ff"/><stop offset="1" stop-color="#aed8ff"/></linearGradient>
6 </defs>
7 <path d="M10.2 10.95L13.5 14.25" fill="none" stroke="#000000" stroke-width="3.6" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.16"/>
8 <path d="M10.2 10.2L13.5 13.5" fill="none" stroke="#2b323b" stroke-width="3.6" stroke-linecap="round" stroke-linejoin="round"/>
9 <path d="M10.2 10.2L13.5 13.5" fill="none" stroke="url(#g0)" stroke-width="2.7" stroke-linecap="round" stroke-linejoin="round"/>
10 <path d="M2 7.25A4.5 4.5 0 1 1 11 7.25A4.5 4.5 0 1 1 2 7.25Z" fill="none" stroke="#000000" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.16"/>
11 <path d="M2 6.5A4.5 4.5 0 1 1 11 6.5A4.5 4.5 0 1 1 2 6.5Z" fill="none" stroke="#5a6676" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
12 <path d="M2 6.5A4.5 4.5 0 1 1 11 6.5A4.5 4.5 0 1 1 2 6.5Z" fill="none" stroke="url(#g1)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>
13 <path d="M3 6.5A3.5 3.5 0 1 1 10 6.5A3.5 3.5 0 1 1 3 6.5Z" fill="url(#g2)"/>
14 <path d="M3.8 6.8A2.7 2.7 0 0 1 6.2 3.8" fill="none" stroke="#ffffff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.85"/>
3</svg>15</svg>
crates/snowbound/assets/icons/section-group.svg+12-1
...@@ -1,3 +1,14 @@...@@ -1,3 +1,14 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M2.5 4A1.5 1.5 0 0 1 4 2.5H6.5L8 4H12A1.5 1.5 0 0 1 13.5 5.5V12A1.5 1.5 0 0 1 12 13.5H4A1.5 1.5 0 0 1 2.5 12Z" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#86e88a"/><stop offset="1" stop-color="#28b04a"/></linearGradient>
4 <linearGradient id="g1" x2="0" y2="1"><stop offset="0" stop-color="#ffdc80"/><stop offset="1" stop-color="#f39c28"/></linearGradient>
5 <linearGradient id="g2" x2="0" y2="1"><stop offset="0" stop-color="#79c2ff"/><stop offset="1" stop-color="#1f7cf0"/></linearGradient>
6 </defs>
7 <path d="M4.5 9.25L4.5 3.75A1.5 1.5 0 0 1 6 2.25L12.4 2.25C13.2 2.25 13.5 2.55 13.9 3.15L15.5 9.25Z" fill="#000000" fill-opacity="0.16"/>
8 <path d="M4.5 8.5V3A1.5 1.5 0 0 1 6 1.5H12.4C13.2 1.5 13.5 1.8 13.9 2.4L15.5 8.5Z" fill="url(#g0)" stroke="#1b8a36" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
9 <path d="M2.5 12.25L2.5 6.75A1.5 1.5 0 0 1 4 5.25L10.4 5.25C11.2 5.25 11.5 5.55 11.9 6.15L13.5 12.25Z" fill="#000000" fill-opacity="0.16"/>
10 <path d="M2.5 11.5V6A1.5 1.5 0 0 1 4 4.5H10.4C11.2 4.5 11.5 4.8 11.9 5.4L13.5 11.5Z" fill="url(#g1)" stroke="#cc7a10" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
11 <path d="M0.5 15.25L0.5 9.75A1.5 1.5 0 0 1 2 8.25L8.4 8.25C9.2 8.25 9.5 8.55 9.9 9.15L11.5 15.25Z" fill="#000000" fill-opacity="0.16"/>
12 <path class="accent" d="M0.5 14.5V9A1.5 1.5 0 0 1 2 7.5H8.4C9.2 7.5 9.5 7.8 9.9 8.4L11.5 14.5Z" fill="url(#g2)" stroke="#1560c4" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
13 <path d="M1.5 13.5V9.2A0.7 0.7 0 0 1 2.2 8.5H7.5" fill="none" stroke="#ffffff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.85"/>
3</svg>14</svg>
crates/snowbound/assets/icons/section.svg created+3
...@@ -0,0 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M1.5 12V5.5A1.5 1.5 0 0 1 3 4H10.9C11.7 4 12 4.3 12.4 4.9L14.5 12Z" fill="currentColor" fill-opacity="0.2" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
3</svg>
crates/snowbound/assets/icons/sidebar-collapse.svg+11-1
...@@ -1,3 +1,13 @@...@@ -1,3 +1,13 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M3 2.5H13A1.5 1.5 0 0 1 14.5 4V12A1.5 1.5 0 0 1 13 13.5H3A1.5 1.5 0 0 1 1.5 12V4A1.5 1.5 0 0 1 3 2.5ZM10.5 2.5V13.5M4.75 5.5L7.25 8L4.75 10.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#e3e9f0"/></linearGradient>
4 <linearGradient id="g1" x2="0" y2="1"><stop offset="0" stop-color="#79c2ff"/><stop offset="1" stop-color="#1f7cf0"/></linearGradient>
5 <linearGradient id="g2" x2="0" y2="1"><stop offset="0" stop-color="#e9f4ff"/><stop offset="1" stop-color="#cfe6ff"/></linearGradient>
6 </defs>
7 <path d="M2.5 3.25L13.5 3.25A1 1 0 0 1 14.5 4.25L14.5 13.25A1 1 0 0 1 13.5 14.25L2.5 14.25A1 1 0 0 1 1.5 13.25L1.5 4.25A1 1 0 0 1 2.5 3.25Z" fill="#000000" fill-opacity="0.16"/>
8 <path d="M2.5 2.5H13.5A1 1 0 0 1 14.5 3.5V12.5A1 1 0 0 1 13.5 13.5H2.5A1 1 0 0 1 1.5 12.5V3.5A1 1 0 0 1 2.5 2.5Z" fill="url(#g0)" stroke="#98a4b3" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
9 <path class="accent" d="M2.5 3H13.5A0.5 0.5 0 0 1 14 3.5V4.5A0.5 0.5 0 0 1 13.5 5H2.5A0.5 0.5 0 0 1 2 4.5V3.5A0.5 0.5 0 0 1 2.5 3Z" fill="url(#g1)"/>
10 <path class="accent" d="M10 5H14V13H10Z" fill="url(#g2)"/>
11 <path d="M10.5 5V13" fill="none" stroke="#b7c5d6" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"/>
12 <path class="accent" d="M5.5 6.5L7.5 8.5L5.5 10.5" fill="none" stroke="#1f6fe0" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
3</svg>13</svg>
crates/snowbound/assets/icons/sidebar-expand.svg+11-1
...@@ -1,3 +1,13 @@...@@ -1,3 +1,13 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M3 2.5H13A1.5 1.5 0 0 1 14.5 4V12A1.5 1.5 0 0 1 13 13.5H3A1.5 1.5 0 0 1 1.5 12V4A1.5 1.5 0 0 1 3 2.5ZM10.5 2.5V13.5M7.25 5.5L4.75 8L7.25 10.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#e3e9f0"/></linearGradient>
4 <linearGradient id="g1" x2="0" y2="1"><stop offset="0" stop-color="#79c2ff"/><stop offset="1" stop-color="#1f7cf0"/></linearGradient>
5 <linearGradient id="g2" x2="0" y2="1"><stop offset="0" stop-color="#e9f4ff"/><stop offset="1" stop-color="#cfe6ff"/></linearGradient>
6 </defs>
7 <path d="M2.5 3.25L13.5 3.25A1 1 0 0 1 14.5 4.25L14.5 13.25A1 1 0 0 1 13.5 14.25L2.5 14.25A1 1 0 0 1 1.5 13.25L1.5 4.25A1 1 0 0 1 2.5 3.25Z" fill="#000000" fill-opacity="0.16"/>
8 <path d="M2.5 2.5H13.5A1 1 0 0 1 14.5 3.5V12.5A1 1 0 0 1 13.5 13.5H2.5A1 1 0 0 1 1.5 12.5V3.5A1 1 0 0 1 2.5 2.5Z" fill="url(#g0)" stroke="#98a4b3" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
9 <path class="accent" d="M2.5 3H13.5A0.5 0.5 0 0 1 14 3.5V4.5A0.5 0.5 0 0 1 13.5 5H2.5A0.5 0.5 0 0 1 2 4.5V3.5A0.5 0.5 0 0 1 2.5 3Z" fill="url(#g1)"/>
10 <path class="accent" d="M10 5H14V13H10Z" fill="url(#g2)"/>
11 <path d="M10.5 5V13" fill="none" stroke="#b7c5d6" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"/>
12 <path class="accent" d="M7.5 6.5L5.5 8.5L7.5 10.5" fill="none" stroke="#1f6fe0" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
3</svg>13</svg>
crates/snowbound/assets/icons/spelling.svg created+9
...@@ -0,0 +1,9 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" gradientUnits="userSpaceOnUse" x2="0" y1="7.85" y2="14.65"><stop offset="0" stop-color="#86e88a"/><stop offset="1" stop-color="#28b04a"/></linearGradient>
4 </defs>
5 <path d="M3 1H4V2H3ZM2 2H3V3H2ZM4 2H5V3H4ZM2 3H3V4H2ZM3 3H4V4H3ZM4 3H5V4H4ZM2 4H3V5H2ZM4 4H5V5H4ZM2 5H3V6H2ZM4 5H5V6H4ZM6 1H7V2H6ZM7 1H8V2H7ZM6 2H7V3H6ZM8 2H9V3H8ZM6 3H7V4H6ZM7 3H8V4H7ZM6 4H7V5H6ZM8 4H9V5H8ZM6 5H7V6H6ZM7 5H8V6H7ZM11 1H12V2H11ZM12 1H13V2H12ZM10 2H11V3H10ZM10 3H11V4H10ZM10 4H11V5H10ZM11 5H12V6H11ZM12 5H13V6H12Z" fill="currentColor"/>
6 <path d="M4 12.25L7 14.75L12.5 9.25" fill="none" stroke="#000000" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.16"/>
7 <path d="M4 11.5L7 14L12.5 8.5" fill="none" stroke="#1b8a36" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
8 <path d="M4 11.5L7 14L12.5 8.5" fill="none" stroke="url(#g0)" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
9</svg>
crates/snowbound/assets/icons/strikethrough.svg+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M11.5 3.5C10.9 2.8 10 2.5 8 2.5C5.5 2.5 4.5 3.6 4.5 5C4.5 5.6 4.7 6.1 5.1 6.5M10.5 9.5C11.2 10 11.5 10.6 11.5 11.25C11.5 12.6 10.2 13.5 8 13.5C6.2 13.5 4.9 13.1 4.5 12.3M2.5 8.5H13.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>2 <path d="M11.6 3.9C11 2.9 9.8 2.3 8.1 2.3C6.1 2.3 4.7 3.3 4.7 4.9C4.7 8.4 11.6 7.2 11.6 11.1C11.6 12.7 10.1 13.7 8 13.7C6.2 13.7 4.8 13 4.2 11.8" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/>
3 <path d="M2 7.5H14V8.75H2Z" fill="currentColor"/>
3</svg>4</svg>
crates/snowbound/assets/icons/subscript.svg+2-2
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M2.5 3.5L9.5 11.5M9.5 3.5L2.5 11.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>2 <path d="M2.5 2.5L8.5 10.5M8.5 2.5L2.5 10.5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>
3 <path d="M11 11C11.2 9.9 13.6 9.8 13.6 11.1C13.6 12.3 11 12.8 11 14H13.8" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>3 <path class="accent" d="M10.5 9.9C10.8 9.2 11.4 8.8 12.2 8.8C13.1 8.8 13.7 9.4 13.7 10.2C13.7 11.5 10.5 12.4 10.5 13.8L13.8 13.8" fill="none" stroke="#1f6fe0" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round"/>
4</svg>4</svg>
crates/snowbound/assets/icons/superscript.svg+2-2
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M2.5 4.5L9.5 12.5M9.5 4.5L2.5 12.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>2 <path d="M2.5 6.1L8.5 14.1M8.5 6.1L2.5 14.1" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>
3 <path d="M11 2.5C11.2 1.4 13.6 1.3 13.6 2.6C13.6 3.8 11 4.3 11 5.5H13.8" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>3 <path class="accent" d="M10.5 2.7C10.8 2 11.4 1.6 12.2 1.6C13.1 1.6 13.7 2.2 13.7 3C13.7 4.3 10.5 5.2 10.5 6.6L13.8 6.6" fill="none" stroke="#1f6fe0" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round"/>
4</svg>4</svg>
crates/snowbound/assets/icons/symbol.svg created+8
...@@ -0,0 +1,8 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" gradientUnits="userSpaceOnUse" x2="0" y1="1.9" y2="14.1"><stop offset="0" stop-color="#79c2ff"/><stop offset="1" stop-color="#1f7cf0"/></linearGradient>
4 </defs>
5 <path d="M3 14.25H6V12.75C4.3 11.75 3.2 10.15 3.2 8.15C3.2 5.25 5.3 3.25 8 3.25C10.7 3.25 12.8 5.25 12.8 8.15C12.8 10.15 11.7 11.75 10 12.75V14.25H13" fill="none" stroke="#000000" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.16"/>
6 <path class="accent" d="M3 13.5H6V12C4.3 11 3.2 9.4 3.2 7.4C3.2 4.5 5.3 2.5 8 2.5C10.7 2.5 12.8 4.5 12.8 7.4C12.8 9.4 11.7 11 10 12V13.5H13" fill="none" stroke="#1560c4" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/>
7 <path class="accent" d="M3 13.5H6V12C4.3 11 3.2 9.4 3.2 7.4C3.2 4.5 5.3 2.5 8 2.5C10.7 2.5 12.8 4.5 12.8 7.4C12.8 9.4 11.7 11 10 12V13.5H13" fill="none" stroke="url(#g0)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
8</svg>
crates/snowbound/assets/icons/table.svg+9-1
...@@ -1,3 +1,11 @@...@@ -1,3 +1,11 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M4 2.5H12A1.5 1.5 0 0 1 13.5 4V12A1.5 1.5 0 0 1 12 13.5H4A1.5 1.5 0 0 1 2.5 12V4A1.5 1.5 0 0 1 4 2.5ZM2.5 6.5H13.5M2.5 9.5H13.5M6.5 2.5V13.5M9.5 2.5V13.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#e3e9f0"/></linearGradient>
4 <linearGradient id="g1" x2="0" y2="1"><stop offset="0" stop-color="#79c2ff"/><stop offset="1" stop-color="#1f7cf0"/></linearGradient>
5 </defs>
6 <path d="M1 1.75H14V14.75H1Z" fill="#000000" fill-opacity="0.16"/>
7 <path d="M1 1H14V14H1Z" fill="url(#g0)"/>
8 <path class="accent" d="M2 2H13V5H2Z" fill="url(#g1)"/>
9 <path d="M5 2H6V13H5ZM9 2H10V13H9ZM2 5H13V6H2ZM2 9H13V10H2Z" fill="#b7c5d6"/>
10 <path d="M1 1H14V14H1ZM2 2V13H13V2Z" fill="#8e9aab" fill-rule="evenodd"/>
3</svg>11</svg>
crates/snowbound/assets/icons/underline.svg+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M4.5 2.5V7A3.5 3.5 0 0 0 11.5 7V2.5M3.5 13.5H12.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>2 <path d="M3.5 1.5H6.2V8.2C6.2 9.8 6.9 10.6 8 10.6C9.1 10.6 9.8 9.8 9.8 8.2V1.5H12.5V8.3C12.5 11.1 10.9 12.6 8 12.6C5.1 12.6 3.5 11.1 3.5 8.3Z" fill="currentColor"/>
3 <path d="M3 14H13V15H3Z" fill="currentColor"/>
3</svg>4</svg>
crates/snowbound/assets/icons/undo.svg+6-1
...@@ -1,3 +1,8 @@...@@ -1,3 +1,8 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M5.5 3.5L2.5 6.5L5.5 9.5M2.5 6.5H9.75A3.5 3.5 0 0 1 9.75 13.5H6.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#79c2ff"/><stop offset="1" stop-color="#1f7cf0"/></linearGradient>
4 </defs>
5 <path d="M8.5 4.75A5.5 5.5 0 1 1 7.076 15.563L7.853 12.665A2.5 2.5 0 1 0 8.5 7.75L7.5 7.75L7.5 10.75L1.5 6.25L7.5 1.75L7.5 4.75Z" fill="#000000" fill-opacity="0.16"/>
6 <path class="accent" d="M8.5 4A5.5 5.5 0 1 1 7.076 14.813L7.853 11.915A2.5 2.5 0 1 0 8.5 7L7.5 7L7.5 10L1.5 5.5L7.5 1L7.5 4Z" fill="url(#g0)" stroke="#1560c4" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
7 <path d="M8.5 5A4.5 4.5 0 0 1 12.9 8.7" fill="none" stroke="#ffffff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.85"/>
3</svg>8</svg>
crates/snowbound/assets/icons/zoom-in.svg+14-1
...@@ -1,3 +1,16 @@...@@ -1,3 +1,16 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M2.25 7A4.75 4.75 0 1 0 11.75 7A4.75 4.75 0 1 0 2.25 7ZM10.5 10.5L13.5 13.5M4.75 7H9.25M7 4.75V9.25" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>2 <defs>
3 <linearGradient id="g0" gradientUnits="userSpaceOnUse" x2="0" y1="8.85" y2="14.85"><stop offset="0" stop-color="#6a7482"/><stop offset="1" stop-color="#3b4350"/></linearGradient>
4 <linearGradient id="g1" gradientUnits="userSpaceOnUse" x2="0" y1="1.15" y2="11.85"><stop offset="0" stop-color="#d3d9e0"/><stop offset="1" stop-color="#7b8796"/></linearGradient>
5 <linearGradient id="g2" x2="0" y2="1"><stop offset="0" stop-color="#eaf6ff"/><stop offset="1" stop-color="#aed8ff"/></linearGradient>
6 </defs>
7 <path d="M10.2 10.95L13.5 14.25" fill="none" stroke="#000000" stroke-width="3.6" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.16"/>
8 <path d="M10.2 10.2L13.5 13.5" fill="none" stroke="#2b323b" stroke-width="3.6" stroke-linecap="round" stroke-linejoin="round"/>
9 <path d="M10.2 10.2L13.5 13.5" fill="none" stroke="url(#g0)" stroke-width="2.7" stroke-linecap="round" stroke-linejoin="round"/>
10 <path d="M2 7.25A4.5 4.5 0 1 1 11 7.25A4.5 4.5 0 1 1 2 7.25Z" fill="none" stroke="#000000" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.16"/>
11 <path d="M2 6.5A4.5 4.5 0 1 1 11 6.5A4.5 4.5 0 1 1 2 6.5Z" fill="none" stroke="#5a6676" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
12 <path d="M2 6.5A4.5 4.5 0 1 1 11 6.5A4.5 4.5 0 1 1 2 6.5Z" fill="none" stroke="url(#g1)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>
13 <path d="M3 6.5A3.5 3.5 0 1 1 10 6.5A3.5 3.5 0 1 1 3 6.5Z" fill="url(#g2)"/>
14 <path d="M3.8 6.8A2.7 2.7 0 0 1 6.2 3.8" fill="none" stroke="#ffffff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.85"/>
15 <path class="accent" d="M4.5 6H8.5V7H4.5ZM6 4.5H7V8.5H6Z" fill="#1f6fe0"/>
3</svg>16</svg>
crates/snowbound/assets/icons/zoom-out.svg+14-1
...@@ -1,3 +1,16 @@...@@ -1,3 +1,16 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M2.25 7A4.75 4.75 0 1 0 11.75 7A4.75 4.75 0 1 0 2.25 7ZM10.5 10.5L13.5 13.5M4.75 7H9.25" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>2 <defs>
3 <linearGradient id="g0" gradientUnits="userSpaceOnUse" x2="0" y1="8.85" y2="14.85"><stop offset="0" stop-color="#6a7482"/><stop offset="1" stop-color="#3b4350"/></linearGradient>
4 <linearGradient id="g1" gradientUnits="userSpaceOnUse" x2="0" y1="1.15" y2="11.85"><stop offset="0" stop-color="#d3d9e0"/><stop offset="1" stop-color="#7b8796"/></linearGradient>
5 <linearGradient id="g2" x2="0" y2="1"><stop offset="0" stop-color="#eaf6ff"/><stop offset="1" stop-color="#aed8ff"/></linearGradient>
6 </defs>
7 <path d="M10.2 10.95L13.5 14.25" fill="none" stroke="#000000" stroke-width="3.6" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.16"/>
8 <path d="M10.2 10.2L13.5 13.5" fill="none" stroke="#2b323b" stroke-width="3.6" stroke-linecap="round" stroke-linejoin="round"/>
9 <path d="M10.2 10.2L13.5 13.5" fill="none" stroke="url(#g0)" stroke-width="2.7" stroke-linecap="round" stroke-linejoin="round"/>
10 <path d="M2 7.25A4.5 4.5 0 1 1 11 7.25A4.5 4.5 0 1 1 2 7.25Z" fill="none" stroke="#000000" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.16"/>
11 <path d="M2 6.5A4.5 4.5 0 1 1 11 6.5A4.5 4.5 0 1 1 2 6.5Z" fill="none" stroke="#5a6676" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
12 <path d="M2 6.5A4.5 4.5 0 1 1 11 6.5A4.5 4.5 0 1 1 2 6.5Z" fill="none" stroke="url(#g1)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>
13 <path d="M3 6.5A3.5 3.5 0 1 1 10 6.5A3.5 3.5 0 1 1 3 6.5Z" fill="url(#g2)"/>
14 <path d="M3.8 6.8A2.7 2.7 0 0 1 6.2 3.8" fill="none" stroke="#ffffff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.85"/>
15 <path class="accent" d="M4.5 6H8.5V7H4.5Z" fill="#1f6fe0"/>
3</svg>16</svg>
crates/snowbound/src/art.rs+38
...@@ -33,6 +33,7 @@ pub const PICTURE: &[&str] = art!("icons/picture");...@@ -33,6 +33,7 @@ pub const PICTURE: &[&str] = art!("icons/picture");
33pub const PLUS: &[&str] = art!("icons/plus");33pub const PLUS: &[&str] = art!("icons/plus");
34pub const REDO: &[&str] = art!("icons/redo");34pub const REDO: &[&str] = art!("icons/redo");
35pub const SEARCH: &[&str] = art!("icons/search");35pub const SEARCH: &[&str] = art!("icons/search");
36pub const SECTION: &[&str] = art!("icons/section");
36pub const SECTION_GROUP: &[&str] = art!("icons/section-group");37pub const SECTION_GROUP: &[&str] = art!("icons/section-group");
37pub const SIDEBAR_COLLAPSE: &[&str] = art!("icons/sidebar-collapse");38pub const SIDEBAR_COLLAPSE: &[&str] = art!("icons/sidebar-collapse");
38pub const SIDEBAR_EXPAND: &[&str] = art!("icons/sidebar-expand");39pub const SIDEBAR_EXPAND: &[&str] = art!("icons/sidebar-expand");
...@@ -45,5 +46,42 @@ pub const UNDO: &[&str] = art!("icons/undo");...@@ -45,5 +46,42 @@ pub const UNDO: &[&str] = art!("icons/undo");
45pub const ZOOM_IN: &[&str] = art!("icons/zoom-in");46pub const ZOOM_IN: &[&str] = art!("icons/zoom-in");
46pub const ZOOM_OUT: &[&str] = art!("icons/zoom-out");47pub const ZOOM_OUT: &[&str] = art!("icons/zoom-out");
4748
49// The toolbar layout's new buttons (resources/toolbar-spec.md); each loses its `expect`
50// when its button lands.
51#[expect(dead_code, reason = "Its toolbar button has not landed")]
52pub const BACK: &[&str] = art!("icons/back");
53#[expect(dead_code, reason = "Its toolbar button has not landed")]
54pub const COPY: &[&str] = art!("icons/copy");
55#[expect(dead_code, reason = "Its toolbar button has not landed")]
56pub const CUT: &[&str] = art!("icons/cut");
57#[expect(dead_code, reason = "Its toolbar button has not landed")]
58pub const DATE_TIME: &[&str] = art!("icons/date-time");
59#[expect(dead_code, reason = "Its toolbar button has not landed")]
60pub const FIND_TAGS: &[&str] = art!("icons/find-tags");
61#[expect(dead_code, reason = "Its toolbar button has not landed")]
62pub const FORMAT_PAINTER: &[&str] = art!("icons/format-painter");
63#[expect(dead_code, reason = "Its toolbar button has not landed")]
64pub const FORWARD: &[&str] = art!("icons/forward");
65#[expect(dead_code, reason = "Its toolbar button has not landed")]
66pub const FULL_PAGE_VIEW: &[&str] = art!("icons/full-page-view");
67#[expect(dead_code, reason = "Its toolbar button has not landed")]
68pub const INSERT_SPACE: &[&str] = art!("icons/insert-space");
69#[expect(dead_code, reason = "Its toolbar button has not landed")]
70pub const PAGE_COLOR: &[&str] = art!("icons/page-color");
71#[expect(dead_code, reason = "Its toolbar button has not landed")]
72pub const PASTE: &[&str] = art!("icons/paste");
73#[expect(dead_code, reason = "Its toolbar button has not landed")]
74pub const RECORD_AUDIO: &[&str] = art!("icons/record-audio");
75#[expect(dead_code, reason = "Its toolbar button has not landed")]
76pub const RECORD_VIDEO: &[&str] = art!("icons/record-video");
77#[expect(dead_code, reason = "Its toolbar button has not landed")]
78pub const RULE_LINES: &[&str] = art!("icons/rule-lines");
79#[expect(dead_code, reason = "Its toolbar button has not landed")]
80pub const SCREEN_CLIPPING: &[&str] = art!("icons/screen-clipping");
81#[expect(dead_code, reason = "Its toolbar button has not landed")]
82pub const SPELLING: &[&str] = art!("icons/spelling");
83#[expect(dead_code, reason = "Its toolbar button has not landed")]
84pub const SYMBOL: &[&str] = art!("icons/symbol");
85
48pub const TAG_REMEMBER: &[&str] = art!("tags/remember");86pub const TAG_REMEMBER: &[&str] = art!("tags/remember");
49pub const TAG_DEFINITION: &[&str] = art!("tags/definition");87pub const TAG_DEFINITION: &[&str] = art!("tags/definition");
crates/snowbound/src/commands.rs created+1015
...@@ -0,0 +1,1015 @@
1//! The commands the menu bar, the toolbar and the keyboard share: each one's title, its
2//! chords on each platform, when it applies and what it does.
3
4use crate::{Command as Work, FONTS, HIGHLIGHTS, SIZES, State, page, platform, search};
5use canvas::editor::{Alignment, FormatState, Formatting, ListStyle, NoteTag, Toggle};
6use canvas::interaction::Request;
7use draw::edit::{Key, Modifiers, NamedKey, Platform};
8use std::{error::Error, sync::Arc};
9
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum Id {
12 Settings,
13 NewNotebook,
14 OpenNotebook,
15 CloseNotebook,
16 NewSection,
17 NewSectionGroup,
18 NewPage,
19 NewSubpage,
20 PageVersions,
21 CopyPageLink,
22 ShowNotebook,
23 Undo,
24 Redo,
25 Cut,
26 Copy,
27 Paste,
28 SelectAll,
29 Find,
30 Search,
31 Back,
32 Forward,
33 ZoomIn,
34 ZoomOut,
35 ActualSize,
36 Sidebar,
37 PageList,
38 DarkPages,
39 Table,
40 Picture,
41 Attachment,
42 Link,
43 Equation,
44 Date,
45 Time,
46 DateTime,
47 Toggle(Toggle),
48 Highlight,
49 FontColor,
50 Bullets,
51 Numbering,
52 Align(Alignment),
53 Indent,
54 Outdent,
55 ClearFormatting,
56 Tag(NoteTag),
57 RemoveTags,
58 Help,
59}
60
61/// What a menu or toolbar offers: a command, or one of a list's entries.
62#[derive(Clone, Debug, PartialEq)]
63pub enum Choice {
64 Command(Id),
65 Font(String),
66 Size(f32),
67 /// A COLORREF, or `None` for no highlight.
68 Highlight(Option<u32>),
69 /// A COLORREF, or `None` for automatic.
70 Color(Option<u32>),
71 List(Option<ListStyle>),
72}
73
74#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
75pub struct Status {
76 pub enabled: bool,
77 pub checked: bool,
78}
79
80/// A key with modifiers; `command` is the shortcut modifier, Control off macOS.
81#[derive(Clone, Copy, Debug, PartialEq)]
82pub struct Chord {
83 pub key: Press,
84 pub shift: bool,
85 pub option: bool,
86 pub control: bool,
87 pub command: bool,
88}
89
90#[derive(Clone, Copy, Debug, PartialEq)]
91pub enum Press {
92 /// Unshifted and lowercase, as on a US keyboard.
93 Char(char),
94 Named(NamedKey),
95}
96
97pub struct Command {
98 pub id: Id,
99 pub title: &'static str,
100 pub mac: &'static [Chord],
101 pub pc: &'static [Chord],
102}
103
104const fn key(key: char) -> Chord {
105 press(Press::Char(key))
106}
107
108pub(crate) const fn named(key: NamedKey) -> Chord {
109 press(Press::Named(key))
110}
111
112const fn press(key: Press) -> Chord {
113 Chord {
114 key,
115 shift: false,
116 option: false,
117 control: false,
118 command: false,
119 }
120}
121
122/// `key` with the shortcut modifier.
123pub(crate) const fn cmd(key: char) -> Chord {
124 self::key(key).command()
125}
126
127impl Chord {
128 pub(crate) const fn command(mut self) -> Self {
129 self.command = true;
130 self
131 }
132
133 const fn shift(mut self) -> Self {
134 self.shift = true;
135 self
136 }
137
138 pub(crate) const fn option(mut self) -> Self {
139 self.option = true;
140 self
141 }
142
143 pub(crate) const fn control(mut self) -> Self {
144 self.control = true;
145 self
146 }
147}
148
149macro_rules! row {
150 ($id:expr, $title:expr, $mac:expr, $pc:expr $(,)?) => {
151 Command {
152 id: $id,
153 title: $title,
154 mac: $mac,
155 pc: $pc,
156 }
157 };
158}
159
160macro_rules! tag {
161 ($tag:expr, $digit:literal) => {
162 row!(Id::Tag($tag), $tag.label(), &[cmd($digit)], &[cmd($digit)])
163 };
164}
165
166const NONE: &[Chord] = &[];
167
168/// OneNote 2010's chords off macOS; there, AppKit's and OneNote for Mac's.
169pub const COMMANDS: &[Command] = &[
170 row!(Id::Settings, "Settings…", &[cmd(',')], &[cmd(',')]),
171 row!(Id::NewNotebook, "New Notebook…", NONE, NONE),
172 row!(Id::OpenNotebook, "Open Notebook…", &[cmd('o')], &[cmd('o')]),
173 row!(Id::CloseNotebook, "Close This Notebook", NONE, NONE),
174 row!(Id::NewSection, "New Section", &[cmd('t')], &[cmd('t')]),
175 row!(Id::NewSectionGroup, "New Section Group", NONE, NONE),
176 row!(Id::NewPage, "New Page", &[cmd('n')], &[cmd('n')]),
177 row!(
178 Id::NewSubpage,
179 "New Subpage",
180 &[cmd('n').shift().option()],
181 &[cmd('n').shift().option()],
182 ),
183 row!(Id::PageVersions, "Page Versions", NONE, NONE),
184 row!(Id::CopyPageLink, "Copy Link to Page", NONE, NONE),
185 row!(
186 Id::ShowNotebook,
187 if cfg!(target_os = "macos") {
188 "Show in Finder"
189 } else {
190 "Open Notebook Folder"
191 },
192 NONE,
193 NONE,
194 ),
195 row!(Id::Undo, "Undo", &[cmd('z')], &[cmd('z')]),
196 row!(
197 Id::Redo,
198 "Redo",
199 &[cmd('z').shift()],
200 if cfg!(windows) {
201 &[cmd('y'), cmd('z').shift()]
202 } else {
203 &[cmd('z').shift()]
204 },
205 ),
206 row!(Id::Cut, "Cut", &[cmd('x')], &[cmd('x')]),
207 row!(Id::Copy, "Copy", &[cmd('c')], &[cmd('c')]),
208 row!(Id::Paste, "Paste", &[cmd('v')], &[cmd('v')]),
209 row!(Id::SelectAll, "Select All", &[cmd('a')], &[cmd('a')]),
210 row!(Id::Find, "Find on This Page", &[cmd('f')], &[cmd('f')]),
211 row!(Id::Search, "Search Notebooks", &[cmd('e')], &[cmd('e')]),
212 // Option-Command-arrows move the caret; Xcode goes back with Control-Command.
213 row!(
214 Id::Back,
215 "Back",
216 &[named(NamedKey::ArrowLeft).command().control()],
217 &[named(NamedKey::ArrowLeft).option()],
218 ),
219 row!(
220 Id::Forward,
221 "Forward",
222 &[named(NamedKey::ArrowRight).command().control()],
223 &[named(NamedKey::ArrowRight).option()],
224 ),
225 // OneNote 2010 leaves Control with = and - to subscript and strikethrough.
226 row!(
227 Id::ZoomIn,
228 "Zoom In",
229 &[cmd('='), cmd('=').shift()],
230 &[cmd('=').option(), cmd('=').option().shift()],
231 ),
232 row!(Id::ZoomOut, "Zoom Out", &[cmd('-')], &[cmd('-').option()]),
233 row!(
234 Id::ActualSize,
235 "Actual Size",
236 &[cmd('0')],
237 &[cmd('0').option()]
238 ),
239 row!(Id::Sidebar, "Sidebar", &[cmd('s').control()], NONE),
240 row!(Id::PageList, "Page List", NONE, NONE),
241 row!(Id::DarkPages, "Dark Pages", NONE, NONE),
242 row!(Id::Table, "Table", NONE, NONE),
243 row!(Id::Picture, "Picture…", NONE, NONE),
244 row!(Id::Attachment, "Attach File…", NONE, NONE),
245 row!(Id::Link, "Link…", &[cmd('k')], &[cmd('k')]),
246 row!(
247 Id::Equation,
248 "Equation",
249 &[key('=').control()],
250 &[key('=').option()],
251 ),
252 row!(Id::Date, "Date", NONE, &[key('d').option().shift()]),
253 row!(Id::Time, "Time", NONE, &[key('t').option().shift()]),
254 row!(
255 Id::DateTime,
256 "Date & Time",
257 NONE,
258 &[key('f').option().shift()]
259 ),
260 row!(Id::Toggle(Toggle::Bold), "Bold", &[cmd('b')], &[cmd('b')]),
261 row!(
262 Id::Toggle(Toggle::Italic),
263 "Italic",
264 &[cmd('i')],
265 &[cmd('i')]
266 ),
267 row!(
268 Id::Toggle(Toggle::Underline),
269 "Underline",
270 &[cmd('u')],
271 &[cmd('u')]
272 ),
273 row!(
274 Id::Toggle(Toggle::Strikethrough),
275 "Strikethrough",
276 &[cmd('x').shift()],
277 &[cmd('-')],
278 ),
279 row!(
280 Id::Toggle(Toggle::Subscript),
281 "Subscript",
282 &[cmd('=').control().option()],
283 &[cmd('=')],
284 ),
285 row!(
286 Id::Toggle(Toggle::Superscript),
287 "Superscript",
288 &[cmd('=').option().shift()],
289 &[cmd('=').shift()],
290 ),
291 // AppKit's Hide Others takes Option-Command-H.
292 row!(
293 Id::Highlight,
294 "Highlight",
295 &[cmd('h').control()],
296 &[cmd('h').option()]
297 ),
298 row!(Id::FontColor, "Font Color", NONE, NONE),
299 row!(Id::Bullets, "Bullets", &[cmd('.')], &[cmd('.')]),
300 row!(Id::Numbering, "Numbering", &[cmd('/')], &[cmd('/')]),
301 row!(
302 Id::Align(Alignment::Left),
303 "Align Left",
304 &[cmd('l')],
305 &[cmd('l')]
306 ),
307 row!(Id::Align(Alignment::Center), "Center", NONE, NONE),
308 row!(
309 Id::Align(Alignment::Right),
310 "Align Right",
311 &[cmd('r')],
312 &[cmd('r')]
313 ),
314 row!(
315 Id::Indent,
316 "Increase Indent",
317 &[cmd(']')],
318 &[named(NamedKey::ArrowRight).option().shift()],
319 ),
320 row!(
321 Id::Outdent,
322 "Decrease Indent",
323 &[cmd('[')],
324 &[named(NamedKey::ArrowLeft).option().shift()],
325 ),
326 row!(
327 Id::ClearFormatting,
328 "Clear Formatting",
329 &[cmd('n').shift()],
330 &[cmd('n').shift()]
331 ),
332 tag!(NoteTag::ToDo, '1'),
333 tag!(NoteTag::Important, '2'),
334 tag!(NoteTag::Question, '3'),
335 tag!(NoteTag::RememberForLater, '4'),
336 tag!(NoteTag::Definition, '5'),
337 tag!(NoteTag::Highlight, '6'),
338 tag!(NoteTag::Contact, '7'),
339 tag!(NoteTag::Address, '8'),
340 tag!(NoteTag::PhoneNumber, '9'),
341 row!(
342 Id::RemoveTags,
343 "Remove Tag",
344 &[cmd('0').control()],
345 &[cmd('0')]
346 ),
347 row!(Id::Help, "Snowbound Help", NONE, NONE),
348];
349
350/// Where Snowbound Help leads.
351const HELP: &str = "https://shale.paperclover.net/snowbound";
352
353pub fn command(id: Id) -> &'static Command {
354 COMMANDS
355 .iter()
356 .find(|command| command.id == id)
357 .expect("Every command has a row")
358}
359
360impl Command {
361 pub fn chords(&self, platform: Platform) -> &'static [Chord] {
362 match platform {
363 Platform::MacOs => self.mac,
364 Platform::Gtk | Platform::Windows => self.pc,
365 }
366 }
367}
368
369/// Every choice the menu bar offers, in the order its `index` counts them.
370pub fn choices() -> impl Iterator<Item = Choice> {
371 COMMANDS
372 .iter()
373 .map(|command| Choice::Command(command.id))
374 .chain(FONTS.map(|name| Choice::Font(name.to_owned())))
375 .chain(SIZES.into_iter().map(Choice::Size))
376 .chain(std::iter::once(Choice::Highlight(None)))
377 .chain(
378 HIGHLIGHTS
379 .iter()
380 .map(|(color, _)| Choice::Highlight(Some(*color))),
381 )
382}
383
384/// How a menu names `choice`.
385#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
386pub fn title(choice: Choice) -> String {
387 match choice {
388 Choice::Command(id) => command(id).title.to_owned(),
389 Choice::Font(name) => name,
390 Choice::Size(size) => format!("{size}"),
391 Choice::Highlight(None) => "No Color".to_owned(),
392 Choice::Highlight(Some(color)) => HIGHLIGHTS
393 .iter()
394 .find(|(listed, _)| *listed == color)
395 .map_or_else(String::new, |(_, name)| (*name).to_owned()),
396 Choice::Color(_) | Choice::List(_) => unreachable!("Only the toolbar offers these"),
397 }
398}
399
400#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
401pub fn index(choice: &Choice) -> usize {
402 choices()
403 .position(|listed| listed == *choice)
404 .expect("Menus offer listed choices")
405}
406
407/// The command `key` with `modifiers` runs here.
408pub fn find(key: &Key, modifiers: Modifiers) -> Option<Id> {
409 let pressed = pressed(key, modifiers, Platform::CURRENT)?;
410 COMMANDS
411 .iter()
412 .find(|command| command.chords(Platform::CURRENT).contains(&pressed))
413 .map(|command| command.id)
414}
415
416fn pressed(key: &Key, modifiers: Modifiers, platform: Platform) -> Option<Chord> {
417 let (key, shifted) = match key {
418 Key::Named(named) => (Press::Named(*named), false),
419 Key::Character(text) => {
420 let mut chars = text.chars();
421 let character = chars.next().filter(|_| chars.next().is_none())?;
422 match SHIFTED.iter().find(|(shifted, _)| *shifted == character) {
423 Some((_, base)) => (Press::Char(*base), true),
424 None => (Press::Char(character.to_ascii_lowercase()), false),
425 }
426 }
427 };
428 Some(Chord {
429 key,
430 shift: modifiers.shift || shifted,
431 option: modifiers.option,
432 // Off macOS Control is the shortcut modifier.
433 control: modifiers.control && platform == Platform::MacOs,
434 command: modifiers.command,
435 })
436}
437
438/// The US keyboard's shifted symbols and the keys they share.
439const SHIFTED: [(char, char); 21] = [
440 ('+', '='),
441 ('_', '-'),
442 ('{', '['),
443 ('}', ']'),
444 ('<', ','),
445 ('>', '.'),
446 ('?', '/'),
447 (':', ';'),
448 ('"', '\''),
449 ('|', '\\'),
450 ('~', '`'),
451 (')', '0'),
452 ('!', '1'),
453 ('@', '2'),
454 ('#', '3'),
455 ('$', '4'),
456 ('%', '5'),
457 ('^', '6'),
458 ('&', '7'),
459 ('*', '8'),
460 ('(', '9'),
461];
462
463impl Chord {
464 /// The key AppKit matches and shows: a shifted symbol stands for Shift with its key.
465 pub fn equivalent(self) -> (String, bool) {
466 match self.key {
467 Press::Char(base) if self.shift && !base.is_ascii_alphabetic() => {
468 let shifted = SHIFTED.iter().find(|(_, key)| *key == base);
469 shifted.map_or((base.to_string(), true), |(symbol, _)| {
470 (symbol.to_string(), false)
471 })
472 }
473 Press::Char(base) => (base.to_string(), self.shift),
474 Press::Named(NamedKey::Space) => (" ".to_owned(), self.shift),
475 Press::Named(NamedKey::ArrowLeft) => ('\u{f702}'.to_string(), self.shift),
476 Press::Named(NamedKey::ArrowRight) => ('\u{f703}'.to_string(), self.shift),
477 Press::Named(named) => unreachable!("No command takes {named:?}"),
478 }
479 }
480
481 /// As a menu shows it on `platform`: ⌃⌥⇧⌘K on macOS, Ctrl+Alt+Shift+K elsewhere.
482 pub fn label(self, platform: Platform) -> String {
483 if platform == Platform::MacOs {
484 let (key, shift) = self.equivalent();
485 let key = match self.key {
486 Press::Named(NamedKey::Space) => "Space".to_owned(),
487 Press::Named(NamedKey::ArrowLeft) => "←".to_owned(),
488 Press::Named(NamedKey::ArrowRight) => "→".to_owned(),
489 _ => key.to_uppercase(),
490 };
491 [
492 (self.control, "⌃"),
493 (self.option, "⌥"),
494 (shift, "⇧"),
495 (self.command, "⌘"),
496 ]
497 .iter()
498 .filter(|(held, _)| *held)
499 .map(|(_, symbol)| *symbol)
500 .chain([key.as_str()])
501 .collect()
502 } else {
503 let key = match self.key {
504 Press::Char(key) => key.to_uppercase().to_string(),
505 Press::Named(NamedKey::ArrowLeft) => "Left".to_owned(),
506 Press::Named(NamedKey::ArrowRight) => "Right".to_owned(),
507 Press::Named(named) => format!("{named:?}"),
508 };
509 [
510 (self.command, "Ctrl+"),
511 (self.option, "Alt+"),
512 (self.shift, "Shift+"),
513 ]
514 .iter()
515 .filter(|(held, _)| *held)
516 .map(|(_, modifier)| *modifier)
517 .chain([key.as_str()])
518 .collect()
519 }
520 }
521}
522
523/// The chord a menu or hint shows for `id` here, empty where it has none.
524pub fn shortcut(id: Id) -> String {
525 command(id)
526 .chords(Platform::CURRENT)
527 .first()
528 .map_or_else(String::new, |chord| chord.label(Platform::CURRENT))
529}
530
531impl State {
532 pub(crate) fn format_state(&self) -> FormatState {
533 self.view.editor.format_state().unwrap_or_default()
534 }
535
536 /// Queues `choice` to run after this frame's input, as a key or a click on it does.
537 pub(crate) fn choose(&mut self, choice: Choice) {
538 self.commands.push(Work::Choose(choice));
539 self.window.request_redraw();
540 }
541
542 /// Every choice's status, in `choices` order, for the menu bar.
543 pub(crate) fn statuses(&self) -> Vec<Status> {
544 let format = self.format_state();
545 choices()
546 .map(|choice| self.status(&choice, &format))
547 .collect()
548 }
549
550 pub(crate) fn status(&self, choice: &Choice, format: &FormatState) -> Status {
551 let session = self.session.as_ref();
552 let welcome = session.is_none() && !self.temporary && self.sectionless.is_none();
553 let modal = self.options.is_some() || self.link.is_some();
554 let page = (session.is_some() || self.temporary) && !modal;
555 let writable = page && !session.is_some_and(|session| session.read_only());
556 let text = writable && self.view.accepts_text();
557 // Edit commands act on a focused field instead of the page.
558 let field = self
559 .ui
560 .focused()
561 .is_some_and(|focus| focus != crate::page());
562 let [anchor, focus] = self.view.editor.selection().positions;
563 let selected = page && !field && self.view.accepts_text() && anchor != focus;
564 let enabled = |enabled| Status {
565 enabled,
566 checked: false,
567 };
568 let checked = |checked| Status {
569 enabled: text,
570 checked,
571 };
572 let id = match choice {
573 Choice::Command(id) => *id,
574 Choice::Font(name) => return checked(format.font.as_ref() == Some(name)),
575 Choice::Size(size) => return checked(format.font_size == Some(*size)),
576 Choice::Highlight(_) | Choice::Color(_) | Choice::List(_) => return enabled(text),
577 };
578 match id {
579 Id::Settings | Id::NewNotebook | Id::OpenNotebook | Id::Help => enabled(!modal),
580 Id::CloseNotebook | Id::ShowNotebook => enabled(!modal && self.notebook().is_some()),
581 Id::NewSection | Id::NewSectionGroup => enabled(
582 !modal
583 && self
584 .notebook()
585 .is_some_and(|library| library.catalog().is_some()),
586 ),
587 Id::NewPage | Id::NewSubpage | Id::CopyPageLink | Id::Find | Id::Search => {
588 enabled(!modal && session.is_some())
589 }
590 Id::PageVersions => {
591 session
592 .filter(|_| !modal)
593 .map_or_else(Status::default, |session| Status {
594 enabled: !session.page_versions(session.space).is_empty(),
595 checked: session.shown_history == Some(session.space),
596 })
597 }
598 Id::Undo => enabled(writable && !field && self.view.editor.can_undo()),
599 Id::Redo => enabled(writable && !field && self.view.editor.can_redo()),
600 Id::Cut => enabled(writable && selected),
601 Id::Copy => enabled(selected),
602 Id::Paste => enabled(text && !field),
603 Id::SelectAll => enabled(field || page),
604 Id::Back => enabled(!modal && self.trail.open()[0]),
605 Id::Forward => enabled(!modal && self.trail.open()[1]),
606 Id::ZoomIn | Id::ZoomOut | Id::ActualSize => enabled(page),
607 Id::Sidebar => Status {
608 enabled: !welcome && !modal,
609 checked: self.sidebar,
610 },
611 Id::PageList => Status {
612 enabled: !modal && session.is_some(),
613 checked: self.pages_open,
614 },
615 Id::DarkPages => Status {
616 enabled: !modal,
617 checked: !self.light_pages,
618 },
619 Id::Table | Id::Picture | Id::Attachment => enabled(false),
620 Id::Link
621 | Id::Equation
622 | Id::Date
623 | Id::Time
624 | Id::DateTime
625 | Id::Highlight
626 | Id::FontColor
627 | Id::Indent
628 | Id::Outdent
629 | Id::ClearFormatting
630 | Id::RemoveTags => enabled(text),
631 Id::Toggle(toggle) => checked(format.toggles.contains(&toggle)),
632 Id::Bullets => checked(format.bullets),
633 Id::Numbering => checked(format.numbering),
634 Id::Align(alignment) => checked(format.alignment == Some(alignment)),
635 Id::Tag(tag) => checked(format.tags.contains(&tag)),
636 }
637 }
638
639 /// Applies `formatting`, remembering its font, colour or list style as the toolbar's
640 /// last pick.
641 fn format(&mut self, formatting: Formatting) -> Result<(), Box<dyn Error>> {
642 let kept = self.toolbar.clone();
643 let pens = &mut self.toolbar;
644 match &formatting {
645 Formatting::Font(name) => crate::remember(&mut self.recent_fonts, name.clone()),
646 Formatting::Highlight(color) => pens.highlight = *color,
647 Formatting::Color(color) => pens.font_color = *color,
648 Formatting::List(Some(ListStyle::Bullet(place))) => {
649 crate::remember(&mut pens.bullets, *place);
650 }
651 Formatting::List(Some(ListStyle::Number(place))) => {
652 crate::remember(&mut pens.numbering, *place);
653 }
654 _ => {}
655 }
656 if matches!(formatting, Formatting::Font(_)) || self.toolbar != kept {
657 self.save_settings();
658 }
659 let response = self.view.format(formatting)?;
660 self.respond(response);
661 Ok(())
662 }
663
664 /// The notebook the open section, or the notebook showing none, belongs to.
665 fn notebook(&self) -> Option<&Arc<crate::Library>> {
666 self.session
667 .as_ref()
668 .map(|session| &session.library)
669 .or(self.sectionless.as_ref())
670 }
671
672 /// Runs `choice` where it applies now.
673 pub(crate) fn run(&mut self, choice: Choice) -> Result<(), Box<dyn Error>> {
674 if !self.status(&choice, &self.format_state()).enabled {
675 return Ok(());
676 }
677 let format = Self::format;
678 let id = match choice {
679 Choice::Command(id) => id,
680 Choice::Font(name) => return format(self, Formatting::Font(name)),
681 Choice::Size(size) => return format(self, Formatting::FontSize(size)),
682 Choice::Highlight(color) => return format(self, Formatting::Highlight(color)),
683 Choice::Color(color) => return format(self, Formatting::Color(color)),
684 Choice::List(style) => return format(self, Formatting::List(style)),
685 };
686 let field = self.ui.focused().filter(|focus| *focus != page());
687 let response = match id {
688 Id::Settings => {
689 self.open_options();
690 return Ok(());
691 }
692 Id::NewNotebook => Work::NewNotebook,
693 Id::OpenNotebook => Work::OpenNotebook,
694 Id::CloseNotebook => {
695 Work::CloseNotebook(Arc::clone(self.notebook().ok_or("No notebook is open")?))
696 }
697 Id::NewSection | Id::NewSectionGroup => {
698 let library = Arc::clone(self.notebook().ok_or("No notebook is open")?);
699 let folder = self.session.as_ref().map_or_else(String::new, |session| {
700 crate::menus::folder(&session.tabs[session.tab].path)
701 });
702 Work::Structure(
703 library,
704 if id == Id::NewSection {
705 crate::manage::Structure::NewSection { folder }
706 } else {
707 crate::manage::Structure::NewGroup { folder }
708 },
709 )
710 }
711 Id::NewPage => Work::NewPage { under: None },
712 Id::NewSubpage => Work::NewPage {
713 under: self.session.as_ref().map(|session| session.space),
714 },
715 Id::PageVersions => {
716 let session = self.session.as_ref().ok_or("No notebook is open")?;
717 Work::History {
718 page: session.space,
719 show: session.shown_history != Some(session.space),
720 }
721 }
722 Id::CopyPageLink => {
723 let space = self.session.as_ref().ok_or("No notebook is open")?.space;
724 let link = self.page_link(space, None)?;
725 self.clipboard.set_text(link)?;
726 return Ok(());
727 }
728 Id::ShowNotebook => {
729 platform::reveal(&self.notebook().ok_or("No notebook is open")?.location);
730 return Ok(());
731 }
732 Id::Undo | Id::Redo => {
733 let response = self.view.undo(id == Id::Redo)?;
734 self.respond(response);
735 return Ok(());
736 }
737 Id::Cut | Id::Copy => {
738 let response = self.view.copy(id == Id::Cut)?;
739 self.respond(response);
740 return Ok(());
741 }
742 Id::Paste => Work::Page(Request::Paste),
743 Id::SelectAll => {
744 match field {
745 Some(field) => self.ui.focus_all(field),
746 None => {
747 let response = self.view.widen_selection()?;
748 self.respond(response);
749 }
750 }
751 return Ok(());
752 }
753 Id::Find => {
754 return self.start_find(field == Some(search::field()));
755 }
756 Id::Search => {
757 self.start_search();
758 return Ok(());
759 }
760 Id::Back | Id::Forward => {
761 self.travel(id == Id::Forward);
762 return Ok(());
763 }
764 Id::ZoomIn | Id::ZoomOut | Id::ActualSize => {
765 let zoom = match id {
766 Id::ZoomIn => self.view.zoom() * 1.1,
767 Id::ZoomOut => self.view.zoom() / 1.1,
768 _ => 1.0,
769 };
770 let response = self.view.set_zoom(zoom)?;
771 self.respond(response);
772 return Ok(());
773 }
774 Id::Sidebar => {
775 self.sidebar = !self.sidebar;
776 return Ok(());
777 }
778 Id::PageList => {
779 self.pages_open = !self.pages_open;
780 return Ok(());
781 }
782 Id::DarkPages => {
783 self.light_pages = !self.light_pages;
784 self.follow_color_scheme();
785 self.save_settings();
786 return Ok(());
787 }
788 Id::Table | Id::Picture | Id::Attachment => return Ok(()),
789 Id::Link => {
790 self.open_link_dialog();
791 return Ok(());
792 }
793 Id::Equation => {
794 let response = self.view.insert_equation()?;
795 self.respond(response);
796 return Ok(());
797 }
798 // OneNote inserts the system's short date or time and a space.
799 Id::Date | Id::Time | Id::DateTime => {
800 let now = crate::filetime();
801 let date = platform::short_date(now);
802 let [_, time] = platform::date_text(now);
803 let text = match id {
804 Id::Date => date,
805 Id::Time => time,
806 _ => format!("{date} {time}"),
807 };
808 let response = self.view.insert_text(format!("{text} "))?;
809 self.respond(response);
810 return Ok(());
811 }
812 Id::Help => {
813 platform::reveal(HELP);
814 return Ok(());
815 }
816 Id::Toggle(toggle) => return format(self, Formatting::Toggle(toggle)),
817 // The highlighter and font colour apply their last pick.
818 Id::Highlight => return format(self, Formatting::Highlight(self.toolbar.highlight)),
819 Id::FontColor => return format(self, Formatting::Color(self.toolbar.font_color)),
820 Id::Bullets => return format(self, Formatting::Bullets),
821 Id::Numbering => return format(self, Formatting::Numbering),
822 Id::Align(alignment) => return format(self, Formatting::Align(alignment)),
823 Id::Indent => return format(self, Formatting::Indent),
824 Id::Outdent => return format(self, Formatting::Outdent),
825 Id::ClearFormatting => return format(self, Formatting::Clear),
826 Id::Tag(tag) => return format(self, Formatting::Tag(tag)),
827 Id::RemoveTags => return format(self, Formatting::RemoveTags),
828 };
829 self.commands.push(response);
830 Ok(())
831 }
832}
833
834#[cfg(test)]
835mod tests {
836 use super::*;
837
838 const PLATFORMS: [Platform; 3] = [Platform::MacOs, Platform::Gtk, Platform::Windows];
839
840 #[test]
841 fn each_chord_runs_one_command_on_each_platform() {
842 for platform in PLATFORMS {
843 let chords: Vec<_> = COMMANDS
844 .iter()
845 .flat_map(|command| {
846 command
847 .chords(platform)
848 .iter()
849 .map(move |chord| (chord, command.title))
850 })
851 .collect();
852 for (at, (chord, title)) in chords.iter().enumerate() {
853 if let Some((_, other)) =
854 chords[at + 1..].iter().find(|(listed, _)| listed == chord)
855 {
856 panic!(
857 "{platform:?}: {} runs both {title} and {other}",
858 chord.label(platform)
859 );
860 }
861 }
862 }
863 }
864
865 #[test]
866 fn each_command_and_choice_is_listed_once() {
867 for (at, command) in COMMANDS.iter().enumerate() {
868 assert!(
869 COMMANDS[at + 1..]
870 .iter()
871 .all(|other| other.id != command.id),
872 "{:?}",
873 command.id
874 );
875 }
876 let choices: Vec<_> = choices().collect();
877 for (at, choice) in choices.iter().enumerate() {
878 assert_eq!(index(choice), at, "{choice:?}");
879 }
880 }
881
882 #[test]
883 fn keys_find_their_command_as_each_platform_types_them() {
884 let find = |platform, key: &str, shift, option, control, command| {
885 let modifiers = Modifiers {
886 shift,
887 option,
888 control,
889 command,
890 };
891 let pressed = pressed(&Key::Character(key.into()), modifiers, platform)?;
892 COMMANDS
893 .iter()
894 .find(|listed| listed.chords(platform).contains(&pressed))
895 .map(|listed| listed.id)
896 };
897 let mac = Platform::MacOs;
898 assert_eq!(
899 find(mac, "b", false, false, false, true),
900 Some(Id::Toggle(Toggle::Bold))
901 );
902 assert_eq!(
903 find(mac, "N", true, false, false, true),
904 Some(Id::ClearFormatting)
905 );
906 assert_eq!(find(mac, "+", true, false, false, true), Some(Id::ZoomIn));
907 assert_eq!(find(mac, "=", false, false, false, true), Some(Id::ZoomIn));
908 assert_eq!(
909 find(mac, "+", true, true, false, true),
910 Some(Id::Toggle(Toggle::Superscript))
911 );
912 assert_eq!(
913 find(mac, "=", false, false, true, false),
914 Some(Id::Equation)
915 );
916 assert_eq!(
917 find(mac, "0", false, false, true, true),
918 Some(Id::RemoveTags)
919 );
920 assert_eq!(
921 find(mac, "7", false, false, false, true),
922 Some(Id::Tag(NoteTag::Contact))
923 );
924 assert_eq!(find(mac, "q", false, false, false, true), None);
925 // The highlighter's chord runs the command that applies its last pick.
926 assert_eq!(
927 find(mac, "h", false, false, true, true),
928 Some(Id::Highlight)
929 );
930 // Control comes with the shortcut modifier off macOS.
931 let gtk = Platform::Gtk;
932 assert_eq!(
933 find(gtk, "b", false, false, true, true),
934 Some(Id::Toggle(Toggle::Bold))
935 );
936 assert_eq!(
937 find(gtk, "+", true, false, true, true),
938 Some(Id::Toggle(Toggle::Superscript))
939 );
940 assert_eq!(
941 find(gtk, "=", false, false, true, true),
942 Some(Id::Toggle(Toggle::Subscript))
943 );
944 assert_eq!(find(gtk, "+", true, true, true, true), Some(Id::ZoomIn));
945 assert_eq!(find(gtk, "D", true, true, false, false), Some(Id::Date));
946 assert_eq!(find(gtk, "h", false, true, true, true), Some(Id::Highlight));
947 assert_eq!(
948 find(gtk, "y", false, false, true, true),
949 cfg!(windows).then_some(Id::Redo)
950 );
951 let indent = pressed(
952 &Key::Named(NamedKey::ArrowRight),
953 Modifiers {
954 shift: true,
955 option: true,
956 ..Modifiers::default()
957 },
958 gtk,
959 );
960 assert!(
961 command(Id::Indent)
962 .pc
963 .iter()
964 .any(|chord| Some(*chord) == indent)
965 );
966 }
967
968 #[test]
969 fn chords_read_as_each_platform_labels_them() {
970 let label = |id, platform| command(id).chords(platform)[0].label(platform);
971 assert_eq!(
972 label(Id::Toggle(Toggle::Superscript), Platform::MacOs),
973 "⌥⌘+"
974 );
975 assert_eq!(
976 label(Id::Toggle(Toggle::Subscript), Platform::MacOs),
977 "⌃⌥⌘="
978 );
979 assert_eq!(label(Id::ClearFormatting, Platform::MacOs), "⇧⌘N");
980 assert_eq!(
981 label(Id::Toggle(Toggle::Superscript), Platform::Gtk),
982 "Ctrl+Shift+="
983 );
984 assert_eq!(label(Id::Indent, Platform::Gtk), "Alt+Shift+Right");
985 assert_eq!(label(Id::Date, Platform::Windows), "Alt+Shift+D");
986 }
987
988 /// Each toolbar button chooses a command or a list's entry, so it runs, enables and
989 /// checks as the menu bar and keyboard do.
990 #[test]
991 fn toolbar_buttons_run_commands() {
992 let source = include_str!("main.rs");
993 let start = source
994 .find(" fn toolbar(")
995 .expect("The toolbar is built in main.rs");
996 let body = &source[start..];
997 let body = &body[..body[1..]
998 .find("\n fn ")
999 .map_or(body.len(), |end| end + 1)];
1000 assert!(body.contains("self.choose("));
1001 for effect in [
1002 "self.respond(",
1003 "self.changed",
1004 "self.view.format(",
1005 "self.view.set_zoom(",
1006 "self.view.insert",
1007 "self.open_link_dialog(",
1008 ] {
1009 assert!(
1010 !body.contains(effect),
1011 "A toolbar button acts through `{effect}`; have it choose a command instead"
1012 );
1013 }
1014 }
1015}
crates/snowbound/src/conflict_render.rs+201-38
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1//! The conflict page views drawn offscreen, from the conflict-page corpus row's candidate:1//! The conflict page and page version views drawn offscreen, from the conflict-page and
2//! the page with its information bar, its versions listed beneath it, and a version open.2//! page-versions corpus rows: the page with its information bar, its conflict pages or
3//! `SNOWBOUND_CONFLICT_RENDER` names a directory for the PNGs.3//! versions listed beneath it, and one open. `SNOWBOUND_CONFLICT_RENDER` names a directory
4//! for the PNGs.
45
5use super::*;6use super::*;
67
...@@ -58,7 +59,16 @@ fn layout(ui: &mut Ui, session: &Session, menu: bool) -> Vec<Command> {...@@ -58,7 +59,16 @@ fn layout(ui: &mut Ui, session: &Session, menu: bool) -> Vec<Command> {
58 ..Spec::default()59 ..Spec::default()
59 },60 },
60 );61 );
61 let rows = page_rows(ui, &theme, &section, session, &HashSet::new());62 let rows = page_rows(
63 ui,
64 &theme,
65 &section,
66 session,
67 &HashSet::new(),
68 0.0,
69 None,
70 None,
71 );
62 commands.extend(rows.clicked.map(Command::OpenPage));72 commands.extend(rows.clicked.map(Command::OpenPage));
63 ui.close();73 ui.close();
64 ui.close();74 ui.close();
...@@ -67,7 +77,13 @@ fn layout(ui: &mut Ui, session: &Session, menu: bool) -> Vec<Command> {...@@ -67,7 +77,13 @@ fn layout(ui: &mut Ui, session: &Session, menu: bool) -> Vec<Command> {
67}77}
6878
69/// The view's pixels, RGBA: the interface with `page` drawn in its box.79/// The view's pixels, RGBA: the interface with `page` drawn in its box.
70fn paint(ui: &Ui, page: Page, renderer: &mut draw::Renderer, device: &wgpu::Device, queue: &wgpu::Queue) -> Vec<u8> {80fn paint(
81 ui: &Ui,
82 page: Page,
83 renderer: &mut draw::Renderer,
84 device: &wgpu::Device,
85 queue: &wgpu::Queue,
86) -> Vec<u8> {
71 let size = SIZE.map(|side| (side * SCALE) as u32);87 let size = SIZE.map(|side| (side * SCALE) as u32);
72 let corner = ui.rect(super::page()).unwrap();88 let corner = ui.rect(super::page()).unwrap();
73 let box_size = [89 let box_size = [
...@@ -108,13 +124,7 @@ fn paint(ui: &Ui, page: Page, renderer: &mut draw::Renderer, device: &wgpu::Devi...@@ -108,13 +124,7 @@ fn paint(ui: &Ui, page: Page, renderer: &mut draw::Renderer, device: &wgpu::Devi
108 let layers: Vec<_> = interface124 let layers: Vec<_> = interface
109 .iter()125 .iter()
110 .map(|layer| match layer {126 .map(|layer| match layer {
111 ui::Layer::Primitives { clip, primitives } => draw::Layer {127 ui::Layer::Primitives(primitives) => primitives.layer(SCALE),
112 scale: SCALE,
113 origin: [0.0; 2],
114 clip: clip.map(|clip| clip.map(|value| value * SCALE)),
115 backdrop: None,
116 primitives,
117 },
118 ui::Layer::Custom { rect, .. } => draw::Layer {128 ui::Layer::Custom { rect, .. } => draw::Layer {
119 scale: viewport.scale,129 scale: viewport.scale,
120 origin: [130 origin: [
...@@ -123,6 +133,7 @@ fn paint(ui: &Ui, page: Page, renderer: &mut draw::Renderer, device: &wgpu::Devi...@@ -123,6 +133,7 @@ fn paint(ui: &Ui, page: Page, renderer: &mut draw::Renderer, device: &wgpu::Devi
123 ],133 ],
124 clip: Some(rect.map(|value| value * SCALE)),134 clip: Some(rect.map(|value| value * SCALE)),
125 backdrop: Some(ui.theme.paper),135 backdrop: Some(ui.theme.paper),
136 motion: None,
126 primitives: &primitives,137 primitives: &primitives,
127 },138 },
128 })139 })
...@@ -142,7 +153,12 @@ fn paint(ui: &Ui, page: Page, renderer: &mut draw::Renderer, device: &wgpu::Devi...@@ -142,7 +153,12 @@ fn paint(ui: &Ui, page: Page, renderer: &mut draw::Renderer, device: &wgpu::Devi
142 view_formats: &[],153 view_formats: &[],
143 });154 });
144 renderer155 renderer
145 .draw(&texture.create_view(&Default::default()), size, ui.theme.base, &layers)156 .draw(
157 &texture.create_view(&Default::default()),
158 size,
159 ui.theme.base,
160 &layers,
161 )
146 .unwrap();162 .unwrap();
147 let row = (size[0] * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);163 let row = (size[0] * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
148 let buffer = device.create_buffer(&wgpu::BufferDescriptor {164 let buffer = device.create_buffer(&wgpu::BufferDescriptor {
...@@ -180,6 +196,33 @@ fn paint(ui: &Ui, page: Page, renderer: &mut draw::Renderer, device: &wgpu::Devi...@@ -180,6 +196,33 @@ fn paint(ui: &Ui, page: Page, renderer: &mut draw::Renderer, device: &wgpu::Devi
180 .collect()196 .collect()
181}197}
182198
199/// A renderer drawing into `output`'s PNGs.
200fn gpu(output: &Path) -> (draw::Renderer, wgpu::Device, wgpu::Queue) {
201 std::fs::create_dir_all(output).unwrap();
202 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
203 let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
204 let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
205 let renderer = draw::Renderer::new(
206 device.clone(),
207 queue.clone(),
208 wgpu::TextureFormat::Rgba8UnormSrgb,
209 );
210 (renderer, device, queue)
211}
212
213/// Writes RGBA `pixels` of the view as a PNG.
214fn save(path: &Path, pixels: &[u8]) {
215 let size = SIZE.map(|side| (side * SCALE) as u32);
216 let mut encoder = png::Encoder::new(std::fs::File::create(path).unwrap(), size[0], size[1]);
217 encoder.set_color(png::ColorType::Rgba);
218 encoder.set_depth(png::BitDepth::Eight);
219 encoder
220 .write_header()
221 .unwrap()
222 .write_image_data(pixels)
223 .unwrap();
224}
225
183/// The page shown, a page's versions listed while shown, and the bar's commands: showing226/// The page shown, a page's versions listed while shown, and the bar's commands: showing
184/// opens the newest version, hiding returns to the page.227/// opens the newest version, hiding returns to the page.
185#[test]228#[test]
...@@ -189,7 +232,11 @@ fn conflict_views_offer_the_versions_and_render_offscreen() {...@@ -189,7 +232,11 @@ fn conflict_views_offer_the_versions_and_render_offscreen() {
189 let _ = std::fs::remove_dir_all(&directory);232 let _ = std::fs::remove_dir_all(&directory);
190 std::fs::create_dir_all(directory.join("cache")).unwrap();233 std::fs::create_dir_all(directory.join("cache")).unwrap();
191 let file = directory.join("synthetic.one");234 let file = directory.join("synthetic.one");
192 std::fs::copy(root.join("corpus/conflict-page/candidate/synthetic.one"), &file).unwrap();235 std::fs::copy(
236 root.join("corpus/conflict-page/candidate/synthetic.one"),
237 &file,
238 )
239 .unwrap();
193 let library = Arc::new(crate::Library::section(&file, &directory.join("cache")));240 let library = Arc::new(crate::Library::section(&file, &directory.join("cache")));
194 let section = library.open(&library.location, || {}).unwrap();241 let section = library.open(&library.location, || {}).unwrap();
195 let path = library.location.clone();242 let path = library.location.clone();
...@@ -197,17 +244,7 @@ fn conflict_views_offer_the_versions_and_render_offscreen() {...@@ -197,17 +244,7 @@ fn conflict_views_offer_the_versions_and_render_offscreen() {
197 let page = session.space;244 let page = session.space;
198 let version = session.versions(page)[0].space;245 let version = session.versions(page)[0].space;
199 let output = std::env::var_os("SNOWBOUND_CONFLICT_RENDER").map(PathBuf::from);246 let output = std::env::var_os("SNOWBOUND_CONFLICT_RENDER").map(PathBuf::from);
200 let gpu = output.as_ref().map(|output| {247 let mut gpu = output.as_deref().map(gpu);
201 std::fs::create_dir_all(output).unwrap();
202 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
203 let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
204 let (device, queue) =
205 pollster::block_on(adapter.request_device(&Default::default())).unwrap();
206 let renderer =
207 draw::Renderer::new(device.clone(), queue.clone(), wgpu::TextureFormat::Rgba8UnormSrgb);
208 (renderer, device, queue)
209 });
210 let mut gpu = gpu;
211 for (name, space, shown, menu, appearance) in [248 for (name, space, shown, menu, appearance) in [
212 ("page", page, None, false, Theme::light()),249 ("page", page, None, false, Theme::light()),
213 ("versions", page, Some(page), false, Theme::light()),250 ("versions", page, Some(page), false, Theme::light()),
...@@ -215,7 +252,13 @@ fn conflict_views_offer_the_versions_and_render_offscreen() {...@@ -215,7 +252,13 @@ fn conflict_views_offer_the_versions_and_render_offscreen() {
215 ("version-menu", version, Some(page), true, Theme::light()),252 ("version-menu", version, Some(page), true, Theme::light()),
216 ("page-dark", page, None, false, Theme::dark()),253 ("page-dark", page, None, false, Theme::dark()),
217 ("version-dark", version, Some(page), false, Theme::dark()),254 ("version-dark", version, Some(page), false, Theme::dark()),
218 ("version-menu-dark", version, Some(page), true, Theme::dark()),255 (
256 "version-menu-dark",
257 version,
258 Some(page),
259 true,
260 Theme::dark(),
261 ),
219 ] {262 ] {
220 session.space = space;263 session.space = space;
221 session.shown = shown;264 session.shown = shown;
...@@ -229,7 +272,10 @@ fn conflict_views_offer_the_versions_and_render_offscreen() {...@@ -229,7 +272,10 @@ fn conflict_views_offer_the_versions_and_render_offscreen() {
229 match (name, bar) {272 match (name, bar) {
230 ("page" | "page-dark", Some(Bar::Page { shown: false, .. }))273 ("page" | "page-dark", Some(Bar::Page { shown: false, .. }))
231 | ("versions", Some(Bar::Page { shown: true, .. }))274 | ("versions", Some(Bar::Page { shown: true, .. }))
232 | ("version" | "version-menu" | "version-dark" | "version-menu-dark", Some(Bar::Version { .. })) => {}275 | (
276 "version" | "version-menu" | "version-dark" | "version-menu-dark",
277 Some(Bar::Version { .. }),
278 ) => {}
233 other => panic!("{other:?}", other = other.0),279 other => panic!("{other:?}", other = other.0),
234 }280 }
235 let row = Id::ROOT.child("frame").child("panel").child(version);281 let row = Id::ROOT.child("frame").child("panel").child(version);
...@@ -238,13 +284,7 @@ fn conflict_views_offer_the_versions_and_render_offscreen() {...@@ -238,13 +284,7 @@ fn conflict_views_offer_the_versions_and_render_offscreen() {
238 if let (Some(output), Some((renderer, device, queue))) = (&output, &mut gpu) {284 if let (Some(output), Some((renderer, device, queue))) = (&output, &mut gpu) {
239 let shown = session.reader(space)().unwrap();285 let shown = session.reader(space)().unwrap();
240 let pixels = paint(&ui, shown, renderer, device, queue);286 let pixels = paint(&ui, shown, renderer, device, queue);
241 let size = SIZE.map(|side| (side * SCALE) as u32);287 save(&output.join(format!("conflict-{name}.png")), &pixels);
242 let path = output.join(format!("conflict-{name}.png"));
243 let mut encoder =
244 png::Encoder::new(std::fs::File::create(&path).unwrap(), size[0], size[1]);
245 encoder.set_color(png::ColorType::Rgba);
246 encoder.set_depth(png::BitDepth::Eight);
247 encoder.write_header().unwrap().write_image_data(&pixels).unwrap();
248 }288 }
249 }289 }
250 // A version reads with its conflicting paragraphs banded as OneNote shows them.290 // 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() {...@@ -252,13 +292,136 @@ fn conflict_views_offer_the_versions_and_render_offscreen() {
252 let marked = session.versions(page)[0].objects.clone();292 let marked = session.versions(page)[0].objects.clone();
253 assert!(!marked.is_empty());293 assert!(!marked.is_empty());
254 let highlighted = kept.objects.iter().any(|object| match object {294 let highlighted = kept.objects.iter().any(|object| match object {
255 onestore::page::PageObject::Outline(outline) => outline.paragraphs.iter().any(|paragraph| {295 onestore::page::PageObject::Outline(outline) => {
256 paragraph.text().is_some_and(|text| marked.contains(&text.id))296 outline.paragraphs.iter().any(|paragraph| {
257 && paragraph.format.highlight == Some(canvas::conflict::CONFLICTING)297 paragraph
258 }),298 .text()
299 .is_some_and(|text| marked.contains(&text.id))
300 && paragraph.format.highlight == Some(canvas::conflict::CONFLICTING)
301 })
302 }
259 _ => false,303 _ => false,
260 });304 });
261 assert!(highlighted);305 assert!(highlighted);
262 session.section.close().unwrap();306 session.section.close().unwrap();
263 std::fs::remove_dir_all(&directory).unwrap();307 std::fs::remove_dir_all(&directory).unwrap();
264}308}
309
310/// A page's versions listed under it while shown, each read-only under OneNote's bar with
311/// what it changed banded, from OneNote's own (`corpus/page-versions/native/step-04`).
312#[test]
313fn page_versions_list_open_read_only_and_render_offscreen() {
314 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
315 let directory = std::env::temp_dir().join(format!("snowbound-history-{}", std::process::id()));
316 let _ = std::fs::remove_dir_all(&directory);
317 std::fs::create_dir_all(directory.join("cache")).unwrap();
318 let file = directory.join("History.one");
319 std::fs::copy(
320 root.join("corpus/page-versions/native/step-04/notebook/History.one"),
321 &file,
322 )
323 .unwrap();
324 let library = Arc::new(crate::Library::section(&file, &directory.join("cache")));
325 let section = library.open(&library.location, || {}).unwrap();
326 let path = library.location.clone();
327 let (mut session, _) = read_session(section, library, path, None).unwrap();
328 let page = session.space;
329 let versions: Vec<_> = session
330 .page_versions(page)
331 .iter()
332 .map(|version| version.context)
333 .collect();
334 assert_eq!(versions.len(), 2);
335 assert_eq!(
336 crate::history::label(&session.page_versions(page)[1])
337 .rsplit_once(' ')
338 .map(|(_, who)| who),
339 Some("virtual")
340 );
341 let output = std::env::var_os("SNOWBOUND_CONFLICT_RENDER").map(PathBuf::from);
342 let mut gpu = output.as_deref().map(gpu);
343 for (name, shown, version, menu, appearance) in [
344 ("history-page", None, None, false, Theme::light()),
345 ("history-shown", Some(page), None, false, Theme::light()),
346 (
347 "history-version",
348 Some(page),
349 Some(versions[0]),
350 false,
351 Theme::light(),
352 ),
353 (
354 "history-menu",
355 Some(page),
356 Some(versions[0]),
357 true,
358 Theme::light(),
359 ),
360 (
361 "history-version-dark",
362 Some(page),
363 Some(versions[0]),
364 false,
365 Theme::dark(),
366 ),
367 (
368 "history-menu-dark",
369 Some(page),
370 Some(versions[0]),
371 true,
372 Theme::dark(),
373 ),
374 ] {
375 session.shown_history = shown;
376 session.version = version;
377 assert_eq!(session.read_only(), version.is_some(), "{name}");
378 match (session.bar(), version) {
379 (
380 Some(Bar::History {
381 version: open,
382 grouped: false,
383 ..
384 }),
385 Some(version),
386 ) => {
387 assert_eq!(open, version)
388 }
389 (None, None) => {}
390 _ => panic!("{name}"),
391 }
392 let mut ui = Ui::new(appearance, std::time::Duration::from_millis(500));
393 for _ in 0..8 {
394 layout(&mut ui, &session, menu);
395 std::thread::sleep(std::time::Duration::from_millis(30));
396 }
397 for context in &versions {
398 let row = Id::ROOT.child("frame").child("panel").child(context);
399 assert_eq!(ui.rect(row).is_some(), shown.is_some(), "{name}");
400 }
401 if let (Some(output), Some((renderer, device, queue))) = (&output, &mut gpu) {
402 let shown = match version {
403 Some(version) => session.version_reader(page, version)().unwrap(),
404 None => session.reader(page)().unwrap(),
405 };
406 save(
407 &output.join(format!("{name}.png")),
408 &paint(&ui, shown, renderer, device, queue),
409 );
410 }
411 }
412 // The newest version bands the line its author added since the version before it.
413 let newest = session.version_reader(page, versions[0])().unwrap();
414 let banded: Vec<_> = newest
415 .objects
416 .iter()
417 .flat_map(|object| match object {
418 onestore::page::PageObject::Outline(outline) => outline.paragraphs.clone(),
419 _ => Vec::new(),
420 })
421 .filter(|paragraph| paragraph.format.highlight == Some(canvas::conflict::CHANGED))
422 .filter_map(|paragraph| paragraph.text().map(|text| text.text.text().to_owned()))
423 .collect();
424 assert_eq!(banded, ["Second author line."]);
425 session.section.close().unwrap();
426 std::fs::remove_dir_all(&directory).unwrap();
427}
crates/snowbound/src/history.rs created+302
...@@ -0,0 +1,302 @@
1//! Page versions as OneNote 2010 shows them (Share, Page Versions): a page's earlier states
2//! listed under it by date and author, each read-only under a yellow bar whose menu restores,
3//! deletes or copies it. `corpus/page-versions` records OneNote's own.
4
5use super::*;
6use onestore::PageVersion;
7
8/// Which versions a Delete All Versions deletes.
9#[derive(Clone, Copy, PartialEq)]
10pub enum Scope {
11 Section,
12 /// The section group holding the open section, with the groups inside it.
13 Group,
14 Notebook,
15}
16
17impl Session {
18 /// A page's versions, newest first.
19 pub(crate) fn page_versions(&self, page: ExGuid) -> &[PageVersion] {
20 self.history
21 .iter()
22 .find(|(listed, _)| *listed == page)
23 .map_or(&[], |(_, versions)| versions)
24 }
25
26 /// Whether the open page takes no edits: a conflict page or a version.
27 pub(crate) fn read_only(&self) -> bool {
28 self.version.is_some() || self.conflict(self.space).is_some()
29 }
30
31 /// Version `version` of page `space` as the editor shows it: read-only, what it changed
32 /// since the version before it banded as OneNote bands it.
33 pub(crate) fn version_reader(
34 &self,
35 space: ExGuid,
36 version: ExGuid,
37 ) -> impl FnOnce() -> Result<Page, Box<dyn Error>> + Send + 'static {
38 let replica = Arc::clone(self.section.replica());
39 let versions = self.page_versions(space);
40 let older = versions
41 .iter()
42 .position(|listed| listed.context == version)
43 .and_then(|at| versions.get(at + 1))
44 .map(|older| older.context);
45 move || {
46 let page = replica.version(space, version)?;
47 let older = older
48 .map(|older| replica.version(space, older))
49 .transpose()?;
50 Ok(canvas::conflict::changes(page, older.as_ref()))
51 }
52 }
53
54 pub(crate) fn refresh_history(&mut self) -> Result<(), Box<dyn Error>> {
55 self.history = self.section.versions()?;
56 if self
57 .shown_history
58 .is_some_and(|page| self.page_versions(page).is_empty())
59 {
60 self.shown_history = None;
61 }
62 Ok(())
63 }
64
65 /// Whether the open section lies in a section group; a section opened on its own has a
66 /// file path, not a catalog one.
67 pub(crate) fn grouped(&self) -> bool {
68 self.library.catalog().is_some() && self.tabs[self.tab].path.contains('/')
69 }
70}
71
72/// A version's row in the page list: its date and who last changed it.
73pub fn label(version: &PageVersion) -> String {
74 match (version.modified, &version.author) {
75 (Some(modified), Some(author)) => format!("{} {author}", platform::short_date(modified)),
76 (Some(modified), None) => platform::short_date(modified),
77 (None, author) => author.clone().unwrap_or_default(),
78 }
79}
80
81/// The bar's menu over an open version, in OneNote 2010's words, and what its choice asks.
82/// Copy Page To opens `copy`, listing the folder's sections.
83pub fn menu(
84 ui: &mut Ui,
85 anchor: ui::Anchor,
86 [page, version]: [ExGuid; 2],
87 grouped: bool,
88 sections: &[&str],
89) -> Option<Command> {
90 let menu = Id::ROOT.child("conflict-menu");
91 let copy = Id::ROOT.child("conflict-copy");
92 let item = |text| ui::popup::Item {
93 text,
94 ..Default::default()
95 };
96 let items = [
97 item("Restore Version"),
98 item("Delete Version"),
99 ui::popup::Item {
100 disabled: sections.is_empty(),
101 ..item("Copy Page To…")
102 },
103 ui::popup::Item {
104 separated: true,
105 ..item("Delete All Versions in Section")
106 },
107 ui::popup::Item {
108 disabled: !grouped,
109 ..item("Delete All Versions in Section Group")
110 },
111 item("Delete All Versions in Notebook"),
112 ui::popup::Item {
113 separated: true,
114 ..item("Hide Page Versions")
115 },
116 ];
117 let chosen = match ui::popup::menu(ui, menu, anchor, &items, None) {
118 Some(0) => Some(Command::RestoreVersion { page, version }),
119 Some(1) => Some(Command::DeletePageVersion { page, version }),
120 Some(2) => {
121 ui.open_popup(copy);
122 None
123 }
124 Some(3) => Some(Command::DeleteAllVersions(Scope::Section)),
125 Some(4) => Some(Command::DeleteAllVersions(Scope::Group)),
126 Some(5) => Some(Command::DeleteAllVersions(Scope::Notebook)),
127 Some(_) => Some(Command::History { page, show: false }),
128 None => None,
129 };
130 let targets: Vec<_> = sections.iter().map(|name| item(name)).collect();
131 let tab = ui::popup::menu(ui, copy, anchor, &targets, None);
132 chosen.or(tab.map(|tab| Command::CopyVersion { version, tab }))
133}
134
135impl State {
136 /// Shows or hides a page's versions under it; hiding leaves an open version for its page.
137 pub(crate) fn show_history(&mut self, page: ExGuid, show: bool) -> Result<(), Box<dyn Error>> {
138 let session = self.session.as_mut().ok_or("No section is open")?;
139 session.shown_history = show.then_some(page);
140 if !show && session.version.is_some() {
141 self.commands.push(Command::OpenPage(page));
142 }
143 Ok(())
144 }
145
146 pub(crate) fn open_version(
147 &mut self,
148 page: ExGuid,
149 version: ExGuid,
150 ) -> Result<(), Box<dyn Error>> {
151 let session = self.session.as_ref().ok_or("No section is open")?;
152 let read = session.version_reader(page, version);
153 self.load(move || Ok(Loaded::Version(page, version, read()?)));
154 Ok(())
155 }
156
157 /// Restore Version: the version becomes the page, and the page as it stood the newest
158 /// version.
159 pub(crate) fn restore_version(
160 &mut self,
161 page: ExGuid,
162 version: ExGuid,
163 ) -> Result<(), Box<dyn Error>> {
164 let session = self.session.as_mut().ok_or("No section is open")?;
165 session
166 .section
167 .restore_version(page, version, &self.author)?;
168 session.status = "Saving";
169 session.refresh_history()?;
170 self.commands.push(Command::OpenPage(page));
171 Ok(())
172 }
173
174 pub(crate) fn delete_version(
175 &mut self,
176 page: ExGuid,
177 version: ExGuid,
178 ) -> Result<(), Box<dyn Error>> {
179 let session = self.session.as_mut().ok_or("No section is open")?;
180 session.section.delete_versions(&[(page, vec![version])])?;
181 session.status = "Saving";
182 session.refresh_history()?;
183 self.commands.push(Command::OpenPage(page));
184 Ok(())
185 }
186
187 /// Delete All Versions in the section, its group or the notebook, once confirmed.
188 pub(crate) fn delete_all_versions(&mut self, scope: Scope) -> Result<(), Box<dyn Error>> {
189 let session = self.session.as_mut().ok_or("No section is open")?;
190 let tab = &session.tabs[session.tab];
191 let (open, section) = (tab.path.clone(), tab.name.clone());
192 let folder = open
193 .rsplit_once('/')
194 .map_or("", |(folder, _)| folder)
195 .to_owned();
196 let (container, name) = match scope {
197 Scope::Section => ("section", section),
198 Scope::Group => (
199 "section group",
200 folder.rsplit('/').next().unwrap_or(&folder).to_owned(),
201 ),
202 Scope::Notebook => ("notebook", session.library.name.clone()),
203 };
204 if !platform::confirm(
205 &format!("Do you want to delete all page versions in the {container} \"{name}\"?"),
206 "You can't restore these versions afterward.",
207 "Cancel",
208 "Delete Versions",
209 ) {
210 return Ok(());
211 }
212 let all: Vec<(ExGuid, Vec<ExGuid>)> = session
213 .history
214 .iter()
215 .map(|(page, versions)| {
216 (
217 *page,
218 versions.iter().map(|version| version.context).collect(),
219 )
220 })
221 .collect();
222 if !all.is_empty() {
223 session.section.delete_versions(&all)?;
224 session.status = "Saving";
225 }
226 session.refresh_history()?;
227 if session.version.is_some() {
228 self.commands.push(Command::OpenPage(session.space));
229 }
230 if scope == Scope::Section {
231 return Ok(());
232 }
233 // The other sections, each opened, cleared and closed in the background.
234 let folder = match scope {
235 Scope::Group => folder,
236 _ => String::new(),
237 };
238 let library = Arc::clone(&session.library);
239 let proxy = self.proxy.clone();
240 std::thread::spawn(move || {
241 for path in sections(&library, &folder) {
242 if path == open {
243 continue;
244 }
245 let cleared = library
246 .open(&path, notify(proxy.clone()))
247 .and_then(|section| {
248 let all: Vec<(ExGuid, Vec<ExGuid>)> = section
249 .versions()?
250 .into_iter()
251 .map(|(page, versions)| {
252 (
253 page,
254 versions
255 .into_iter()
256 .map(|version| version.context)
257 .collect(),
258 )
259 })
260 .collect();
261 if !all.is_empty() {
262 section.delete_versions(&all)?;
263 }
264 Ok(section.close()?)
265 });
266 if let Err(error) = cleared {
267 eprintln!("Deleting the versions in {path} failed: {error}");
268 }
269 }
270 });
271 Ok(())
272 }
273}
274
275/// The readable sections at catalog path `folder` and in the groups inside it, leaving out
276/// the recycle bin.
277fn sections(library: &Library, folder: &str) -> Vec<String> {
278 let Some(catalog) = library.catalog() else {
279 return vec![library.location.clone()];
280 };
281 let mut pending = vec![catalog];
282 let mut root = None;
283 while let Some(candidate) = pending.pop() {
284 if candidate.path == folder {
285 root = Some(candidate);
286 break;
287 }
288 pending.extend(&candidate.groups);
289 }
290 let mut pending: Vec<_> = root.into_iter().collect();
291 let mut paths = Vec::new();
292 while let Some(folder) = pending.pop() {
293 paths.extend(library.tabs(&folder.path).into_iter().map(|tab| tab.path));
294 pending.extend(
295 folder
296 .groups
297 .iter()
298 .filter(|group| !library::recycle_bin(&group.path)),
299 );
300 }
301 paths
302}
crates/snowbound/src/library.rs+10-4
...@@ -83,6 +83,7 @@ pub struct Login {...@@ -83,6 +83,7 @@ pub struct Login {
8383
84impl Login {84impl Login {
85 /// The guest account a mount without one signed in as.85 /// The guest account a mount without one signed in as.
86 #[cfg(any(target_os = "macos", test))]
86 pub fn guest(mount: &Mount) -> Self {87 pub fn guest(mount: &Mount) -> Self {
87 Self {88 Self {
88 user: String::new(),89 user: String::new(),
...@@ -327,7 +328,12 @@ impl Library {...@@ -327,7 +328,12 @@ impl Library {
327 if let Some(tab) = tabs(folder).into_iter().next() {328 if let Some(tab) = tabs(folder).into_iter().next() {
328 return Some(tab.path);329 return Some(tab.path);
329 }330 }
330 folders.extend(folder.groups.iter().filter(|group| !recycle_bin(group)));331 folders.extend(
332 folder
333 .groups
334 .iter()
335 .filter(|group| !recycle_bin(&group.path)),
336 );
331 }337 }
332 None338 None
333 }339 }
...@@ -356,9 +362,9 @@ impl Library {...@@ -356,9 +362,9 @@ impl Library {
356 }362 }
357}363}
358364
359/// Whether `folder` is the notebook's recycle bin, which OneNote keeps out of its lists.365/// Whether the folder at `path` is the notebook's recycle bin, which OneNote keeps out of its lists.
360pub fn recycle_bin(folder: &Folder) -> bool {366pub fn recycle_bin(path: &str) -> bool {
361 folder.path.rsplit('/').next() == Some("OneNote_RecycleBin")367 path.rsplit('/').next() == Some("OneNote_RecycleBin")
362}368}
363369
364pub fn file_name(path: &Path) -> String {370pub fn file_name(path: &Path) -> String {
crates/snowbound/src/link.rs created+464
...@@ -0,0 +1,464 @@
1//! Links and equations on the page as OneNote 2010 offers them: the Link dialog,
2//! the page's context menu, and following a clicked link, to a page
3//! of the notebook or with the system's handler.
4
5use crate::{Command, State, page, platform};
6use onestore::ExGuid;
7use onestore::page::link::{LinkTarget, internal_link};
8use std::error::Error;
9use ui::{Anchor, Axis, Flags, Id, Spec, children, fill, popup::Item, px};
10use winit::keyboard::NamedKey;
11
12const WIDTH: f32 = 420.0;
13/// Pages the dialog lists at once; the search narrows the rest.
14const PAGES: usize = 8;
15
16/// The Link dialog's fields while it is open.
17pub struct LinkDialog {
18 text: String,
19 address: String,
20 search: String,
21}
22
23fn id() -> Id {
24 Id::ROOT.child("link")
25}
26
27fn text_field() -> Id {
28 id().child("text")
29}
30
31fn address_field() -> Id {
32 id().child("address")
33}
34
35fn search_field() -> Id {
36 id().child("search")
37}
38
39fn menu() -> Id {
40 Id::ROOT.child("text-menu")
41}
42
43/// What a link's address opens with the system's handler: a web address typed without its
44/// scheme is a web page, a share's `\\server\share` path its `smb:` URL.
45fn system_url(address: &str) -> String {
46 if let Some(share) = address.strip_prefix("\\\\") {
47 return format!("smb://{}", share.replace('\\', "/"));
48 }
49 let scheme = address.split_once(':').is_some_and(|(scheme, _)| {
50 scheme.len() > 1
51 && scheme
52 .chars()
53 .all(|c| c.is_ascii_alphanumeric() || "+-.".contains(c))
54 });
55 if scheme {
56 address.to_owned()
57 } else {
58 format!("http://{address}")
59 }
60}
61
62/// A page, or a paragraph on it, as Copy Link to Page gives it: the section file's path
63/// before the title, then the identities OneNote finds it by.
64fn clipboard_link(
65 path: &str,
66 section: [u8; 16],
67 title: &str,
68 page: [u8; 16],
69 object: Option<ExGuid>,
70) -> String {
71 let target = match object {
72 Some(object) => LinkTarget::Object {
73 identity: page,
74 title,
75 object,
76 },
77 None => LinkTarget::Page {
78 identity: page,
79 title,
80 },
81 };
82 let stored = internal_link(section, "", target);
83 let fragment = stored
84 .strip_prefix("onenote:#")
85 .and_then(|rest| rest.strip_suffix("&base-path="))
86 .unwrap_or_default();
87 format!("onenote:///{}#{fragment}", path.replace(' ', "%20"))
88}
89
90impl State {
91 /// Ctrl+K and the toolbar's Link: the dialog opens on what the selection links.
92 pub(crate) fn open_link_dialog(&mut self) {
93 let (text, address) = self.view.link_prefill();
94 self.link = Some(LinkDialog {
95 text,
96 address,
97 search: String::new(),
98 });
99 self.ui.open_popup(id());
100 self.ui.set_focus(Some(address_field()));
101 }
102
103 /// Builds the Link dialog while it is open. OK, or Enter in a field, links the address
104 /// shown as the text; a page picked from the list fills both.
105 pub(crate) fn link_dialog(&mut self) -> Result<(), Box<dyn Error>> {
106 let Some(dialog) = &mut self.link else {
107 return Ok(());
108 };
109 let ui = &mut self.ui;
110 if !ui.popup_open(id()) {
111 self.link = None;
112 return Ok(());
113 }
114 let theme = ui.theme.clone();
115 let row = theme.font_size * 2.0;
116 let window = ui.rect(Id::ROOT).unwrap_or_default();
117 let height = ui.rect(id()).map_or(360.0, |rect| rect[3] - rect[1]);
118 let [left, top] = [
119 ((window[2] - WIDTH) / 2.0).max(0.0),
120 ((window[3] - height) / 2.0).max(0.0),
121 ];
122 let fields = [text_field(), address_field(), search_field()];
123 let entered =
124 ui::popup::navigation(ui, &fields[..2], &[NamedKey::Enter]).contains(&NamedKey::Enter);
125 ui.open_as(
126 id(),
127 Spec {
128 axis: Axis::Y,
129 size: [px(WIDTH), children()],
130 fill: Some(theme.popup),
131 border: Some(theme.chip),
132 shadow: Some(theme.shadow),
133 radius: 8.0,
134 pad: [16.0, 12.0],
135 gap: 4.0,
136 anchor: Some(Anchor::Below([left, top, left, top])),
137 ..Spec::default()
138 },
139 );
140 let field = |ui: &mut ui::Ui, label: &str, id: Id, value: &mut String, hint: &str| {
141 if !label.is_empty() {
142 ui.leaf(
143 label,
144 Spec {
145 size: [fill(), px(row * 0.8)],
146 text: Some(label),
147 ..Spec::default()
148 },
149 );
150 }
151 ui::text_field(
152 ui,
153 id,
154 value,
155 hint,
156 Spec {
157 size: [fill(), px(row)],
158 fill: Some(theme.base),
159 border: Some(theme.accent),
160 radius: 4.0,
161 pad: [6.0, 0.0],
162 ..Spec::default()
163 },
164 );
165 };
166 field(ui, "Text to display:", text_field(), &mut dialog.text, "");
167 field(ui, "Address:", address_field(), &mut dialog.address, "");
168 field(
169 ui,
170 "Or pick a location in OneNote:",
171 search_field(),
172 &mut dialog.search,
173 "Search by text in title",
174 );
175 let search = dialog.search.to_lowercase();
176 let pages: Vec<(ExGuid, String)> = self
177 .session
178 .as_ref()
179 .map(|session| {
180 session
181 .pages
182 .iter()
183 .filter(|(_, title, _)| title.to_lowercase().contains(&search))
184 .take(PAGES)
185 .map(|(space, title, _)| (*space, title.clone()))
186 .collect()
187 })
188 .unwrap_or_default();
189 ui.open(
190 "pages",
191 Spec {
192 axis: Axis::Y,
193 size: [fill(), px(row * PAGES as f32 + 8.0)],
194 fill: Some(theme.base),
195 border: Some(theme.chip),
196 radius: 4.0,
197 pad: [4.0, 4.0],
198 flags: Flags::CLIP,
199 ..Spec::default()
200 },
201 );
202 let mut picked = None;
203 for (space, title) in &pages {
204 let shown = if title.is_empty() {
205 "Untitled page"
206 } else {
207 title
208 };
209 let spec = Spec {
210 flags: Flags::CLICKABLE,
211 size: [fill(), px(row)],
212 text: Some(shown),
213 hover_fill: Some(theme.hover()),
214 radius: 4.0,
215 pad: [8.0, 0.0],
216 ..Spec::default()
217 };
218 if ui.leaf(space, spec).clicked {
219 picked = Some((*space, title.clone()));
220 }
221 }
222 ui.close();
223 ui.open(
224 "buttons",
225 Spec {
226 size: [fill(), children()],
227 pad: [0.0, 8.0],
228 gap: 8.0,
229 ..Spec::default()
230 },
231 );
232 ui.leaf(
233 "space",
234 Spec {
235 size: [fill(), px(1.0)],
236 ..Spec::default()
237 },
238 );
239 let ok = ui::button(ui, "ok", "OK").clicked || entered;
240 let cancel = ui::button(ui, "cancel", "Cancel").clicked;
241 ui.close();
242 ui.close();
243 if let Some((space, title)) = picked
244 && let Some(session) = &self.session
245 {
246 let page = session.section.page(space)?;
247 let identity = page
248 .identity
249 .ok_or("This page has no identity to link to")?;
250 let address = internal_link(
251 session.section.identity()?,
252 &session.section.file().to_string_lossy(),
253 LinkTarget::Page {
254 identity,
255 title: &title,
256 },
257 );
258 let dialog = self.link.as_mut().unwrap();
259 dialog.address = address;
260 if dialog.text.is_empty() {
261 dialog.text = title;
262 }
263 return Ok(());
264 }
265 let dialog = self.link.as_ref().unwrap();
266 if ok && !dialog.address.trim().is_empty() {
267 let response = self.view.set_link(&dialog.text, &dialog.address)?;
268 self.respond(response);
269 } else if !cancel {
270 return Ok(());
271 }
272 self.ui.close_popup(id());
273 self.ui.set_focus(Some(page()));
274 self.link = None;
275 Ok(())
276 }
277
278 /// A secondary press on the page's text opens its context menu there.
279 pub(crate) fn open_text_menu(&mut self) -> Result<(), Box<dyn Error>> {
280 let Some((response, context)) = self.view.context()? else {
281 return Ok(());
282 };
283 self.respond(response);
284 let point = self.ui.pointer().unwrap_or_default();
285 self.text_menu = Some((context, point));
286 self.ui.open_popup(menu());
287 Ok(())
288 }
289
290 /// The page's context menu while it is open, with OneNote 2010's commands for text,
291 /// links and equations.
292 pub(crate) fn text_menu(&mut self) -> Result<(), Box<dyn Error>> {
293 let Some((context, point)) = &self.text_menu else {
294 return Ok(());
295 };
296 if !self.ui.popup_open(menu()) {
297 self.text_menu = None;
298 return Ok(());
299 }
300 let item = |text| Item {
301 text,
302 ..Item::default()
303 };
304 let mut items = vec![
305 Item {
306 disabled: !context.selected,
307 ..item("Cut")
308 },
309 Item {
310 disabled: !context.selected,
311 ..item("Copy")
312 },
313 item("Paste"),
314 ];
315 match &context.link {
316 Some(_) => items.extend([
317 Item {
318 separated: true,
319 ..item("Edit Link…")
320 },
321 item("Copy Link to Paragraph"),
322 Item {
323 separated: true,
324 ..item("Copy Link")
325 },
326 item("Select Link"),
327 item("Remove Link"),
328 ]),
329 None => items.extend([
330 Item {
331 separated: true,
332 ..item("Link…")
333 },
334 item("Copy Link to Paragraph"),
335 ]),
336 }
337 if context.equation {
338 items.extend([
339 Item {
340 separated: true,
341 ..item("Professional")
342 },
343 item("Linear"),
344 ]);
345 }
346 let Some(chosen) =
347 ui::popup::menu(&mut self.ui, menu(), Anchor::Point(*point), &items, None)
348 else {
349 return Ok(());
350 };
351 let Some((context, _)) = self.text_menu.take() else {
352 return Ok(());
353 };
354 let response = match items[chosen].text {
355 "Cut" => self.view.copy(true)?,
356 "Copy" => self.view.copy(false)?,
357 "Paste" => {
358 self.commands
359 .push(Command::Page(canvas::interaction::Request::Paste));
360 return Ok(());
361 }
362 "Edit Link…" | "Link…" => {
363 self.open_link_dialog();
364 return Ok(());
365 }
366 "Copy Link" => {
367 self.clipboard.set_text(context.link.unwrap_or_default())?;
368 return Ok(());
369 }
370 "Copy Link to Paragraph" => {
371 if let Some(session) = &self.session {
372 let link = self.page_link(session.space, context.paragraph)?;
373 self.clipboard.set_text(link)?;
374 }
375 return Ok(());
376 }
377 "Select Link" => self.view.unlink(true)?,
378 "Remove Link" => self.view.unlink(false)?,
379 text => self.view.switch_equation(text == "Linear")?,
380 };
381 self.respond(response);
382 Ok(())
383 }
384
385 /// Copy Link to Page, or to a paragraph `object` on it: the link OneNote puts on the
386 /// clipboard for page `space` of the open section.
387 pub(crate) fn page_link(
388 &self,
389 space: ExGuid,
390 object: Option<ExGuid>,
391 ) -> Result<String, Box<dyn Error>> {
392 let session = self.session.as_ref().ok_or("No section is open")?;
393 let page = session.section.page(space)?;
394 let identity = page
395 .identity
396 .ok_or("This page has no identity to link to")?;
397 let title = session
398 .pages
399 .iter()
400 .find(|(listed, ..)| *listed == space)
401 .map_or(page.title.as_str(), |(_, title, _)| title.as_str());
402 Ok(clipboard_link(
403 &session.section.file().to_string_lossy(),
404 session.section.identity()?,
405 title,
406 identity,
407 object,
408 ))
409 }
410
411 /// A clicked link: a page or section of an open notebook opens here; anything else opens
412 /// with the system's handler.
413 pub(crate) fn open_link(&mut self, address: &str) -> Result<(), Box<dyn Error>> {
414 if !address.to_ascii_lowercase().starts_with("onenote:") {
415 platform::reveal(system_url(address));
416 return Ok(());
417 }
418 let Some(session) = &self.session else {
419 return Ok(());
420 };
421 let library = std::sync::Arc::clone(&session.library);
422 let Ok(Some(notebook)) = &library.notebook else {
423 return Ok(());
424 };
425 let Some((path, space)) = notebook.find_page(address)? else {
426 return Ok(());
427 };
428 if path == session.tabs[session.tab].path {
429 self.commands.extend(space.map(Command::OpenPage));
430 return Ok(());
431 }
432 if let Some(space) = space {
433 self.last_pages.insert(library.key(&path), space);
434 }
435 self.commands.push(Command::OpenSection(library, path));
436 Ok(())
437 }
438}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443
444 #[test]
445 fn addresses_open_as_onenote_follows_them() {
446 assert_eq!(system_url("www.example.com"), "http://www.example.com");
447 assert_eq!(system_url("example.net/x"), "http://example.net/x");
448 assert_eq!(system_url("https://a.example/p"), "https://a.example/p");
449 assert_eq!(system_url("mailto:me@example.com"), "mailto:me@example.com");
450 assert_eq!(system_url("\\\\server\\share\\f"), "smb://server/share/f");
451 }
452
453 /// OneNote 2010's Copy Link to Page (`corpus/link-edit/native-typed`, README): the file's
454 /// path, the title, and the section and page identities.
455 #[test]
456 fn page_links_copy_as_onenote_copies_them() {
457 let section = *b"\x25\xfc\x1e\x0f\x7c\xdf\xcb\x45\x87\xa4\xbd\xd0\x3f\x42\x05\x7e";
458 let page = *b"\x18\xa3\xcc\xa9\x3e\xa4\x12\x49\xa1\xc7\xf1\x9f\x1e\xaf\x30\xca";
459 assert_eq!(
460 clipboard_link("C:\\one\\links.one", section, "Links", page, None),
461 "onenote:///C:\\one\\links.one#Links&section-id={0F1EFC25-DF7C-45CB-87A4-BDD03F42057E}&page-id={A9CCA318-A43E-4912-A1C7-F19F1EAF30CA}&end"
462 );
463 }
464}
crates/snowbound/src/linux.rs+315-18
...@@ -3,7 +3,16 @@...@@ -3,7 +3,16 @@
3//! the C library's locale.3//! the C library's locale.
44
5use canvas::date::DateField;5use canvas::date::DateField;
6use std::{ffi::CStr, path::PathBuf, process::Command, sync::OnceLock, time::Duration};6use std::{
7 ffi::CStr,
8 path::PathBuf,
9 process::Command,
10 sync::{
11 OnceLock,
12 atomic::{AtomicU32, Ordering},
13 },
14 time::Duration,
15};
7use ui::{Flags, Spec, Ui, px};16use ui::{Flags, Spec, Ui, px};
8use winit::{17use winit::{
9 error::EventLoopError,18 error::EventLoopError,
...@@ -16,8 +25,6 @@ use winit::{...@@ -16,8 +25,6 @@ use winit::{
16const APP_ID: &str = "snowbound";25const APP_ID: &str = "snowbound";
17/// The title bar's leading margin; the window controls sit at its trailing end.26/// The title bar's leading margin; the window controls sit at its trailing end.
18pub const LEADING: f32 = 8.0;27pub const LEADING: f32 = 8.0;
19/// Square, as compositors that round windows don't report by how much.
20pub const CORNER_RADIUS: f32 = 0.0;
21/// How far inside the window's edges a press resizes it, and how far along them a corner28/// How far inside the window's edges a press resizes it, and how far along them a corner
22/// reaches, in logical pixels.29/// reaches, in logical pixels.
23const EDGE: f32 = 5.0;30const EDGE: f32 = 5.0;
...@@ -27,6 +34,9 @@ const MAXIMIZE: &[&str] = &[include_str!("../assets/icons/window-maximize.svg")]...@@ -27,6 +34,9 @@ const MAXIMIZE: &[&str] = &[include_str!("../assets/icons/window-maximize.svg")]
27const RESTORE: &[&str] = &[include_str!("../assets/icons/window-restore.svg")];34const RESTORE: &[&str] = &[include_str!("../assets/icons/window-restore.svg")];
2835
29static QUIT: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new();36static QUIT: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new();
37/// Breeze's corner radius in units of the decoration's pixel grid, as `f32` bits; zero where
38/// KWin leaves windows square.
39static BREEZE_RADIUS: AtomicU32 = AtomicU32::new(0);
3040
31pub fn event_loop(_headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLoopError> {41pub fn event_loop(_headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLoopError> {
32 // Month and day names and date orders follow the user's locale; other categories stay C.42 // 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<EventLoop<crate::UserEvent>, EventL...@@ -34,9 +44,95 @@ pub fn event_loop(_headless: bool) -> Result<EventLoop<crate::UserEvent>, EventL
34 let event_loop = EventLoop::with_user_event().build()?;44 let event_loop = EventLoop::with_user_event().build()?;
35 QUIT.set(event_loop.create_proxy())45 QUIT.set(event_loop.create_proxy())
36 .expect("Only one application event loop is created");46 .expect("Only one application event loop is created");
47 watch_settings(event_loop.create_proxy());
48 if std::env::var("XDG_CURRENT_DESKTOP").is_ok_and(|desktop| desktop.contains("KDE")) {
49 follow_breeze_radius(event_loop.create_proxy());
50 }
37 Ok(event_loop)51 Ok(event_loop)
38}52}
3953
54/// How far the window manager rounds the window's corners: KWin rounds the bottom corners
55/// of Breeze's borderless windows unless they are maximized or full screen.
56pub fn corner_radius(window: &Window) -> f32 {
57 let units = f32::from_bits(BREEZE_RADIUS.load(Ordering::Relaxed));
58 if units == 0.0
59 || !window.is_decorated()
60 || window.is_maximized()
61 || window.fullscreen().is_some()
62 {
63 return 0.0;
64 }
65 // Breeze snaps its radius to the device pixel grid.
66 let scale = window.scale_factor() as f32;
67 (units * scale).round() / scale
68}
69
70/// Reads Breeze's corner radius now and again whenever KWin is told to reload its
71/// configuration, as Breeze's and the border size's settings pages do, redrawing then.
72fn follow_breeze_radius(proxy: EventLoopProxy<crate::UserEvent>) {
73 let Ok(connection) = zbus::blocking::connection::Builder::session()
74 .and_then(|builder| builder.method_timeout(Duration::from_millis(500)).build())
75 else {
76 return;
77 };
78 let read = |connection: &zbus::blocking::Connection| {
79 let units = breeze_radius(connection).unwrap_or(0.0);
80 BREEZE_RADIUS.store(units.to_bits(), Ordering::Relaxed);
81 };
82 // Before the window's first frame, so its corners never change under the user.
83 read(&connection);
84 std::thread::spawn(move || {
85 let Ok(rule) = zbus::MatchRule::builder()
86 .msg_type(zbus::message::Type::Signal)
87 .interface("org.kde.KWin")
88 .and_then(|rule| rule.member("reloadConfig"))
89 .map(|rule| rule.build())
90 else {
91 return;
92 };
93 let Ok(signals) =
94 zbus::blocking::MessageIterator::for_match_rule(rule, &connection, Some(8))
95 else {
96 return;
97 };
98 for _ in signals {
99 read(&connection);
100 let _ = proxy.send_event(crate::UserEvent::Redraw);
101 }
102 });
103}
104
105/// Breeze's `Frame_FrameRadius` of 2.5 small spacings where KWin 6.5 or later decorates
106/// with Breeze, borderless and with rounded corners, as Breeze's own
107/// `Decoration::recalculateBorders` decides.
108fn breeze_radius(connection: &zbus::blocking::Connection) -> Option<f32> {
109 let proxy =
110 zbus::blocking::Proxy::new(connection, "org.kde.KWin", "/KWin", "org.kde.KWin").ok()?;
111 let info: String = proxy.call("supportInformation", &()).ok()?;
112 let field = |name: &str| {
113 info.lines()
114 .find_map(|line| line.strip_prefix(name)?.strip_prefix(": "))
115 };
116 let mut version = field("KWin version")?.split('.').map(str::parse::<u32>);
117 let (Some(Ok(major)), Some(Ok(minor))) = (version.next(), version.next()) else {
118 return None;
119 };
120 let breezerc = kconfig("breezerc");
121 let rounded = kconfig_entries(&breezerc)
122 .get(&("Common", "RoundedCorners"))
123 .is_none_or(|value| *value != "false");
124 // KDecoration's BorderSize::None is 0.
125 let applies = (major, minor) >= (6, 5)
126 && field("Plugin")? == "org.kde.breeze"
127 && field("borderSize")? == "0"
128 && rounded;
129 Some(if applies {
130 2.5 * field("smallSpacing")?.parse::<f32>().ok()?
131 } else {
132 0.0
133 })
134}
135
40/// Decorated by the window manager, or on Wayland by the compositor where it offers136/// Decorated by the window manager, or on Wayland by the compositor where it offers
41/// xdg-decoration and otherwise by winit's Adwaita frame; named so the desktop entry supplies137/// xdg-decoration and otherwise by winit's Adwaita frame; named so the desktop entry supplies
42/// the icon on Wayland, while X11 takes it from the window.138/// the icon on Wayland, while X11 takes it from the window.
...@@ -68,7 +164,10 @@ impl Clipboard {...@@ -68,7 +164,10 @@ impl Clipboard {
68164
69 pub fn set_text(&mut self, text: String) -> Result<(), arboard::Error> {165 pub fn set_text(&mut self, text: String) -> Result<(), arboard::Error> {
70 match self {166 match self {
71 Self::Wayland(clipboard) => Ok(clipboard.store(text)),167 Self::Wayland(clipboard) => {
168 clipboard.store(text);
169 Ok(())
170 }
72 Self::X11(clipboard) => clipboard.set_text(text),171 Self::X11(clipboard) => clipboard.set_text(text),
73 }172 }
74 }173 }
...@@ -87,6 +186,109 @@ pub fn system_titlebar(window: &Window) -> bool {...@@ -87,6 +186,109 @@ pub fn system_titlebar(window: &Window) -> bool {
87 window.is_decorated()186 window.is_decorated()
88}187}
89188
189/// Window managers show no document of a window's.
190pub fn represent(_: &Window, _: Option<&std::path::Path>) {}
191
192/// None: the title bars desktops draw are opaque.
193pub fn install_backdrop(_: &Window) -> bool {
194 false
195}
196
197/// The title bar's fills with the window focused and not, where the desktop's are known and
198/// suit `appearance`: KWin's from the KDE colour scheme, or on GNOME the Adwaita header bar
199/// that winit's frame and GNOME's both draw.
200pub fn titlebar(appearance: Theme) -> Option<[[f32; 4]; 2]> {
201 let desktops = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
202 let on = |name: &str| desktops.split(':').any(|desktop| desktop == name);
203 let fills = if on("KDE") {
204 kde_titlebar(&kconfig("kdeglobals"))
205 } else if on("GNOME") {
206 adwaita_titlebar(appearance)
207 } else {
208 return None;
209 }
210 .map(|[red, green, blue]| draw::srgb(red, green, blue));
211 // Past mid grey, as against a colour scheme chosen in Options, the theme's own strip.
212 let [red, green, blue, _] = fills[0];
213 let dark = 0.2126 * red + 0.7152 * green + 0.0722 * blue < 0.18;
214 (dark == (appearance == Theme::Dark)).then_some(fills)
215}
216
217/// KConfig file `name` from the system's configuration directories up to the user's, in
218/// rising precedence.
219fn kconfig(name: &str) -> Vec<String> {
220 let system = std::env::var("XDG_CONFIG_DIRS")
221 .ok()
222 .filter(|dirs| !dirs.is_empty())
223 .unwrap_or_else(|| "/etc/xdg".into());
224 let mut dirs: Vec<PathBuf> = std::env::split_paths(&system).collect();
225 dirs.reverse();
226 dirs.extend(xdg_base("XDG_CONFIG_HOME", ".config"));
227 dirs.iter()
228 .filter_map(|dir| std::fs::read_to_string(dir.join(name)).ok())
229 .collect()
230}
231
232/// The entries of KConfig `files` in rising precedence, by group and key.
233fn kconfig_entries(files: &[String]) -> std::collections::HashMap<(&str, &str), &str> {
234 let mut entries = std::collections::HashMap::new();
235 for file in files {
236 let mut group = "";
237 for line in file.lines().map(str::trim) {
238 if let Some(name) = line
239 .strip_prefix('[')
240 .and_then(|line| line.strip_suffix(']'))
241 {
242 group = name;
243 } else if let Some((key, value)) = line.split_once('=') {
244 entries.insert((group, key.trim()), value.trim());
245 }
246 }
247 }
248 entries
249}
250
251/// KWin's title bar fills, focused and not, from kdeglobals `files` in rising precedence: the
252/// colour scheme's header colours where it has them, as KWin prefers, else its window
253/// manager's, else Breeze Light's header.
254fn kde_titlebar(files: &[String]) -> [[u8; 3]; 2] {
255 let entries = kconfig_entries(files);
256 let color = |group, key| kde_color(entries.get(&(group, key))?);
257 let pair = |active: [u8; 3], inactive: Option<[u8; 3]>| [active, inactive.unwrap_or(active)];
258 if let Some(active) = color("Colors:Header", "BackgroundNormal") {
259 pair(active, color("Colors:Header][Inactive", "BackgroundNormal"))
260 } else if let Some(active) = color("WM", "activeBackground") {
261 pair(active, color("WM", "inactiveBackground"))
262 } else {
263 [[222, 224, 226], [239, 240, 241]]
264 }
265}
266
267/// A KConfig colour: `r,g,b` with an optional alpha, or `#rrggbb`.
268fn kde_color(value: &str) -> Option<[u8; 3]> {
269 if let Some(hex) = value.strip_prefix('#') {
270 let [_, red, green, blue] = u32::from_str_radix(hex, 16)
271 .ok()
272 .filter(|_| hex.len() == 6)?
273 .to_be_bytes();
274 return Some([red, green, blue]);
275 }
276 let mut channels = value.split(',').map(|channel| channel.trim().parse().ok());
277 Some([channels.next()??, channels.next()??, channels.next()??])
278}
279
280/// The header bar of winit's Adwaita frame, focused and not.
281fn adwaita_titlebar(appearance: Theme) -> [[u8; 3]; 2] {
282 let theme = match appearance {
283 Theme::Dark => sctk_adwaita::theme::ColorTheme::dark(),
284 Theme::Light => sctk_adwaita::theme::ColorTheme::light(),
285 };
286 [theme.active, theme.inactive].map(|colors| {
287 let color = colors.headerbar.to_color_u8();
288 [color.red(), color.green(), color.blue()]
289 })
290}
291
90/// Minimize, maximize and close at the title bar's trailing end.292/// Minimize, maximize and close at the title bar's trailing end.
91pub fn window_controls(ui: &mut Ui, window: &Window) {293pub fn window_controls(ui: &mut Ui, window: &Window) {
92 let theme = ui.theme.clone();294 let theme = ui.theme.clone();
...@@ -166,15 +368,15 @@ pub fn install_text_input(_: &Window) {}...@@ -166,15 +368,15 @@ pub fn install_text_input(_: &Window) {}
166/// Options opens from the sidebar's footer and Ctrl+Comma, as Linux apps have no shared menu.368/// Options opens from the sidebar's footer and Ctrl+Comma, as Linux apps have no shared menu.
167pub fn install_menu() {}369pub fn install_menu() {}
168370
371/// The toolbar and keyboard stand in for a menu bar.
372pub fn update_menu(_: impl FnOnce() -> Vec<crate::commands::Status>) {}
373
169pub fn clear_marked_text(_: &Window) {}374pub fn clear_marked_text(_: &Window) {}
170375
171pub fn configure_presentation(_: &wgpu::Surface<'_>) {}376pub fn configure_presentation(_: &wgpu::Surface<'_>) {}
172377
173pub fn commit_presentation(_: &Window) {}378pub fn commit_presentation(_: &Window) {}
174379
175/// GNOME has no character palette to open from an app.
176pub fn show_character_palette() {}
177
178/// GTK's default double-click time.380/// GTK's default double-click time.
179pub fn double_click_interval() -> Duration {381pub fn double_click_interval() -> Duration {
180 Duration::from_millis(400)382 Duration::from_millis(400)
...@@ -188,6 +390,41 @@ pub fn appearance(_: &Window) -> Theme {...@@ -188,6 +390,41 @@ pub fn appearance(_: &Window) -> Theme {
188 if dark { Theme::Dark } else { Theme::Light }390 if dark { Theme::Dark } else { Theme::Light }
189}391}
190392
393/// Asks for the desktop's colours again whenever the settings portal reports a change to the
394/// colour scheme, or to KDE's colours.
395fn watch_settings(proxy: EventLoopProxy<crate::UserEvent>) {
396 std::thread::spawn(move || {
397 let Ok(connection) = zbus::blocking::Connection::session() else {
398 return;
399 };
400 let Ok(portal) = zbus::blocking::Proxy::new(
401 &connection,
402 "org.freedesktop.portal.Desktop",
403 "/org/freedesktop/portal/desktop",
404 "org.freedesktop.portal.Settings",
405 ) else {
406 return;
407 };
408 let Ok(changes) = portal.receive_signal("SettingChanged") else {
409 return;
410 };
411 for change in changes {
412 let Ok((namespace, _, _)) = change
413 .body()
414 .deserialize::<(String, String, zbus::zvariant::OwnedValue)>()
415 else {
416 continue;
417 };
418 if (namespace == "org.freedesktop.appearance"
419 || namespace.starts_with("org.kde.kdeglobals"))
420 && proxy.send_event(crate::UserEvent::Appearance).is_err()
421 {
422 return;
423 }
424 }
425 });
426}
427
191fn portal_color_scheme() -> Option<bool> {428fn portal_color_scheme() -> Option<bool> {
192 use zbus::zvariant::{OwnedValue, Value};429 use zbus::zvariant::{OwnedValue, Value};
193 // A missing portal must not hold up the window for D-Bus's 25 s default.430 // 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<crate::library::Mount> {...@@ -232,14 +469,22 @@ pub fn smb_mount(path: &std::path::Path) -> Option<crate::library::Mount> {
232 .lines()469 .lines()
233 .filter_map(|line| {470 .filter_map(|line| {
234 let mut fields = line.split_whitespace();471 let mut fields = line.split_whitespace();
235 let (source, point, kind, options) =472 let (source, point, kind, options) = (
236 (fields.next()?, fields.next()?, fields.next()?, fields.next()?);473 fields.next()?,
474 fields.next()?,
475 fields.next()?,
476 fields.next()?,
477 );
237 let point = point.replace("\\040", " ");478 let point = point.replace("\\040", " ");
238 (matches!(kind, "cifs" | "smb3") && path.starts_with(&point))479 (matches!(kind, "cifs" | "smb3") && path.starts_with(&point))
239 .then(|| (source.replace("\\040", " "), point, options.to_owned()))480 .then(|| (source.replace("\\040", " "), point, options.to_owned()))
240 })481 })
241 .max_by_key(|(_, point, _)| point.len())?;482 .max_by_key(|(_, point, _)| point.len())?;
242 let within = path.strip_prefix(&point).ok()?.to_string_lossy().into_owned();483 let within = path
484 .strip_prefix(&point)
485 .ok()?
486 .to_string_lossy()
487 .into_owned();
243 crate::library::Mount::parse(&source, &within, &options)488 crate::library::Mount::parse(&source, &within, &options)
244}489}
245490
...@@ -258,7 +503,9 @@ pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login,...@@ -258,7 +503,9 @@ pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login,
258 {503 {
259 return Ok(crate::library::Login {504 return Ok(crate::library::Login {
260 user,505 user,
261 password: String::from_utf8_lossy(&output.stdout).trim_end_matches('\n').to_owned(),506 password: String::from_utf8_lossy(&output.stdout)
507 .trim_end_matches('\n')
508 .to_owned(),
262 domain: mount.domain.clone(),509 domain: mount.domain.clone(),
263 });510 });
264 }511 }
...@@ -268,7 +515,7 @@ pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login,...@@ -268,7 +515,7 @@ pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login,
268 ["--password", &title],515 ["--password", &title],
269 )516 )
270 .map_err(str::to_owned)?517 .map_err(str::to_owned)?
271 .ok_or_else(|| "Signing in was cancelled".to_owned())?;518 .ok_or_else(|| "Signing in was canceled".to_owned())?;
272 // zenity answers "user|password"; kdialog only the password.519 // zenity answers "user|password"; kdialog only the password.
273 let (typed, password) = asked520 let (typed, password) = asked
274 .split_once('|')521 .split_once('|')
...@@ -288,11 +535,14 @@ pub fn settings_dir() -> Option<PathBuf> {...@@ -288,11 +535,14 @@ pub fn settings_dir() -> Option<PathBuf> {
288535
289/// The app's folder in the XDG base directory `variable` names, or in `fallback` under home.536/// The app's folder in the XDG base directory `variable` names, or in `fallback` under home.
290fn xdg_dir(variable: &str, fallback: &str) -> Option<PathBuf> {537fn xdg_dir(variable: &str, fallback: &str) -> Option<PathBuf> {
538 Some(xdg_base(variable, fallback)?.join(APP_ID))
539}
540
541fn xdg_base(variable: &str, fallback: &str) -> Option<PathBuf> {
291 std::env::var_os(variable)542 std::env::var_os(variable)
292 .map(PathBuf::from)543 .map(PathBuf::from)
293 .filter(|path| path.is_absolute())544 .filter(|path| path.is_absolute())
294 .or_else(|| Some(PathBuf::from(std::env::var_os("HOME")?).join(fallback)))545 .or_else(|| Some(PathBuf::from(std::env::var_os("HOME")?).join(fallback)))
295 .map(|base| base.join(APP_ID))
296}546}
297547
298/// The application's icon from the icon theme, the smallest at least `pixels` square.548/// The application's icon from the icon theme, the smallest at least `pixels` square.
...@@ -543,10 +793,11 @@ fn dialog<const Z: usize, const K: usize>(...@@ -543,10 +793,11 @@ fn dialog<const Z: usize, const K: usize>(
543 .then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned()))793 .then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned()))
544}794}
545795
546/// Opens `folder` in the file manager.796/// Opens `target`, a folder or a link's URL, with the desktop's handler.
547pub fn reveal(folder: &std::path::Path) {797pub fn reveal(target: impl AsRef<std::ffi::OsStr>) {
548 if let Err(error) = Command::new("xdg-open").arg(folder).spawn() {798 let target = target.as_ref();
549 eprintln!("Cannot open {}: {error}", folder.display());799 if let Err(error) = Command::new("xdg-open").arg(target).spawn() {
800 eprintln!("Cannot open {}: {error}", target.display());
550 }801 }
551}802}
552803
...@@ -585,7 +836,13 @@ pub fn pick_notebook(title: &str) -> Option<PathBuf> {...@@ -585,7 +836,13 @@ pub fn pick_notebook(title: &str) -> Option<PathBuf> {
585 &format!("--title={title}"),836 &format!("--title={title}"),
586 "--file-filter=OneNote notebooks and sections | *.onetoc2 *.one",837 "--file-filter=OneNote notebooks and sections | *.onetoc2 *.one",
587 ],838 ],
588 ["--getopenfilename", ".", "*.onetoc2 *.one", "--title", title],839 [
840 "--getopenfilename",
841 ".",
842 "*.onetoc2 *.one",
843 "--title",
844 title,
845 ],
589 );846 );
590 asked847 asked
591 .unwrap_or_else(|error| {848 .unwrap_or_else(|error| {
...@@ -675,4 +932,44 @@ mod tests {...@@ -675,4 +932,44 @@ mod tests {
675 assert_eq!(locale_tag("de_DE"), "de-DE");932 assert_eq!(locale_tag("de_DE"), "de-DE");
676 assert_eq!(locale_tag("C.UTF-8"), "");933 assert_eq!(locale_tag("C.UTF-8"), "");
677 }934 }
935
936 #[test]
937 fn kwin_title_bars_take_the_colour_scheme_s_header_then_its_window_manager_colours() {
938 let breeze_dark = "[Colors:Header]\nBackgroundNormal=41,44,48\nForegroundNormal=252,252,252\n\
939 [Colors:Header][Inactive]\nBackgroundNormal=32,35,38\n\
940 [Colors:Window]\nBackgroundNormal=32,35,38\n\
941 [WM]\nactiveBackground=49,54,59\ninactiveBackground=42,46,50\n";
942 assert_eq!(
943 kde_titlebar(&[breeze_dark.into()]),
944 [[41, 44, 48], [32, 35, 38]]
945 );
946 let legacy = "[General]\nColorScheme=Old\n\n[WM]\nactiveBackground=48,174,232\n\
947 inactiveBackground=#eff0f1\n";
948 assert_eq!(
949 kde_titlebar(&[legacy.into()]),
950 [[48, 174, 232], [239, 240, 241]]
951 );
952 let header_only = "[Colors:Header]\nBackgroundNormal = 10, 20, 30, 255\n";
953 assert_eq!(kde_titlebar(&[header_only.into()]), [[10, 20, 30]; 2]);
954 // The user's file, last, overrides the system's.
955 assert_eq!(
956 kde_titlebar(&[breeze_dark.into(), header_only.into()]),
957 [[10, 20, 30], [32, 35, 38]]
958 );
959 assert_eq!(
960 kde_titlebar(&["# no colours\n[KDE]\nLookAndFeelPackage=x\n".into()]),
961 [[222, 224, 226], [239, 240, 241]],
962 "Breeze Light's header by default"
963 );
964 }
965
966 #[test]
967 fn kconfig_colours_read_as_channels_or_hex() {
968 assert_eq!(kde_color("222,224,226"), Some([222, 224, 226]));
969 assert_eq!(kde_color("1, 2, 3, 128"), Some([1, 2, 3]));
970 assert_eq!(kde_color("#2a2e32"), Some([0x2a, 0x2e, 0x32]));
971 assert_eq!(kde_color("#2a2e"), None);
972 assert_eq!(kde_color("256,0,0"), None);
973 assert_eq!(kde_color("1,2"), None);
974 }
678}975}
crates/snowbound/src/macos.rs+157-86
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1use crate::commands;
1use canvas::date::DateField;2use canvas::date::DateField;
2use objc2::{3use objc2::{
3 ClassType, DeclaredClass,4 ClassType, DeclaredClass,
...@@ -14,9 +15,12 @@ use objc2_app_kit::{...@@ -14,9 +15,12 @@ use objc2_app_kit::{
14};15};
15use objc2_foundation::{16use objc2_foundation::{
16 MainThreadMarker, NSAttributedString, NSCalendar, NSCalendarUnit, NSDate, NSDateFormatter,17 MainThreadMarker, NSAttributedString, NSCalendar, NSCalendarUnit, NSDate, NSDateFormatter,
17 NSDateFormatterStyle, NSPoint, NSRange, NSRect, NSSize, NSString,18 NSDateFormatterStyle, NSPoint, NSRange, NSRect, NSString,
19};
20use std::{
21 cell::{Cell, RefCell},
22 sync::OnceLock,
18};23};
19use std::{cell::Cell, sync::OnceLock};
20use winit::{24use winit::{
21 error::EventLoopError,25 error::EventLoopError,
22 event_loop::{EventLoop, EventLoopProxy},26 event_loop::{EventLoop, EventLoopProxy},
...@@ -27,7 +31,12 @@ use winit::{...@@ -27,7 +31,12 @@ use winit::{
2731
28static QUIT: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new();32static QUIT: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new();
29static INPUT_CLASS: OnceLock<&'static AnyClass> = OnceLock::new();33static INPUT_CLASS: OnceLock<&'static AnyClass> = OnceLock::new();
30thread_local! { static IN_KEY_DOWN: Cell<bool> = const { Cell::new(false) }; }34static BACKDROP_CLASS: OnceLock<&'static AnyClass> = OnceLock::new();
35static DELEGATE_CLASS: OnceLock<&'static AnyClass> = OnceLock::new();
36thread_local! {
37 static IN_KEY_DOWN: Cell<bool> = const { Cell::new(false) };
38 static STATUSES: RefCell<Vec<commands::Status>> = const { RefCell::new(Vec::new()) };
39}
3140
32unsafe extern "C" fn key_down(view: &AnyObject, _: Sel, event: &NSEvent) {41unsafe extern "C" fn key_down(view: &AnyObject, _: Sel, event: &NSEvent) {
33 let previous = IN_KEY_DOWN.replace(true);42 let previous = IN_KEY_DOWN.replace(true);
...@@ -77,11 +86,14 @@ pub const LEADING: f32 = 78.0;...@@ -77,11 +86,14 @@ pub const LEADING: f32 = 78.0;
77/// How far AppKit rounds a window's corners.86/// How far AppKit rounds a window's corners.
78pub const CORNER_RADIUS: f32 = 10.0;87pub const CORNER_RADIUS: f32 = 10.0;
7988
89pub fn corner_radius(_: &Window) -> f32 {
90 CORNER_RADIUS
91}
92
80/// A transparent title bar over the content, where the window draws its own.93/// A transparent title bar over the content, where the window draws its own.
81pub fn window_attributes() -> WindowAttributes {94pub fn window_attributes() -> WindowAttributes {
82 Window::default_attributes()95 Window::default_attributes()
83 .with_titlebar_transparent(true)96 .with_titlebar_transparent(true)
84 .with_title_hidden(true)
85 .with_fullsize_content_view(true)97 .with_fullsize_content_view(true)
86}98}
8799
...@@ -109,6 +121,96 @@ pub fn system_titlebar(_: &Window) -> bool {...@@ -109,6 +121,96 @@ pub fn system_titlebar(_: &Window) -> bool {
109 false121 false
110}122}
111123
124unsafe extern "C" fn no_document_drag(
125 _: &AnyObject,
126 _: Sel,
127 _: &AnyObject,
128 _: &AnyObject,
129 _: NSPoint,
130 _: &AnyObject,
131) -> objc2::runtime::Bool {
132 objc2::runtime::Bool::NO
133}
134
135/// Names `file` as the window's document: the title shows its icon, and Command-clicking the
136/// title lists the folders it lies in. Dragging the icon, which could move the file out of its
137/// notebook, is off.
138pub fn represent(window: &Window, file: Option<&std::path::Path>) {
139 let window = ns_window(window);
140 unsafe {
141 let delegate: Option<Retained<AnyObject>> = msg_send_id![&window, delegate];
142 if let Some(delegate) = delegate {
143 let class = DELEGATE_CLASS.get_or_init(|| {
144 let mut class = ClassBuilder::new("SnowboundWindowDelegate", delegate.class())
145 .expect("Unique window delegate class");
146 class.add_method(
147 sel!(window:shouldDragDocumentWithEvent:from:withPasteboard:),
148 no_document_drag as unsafe extern "C" fn(_, _, _, _, _, _) -> _,
149 );
150 class.register()
151 });
152 if delegate.class() != *class {
153 assert_eq!(class.superclass(), Some(delegate.class()));
154 assert_eq!(class.instance_size(), delegate.class().instance_size());
155 // The subclass adds no ivars, so the existing allocation remains valid.
156 AnyObject::set_class(&delegate, class);
157 }
158 }
159 let url = file.map(|file| {
160 objc2_foundation::NSURL::fileURLWithPath(&NSString::from_str(&file.to_string_lossy()))
161 });
162 let _: () = msg_send![&window, setRepresentedURL: url.as_deref()];
163 }
164}
165
166/// Nothing the backdrop leaves to colour.
167pub fn titlebar(_: winit::window::Theme) -> Option<[[f32; 4]; 2]> {
168 None
169}
170
171unsafe extern "C" fn hit_nothing(_: &AnyObject, _: Sel, _: NSPoint) -> *mut AnyObject {
172 std::ptr::null_mut()
173}
174
175/// Lays the title bar's material under the window's content, where it shows through the
176/// app's transparent pixels as AppKit's title bars and toolbars show it: tinted by the
177/// desktop, and following the window's appearance and whether it is key. Presses pass
178/// through it to the view it lies in.
179pub fn install_backdrop(window: &Window) -> bool {
180 MainThreadMarker::new().expect("Views belong to the main thread");
181 let RawWindowHandle::AppKit(handle) =
182 window.window_handle().expect("Live AppKit window").as_raw()
183 else {
184 unreachable!()
185 };
186 unsafe {
187 let view = &*handle.ns_view.as_ptr().cast::<AnyObject>();
188 let class = BACKDROP_CLASS.get_or_init(|| {
189 let effect = AnyClass::get("NSVisualEffectView").expect("AppKit is linked");
190 let mut class =
191 ClassBuilder::new("SnowboundBackdrop", effect).expect("Unique backdrop class");
192 class.add_method(
193 sel!(hitTest:),
194 hit_nothing as unsafe extern "C" fn(_, _, _) -> _,
195 );
196 class.register()
197 });
198 let bounds: NSRect = msg_send![view, bounds];
199 let backdrop: Allocated<AnyObject> = msg_send_id![*class, alloc];
200 let backdrop: Retained<AnyObject> = msg_send_id![backdrop, initWithFrame: bounds];
201 // NSVisualEffectMaterialTitlebar, NSVisualEffectBlendingModeWithinWindow (which
202 // AppKit's own title bar measures as, over the window's desktop-tinted background)
203 // and NSVisualEffectStateFollowsWindowActiveState.
204 let _: () = msg_send![&backdrop, setMaterial: 3isize];
205 let _: () = msg_send![&backdrop, setBlendingMode: 1isize];
206 let _: () = msg_send![&backdrop, setState: 0isize];
207 // NSViewWidthSizable | NSViewHeightSizable
208 let _: () = msg_send![&backdrop, setAutoresizingMask: 18usize];
209 let _: () = msg_send![view, addSubview: &*backdrop];
210 }
211 true
212}
213
112/// AppKit's window frame takes resizing presses.214/// AppKit's window frame takes resizing presses.
113pub fn resize_direction(_: &Window, _: [f32; 2]) -> Option<winit::window::ResizeDirection> {215pub fn resize_direction(_: &Window, _: [f32; 2]) -> Option<winit::window::ResizeDirection> {
114 None216 None
...@@ -157,7 +259,10 @@ unsafe extern "C" {...@@ -157,7 +259,10 @@ unsafe extern "C" {
157 password: *mut *mut std::ffi::c_void,259 password: *mut *mut std::ffi::c_void,
158 item: *mut *const std::ffi::c_void,260 item: *mut *const std::ffi::c_void,
159 ) -> i32;261 ) -> i32;
160 fn SecKeychainItemFreeContent(list: *const std::ffi::c_void, data: *mut std::ffi::c_void) -> i32;262 fn SecKeychainItemFreeContent(
263 list: *const std::ffi::c_void,
264 data: *mut std::ffi::c_void,
265 ) -> i32;
161}266}
162267
163/// The password the keychain keeps for `mount`'s account on its server, as macOS saved it268/// 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 {...@@ -307,11 +412,6 @@ pub fn double_click_interval() -> std::time::Duration {
307 std::time::Duration::from_secs_f64(unsafe { NSEvent::doubleClickInterval() })412 std::time::Duration::from_secs_f64(unsafe { NSEvent::doubleClickInterval() })
308}413}
309414
310pub fn show_character_palette() {
311 let mtm = MainThreadMarker::new().expect("Text input belongs to the main thread");
312 NSApplication::sharedApplication(mtm).orderFrontCharacterPalette(None);
313}
314
315pub fn edit_date(415pub fn edit_date(
316 timestamp: u64,416 timestamp: u64,
317 field: DateField,417 field: DateField,
...@@ -502,10 +602,10 @@ pub fn configure_presentation(surface: &wgpu::Surface<'_>) {...@@ -502,10 +602,10 @@ pub fn configure_presentation(surface: &wgpu::Surface<'_>) {
502 MainThreadMarker::new().expect("Layer presentation belongs to the main thread");602 MainThreadMarker::new().expect("Layer presentation belongs to the main thread");
503 // Retain wgpu's surface ownership while synchronizing presentation with AppKit resize transactions.603 // Retain wgpu's surface ownership while synchronizing presentation with AppKit resize transactions.
504 if let Some(surface) = unsafe { surface.as_hal::<wgpu::hal::api::Metal>() } {604 if let Some(surface) = unsafe { surface.as_hal::<wgpu::hal::api::Metal>() } {
505 surface605 let layer = surface.render_layer().lock();
506 .render_layer()606 layer.setPresentsWithTransaction(true);
507 .lock()607 // Over the backdrop's layer, which AppKit orders after it.
508 .setPresentsWithTransaction(true);608 layer.setZPosition(1.0);
509 }609 }
510}610}
511611
...@@ -520,54 +620,9 @@ pub fn commit_presentation(window: &Window) {...@@ -520,54 +620,9 @@ pub fn commit_presentation(window: &Window) {
520 }620 }
521}621}
522622
523/// The application's icon as the Dock shows it, `pixels` square.623/// None: AppKit's title shows the document's icon in its place.
524pub fn app_icon(pixels: u32) -> Option<draw::RasterImage> {624pub fn app_icon(_: u32) -> Option<draw::RasterImage> {
525 let side = pixels as usize;625 None
526 let mut rgba = unsafe {
527 let mtm = MainThreadMarker::new()?;
528 let icon: Retained<AnyObject> =
529 msg_send_id![&NSApplication::sharedApplication(mtm), applicationIconImage];
530 let bitmap: Allocated<AnyObject> = msg_send_id![AnyClass::get("NSBitmapImageRep")?, alloc];
531 let planes: *mut *mut u8 = std::ptr::null_mut();
532 let bitmap: Option<Retained<AnyObject>> = msg_send_id![
533 bitmap,
534 initWithBitmapDataPlanes: planes,
535 pixelsWide: side as isize,
536 pixelsHigh: side as isize,
537 bitsPerSample: 8isize,
538 samplesPerPixel: 4isize,
539 hasAlpha: true,
540 isPlanar: false,
541 colorSpaceName: &*NSString::from_str("NSCalibratedRGBColorSpace"),
542 bytesPerRow: 4 * side as isize,
543 bitsPerPixel: 32isize
544 ];
545 let bitmap = bitmap?;
546 let contexts = AnyClass::get("NSGraphicsContext")?;
547 let context: Option<Retained<AnyObject>> =
548 msg_send_id![contexts, graphicsContextWithBitmapImageRep: &*bitmap];
549 let _: () = msg_send![contexts, saveGraphicsState];
550 let _: () = msg_send![contexts, setCurrentContext: &*context?];
551 let bounds = NSRect::new(
552 NSPoint::new(0.0, 0.0),
553 NSSize::new(side as f64, side as f64),
554 );
555 // NSCompositingOperationSourceOver
556 let _: () = msg_send![&icon, drawInRect: bounds, fromRect: NSRect::ZERO, operation: 2usize, fraction: 1.0f64];
557 let _: () = msg_send![contexts, restoreGraphicsState];
558 let data: *const u8 = msg_send![&bitmap, bitmapData];
559 std::slice::from_raw_parts(data, 4 * side * side).to_vec()
560 };
561 // AppKit's bitmap is premultiplied.
562 for pixel in rgba.chunks_exact_mut(4) {
563 let alpha = u32::from(pixel[3]);
564 for channel in &mut pixel[..3] {
565 *channel = (u32::from(*channel) * 255)
566 .checked_div(alpha)
567 .map_or(0, |value| value.min(255) as u8);
568 }
569 }
570 draw::RasterImage::new([pixels; 2], rgba).ok()
571}626}
572627
573/// The insertion point's colour and selected text's fill with and without keyboard focus,628/// The insertion point's colour and selected text's fill with and without keyboard focus,
...@@ -635,9 +690,28 @@ declare_class!(...@@ -635,9 +690,28 @@ declare_class!(
635 if let Some(proxy) = QUIT.get() { let _ = proxy.send_event(crate::UserEvent::Quit); }690 if let Some(proxy) = QUIT.get() { let _ = proxy.send_event(crate::UserEvent::Quit); }
636 }691 }
637692
638 #[method(showOptions:)]693 #[method(choose:)]
639 fn show_options(&self, _sender: Option<&AnyObject>) {694 fn choose(&self, sender: &NSMenuItem) {
640 if let Some(proxy) = QUIT.get() { let _ = proxy.send_event(crate::UserEvent::Options); }695 let tag = usize::try_from(unsafe { sender.tag() }).ok();
696 if let (Some(proxy), Some(choice)) = (QUIT.get(), tag.and_then(|tag| commands::choices().nth(tag))) {
697 let _ = proxy.send_event(crate::UserEvent::Choose(choice));
698 }
699 }
700
701 #[method(validateMenuItem:)]
702 fn validate_menu_item(&self, item: &NSMenuItem) -> objc2::runtime::Bool {
703 if unsafe { item.action() } != Some(sel!(choose:)) {
704 // AppKit's own items validate as NSApplication does.
705 return objc2::runtime::Bool::new(
706 !NSApplication::class().responds_to(sel!(validateMenuItem:))
707 || unsafe { msg_send![super(self), validateMenuItem: item] },
708 );
709 }
710 let status = usize::try_from(unsafe { item.tag() }).ok().and_then(|tag| {
711 STATUSES.with_borrow(|statuses| statuses.get(tag).copied())
712 }).unwrap_or_default();
713 unsafe { item.setState(isize::from(status.checked)) };
714 objc2::runtime::Bool::new(status.enabled)
641 }715 }
642716
643 // NSAlert consumes Escape before it reaches cancelOperation:.717 // NSAlert consumes Escape before it reaches cancelOperation:.
...@@ -665,6 +739,7 @@ pub fn event_loop(headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLo...@@ -665,6 +739,7 @@ pub fn event_loop(headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLo
665 let _: Retained<CanvasApplication> =739 let _: Retained<CanvasApplication> =
666 unsafe { msg_send_id![CanvasApplication::class(), sharedApplication] };740 unsafe { msg_send_id![CanvasApplication::class(), sharedApplication] };
667 let mut builder = EventLoop::with_user_event();741 let mut builder = EventLoop::with_user_event();
742 builder.with_default_menu(false);
668 if headless {743 if headless {
669 builder744 builder
670 .with_activation_policy(ActivationPolicy::Prohibited)745 .with_activation_policy(ActivationPolicy::Prohibited)
...@@ -676,34 +751,30 @@ pub fn event_loop(headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLo...@@ -676,34 +751,30 @@ pub fn event_loop(headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLo
676 Ok(event_loop)751 Ok(event_loop)
677}752}
678753
679/// Adds Settings… to the application menu Winit made, below About as macOS places it.754/// Replaces Winit's application menu with the menu bar, whose items the table validates.
680pub fn install_menu() {755pub fn install_menu() {
681 let mtm = MainThreadMarker::new().expect("Menus belong to the main thread");756 let mtm = MainThreadMarker::new().expect("Menus belong to the main thread");
682 let app = NSApplication::sharedApplication(mtm);757 let app = NSApplication::sharedApplication(mtm);
683 // Winit's menu is plain AppKit objects on the main thread.758 let menus = crate::menubar::build(mtm, &app);
759 app.setMainMenu(Some(&menus.bar));
760 // The bar holds these for as long as the application runs.
684 unsafe {761 unsafe {
685 let Some(menu) = app762 app.setServicesMenu(Some(&menus.services));
686 .mainMenu()763 app.setWindowsMenu(Some(&menus.window));
687 .and_then(|bar| bar.itemAtIndex(0))764 app.setHelpMenu(Some(&menus.help));
688 .and_then(|item| item.submenu())
689 else {
690 return;
691 };
692 let settings = NSMenuItem::initWithTitle_action_keyEquivalent(
693 mtm.alloc(),
694 &NSString::from_str("Settings…"),
695 Some(sel!(showOptions:)),
696 &NSString::from_str(","),
697 );
698 menu.insertItem_atIndex(&settings, 2);
699 menu.insertItem_atIndex(&NSMenuItem::separatorItem(mtm), 3);
700 }765 }
701}766}
702767
703/// Opens `folder` in Finder.768/// The table's statuses, in `commands::choices` order, which the menu bar's items show.
704pub fn reveal(folder: &std::path::Path) {769pub fn update_menu(statuses: impl FnOnce() -> Vec<commands::Status>) {
705 if let Err(error) = std::process::Command::new("open").arg(folder).spawn() {770 STATUSES.set(statuses());
706 eprintln!("Cannot open {}: {error}", folder.display());771}
772
773/// Opens `target`, a folder or a link's URL, as Finder would.
774pub fn reveal(target: impl AsRef<std::ffi::OsStr>) {
775 let target = target.as_ref();
776 if let Err(error) = std::process::Command::new("open").arg(target).spawn() {
777 eprintln!("Cannot open {}: {error}", target.display());
707 }778 }
708}779}
709780
crates/snowbound/src/main.rs+1123-435
...@@ -1,14 +1,21 @@...@@ -1,14 +1,21 @@
1mod art;1mod art;
2mod commands;
2#[cfg(test)]3#[cfg(test)]
3mod conflict_render;4mod conflict_render;
5mod history;
4mod library;6mod library;
7mod link;
5mod manage;8mod manage;
6mod meeting;9mod meeting;
10#[cfg(target_os = "macos")]
11mod menubar;
7mod menus;12mod menus;
13mod navigation;
8mod options;14mod options;
9#[cfg_attr(target_os = "linux", path = "linux.rs")]15#[cfg_attr(target_os = "linux", path = "linux.rs")]
10#[cfg_attr(target_os = "macos", path = "macos.rs")]16#[cfg_attr(target_os = "macos", path = "macos.rs")]
11mod platform;17mod platform;
18mod rename;
12mod screenshot;19mod screenshot;
13mod search;20mod search;
14mod settings;21mod settings;
...@@ -28,11 +35,11 @@ use canvas::{...@@ -28,11 +35,11 @@ use canvas::{
28 outline::TagIcon,35 outline::TagIcon,
29};36};
30use draw::Renderer;37use draw::Renderer;
38use library::Library;
31use onestore::ExGuid;39use onestore::ExGuid;
32use onestore::document::Format;40use onestore::document::Format;
33use onestore::page::Page;41use onestore::page::Page;
34use onestore::page::text::Paragraph;42use onestore::page::text::Paragraph;
35use library::Library;
36use std::{43use std::{
37 collections::{HashMap, HashSet},44 collections::{HashMap, HashSet},
38 error::Error,45 error::Error,
...@@ -43,7 +50,7 @@ use std::{...@@ -43,7 +50,7 @@ use std::{
43use ui::{Axis, Flags, Id, Spec, Theme, Ui, children, fill, fit, px};50use ui::{Axis, Flags, Id, Spec, Theme, Ui, children, fill, fit, px};
44use winit::{51use winit::{
45 application::ApplicationHandler,52 application::ApplicationHandler,
46 dpi::{LogicalSize, PhysicalPosition, PhysicalSize},53 dpi::{LogicalPosition, LogicalSize},
47 event::{ElementState, Ime, MouseButton, MouseScrollDelta, WindowEvent},54 event::{ElementState, Ime, MouseButton, MouseScrollDelta, WindowEvent},
48 event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy},55 event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy},
49 keyboard::{Key, ModifiersState, NamedKey},56 keyboard::{Key, ModifiersState, NamedKey},
...@@ -55,23 +62,37 @@ const TITLE: f32 = 28.0;...@@ -55,23 +62,37 @@ const TITLE: f32 = 28.0;
55const TAB_ROW: f32 = 28.0;62const TAB_ROW: f32 = 28.0;
56/// Width of the section colour around the page.63/// Width of the section colour around the page.
57const FRAME: f32 = 6.0;64const FRAME: f32 = 6.0;
58/// The page's corners share their centres with the window's.
59const ROUNDING: f32 = (platform::CORNER_RADIUS - FRAME).max(0.0);
60const PAGE_LIST: f32 = 240.0;65const PAGE_LIST: f32 = 240.0;
61/// Font sizes the size box offers, OneNote's list in points.66/// Font sizes the size box offers, OneNote's list in points.
62/// Fonts the font box offers first, OneNote's default leading.67/// Fonts the font box offers first, OneNote's default leading.
63const FONTS: [&str; 4] = ["Calibri", "Arial", "Times New Roman", "Courier New"];68const FONTS: [&str; 4] = ["Calibri", "Arial", "Times New Roman", "Courier New"];
64/// Fonts picked this session that the font box offers after `FONTS`, at most.69/// Picks the font box and the list galleries offer as recent, at most, as OneNote's
65const RECENT_FONTS: usize = 5;70/// galleries do.
71const RECENT: usize = 5;
72/// Height of a line in a numbering gallery's previews.
73const NUMBER_LINE: f32 = 16.0;
66/// Font sizes the size box offers, OneNote's list in points.74/// Font sizes the size box offers, OneNote's list in points.
67const SIZES: [f32; 17] = [75const SIZES: [f32; 17] = [
68 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,76 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,
69 72.0,77 72.0,
70];78];
71/// OneNote's highlight colours, COLORREF.79/// OneNote's highlight colours, COLORREF.
72const HIGHLIGHTS: &[u32] = &[80const HIGHLIGHTS: [(u32, &str); 15] = [
73 0x00ffff, 0x00ff00, 0xffff00, 0xff00ff, 0xff0000, 0x0000ff, 0x800000, 0x808000, 0x008000,81 (0x00ffff, "Yellow"),
74 0x800080, 0x000080, 0x008080, 0x808080, 0xc0c0c0, 0x000000,82 (0x00ff00, "Bright Green"),
83 (0xffff00, "Turquoise"),
84 (0xff00ff, "Pink"),
85 (0xff0000, "Blue"),
86 (0x0000ff, "Red"),
87 (0x800000, "Dark Blue"),
88 (0x808000, "Teal"),
89 (0x008000, "Green"),
90 (0x800080, "Violet"),
91 (0x000080, "Dark Red"),
92 (0x008080, "Dark Yellow"),
93 (0x808080, "Gray 50%"),
94 (0xc0c0c0, "Gray 25%"),
95 (0x000000, "Black"),
75];96];
76/// Office's theme and standard font colours, COLORREF.97/// Office's theme and standard font colours, COLORREF.
77const FONT_COLORS: [u32; 20] = [98const FONT_COLORS: [u32; 20] = [
...@@ -85,8 +106,6 @@ const ROW: f32 = 29.0;...@@ -85,8 +106,6 @@ const ROW: f32 = 29.0;
85const ROW_GAP: f32 = 3.0;106const ROW_GAP: f32 = 3.0;
86/// Space between the page and a page tab that isn't open.107/// Space between the page and a page tab that isn't open.
87const PILL_MARGIN: f32 = 3.0;108const PILL_MARGIN: f32 = 3.0;
88/// Rounding where the open page tab meets the page, concentric with its neighbours'.
89const JOIN: f32 = PILL_MARGIN + ROUNDING;
90/// Why the platform's date dialog could not change the page's date.109/// Why the platform's date dialog could not change the page's date.
91const DATE_UNCHOSEN: &str = "Choose another date or time.";110const DATE_UNCHOSEN: &str = "Choose another date or time.";
92const DATE_OUT_OF_RANGE: &str = "This date is outside the notebook's supported range.";111const DATE_OUT_OF_RANGE: &str = "This date is outside the notebook's supported range.";
...@@ -104,9 +123,12 @@ enum UserEvent {...@@ -104,9 +123,12 @@ enum UserEvent {
104 Replay(Replay),123 Replay(Replay),
105 /// A page background finished rasterizing on its worker thread.124 /// A page background finished rasterizing on its worker thread.
106 Redraw,125 Redraw,
107 /// The app menu's Settings item was chosen.126 /// A menu bar item was chosen.
108 #[cfg_attr(not(target_os = "macos"), allow(dead_code))]127 #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
109 Options,128 Choose(commands::Choice),
129 /// The desktop's colours changed, where the window system doesn't say so itself.
130 #[cfg_attr(target_os = "macos", allow(dead_code))]
131 Appearance,
110}132}
111133
112/// Asks the event loop for a frame from any thread.134/// Asks the event loop for a frame from any thread.
...@@ -166,8 +188,6 @@ struct App {...@@ -166,8 +188,6 @@ struct App {
166 proxy: EventLoopProxy<UserEvent>,188 proxy: EventLoopProxy<UserEvent>,
167 input: Option<Input>,189 input: Option<Input>,
168 substitutes: Vec<PathBuf>,190 substitutes: Vec<PathBuf>,
169 /// Font files for the interface in place of the system's.
170 interface_font: Vec<Vec<u8>>,
171 /// Where `--screenshot` writes its PNGs, drawn from a window never shown.191 /// Where `--screenshot` writes its PNGs, drawn from a window never shown.
172 screenshot: Option<PathBuf>,192 screenshot: Option<PathBuf>,
173 /// What the window starts from besides its input, until it opens.193 /// What the window starts from besides its input, until it opens.
...@@ -220,13 +240,31 @@ struct Session {...@@ -220,13 +240,31 @@ struct Session {
220 shown: Option<ExGuid>,240 shown: Option<ExGuid>,
221 /// Which of the open conflict page's conflicting changes is selected, in page order.241 /// Which of the open conflict page's conflicting changes is selected, in page order.
222 change: Option<usize>,242 change: Option<usize>,
243 /// Each page's versions, newest first.
244 history: Vec<(ExGuid, Vec<onestore::PageVersion>)>,
245 /// The page whose versions the list shows beneath it (Show Page Versions).
246 shown_history: Option<ExGuid>,
247 /// The version of page `space` open, by its context.
248 version: Option<ExGuid>,
223}249}
224250
225/// The information bar above a page with conflict pages, or above one of them.251/// The information bar above a page with conflict pages, or above one of them.
226#[derive(Clone, Copy)]252#[derive(Clone, Copy)]
227enum Bar {253enum Bar {
228 Page { page: ExGuid, shown: bool },254 Page {
229 Version { page: ExGuid, version: ExGuid },255 page: ExGuid,
256 shown: bool,
257 },
258 Version {
259 page: ExGuid,
260 version: ExGuid,
261 },
262 /// Above a page version, of a section `grouped` in a section group.
263 History {
264 page: ExGuid,
265 version: ExGuid,
266 grouped: bool,
267 },
230}268}
231269
232impl Session {270impl Session {
...@@ -261,11 +299,21 @@ impl Session {...@@ -261,11 +299,21 @@ impl Session {
261 /// The page conflict page `space` belongs to, with it.299 /// The page conflict page `space` belongs to, with it.
262 fn conflict(&self, space: ExGuid) -> Option<(ExGuid, &onestore::ConflictPage)> {300 fn conflict(&self, space: ExGuid) -> Option<(ExGuid, &onestore::ConflictPage)> {
263 self.conflicts.iter().find_map(|(page, versions)| {301 self.conflicts.iter().find_map(|(page, versions)| {
264 Some((*page, versions.iter().find(|version| version.space == space)?))302 Some((
303 *page,
304 versions.iter().find(|version| version.space == space)?,
305 ))
265 })306 })
266 }307 }
267308
268 fn bar(&self) -> Option<Bar> {309 fn bar(&self) -> Option<Bar> {
310 if let Some(version) = self.version {
311 return Some(Bar::History {
312 page: self.space,
313 version,
314 grouped: self.grouped(),
315 });
316 }
269 match self.conflict(self.space) {317 match self.conflict(self.space) {
270 Some((page, _)) => Some(Bar::Version {318 Some((page, _)) => Some(Bar::Version {
271 page,319 page,
...@@ -291,7 +339,10 @@ impl Session {...@@ -291,7 +339,10 @@ impl Session {
291339
292 /// The page in `space` as the editor shows it: a conflict page read-only, its340 /// The page in `space` as the editor shows it: a conflict page read-only, its
293 /// conflicting changes highlighted.341 /// conflicting changes highlighted.
294 fn reader(&self, space: ExGuid) -> impl FnOnce() -> Result<Page, Box<dyn Error>> + Send + 'static {342 fn reader(
343 &self,
344 space: ExGuid,
345 ) -> impl FnOnce() -> Result<Page, Box<dyn Error>> + Send + 'static {
295 let replica = Arc::clone(self.section.replica());346 let replica = Arc::clone(self.section.replica());
296 let objects = self347 let objects = self
297 .conflict(space)348 .conflict(space)
...@@ -311,14 +362,21 @@ impl Session {...@@ -311,14 +362,21 @@ impl Session {
311/// section has in it.362/// section has in it.
312enum Loaded {363enum Loaded {
313 Section(Box<Session>, Page),364 Section(Box<Session>, Page),
365 /// A section just created, whose name opens for renaming as OneNote's New Section does.
366 Created(Box<Session>, Page),
314 Page(ExGuid, Page),367 Page(ExGuid, Page),
315 Library(Arc<Library>, String),368 /// A page's version, by the page's space and the version's context.
369 Version(ExGuid, ExGuid, Page),
370 /// A notebook read again after a change, at the section that stays open, if any.
371 Library(Arc<Library>, Option<String>),
316}372}
317373
318/// What an opening page replaces: the section, or the page of the open one.374/// What an opening page replaces: the section, or the page of the open one.
319enum Shown {375enum Shown {
320 Section(Box<Session>),376 Section(Box<Session>),
377 Created(Box<Session>),
321 Page(ExGuid),378 Page(ExGuid),
379 Version(ExGuid, ExGuid),
322}380}
323381
324/// A loader thread's request number and what it read, its page laid out.382/// A loader thread's request number and what it read, its page laid out.
...@@ -326,7 +384,8 @@ type Read = (u64, Result<Laid, String>);...@@ -326,7 +384,8 @@ type Read = (u64, Result<Laid, String>);
326384
327enum Laid {385enum Laid {
328 Page(Box<Opening>),386 Page(Box<Opening>),
329 Library(Arc<Library>, String),387 /// A notebook read again after a change, at the section that stays open, if any.
388 Library(Arc<Library>, Option<String>),
330}389}
331390
332/// The newest page read, laid out and waiting for the pictures it shows first, so it391/// The newest page read, laid out and waiting for the pictures it shows first, so it
...@@ -355,24 +414,60 @@ enum Command {...@@ -355,24 +414,60 @@ enum Command {
355 Structure(Arc<Library>, manage::Structure),414 Structure(Arc<Library>, manage::Structure),
356 /// Adds a page at the end of the open section, or a subpage of a page, and edits its415 /// Adds a page at the end of the open section, or a subpage of a page, and edits its
357 /// title.416 /// title.
358 NewPage { under: Option<ExGuid> },417 NewPage {
418 under: Option<ExGuid>,
419 },
359 /// Deletes pages of the open section to the notebook's recycle bin.420 /// Deletes pages of the open section to the notebook's recycle bin.
360 DeletePages(Vec<ExGuid>),421 DeletePages(Vec<ExGuid>),
361 /// Moves or indents pages of the open section.422 /// Moves or indents pages of the open section.
362 Pages(Vec<onestore::PageEdit>),423 Pages(Vec<onestore::PageEdit>),
363 /// Moves a page of the open section to the end of another section of its folder.424 /// Moves a page of the open section to the end of another section of its folder.
364 MovePage { space: ExGuid, path: String },425 MovePage {
426 space: ExGuid,
427 path: String,
428 },
365 /// Gives the open page a template's background or colour.429 /// Gives the open page a template's background or colour.
366 Template(templates::Choice),430 Template(templates::Choice),
367 OpenPage(ExGuid),431 OpenPage(ExGuid),
368 /// Shows a page's conflict pages in the list and opens the newest, or hides them.432 /// Shows a page's conflict pages in the list and opens the newest, or hides them.
369 Versions { page: ExGuid, show: bool },433 Versions {
370 DeleteVersion { page: ExGuid, version: ExGuid },434 page: ExGuid,
435 show: bool,
436 },
437 DeleteVersion {
438 page: ExGuid,
439 version: ExGuid,
440 },
371 /// Copies a conflict page, as a page of its own, to the end of section tab `tab`.441 /// Copies a conflict page, as a page of its own, to the end of section tab `tab`.
372 CopyVersion { version: ExGuid, tab: usize },442 CopyVersion {
443 version: ExGuid,
444 tab: usize,
445 },
373 /// Selects the next, or previous, conflicting change on the open conflict page.446 /// Selects the next, or previous, conflicting change on the open conflict page.
374 SelectChange { forward: bool },447 SelectChange {
448 forward: bool,
449 },
450 /// Shows or hides a page's versions in the list.
451 History {
452 page: ExGuid,
453 show: bool,
454 },
455 OpenVersion {
456 page: ExGuid,
457 version: ExGuid,
458 },
459 RestoreVersion {
460 page: ExGuid,
461 version: ExGuid,
462 },
463 DeletePageVersion {
464 page: ExGuid,
465 version: ExGuid,
466 },
467 DeleteAllVersions(history::Scope),
375 Page(Request),468 Page(Request),
469 /// Runs a command, or one of a list's entries, after the frame's input.
470 Choose(commands::Choice),
376}471}
377472
378struct State {473struct State {
...@@ -392,6 +487,8 @@ struct State {...@@ -392,6 +487,8 @@ struct State {
392 places: HashMap<(String, ExGuid), Place>,487 places: HashMap<(String, ExGuid), Place>,
393 /// The page each section showed last this run, by `Library::key`.488 /// The page each section showed last this run, by `Library::key`.
394 last_pages: HashMap<String, ExGuid>,489 last_pages: HashMap<String, ExGuid>,
490 /// Pages visited, for Back and Forward.
491 trail: navigation::Trail,
395 /// Asks for a frame when a worker thread finishes something the page shows.492 /// Asks for a frame when a worker thread finishes something the page shows.
396 redraw: std::task::Waker,493 redraw: std::task::Waker,
397 instance: wgpu::Instance,494 instance: wgpu::Instance,
...@@ -417,10 +514,21 @@ struct State {...@@ -417,10 +514,21 @@ struct State {
417 menu: Option<(menus::Target, [f32; 2])>,514 menu: Option<(menus::Target, [f32; 2])>,
418 /// The Options dialog's choices while it is open.515 /// The Options dialog's choices while it is open.
419 options: Option<options::Options>,516 options: Option<options::Options>,
517 /// The Link dialog's fields while it is open.
518 link: Option<link::LinkDialog>,
519 /// The page's context menu while it is open: what it was opened on, and where.
520 text_menu: Option<(canvas::interaction::Context, [f32; 2])>,
420 color_scheme: settings::ColorScheme,521 color_scheme: settings::ColorScheme,
421 light_pages: bool,522 light_pages: bool,
523 /// Carries frames to the window where the system's backdrop shows through the strip.
524 translucent: Option<draw::Translucent>,
525 /// The strip's fill with the window focused and not, continuing the system's title bar.
526 titlebar: [[f32; 4]; 2],
422 /// A section or group being renamed in the sidebar.527 /// A section or group being renamed in the sidebar.
423 renaming: Option<sidebar::Renaming>,528 renaming: Option<rename::Renaming>,
529 /// The notebook shown while it has no section open, as one whose last section was
530 /// deleted.
531 sectionless: Option<Arc<Library>>,
424 /// What the pointer is dragging: a page's tab, a section tab or a sidebar row.532 /// What the pointer is dragging: a page's tab, a section tab or a sidebar row.
425 drag: Option<menus::Drag>,533 drag: Option<menus::Drag>,
426 /// Pages whose template strip was dismissed this run.534 /// Pages whose template strip was dismissed this run.
...@@ -437,6 +545,8 @@ struct State {...@@ -437,6 +545,8 @@ struct State {
437 fonts: Vec<String>,545 fonts: Vec<String>,
438 /// Fonts picked from the font box this session, latest first.546 /// Fonts picked from the font box this session, latest first.
439 recent_fonts: Vec<String>,547 recent_fonts: Vec<String>,
548 /// What the toolbar's buttons apply from their menus' last picks.
549 toolbar: settings::Toolbar,
440 /// The application's icon at the display's scale, for the title bar.550 /// The application's icon at the display's scale, for the title bar.
441 app_icon: Option<draw::RasterImage>,551 app_icon: Option<draw::RasterImage>,
442 commands: Vec<Command>,552 commands: Vec<Command>,
...@@ -486,7 +596,6 @@ impl State {...@@ -486,7 +596,6 @@ impl State {
486 proxy: EventLoopProxy<UserEvent>,596 proxy: EventLoopProxy<UserEvent>,
487 input: Input,597 input: Input,
488 substitutes: &[PathBuf],598 substitutes: &[PathBuf],
489 interface_font: Vec<Vec<u8>>,
490 visible: bool,599 visible: bool,
491 settings::Launch {600 settings::Launch {
492 file: settings,601 file: settings,
...@@ -510,10 +619,14 @@ impl State {...@@ -510,10 +619,14 @@ impl State {
510 Input::Section { title, .. } => title.clone(),619 Input::Section { title, .. } => title.clone(),
511 Input::Notebooks { .. } => "Snowbound".into(),620 Input::Notebooks { .. } => "Snowbound".into(),
512 })621 })
513 .with_inner_size(LogicalSize::new(1180.0, 760.0)),622 .with_inner_size(LogicalSize::new(1180.0, 760.0))
623 // The page list and a page beside it, under the toolbar's text group.
624 .with_min_inner_size(LogicalSize::new(600.0, 400.0)),
514 )?,625 )?,
515 );626 );
516 platform::install_text_input(&window);627 platform::install_text_input(&window);
628 // Beneath the surface's layer, which is added over it.
629 let backdrop = visible && platform::install_backdrop(&window);
517 platform::install_menu();630 platform::install_menu();
518 let access_adapter =631 let access_adapter =
519 accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy.clone());632 accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy.clone());
...@@ -531,9 +644,17 @@ impl State {...@@ -531,9 +644,17 @@ impl State {
531 .await?;644 .await?;
532 let (device, queue) = adapter.request_device(&Default::default()).await?;645 let (device, queue) = adapter.request_device(&Default::default()).await?;
533 let size = window.inner_size();646 let size = window.inner_size();
534 let config = surface647 let mut config = surface
535 .get_default_config(&adapter, size.width, size.height)648 .get_default_config(&adapter, size.width, size.height)
536 .ok_or("No supported canvas surface")?;649 .ok_or("No supported canvas surface")?;
650 let translucent = backdrop.then(|| {
651 // What wgpu calls post-multiplied is Core Animation's non-opaque layer, which
652 // composites colour premultiplied in sRGB, as `Translucent` leaves it.
653 config.alpha_mode = wgpu::CompositeAlphaMode::PostMultiplied;
654 let window_format = config.format.remove_srgb_suffix();
655 config.view_formats.push(window_format);
656 draw::Translucent::new(&device, config.format, window_format)
657 });
537 surface.configure(&device, &config);658 surface.configure(&device, &config);
538 let renderer = Renderer::new(device, queue, config.format);659 let renderer = Renderer::new(device, queue, config.format);
539 let mut engine = TextEngine::default();660 let mut engine = TextEngine::default();
...@@ -546,6 +667,7 @@ impl State {...@@ -546,6 +667,7 @@ impl State {
546 let temporary = matches!(input, Input::Notes { .. } | Input::Page(_));667 let temporary = matches!(input, Input::Notes { .. } | Input::Page(_));
547 let mut notebooks = Vec::new();668 let mut notebooks = Vec::new();
548 let mut session = None;669 let mut session = None;
670 let mut sectionless = None;
549 let (editor, scene) = match input {671 let (editor, scene) = match input {
550 Input::Notes {672 Input::Notes {
551 document,673 document,
...@@ -605,6 +727,11 @@ impl State {...@@ -605,6 +727,11 @@ impl State {
605 (editor, Some((scene, [0.0; 2])))727 (editor, Some((scene, [0.0; 2])))
606 }728 }
607 None => {729 None => {
730 sectionless = notebooks
731 .iter()
732 .find(|library| Some(&library.location) == current.as_ref())
733 .or(notebooks.first())
734 .cloned();
608 let blank = TextDocument::new(vec![Paragraph::new(735 let blank = TextDocument::new(vec![Paragraph::new(
609 String::new(),736 String::new(),
610 Format::default(),737 Format::default(),
...@@ -629,22 +756,17 @@ impl State {...@@ -629,22 +756,17 @@ impl State {
629 window.request_redraw();756 window.request_redraw();
630 eprintln!("Canvas GPU: {:?}; scale factor {dpr}", adapter.get_info());757 eprintln!("Canvas GPU: {:?}; scale factor {dpr}", adapter.get_info());
631 window.set_theme(stored.color_scheme.theme());758 window.set_theme(stored.color_scheme.theme());
759 let appearance = stored
760 .color_scheme
761 .theme()
762 .unwrap_or_else(|| platform::appearance(&window));
632 let mut ui = Ui::new(763 let mut ui = Ui::new(
633 theme(764 theme(appearance, stored.light_pages, backdrop),
634 stored
635 .color_scheme
636 .theme()
637 .unwrap_or_else(|| platform::appearance(&window)),
638 stored.light_pages,
639 ),
640 platform::double_click_interval(),765 platform::double_click_interval(),
641 );766 );
642 if !interface_font.is_empty() {767 let titlebar = platform::titlebar(appearance).unwrap_or([ui.theme.strip; 2]);
643 let family = ui768 // A window shown but never focused hears no focus event; a hidden one draws as focused.
644 .use_fonts(interface_font)769 ui.window_focused = !visible || window.has_focus();
645 .ok_or("The interface font files hold no font")?;
646 eprintln!("Using {family} for the interface");
647 }
648 ui.set_focus(Some(page()));770 ui.set_focus(Some(page()));
649 for family in FONTS {771 for family in FONTS {
650 for (face, _) in engine.substitute(family).map_or(&[][..], |s| &s.faces) {772 for (face, _) in engine.substitute(family).map_or(&[][..], |s| &s.faces) {
...@@ -692,10 +814,15 @@ impl State {...@@ -692,10 +814,15 @@ impl State {
692 dismissed: HashSet::new(),814 dismissed: HashSet::new(),
693 title_focus: None,815 title_focus: None,
694 menu: None,816 menu: None,
817 link: None,
818 text_menu: None,
695 options: None,819 options: None,
696 color_scheme: stored.color_scheme,820 color_scheme: stored.color_scheme,
697 light_pages: stored.light_pages,821 light_pages: stored.light_pages,
822 translucent,
823 titlebar,
698 renaming: None,824 renaming: None,
825 sectionless,
699 drag: None,826 drag: None,
700 templates: templates::View::Strip,827 templates: templates::View::Strip,
701 thumbnails: templates::Thumbnails::default(),828 thumbnails: templates::Thumbnails::default(),
...@@ -703,6 +830,7 @@ impl State {...@@ -703,6 +830,7 @@ impl State {
703 pages_open: true,830 pages_open: true,
704 fonts,831 fonts,
705 recent_fonts: stored.recent_fonts,832 recent_fonts: stored.recent_fonts,
833 toolbar: stored.toolbar,
706 app_icon: platform::app_icon((16.0 * dpr).round() as u32),834 app_icon: platform::app_icon((16.0 * dpr).round() as u32),
707 commands: Vec::new(),835 commands: Vec::new(),
708 changed: false,836 changed: false,
...@@ -725,6 +853,7 @@ impl State {...@@ -725,6 +853,7 @@ impl State {
725 opening: None,853 opening: None,
726 places: HashMap::new(),854 places: HashMap::new(),
727 last_pages: HashMap::new(),855 last_pages: HashMap::new(),
856 trail: navigation::Trail::default(),
728 access_adapter,857 access_adapter,
729 accessibility: accessibility::Accessibility::default(),858 accessibility: accessibility::Accessibility::default(),
730 };859 };
...@@ -741,9 +870,14 @@ impl State {...@@ -741,9 +870,14 @@ impl State {
741 }870 }
742 lap("open", start);871 lap("open", start);
743 let size = self.window.inner_size();872 let size = self.window.inner_size();
744 let scale = self.window.scale_factor() as f32;873 let scale = self.scale();
874 if scale != self.ui.scale() {
875 self.renderer.clear_glyph_cache();
876 let response = self.view.scale_factor_changed(scale)?;
877 self.respond(response);
878 }
745 self.layout(879 self.layout(
746 [size.width as f32 / scale, size.height as f32 / scale],880 [size.width, size.height].map(|side| side as f32 / self.window.scale_factor() as f32),
747 scale,881 scale,
748 )?;882 )?;
749 lap("build", start);883 lap("build", start);
...@@ -793,6 +927,8 @@ impl State {...@@ -793,6 +927,8 @@ impl State {
793 self.ui.begin(size, scale, Instant::now());927 self.ui.begin(size, scale, Instant::now());
794 let (section, open_tab, open_page) = self.build()?;928 let (section, open_tab, open_page) = self.build()?;
795 self.options_dialog();929 self.options_dialog();
930 self.link_dialog()?;
931 self.text_menu()?;
796 self.ui.end();932 self.ui.end();
797 self.edges(section, open_tab, open_page);933 self.edges(section, open_tab, open_page);
798 if let Some(rect) = self.ui.rect(page()) {934 if let Some(rect) = self.ui.rect(page()) {
...@@ -808,6 +944,22 @@ impl State {...@@ -808,6 +944,22 @@ impl State {
808 Ok(())944 Ok(())
809 }945 }
810946
947 /// Takes `appearance`'s colours.
948 fn set_appearance(&mut self, appearance: winit::window::Theme) {
949 self.ui.theme = theme(appearance, self.light_pages, self.translucent.is_some());
950 self.titlebar = platform::titlebar(appearance).unwrap_or([self.ui.theme.strip; 2]);
951 }
952
953 /// Takes the colour scheme chosen, or the system's where it follows the system.
954 fn follow_color_scheme(&mut self) {
955 self.window.set_theme(self.color_scheme.theme());
956 let appearance = self
957 .color_scheme
958 .theme()
959 .unwrap_or_else(|| platform::appearance(&self.window));
960 self.set_appearance(appearance);
961 }
962
811 /// Builds the frame's boxes and returns the section's colours as they ease, the open963 /// Builds the frame's boxes and returns the section's colours as they ease, the open
812 /// section tab and the open page's tab, for the edges drawn once they are laid out.964 /// section tab and the open page's tab, for the edges drawn once they are laid out.
813 fn build(&mut self) -> Result<(ui::Section, Id, Option<Id>), Box<dyn Error>> {965 fn build(&mut self) -> Result<(ui::Section, Id, Option<Id>), Box<dyn Error>> {
...@@ -816,6 +968,11 @@ impl State {...@@ -816,6 +968,11 @@ impl State {
816 .as_ref()968 .as_ref()
817 .and_then(|session| session.tabs[session.tab].color),969 .and_then(|session| session.tabs[session.tab].color),
818 ));970 ));
971 self.ui.icon_palette = self.ui.theme.icon_palette(
972 target.accent,
973 self.toolbar.highlight.map_or([1.0; 4], colorref),
974 );
975 let strip = self.titlebar[usize::from(!self.ui.window_focused)];
819 let mut ease = |part: &str, color: [f32; 4]| -> [f32; 4] {976 let mut ease = |part: &str, color: [f32; 4]| -> [f32; 4] {
820 std::array::from_fn(|channel| {977 std::array::from_fn(|channel| {
821 self.ui978 self.ui
...@@ -831,6 +988,7 @@ impl State {...@@ -831,6 +988,7 @@ impl State {
831 edge: ease("edge", target.edge),988 edge: ease("edge", target.edge),
832 accent: ease("accent", target.accent),989 accent: ease("accent", target.accent),
833 };990 };
991 self.ui.theme.strip = ease("strip", strip);
834 self.ui.theme.accent = section.accent;992 self.ui.theme.accent = section.accent;
835 // Elsewhere the theme holds the platform's colours.993 // Elsewhere the theme holds the platform's colours.
836 #[cfg(target_os = "macos")]994 #[cfg(target_os = "macos")]
...@@ -847,7 +1005,8 @@ impl State {...@@ -847,7 +1005,8 @@ impl State {
847 if !platform::system_titlebar(&self.window) {1005 if !platform::system_titlebar(&self.window) {
848 self.title_bar(&theme);1006 self.title_bar(&theme);
849 }1007 }
850 if self.session.is_none() && !self.temporary {1008 platform::update_menu(|| self.statuses());
1009 if self.session.is_none() && !self.temporary && self.sectionless.is_none() {
851 self.welcome(&theme);1010 self.welcome(&theme);
852 return Ok((section, Id::ROOT, None));1011 return Ok((section, Id::ROOT, None));
853 }1012 }
...@@ -859,7 +1018,7 @@ impl State {...@@ -859,7 +1018,7 @@ impl State {
859 ..Spec::default()1018 ..Spec::default()
860 },1019 },
861 );1020 );
862 self.sidebar(&theme);1021 let sidebar = self.sidebar(&theme);
863 self.ui.open(1022 self.ui.open(
864 "main",1023 "main",
865 Spec {1024 Spec {
...@@ -877,26 +1036,40 @@ impl State {...@@ -877,26 +1036,40 @@ impl State {
877 ..Spec::default()1036 ..Spec::default()
878 },1037 },
879 );1038 );
1039 self.sidebar_button(&theme, sidebar);
880 self.ui.leaf(1040 self.ui.leaf(
881 "corner",1041 "corner",
882 Spec {1042 Spec {
883 size: [px(ROUNDING), px(1.0)],1043 size: [px(self.rounding()), px(1.0)],
884 ..Spec::default()1044 ..Spec::default()
885 },1045 },
886 );1046 );
887 let row = self.ui.id("sections");1047 let row = self.ui.id("sections");
888 let (clicked, open_tab) = match &self.session {1048 let (clicked, open_tab) = match &self.session {
889 Some(session) => {1049 Some(session) => {
1050 // A tab being renamed takes the name typed, which its field covers.
890 let tabs: Vec<_> = session1051 let tabs: Vec<_> = session
891 .tabs1052 .tabs
892 .iter()1053 .iter()
893 .map(|tab| (tab.name.as_str(), section_color(tab.color)))1054 .map(|tab| {
1055 let name = match &self.renaming {
1056 Some(renaming) if renaming.entry(&session.library, &tab.path, true) => {
1057 renaming.name.as_str()
1058 }
1059 _ => tab.name.as_str(),
1060 };
1061 (name, section_color(tab.color))
1062 })
894 .collect();1063 .collect();
895 let lit = self.page_drop(row);1064 let lit = self.page_drop(row);
1065 let dragged = self.dragged_tab(row);
896 let ui::shell::Tabs {1066 let ui::shell::Tabs {
897 clicked,1067 clicked,
898 context,1068 context,
899 held,1069 held,
1070 renamed,
1071 slot,
1072 settled,
900 open: open_tab,1073 open: open_tab,
901 } = ui::shell::section_tabs(1074 } = ui::shell::section_tabs(
902 &mut self.ui,1075 &mut self.ui,
...@@ -904,16 +1077,19 @@ impl State {...@@ -904,16 +1077,19 @@ impl State {
904 &tabs,1077 &tabs,
905 session.tab,1078 session.tab,
906 lit,1079 lit,
1080 dragged,
907 &section,1081 &section,
908 TAB_ROW,1082 TAB_ROW,
909 );1083 );
910 let bar = self.ui.rect(tab_row).unwrap_or_default();1084 self.drag_tabs(held, slot, settled, row);
911 self.drag_tabs(held, row, bar, section.accent);
912 let Some(session) = &self.session else {1085 let Some(session) = &self.session else {
913 unreachable!("The tabs are the session's")1086 unreachable!("The tabs are the session's")
914 };1087 };
915 let open = |tab: usize| {1088 let open = |tab: usize| {
916 Command::OpenSection(Arc::clone(&session.library), session.tabs[tab].path.clone())1089 Command::OpenSection(
1090 Arc::clone(&session.library),
1091 session.tabs[tab].path.clone(),
1092 )
917 };1093 };
918 if let Some((tab, point)) = context {1094 if let Some((tab, point)) = context {
919 self.menu = Some((1095 self.menu = Some((
...@@ -925,7 +1101,18 @@ impl State {...@@ -925,7 +1101,18 @@ impl State {
925 ));1101 ));
926 self.ui.open_popup(menus::id());1102 self.ui.open_popup(menus::id());
927 }1103 }
928 (clicked.map(open), open_tab)1104 // Letting go of a dragged tab isn't a click on it.
1105 let clicked = clicked.filter(|_| !self.dragged()).map(open);
1106 if let Some(tab) = renamed {
1107 let target = rename::Target::Entry {
1108 library: Arc::clone(&session.library),
1109 path: session.tabs[tab].path.clone(),
1110 in_tab: true,
1111 };
1112 self.rename(target);
1113 }
1114 self.tab_rename_field(&theme, row, tab_row);
1115 (clicked, open_tab)
929 }1116 }
930 None => {1117 None => {
931 let tabs = [("Temporary page", section_color(None))];1118 let tabs = [("Temporary page", section_color(None))];
...@@ -936,6 +1123,7 @@ impl State {...@@ -936,6 +1123,7 @@ impl State {
936 shown,1123 shown,
937 0,1124 0,
938 None,1125 None,
1126 None,
939 &section,1127 &section,
940 TAB_ROW,1128 TAB_ROW,
941 )1129 )
...@@ -971,11 +1159,7 @@ impl State {...@@ -971,11 +1159,7 @@ impl State {
971 && let Some(bar) = session.bar()1159 && let Some(bar) = session.bar()
972 {1160 {
973 let changes = self.changes().len();1161 let changes = self.changes().len();
974 let sections: Vec<&str> = session1162 let sections: Vec<&str> = session.tabs.iter().map(|tab| tab.name.as_str()).collect();
975 .tabs
976 .iter()
977 .map(|tab| tab.name.as_str())
978 .collect();
979 let steps = [1163 let steps = [
980 session.change.is_some_and(|at| at > 0),1164 session.change.is_some_and(|at| at > 0),
981 session.change.map_or(changes > 0, |at| at + 1 < changes),1165 session.change.map_or(changes > 0, |at| at + 1 < changes),
...@@ -983,6 +1167,15 @@ impl State {...@@ -983,6 +1167,15 @@ impl State {
983 self.commands1167 self.commands
984 .extend(conflict_bar(&mut self.ui, bar, &sections, steps));1168 .extend(conflict_bar(&mut self.ui, bar, &sections, steps));
985 }1169 }
1170 if let Some(library) = self.sectionless.clone().filter(|_| self.session.is_none()) {
1171 self.no_sections(&theme, library);
1172 self.ui.close();
1173 self.ui.close();
1174 self.ui.close();
1175 self.ui.close();
1176 self.context_menu();
1177 return Ok((section, open_tab, None));
1178 }
986 self.ui.open_as(1179 self.ui.open_as(
987 page(),1180 page(),
988 Spec {1181 Spec {
...@@ -1023,12 +1216,14 @@ impl State {...@@ -1023,12 +1216,14 @@ impl State {
1023 self.ui.close();1216 self.ui.close();
1024 self.ui.close();1217 self.ui.close();
1025 self.context_menu();1218 self.context_menu();
1026 if self.renaming.is_some() && self.ui.focused() != Some(sidebar::rename_field()) {
1027 self.ui.set_focus(Some(sidebar::rename_field()));
1028 }
1029 Ok((section, open_tab, open_page))1219 Ok((section, open_tab, open_page))
1030 }1220 }
10311221
1222 /// The page's corner radius, sharing its centres with the window's corners.
1223 fn rounding(&self) -> f32 {
1224 (platform::corner_radius(&self.window) - FRAME).max(0.0)
1225 }
1226
1032 /// Borders the open section tab and the frame's top, and rounds and borders the page1227 /// Borders the open section tab and the frame's top, and rounds and borders the page
1033 /// together with the open page's tab, joined where they meet.1228 /// together with the open page's tab, joined where they meet.
1034 fn edges(&mut self, section: ui::Section, open_tab: Id, open_page: Option<Id>) {1229 fn edges(&mut self, section: ui::Section, open_tab: Id, open_page: Option<Id>) {
...@@ -1036,6 +1231,9 @@ impl State {...@@ -1036,6 +1231,9 @@ impl State {
1036 let (Some(frame), Some(page)) = (self.ui.rect(frame()), self.ui.rect(page())) else {1231 let (Some(frame), Some(page)) = (self.ui.rect(frame()), self.ui.rect(page())) else {
1037 return;1232 return;
1038 };1233 };
1234 let rounding = self.rounding();
1235 // Where the open page tab meets the page, concentric with its neighbours.
1236 let join = PILL_MARGIN + rounding;
1039 let [left, top, right, bottom] = page;1237 let [left, top, right, bottom] = page;
1040 let tab = open_page1238 let tab = open_page
1041 .and_then(|id| self.ui.rect(id))1239 .and_then(|id| self.ui.rect(id))
...@@ -1047,14 +1245,14 @@ impl State {...@@ -1047,14 +1245,14 @@ impl State {
1047 row[3].min(panel[3]).min(bottom),1245 row[3].min(panel[3]).min(bottom),
1048 ];1246 ];
1049 let reach = row[2].min(panel[2]);1247 let reach = row[2].min(panel[2]);
1050 (reach - right > 2.0 * JOIN && end - start > 2.0 * ROUNDING).then(|| {1248 (reach - right > 2.0 * join && end - start > 2.0 * rounding).then(|| {
1051 // A tab too near the page's corner joins it along the edge.1249 // A tab too near the page's corner joins it along the edge.
1052 let start = if start < top + ROUNDING + JOIN {1250 let start = if start < top + rounding + join {
1053 top1251 top
1054 } else {1252 } else {
1055 start1253 start
1056 };1254 };
1057 let end = if end > bottom - ROUNDING - JOIN {1255 let end = if end > bottom - rounding - join {
1058 bottom1256 bottom
1059 } else {1257 } else {
1060 end1258 end
...@@ -1062,59 +1260,75 @@ impl State {...@@ -1062,59 +1260,75 @@ impl State {
1062 [start, end, reach]1260 [start, end, reach]
1063 })1261 })
1064 });1262 });
1065 let mut outline = vec![([left, top], ROUNDING)];1263 let mut outline = vec![([left, top], rounding)];
1066 match tab {1264 match tab {
1067 Some([start, end, reach]) => {1265 Some([start, end, reach]) => {
1068 if start > top {1266 if start > top {
1069 outline.extend([([right, top], ROUNDING), ([right, start], JOIN)]);1267 outline.extend([([right, top], rounding), ([right, start], join)]);
1070 }1268 }
1071 outline.extend([([reach, start], ROUNDING), ([reach, end], ROUNDING)]);1269 outline.extend([([reach, start], rounding), ([reach, end], rounding)]);
1072 if end < bottom {1270 if end < bottom {
1073 outline.extend([([right, end], JOIN), ([right, bottom], ROUNDING)]);1271 outline.extend([([right, end], join), ([right, bottom], rounding)]);
1074 }1272 }
1075 }1273 }
1076 None => outline.extend([([right, top], ROUNDING), ([right, bottom], ROUNDING)]),1274 None => outline.extend([([right, top], rounding), ([right, bottom], rounding)]),
1077 }1275 }
1078 outline.push(([left, bottom], ROUNDING));1276 outline.push(([left, bottom], rounding));
1079 let height = (frame[3] - frame[1]).max(1.0);1277 let height = (frame[3] - frame[1]).max(1.0);
1080 let paper = self.paper().color;1278 let paper = self.paper().color;
1081 self.ui.round_corners(&outline, paper, |y| {1279 self.ui.round_corners(&outline, paper, |y| {
1082 ui::mix(section.frame[0], section.frame[1], (y - frame[1]) / height)1280 ui::mix(section.frame[0], section.frame[1], (y - frame[1]) / height)
1083 });1281 });
1084 self.ui.border(&outline, true, section.edge);1282 self.ui.border(&outline, true, section.edge);
1085 // The frame's top corners share the page's centres; its top edge breaks where the1283 // The frame's top corners share the page's centres, and beside the sidebar so does
1086 // open tab stands on it.1284 // its bottom one, rounding in as the sidebar opens; the frame's top edge breaks where
1285 // the open tab stands on it.
1087 let [start, end] = [frame[0], frame[2]];1286 let [start, end] = [frame[0], frame[2]];
1088 let outer = platform::CORNER_RADIUS;1287 let outer = platform::corner_radius(&self.window);
1288 let beside = outer * start / sidebar::WIDTH;
1089 let strip = self.ui.theme.strip;1289 let strip = self.ui.theme.strip;
1090 self.ui.round_corners(1290 // The border runs half its width inside the frame, on whole device pixels at any
1091 &[1291 // scale, its corners concentric with the fill's. The fill is cut at the border's
1092 ([start, frame[1]], outer),1292 // outer edge, then again at its middle, so none shows through its antialiased rim.
1093 ([end, frame[1]], outer),1293 let [west, north, east] = [start + 0.5, frame[1] + 0.5, end - 0.5];
1094 ([end, frame[3]], 0.0),1294 let inner = |radius: f32| (radius - 0.5).max(0.0);
1095 ([start, frame[3]], 0.0),1295 for [left, top, right, cut] in [[start, frame[1], end, 0.0], [west, north, east, 0.5]] {
1096 ],1296 self.ui.round_corners(
1097 paper,1297 &[
1098 |_| strip,1298 ([left, top], (outer - cut).max(0.0)),
1099 );1299 ([right, top], (outer - cut).max(0.0)),
1300 ([right, frame[3]], 0.0),
1301 ([left, frame[3]], (beside - cut).max(0.0)),
1302 ],
1303 paper,
1304 |_| strip,
1305 );
1306 }
1100 let [foot, toe] = self1307 let [foot, toe] = self
1101 .ui1308 .ui
1102 .rect(open_tab)1309 .rect(open_tab)
1103 .map_or([end; 2], |tab| ui::shell::tab_base(tab, TAB_ROW));1310 .map_or([end; 2], |tab| ui::shell::tab_base(tab, TAB_ROW));
1104 for edge in [1311 // The border runs down both sides to the window's bottom, round the bottom corner
1105 [1312 // beside the sidebar.
1106 ([start, frame[1] + outer], 0.0),1313 self.ui.border(
1107 ([start, frame[1]], outer),1314 &[
1108 ([foot, frame[1]], 0.0),1315 ([west + beside, frame[3]], 0.0),
1316 ([west, frame[3]], inner(beside)),
1317 ([west, north], inner(outer)),
1318 ([foot, north], 0.0),
1109 ],1319 ],
1110 [1320 false,
1111 ([toe, frame[1]], 0.0),1321 section.edge,
1112 ([end, frame[1]], outer),1322 );
1113 ([end, frame[1] + outer], 0.0),1323 self.ui.border(
1324 &[
1325 ([toe, north], 0.0),
1326 ([east, north], inner(outer)),
1327 ([east, frame[3]], 0.0),
1114 ],1328 ],
1115 ] {1329 false,
1116 self.ui.border(&edge, false, section.edge);1330 section.edge,
1117 }1331 );
1118 }1332 }
11191333
1120 /// The window's title bar beside the traffic lights: the application's icon, the1334 /// The window's title bar beside the traffic lights: the application's icon, the
...@@ -1136,19 +1350,22 @@ impl State {...@@ -1136,19 +1350,22 @@ impl State {
1136 ..Spec::default()1350 ..Spec::default()
1137 },1351 },
1138 );1352 );
1139 self.ui.leaf(1353 // AppKit centres the window's title over the strip.
1140 "notebook",1354 if !cfg!(target_os = "macos") {
1141 Spec {1355 self.ui.leaf(
1142 size: [fit(), px(TITLE)],1356 "notebook",
1143 image: self.app_icon.as_ref(),1357 Spec {
1144 text: Some(match &self.session {1358 size: [fit(), px(TITLE)],
1145 Some(session) => &session.library.name,1359 image: self.app_icon.as_ref(),
1146 None if self.temporary => "Temporary page",1360 text: Some(match &self.session {
1147 None => "Snowbound",1361 Some(session) => &session.library.name,
1148 }),1362 None if self.temporary => "Temporary page",
1149 ..Spec::default()1363 None => "Snowbound",
1150 },1364 }),
1151 );1365 ..Spec::default()
1366 },
1367 );
1368 }
1152 self.ui.leaf(1369 self.ui.leaf(
1153 "space",1370 "space",
1154 Spec {1371 Spec {
...@@ -1175,23 +1392,15 @@ impl State {...@@ -1175,23 +1392,15 @@ impl State {
1175 /// Two rows of formatting, tag, insert and zoom buttons, grouped as OneNote's Home1392 /// Two rows of formatting, tag, insert and zoom buttons, grouped as OneNote's Home
1176 /// ribbon groups them.1393 /// ribbon groups them.
1177 fn toolbar(&mut self, theme: &Theme) -> Result<(), Box<dyn Error>> {1394 fn toolbar(&mut self, theme: &Theme) -> Result<(), Box<dyn Error>> {
1178 use canvas::editor::{Alignment, FormatState, Formatting, NoteTag, Toggle};1395 use canvas::editor::{
1396 Alignment, BULLET_LIBRARY, ListStyle, NUMBER_LIBRARY, NoteTag, Toggle,
1397 };
1398 use commands::{Choice, Id as Cmd, shortcut};
1179 // A focused picture has no text to show a format for.1399 // A focused picture has no text to show a format for.
1180 let state = self1400 let state = self.format_state();
1181 .view
1182 .editor
1183 .format_state()
1184 .unwrap_or_else(|_| FormatState {
1185 toggles: Vec::new(),
1186 font: None,
1187 font_size: None,
1188 alignment: None,
1189 bullets: false,
1190 numbering: false,
1191 tags: Vec::new(),
1192 });
1193 let fonts = &self.fonts;1401 let fonts = &self.fonts;
1194 let recent_fonts = &self.recent_fonts;1402 let recent_fonts = &self.recent_fonts;
1403 let pens = &self.toolbar;
1195 let engine = &self.view.engine;1404 let engine = &self.view.engine;
1196 // Under the system's title bar the save status joins the toolbar.1405 // Under the system's title bar the save status joins the toolbar.
1197 let status = platform::system_titlebar(&self.window)1406 let status = platform::system_titlebar(&self.window)
...@@ -1200,8 +1409,7 @@ impl State {...@@ -1200,8 +1409,7 @@ impl State {
1200 let ui = &mut self.ui;1409 let ui = &mut self.ui;
1201 let text = theme.text;1410 let text = theme.text;
1202 let on = |toggle| state.toggles.contains(&toggle);1411 let on = |toggle| state.toggles.contains(&toggle);
1203 let mut command = None;1412 let mut choice = None;
1204 let mut history = None;
1205 ui.open(1413 ui.open(
1206 "toolbar",1414 "toolbar",
1207 Spec {1415 Spec {
...@@ -1214,10 +1422,10 @@ impl State {...@@ -1214,10 +1422,10 @@ impl State {
1214 );1422 );
1215 group(ui, "history", |ui| {1423 group(ui, "history", |ui| {
1216 if ui::shell::tool_button(ui, "undo", art::UNDO, text, false).clicked {1424 if ui::shell::tool_button(ui, "undo", art::UNDO, text, false).clicked {
1217 history = Some(true);1425 choice = Some(Choice::Command(Cmd::Undo));
1218 }1426 }
1219 if ui::shell::tool_button(ui, "redo", art::REDO, text, false).clicked {1427 if ui::shell::tool_button(ui, "redo", art::REDO, text, false).clicked {
1220 history = Some(false);1428 choice = Some(Choice::Command(Cmd::Redo));
1221 }1429 }
1222 });1430 });
1223 divider(ui, "history", theme);1431 divider(ui, "history", theme);
...@@ -1272,7 +1480,7 @@ impl State {...@@ -1272,7 +1480,7 @@ impl State {
1272 ui::popup::menu(ui, popup("font"), anchor, &items, Some("Font"))1480 ui::popup::menu(ui, popup("font"), anchor, &items, Some("Font"))
1273 && let Some(name) = choices[index].11481 && let Some(name) = choices[index].1
1274 {1482 {
1275 command = Some(Formatting::Font(name.to_owned()));1483 choice = Some(Choice::Font(name.to_owned()));
1276 }1484 }
1277 let combo = ui.id("size");1485 let combo = ui.id("size");
1278 if ui::shell::combo(ui, "size", &size, 44.0).pressed {1486 if ui::shell::combo(ui, "size", &size, 44.0).pressed {
...@@ -1300,20 +1508,113 @@ impl State {...@@ -1300,20 +1508,113 @@ impl State {
1300 let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default());1508 let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default());
1301 if let Some(index) = ui::popup::menu(ui, popup("size"), anchor, &items, Some(&size))1509 if let Some(index) = ui::popup::menu(ui, popup("size"), anchor, &items, Some(&size))
1302 {1510 {
1303 command = Some(Formatting::FontSize(sizes[index]));1511 choice = Some(Choice::Size(sizes[index]));
1304 }1512 }
1305 let bullets =1513 // The buttons apply OneNote's default bullet and number, as its own do.
1306 ui::shell::split_button(ui, "bullets", art::BULLETS, None, state.bullets);1514 let [bullets, numbering] = [ui.id("bullets"), ui.id("numbering")];
1307 let numbering =1515 if ui::shell::split_button(
1308 ui::shell::split_button(ui, "numbering", art::NUMBERING, None, state.numbering);1516 ui,
1309 if bullets.iter().any(|signal| signal.clicked) {1517 "bullets",
1310 command = Some(Formatting::Bullets);1518 art::BULLETS,
1519 None,
1520 state.bullets,
1521 popup("bullets"),
1522 )
1523 .clicked
1524 {
1525 choice = Some(Choice::Command(Cmd::Bullets));
1526 }
1527 if ui::shell::split_button(
1528 ui,
1529 "numbering",
1530 art::NUMBERING,
1531 None,
1532 state.numbering,
1533 popup("numbering"),
1534 )
1535 .clicked
1536 {
1537 choice = Some(Choice::Command(Cmd::Numbering));
1538 }
1539 let current = |bullet| match state.list {
1540 Some(ListStyle::Bullet(place)) if bullet => Some(place),
1541 Some(ListStyle::Number(place)) if !bullet => Some(place),
1542 _ => None,
1543 };
1544 let anchor = ui::Anchor::Below(ui.rect(bullets).unwrap_or_default());
1545 if let Some(place) = list_gallery(
1546 ui,
1547 popup("bullets"),
1548 anchor,
1549 ["Recently Used Bullets", "Bullet Library"],
1550 (&pens.bullets, BULLET_LIBRARY.len()),
1551 current(true),
1552 [36.0, 36.0],
1553 |ui, place| {
1554 let (font, glyph, _) = BULLET_LIBRARY[place];
1555 ui.leaf(
1556 "glyph",
1557 Spec {
1558 size: [fill(), fill()],
1559 text: Some(&canvas::outline::symbol_text(font, glyph)),
1560 font: Some(font),
1561 font_size: Some(18.0),
1562 center: true,
1563 ..Spec::default()
1564 },
1565 );
1566 },
1567 ) {
1568 choice = Some(Choice::List(place.map(ListStyle::Bullet)));
1311 }1569 }
1312 if numbering.iter().any(|signal| signal.clicked) {1570 let anchor = ui::Anchor::Below(ui.rect(numbering).unwrap_or_default());
1313 command = Some(Formatting::Numbering);1571 if let Some(place) = list_gallery(
1572 ui,
1573 popup("numbering"),
1574 anchor,
1575 ["Recently Used Number Formats", "Numbering Library"],
1576 (&pens.numbering, NUMBER_LIBRARY.len()),
1577 current(false),
1578 [72.0, 3.0 * NUMBER_LINE + 8.0],
1579 |ui, place| {
1580 for number in 1..=3 {
1581 let label = canvas::outline::numbered(NUMBER_LIBRARY[place], number)
1582 .unwrap_or_default();
1583 ui.open(
1584 ("line", number),
1585 Spec {
1586 size: [fill(), px(NUMBER_LINE)],
1587 gap: 3.0,
1588 ..Spec::default()
1589 },
1590 );
1591 ui.leaf(
1592 "number",
1593 Spec {
1594 size: [fit(), fill()],
1595 text: Some(&label),
1596 font_size: Some(11.0),
1597 ..Spec::default()
1598 },
1599 );
1600 // A line of text after the number, as OneNote's previews draw it.
1601 ui.leaf(
1602 "text",
1603 Spec {
1604 size: [fill(), fill()],
1605 fill: Some(theme.text_dim),
1606 inset: [0.0, NUMBER_LINE / 2.0, 2.0, NUMBER_LINE / 2.0 - 1.0],
1607 ..Spec::default()
1608 },
1609 );
1610 ui.close();
1611 }
1612 },
1613 ) {
1614 choice = Some(Choice::List(place.map(ListStyle::Number)));
1314 }1615 }
1315 if ui::shell::tool_button(ui, "clear", art::CLEAR_FORMATTING, text, false).clicked {1616 if ui::shell::tool_button(ui, "clear", art::CLEAR_FORMATTING, text, false).clicked {
1316 command = Some(Formatting::Clear);1617 choice = Some(Choice::Command(Cmd::ClearFormatting));
1317 }1618 }
1318 });1619 });
1319 row(ui, 1, |ui| {1620 row(ui, 1, |ui| {
...@@ -1324,12 +1625,12 @@ impl State {...@@ -1324,12 +1625,12 @@ impl State {
1324 ("strikethrough", art::STRIKETHROUGH, Toggle::Strikethrough),1625 ("strikethrough", art::STRIKETHROUGH, Toggle::Strikethrough),
1325 ] {1626 ] {
1326 if ui::shell::tool_button(ui, part, icon, text, on(toggle)).clicked {1627 if ui::shell::tool_button(ui, part, icon, text, on(toggle)).clicked {
1327 command = Some(Formatting::Toggle(toggle));1628 choice = Some(Choice::Command(Cmd::Toggle(toggle)));
1328 }1629 }
1329 }1630 }
1330 let script = ui.id("script");1631 let script = ui.id("script");
1331 let superscript = on(Toggle::Superscript);1632 let superscript = on(Toggle::Superscript);
1332 let [button, menu] = ui::shell::split_button(1633 if ui::shell::split_button(
1333 ui,1634 ui,
1334 "script",1635 "script",
1335 if superscript {1636 if superscript {
...@@ -1339,47 +1640,46 @@ impl State {...@@ -1339,47 +1640,46 @@ impl State {
1339 },1640 },
1340 None,1641 None,
1341 on(Toggle::Subscript) || superscript,1642 on(Toggle::Subscript) || superscript,
1342 );1643 popup("script"),
1343 if button.clicked {1644 )
1344 command = Some(Formatting::Toggle(if superscript {1645 .clicked
1646 {
1647 choice = Some(Choice::Command(Cmd::Toggle(if superscript {
1345 Toggle::Superscript1648 Toggle::Superscript
1346 } else {1649 } else {
1347 Toggle::Subscript1650 Toggle::Subscript
1348 }));1651 })));
1349 }
1350 if menu.pressed {
1351 ui.open_popup(popup("script"));
1352 }1652 }
1353 let items = [1653 let scripts = [
1354 ui::popup::Item {1654 (Toggle::Subscript, art::SUBSCRIPT),
1355 text: "Subscript",1655 (Toggle::Superscript, art::SUPERSCRIPT),
1356 icon: Some(art::SUBSCRIPT),
1357 shortcut: "⌘=",
1358 checked: on(Toggle::Subscript),
1359 ..Default::default()
1360 },
1361 ui::popup::Item {
1362 text: "Superscript",
1363 icon: Some(art::SUPERSCRIPT),
1364 shortcut: "⇧⌘=",
1365 checked: superscript,
1366 ..Default::default()
1367 },
1368 ];1656 ];
1657 let keys = scripts.map(|(toggle, _)| shortcut(Cmd::Toggle(toggle)));
1658 let items: Vec<_> = scripts
1659 .iter()
1660 .zip(&keys)
1661 .map(|((toggle, icon), key)| ui::popup::Item {
1662 text: commands::command(Cmd::Toggle(*toggle)).title,
1663 icon: Some(icon),
1664 shortcut: key,
1665 checked: on(*toggle),
1666 ..Default::default()
1667 })
1668 .collect();
1369 let anchor = ui::Anchor::Below(ui.rect(script).unwrap_or_default());1669 let anchor = ui::Anchor::Below(ui.rect(script).unwrap_or_default());
1370 if let Some(index) = ui::popup::menu(ui, popup("script"), anchor, &items, None) {1670 if let Some(index) = ui::popup::menu(ui, popup("script"), anchor, &items, None) {
1371 command = Some(Formatting::Toggle(1671 choice = Some(Choice::Command(Cmd::Toggle(scripts[index].0)));
1372 [Toggle::Subscript, Toggle::Superscript][index],
1373 ));
1374 }1672 }
1375 for (part, icon, swatches, columns, none, default) in [1673 // Each button applies its menu's last pick: the highlighter shows it in its
1674 // artwork, the font colour in a bar.
1675 for (part, icon, swatches, columns, none, bar) in [
1376 (1676 (
1377 "highlight",1677 "highlight",
1378 art::HIGHLIGHTER,1678 art::HIGHLIGHTER,
1379 HIGHLIGHTS,1679 &HIGHLIGHTS.map(|(color, _)| color)[..],
1380 5,1680 5,
1381 "No colour",1681 "No Color",
1382 0x00ffff,1682 None,
1383 ),1683 ),
1384 (1684 (
1385 "color",1685 "color",
...@@ -1387,68 +1687,69 @@ impl State {...@@ -1387,68 +1687,69 @@ impl State {
1387 &FONT_COLORS[..],1687 &FONT_COLORS[..],
1388 10,1688 10,
1389 "Automatic",1689 "Automatic",
1390 0x3a3ae8,1690 Some(pens.font_color.map_or(text, colorref)),
1391 ),1691 ),
1392 ] {1692 ] {
1393 let split = ui.id(part);1693 let split = ui.id(part);
1394 let [button, menu] =1694 let button = ui::shell::split_button(ui, part, icon, bar, false, popup(part));
1395 ui::shell::split_button(ui, part, icon, Some(colorref(default)), false);1695 let highlight = part == "highlight";
1396 let paint = |color| {
1397 if part == "highlight" {
1398 Formatting::Highlight(color)
1399 } else {
1400 Formatting::Color(color)
1401 }
1402 };
1403 if button.clicked {1696 if button.clicked {
1404 command = Some(paint(Some(default)));1697 choice = Some(Choice::Command(if highlight {
1405 }1698 Cmd::Highlight
1406 if menu.pressed {1699 } else {
1407 ui.open_popup(popup(part));1700 Cmd::FontColor
1701 }));
1408 }1702 }
1409 let colors: Vec<_> = swatches.iter().map(|color| colorref(*color)).collect();1703 let colors: Vec<_> = swatches.iter().map(|color| colorref(*color)).collect();
1410 let anchor = ui::Anchor::Below(ui.rect(split).unwrap_or_default());1704 let anchor = ui::Anchor::Below(ui.rect(split).unwrap_or_default());
1411 if let Some(chosen) =1705 if let Some(chosen) =
1412 ui::popup::colors(ui, popup(part), anchor, none, &colors, columns)1706 ui::popup::colors(ui, popup(part), anchor, none, &colors, columns)
1413 {1707 {
1414 command = Some(paint(chosen.and_then(|chosen| {1708 let color = chosen.and_then(|chosen| {
1415 swatches1709 swatches
1416 .iter()1710 .iter()
1417 .zip(&colors)1711 .zip(&colors)
1418 .find(|(_, color)| **color == chosen)1712 .find(|(_, color)| **color == chosen)
1419 .map(|(stored, _)| *stored)1713 .map(|(stored, _)| *stored)
1420 })));1714 });
1715 choice = Some(if highlight {
1716 Choice::Highlight(color)
1717 } else {
1718 Choice::Color(color)
1719 });
1421 }1720 }
1422 }1721 }
1423 if ui::shell::tool_button(ui, "outdent", art::OUTDENT, text, false).clicked {1722 if ui::shell::tool_button(ui, "outdent", art::OUTDENT, text, false).clicked {
1424 command = Some(Formatting::Outdent);1723 choice = Some(Choice::Command(Cmd::Outdent));
1425 }1724 }
1426 if ui::shell::tool_button(ui, "indent", art::INDENT, text, false).clicked {1725 if ui::shell::tool_button(ui, "indent", art::INDENT, text, false).clicked {
1427 command = Some(Formatting::Indent);1726 choice = Some(Choice::Command(Cmd::Indent));
1428 }1727 }
1429 let align = ui.id("align");
1430 let alignments = [1728 let alignments = [
1431 (Alignment::Left, "Align left", art::ALIGN_LEFT),1729 (Alignment::Left, art::ALIGN_LEFT),
1432 (Alignment::Center, "Centre", art::ALIGN_CENTER),1730 (Alignment::Center, art::ALIGN_CENTER),
1433 (Alignment::Right, "Align right", art::ALIGN_RIGHT),1731 (Alignment::Right, art::ALIGN_RIGHT),
1434 ];1732 ];
1733 let keys = alignments.map(|(alignment, _)| shortcut(Cmd::Align(alignment)));
1435 let current = alignments1734 let current = alignments
1436 .iter()1735 .iter()
1437 .find(|(alignment, ..)| Some(*alignment) == state.alignment)1736 .find(|(alignment, _)| Some(*alignment) == state.alignment)
1438 .unwrap_or(&alignments[0]);1737 .unwrap_or(&alignments[0]);
1439 let [button, menu] = ui::shell::split_button(ui, "align", current.2, None, false);1738 let anchor = ui::shell::menu_button(ui, "align", current.1, popup("align"));
1440 if button.pressed || menu.pressed {1739 // The current alignment starts highlighted, keeping its icon under the button's.
1441 ui.open_popup(popup("align"));1740 let items: Vec<_> = alignments
1442 }1741 .iter()
1443 let items = alignments.map(|(alignment, name, icon)| ui::popup::Item {1742 .zip(&keys)
1444 text: name,1743 .map(|((alignment, icon), key)| ui::popup::Item {
1445 icon: Some(icon),1744 text: commands::command(Cmd::Align(*alignment)).title,
1446 checked: Some(alignment) == state.alignment,1745 icon: Some(icon),
1447 ..Default::default()1746 shortcut: key,
1448 });1747 current: Some(*alignment) == state.alignment,
1449 let anchor = ui::Anchor::Below(ui.rect(align).unwrap_or_default());1748 ..Default::default()
1749 })
1750 .collect();
1450 if let Some(index) = ui::popup::menu(ui, popup("align"), anchor, &items, None) {1751 if let Some(index) = ui::popup::menu(ui, popup("align"), anchor, &items, None) {
1451 command = Some(Formatting::Align(alignments[index].0));1752 choice = Some(Choice::Command(Cmd::Align(alignments[index].0)));
1452 }1753 }
1453 });1754 });
1454 });1755 });
...@@ -1467,42 +1768,86 @@ impl State {...@@ -1467,42 +1768,86 @@ impl State {
1467 (tag_sources(TagIcon::Address), NoteTag::Address),1768 (tag_sources(TagIcon::Address), NoteTag::Address),
1468 (tag_sources(TagIcon::Phone), NoteTag::PhoneNumber),1769 (tag_sources(TagIcon::Phone), NoteTag::PhoneNumber),
1469 ];1770 ];
1771 let all_tags = &tags;
1470 group(ui, "tags", |ui| {1772 group(ui, "tags", |ui| {
1471 for (index, tags) in tags.chunks(5).enumerate() {1773 for (index, tags) in tags.chunks(5).enumerate() {
1472 row(ui, index, |ui| {1774 row(ui, index, |ui| {
1473 for (column, (icon, tag)) in tags.iter().enumerate() {1775 for (column, (icon, tag)) in tags.iter().enumerate() {
1474 let lit = state.tags.contains(tag);1776 let lit = state.tags.contains(tag);
1475 if ui::shell::tool_button(ui, column, icon, [1.0; 4], lit).clicked {1777 if ui::shell::tool_button(ui, column, icon, [1.0; 4], lit).clicked {
1476 command = Some(Formatting::Tag(*tag));1778 choice = Some(Choice::Command(Cmd::Tag(*tag)));
1477 }1779 }
1478 }1780 }
1479 if index == 1 {1781 if index == 1 {
1480 ui::shell::tool_button(ui, "more", ui::shell::CHEVRON, text, false);1782 let more = ui.id("more");
1783 let open = ui.popup_open(popup("tags"));
1784 if ui::shell::tool_button(ui, "more", ui::shell::CHEVRON, text, open)
1785 .pressed
1786 {
1787 ui.open_popup(popup("tags"));
1788 }
1789 let shortcuts: Vec<_> = all_tags
1790 .iter()
1791 .map(|(_, tag)| shortcut(Cmd::Tag(*tag)))
1792 .collect();
1793 let remove = shortcut(Cmd::RemoveTags);
1794 let mut items: Vec<_> = all_tags
1795 .iter()
1796 .zip(&shortcuts)
1797 .map(|((icon, tag), shortcut)| ui::popup::Item {
1798 text: commands::command(Cmd::Tag(*tag)).title,
1799 icon: Some(*icon),
1800 colored: true,
1801 shortcut,
1802 ..Default::default()
1803 })
1804 .collect();
1805 items.push(ui::popup::Item {
1806 text: commands::command(Cmd::RemoveTags).title,
1807 shortcut: &remove,
1808 separated: true,
1809 ..Default::default()
1810 });
1811 let anchor = ui::Anchor::Below(ui.rect(more).unwrap_or_default());
1812 if let Some(chosen) =
1813 ui::popup::menu(ui, popup("tags"), anchor, &items, None)
1814 {
1815 choice = Some(Choice::Command(
1816 all_tags
1817 .get(chosen)
1818 .map_or(Cmd::RemoveTags, |(_, tag)| Cmd::Tag(*tag)),
1819 ));
1820 }
1481 }1821 }
1482 });1822 });
1483 }1823 }
1484 });1824 });
1485 divider(ui, "tags", theme);1825 divider(ui, "tags", theme);
1486 group(ui, "insert", |ui| {1826 group(ui, "insert", |ui| {
1487 row(ui, 0, |ui| {1827 for (index, buttons) in [
1488 for (part, icon) in [1828 &[
1489 ("table", art::TABLE),1829 ("table", art::TABLE, Cmd::Table),
1490 ("picture", art::PICTURE),1830 ("picture", art::PICTURE, Cmd::Picture),
1491 ("file", art::ATTACHMENT),1831 ("file", art::ATTACHMENT, Cmd::Attachment),
1492 ("link", art::LINK),1832 ("link", art::LINK, Cmd::Link),
1493 ] {1833 ][..],
1494 ui::shell::tool_button(ui, part, icon, text, false);1834 &[
1495 }1835 ("date", art::CALENDAR, Cmd::Date),
1496 });1836 ("time", art::CLOCK, Cmd::Time),
1497 row(ui, 1, |ui| {1837 ("equation", art::EQUATION, Cmd::Equation),
1498 for (part, icon) in [1838 ],
1499 ("date", art::CALENDAR),1839 ]
1500 ("time", art::CLOCK),1840 .into_iter()
1501 ("equation", art::EQUATION),1841 .enumerate()
1502 ] {1842 {
1503 ui::shell::tool_button(ui, part, icon, text, false);1843 row(ui, index, |ui| {
1504 }1844 for (part, icon, id) in buttons {
1505 });1845 if ui::shell::tool_button(ui, part, icon, text, false).clicked {
1846 choice = Some(Choice::Command(*id));
1847 }
1848 }
1849 });
1850 }
1506 });1851 });
1507 ui.leaf(1852 ui.leaf(
1508 "space",1853 "space",
...@@ -1525,11 +1870,10 @@ impl State {...@@ -1525,11 +1870,10 @@ impl State {
1525 }1870 }
1526 let zoom = self.view.zoom();1871 let zoom = self.view.zoom();
1527 let label = format!("{:.0}%", zoom * 100.0);1872 let label = format!("{:.0}%", zoom * 100.0);
1528 let mut chosen = None;
1529 group(ui, "zoom", |ui| {1873 group(ui, "zoom", |ui| {
1530 row(ui, 0, |ui| {1874 row(ui, 0, |ui| {
1531 if ui::shell::tool_button(ui, "out", art::ZOOM_OUT, text, false).clicked {1875 if ui::shell::tool_button(ui, "out", art::ZOOM_OUT, text, false).clicked {
1532 chosen = Some(zoom / 1.1);1876 choice = Some(Choice::Command(Cmd::ZoomOut));
1533 }1877 }
1534 let level = ui.leaf(1878 let level = ui.leaf(
1535 "level",1879 "level",
...@@ -1544,36 +1888,16 @@ impl State {...@@ -1544,36 +1888,16 @@ impl State {
1544 },1888 },
1545 );1889 );
1546 if level.clicked {1890 if level.clicked {
1547 chosen = Some(1.0);1891 choice = Some(Choice::Command(Cmd::ActualSize));
1548 }1892 }
1549 if ui::shell::tool_button(ui, "in", art::ZOOM_IN, text, false).clicked {1893 if ui::shell::tool_button(ui, "in", art::ZOOM_IN, text, false).clicked {
1550 chosen = Some(zoom * 1.1);1894 choice = Some(Choice::Command(Cmd::ZoomIn));
1551 }1895 }
1552 });1896 });
1553 });1897 });
1554 ui.close();1898 ui.close();
1555 if let Some(zoom) = chosen {1899 if let Some(choice) = choice {
1556 let response = self.view.set_zoom(zoom)?;1900 self.choose(choice);
1557 self.respond(response);
1558 }
1559 if let Some(command) = command {
1560 if let Formatting::Font(name) = &command {
1561 self.recent_fonts.retain(|recent| recent != name);
1562 self.recent_fonts.insert(0, name.clone());
1563 self.recent_fonts.truncate(RECENT_FONTS);
1564 self.save_settings();
1565 }
1566 let response = self.view.format(command)?;
1567 self.respond(response);
1568 }
1569 if let Some(undo) = history {
1570 let view = &mut self.view;
1571 let done = if undo {
1572 view.editor.undo(&mut view.engine)?
1573 } else {
1574 view.editor.redo(&mut view.engine)?
1575 };
1576 self.changed |= done;
1577 }1901 }
1578 Ok(())1902 Ok(())
1579 }1903 }
...@@ -1626,24 +1950,43 @@ impl State {...@@ -1626,24 +1950,43 @@ impl State {
1626 let found = self1950 let found = self
1627 .search1951 .search
1628 .found_in(&session.library.key(&session.tabs[session.tab].path));1952 .found_in(&session.library.key(&session.tabs[session.tab].path));
1629 let rows = page_rows(&mut self.ui, theme, section, session, &found);1953 let rounding = self.rounding();
1630 self.commands.extend(rows.clicked.map(Command::OpenPage));1954 let dragged = self.dragged_page();
1955 let rows = page_rows(
1956 &mut self.ui,
1957 theme,
1958 section,
1959 session,
1960 &found,
1961 rounding,
1962 self.renaming.as_mut(),
1963 dragged,
1964 );
1965 self.commands.extend(
1966 rows.version
1967 .map(|(page, version)| Command::OpenVersion { page, version }),
1968 );
1631 if let Some((space, point)) = rows.context {1969 if let Some((space, point)) = rows.context {
1632 self.menu = Some((menus::Target::Page(space), point));1970 self.menu = Some((menus::Target::Page(space), point));
1633 self.ui.open_popup(menus::id());1971 self.ui.open_popup(menus::id());
1634 }1972 }
1635 let rect = self.ui.rect(panel).unwrap_or_default();1973 if let Some(space) = rows.renamed {
1636 self.drag_pages(rows.held, section, rect, tabs);1974 self.rename(rename::Target::Page(space));
1975 }
1976 if let Some(keep) = rows.kept {
1977 self.finish_renaming(keep);
1978 }
1979 self.drag_pages(rows.held, &rows.places, rows.settled, tabs);
1980 if !self.dragged() {
1981 self.commands.extend(rows.clicked.map(Command::OpenPage));
1982 }
1637 self.ui.close();1983 self.ui.close();
1638 rows.open1984 rows.open
1639 }1985 }
16401986
1641 /// Hands the page the events routed to its box, in its device pixels.1987 /// Hands the page the events routed to its box, in its device pixels.
1642 fn page_events(&mut self, events: Vec<ui::Event>) -> Result<(), Box<dyn Error>> {1988 fn page_events(&mut self, events: Vec<ui::Event>) -> Result<(), Box<dyn Error>> {
1643 let read_only = self1989 let read_only = self.session.as_ref().is_some_and(Session::read_only);
1644 .session
1645 .as_ref()
1646 .is_some_and(|session| session.conflict(session.space).is_some());
1647 let scale = self.ui.scale();1990 let scale = self.ui.scale();
1648 let corner = self.ui.rect(page()).unwrap_or_default();1991 let corner = self.ui.rect(page()).unwrap_or_default();
1649 let device = |point: [f32; 2]| {1992 let device = |point: [f32; 2]| {
...@@ -1667,6 +2010,14 @@ impl State {...@@ -1667,6 +2010,14 @@ impl State {
1667 self.view.pointer_released()?2010 self.view.pointer_released()?
1668 }2011 }
1669 }2012 }
2013 ui::Event::Button {
2014 button: MouseButton::Right,
2015 pressed: true,
2016 ..
2017 } if !read_only => {
2018 self.open_text_menu()?;
2019 continue;
2020 }
1670 ui::Event::Button { .. } | ui::Event::Ime(Ime::Enabled) => continue,2021 ui::Event::Button { .. } | ui::Event::Ime(Ime::Enabled) => continue,
1671 // A conflict page takes no typing; commands and caret moves still apply.2022 // A conflict page takes no typing; commands and caret moves still apply.
1672 ui::Event::Ime(Ime::Preedit(..) | Ime::Commit(_)) if read_only => continue,2023 ui::Event::Ime(Ime::Preedit(..) | Ime::Commit(_)) if read_only => continue,
...@@ -1691,28 +2042,6 @@ impl State {...@@ -1691,28 +2042,6 @@ impl State {
1691 }2042 }
1692 ui::Event::Wheel(delta) => self.view.wheel([delta[0] * scale, delta[1] * scale])?,2043 ui::Event::Wheel(delta) => self.view.wheel([delta[0] * scale, delta[1] * scale])?,
1693 ui::Event::Key { key, text } => {2044 ui::Event::Key { key, text } => {
1694 let modifiers = self.view.modifiers();
1695 // OneNote's Find on This Page is Ctrl+F, its search Ctrl+E.
1696 if modifiers.command && !modifiers.shift && self.session.is_some() {
1697 match &key {
1698 Key::Character(character) if character.eq_ignore_ascii_case("f") => {
1699 self.start_find(false)?;
1700 continue;
1701 }
1702 Key::Character(character) if character.eq_ignore_ascii_case("e") => {
1703 self.start_search();
1704 continue;
1705 }
1706 _ => {}
1707 }
1708 }
1709 // OneNote's New Page is Ctrl+N.
1710 let new = matches!(&key, Key::Character(character)
1711 if character.eq_ignore_ascii_case("n"));
1712 if modifiers.command && !modifiers.shift && new && self.session.is_some() {
1713 self.commands.push(Command::NewPage { under: None });
1714 continue;
1715 }
1716 self.view.key(&ui::edit_key(&key), text.as_deref())?2045 self.view.key(&ui::edit_key(&key), text.as_deref())?
1717 }2046 }
1718 ui::Event::Ime(Ime::Preedit(text, cursor)) => self.view.compose(text, cursor)?,2047 ui::Event::Ime(Ime::Preedit(text, cursor)) => self.view.compose(text, cursor)?,
...@@ -1774,7 +2103,12 @@ impl State {...@@ -1774,7 +2103,12 @@ impl State {
1774 }2103 }
1775 Command::CopyVersion { version, tab } => {2104 Command::CopyVersion { version, tab } => {
1776 let session = self.session.as_ref().ok_or("No section is open")?;2105 let session = self.session.as_ref().ok_or("No section is open")?;
1777 let page = session.section.page(version)?;2106 let page = match session.version {
2107 Some(open) if open == version => {
2108 session.section.version(session.space, open)?
2109 }
2110 _ => session.section.page(version)?,
2111 };
1778 if tab == session.tab {2112 if tab == session.tab {
1779 session.section.import_page(&page, &self.author)?;2113 session.section.import_page(&page, &self.author)?;
1780 } else {2114 } else {
...@@ -1793,6 +2127,11 @@ impl State {...@@ -1793,6 +2127,11 @@ impl State {
1793 }2127 }
1794 }2128 }
1795 Command::SelectChange { forward } => self.select_change(forward)?,2129 Command::SelectChange { forward } => self.select_change(forward)?,
2130 Command::History { page, show } => self.show_history(page, show)?,
2131 Command::OpenVersion { page, version } => self.open_version(page, version)?,
2132 Command::RestoreVersion { page, version } => self.restore_version(page, version)?,
2133 Command::DeletePageVersion { page, version } => self.delete_version(page, version)?,
2134 Command::DeleteAllVersions(scope) => self.delete_all_versions(scope)?,
1796 Command::OpenNotebook => {2135 Command::OpenNotebook => {
1797 if let Some(path) = platform::pick_notebook("Open Notebook") {2136 if let Some(path) = platform::pick_notebook("Open Notebook") {
1798 self.open_path(&path);2137 self.open_path(&path);
...@@ -1824,17 +2163,15 @@ impl State {...@@ -1824,17 +2163,15 @@ impl State {
1824 let response = self.view.paste(&text, language)?;2163 let response = self.view.paste(&text, language)?;
1825 self.respond(response);2164 self.respond(response);
1826 }2165 }
1827 Command::Page(Request::CharacterPalette) => platform::show_character_palette(),2166 Command::Page(Request::OpenLink(address)) => self.open_link(&address)?,
2167 Command::Choose(choice) => self.run(choice)?,
1828 }2168 }
1829 Ok(())2169 Ok(())
1830 }2170 }
18312171
1832 /// Runs `read` on a thread of its own, which lays out the page it read; `open_loaded`2172 /// Runs `read` on a thread of its own, which lays out the page it read; `open_loaded`
1833 /// shows it unless a newer read was asked for meanwhile.2173 /// shows it unless a newer read was asked for meanwhile.
1834 fn load(2174 fn load(&mut self, read: impl FnOnce() -> Result<Loaded, Box<dyn Error>> + Send + 'static) {
1835 &mut self,
1836 read: impl FnOnce() -> Result<Loaded, Box<dyn Error>> + Send + 'static,
1837 ) {
1838 self.loading += 1;2175 self.loading += 1;
1839 let (id, sender, redraw) = (self.loading, self.loads.0.clone(), self.redraw.clone());2176 let (id, sender, redraw) = (self.loading, self.loads.0.clone(), self.redraw.clone());
1840 let layouts = Arc::clone(&self.layouts);2177 let layouts = Arc::clone(&self.layouts);
...@@ -1842,7 +2179,9 @@ impl State {...@@ -1842,7 +2179,9 @@ impl State {
1842 let laid = read().and_then(|loaded| {2179 let laid = read().and_then(|loaded| {
1843 let (shown, page) = match loaded {2180 let (shown, page) = match loaded {
1844 Loaded::Section(session, page) => (Shown::Section(session), page),2181 Loaded::Section(session, page) => (Shown::Section(session), page),
2182 Loaded::Created(session, page) => (Shown::Created(session), page),
1845 Loaded::Page(space, page) => (Shown::Page(space), page),2183 Loaded::Page(space, page) => (Shown::Page(space), page),
2184 Loaded::Version(space, version, page) => (Shown::Version(space, version), page),
1846 Loaded::Library(library, path) => return Ok(Laid::Library(library, path)),2185 Loaded::Library(library, path) => return Ok(Laid::Library(library, path)),
1847 };2186 };
1848 let mut engine = layouts.lock().map_err(|_| "Page layout failed")?;2187 let mut engine = layouts.lock().map_err(|_| "Page layout failed")?;
...@@ -1864,12 +2203,11 @@ impl State {...@@ -1864,12 +2203,11 @@ impl State {
1864 fn open_loaded(&mut self) -> Result<(), Box<dyn Error>> {2203 fn open_loaded(&mut self) -> Result<(), Box<dyn Error>> {
1865 let reads: Vec<Read> = self.loads.1.try_iter().collect();2204 let reads: Vec<Read> = self.loads.1.try_iter().collect();
1866 for (id, loaded) in reads {2205 for (id, loaded) in reads {
1867 if id != self.loading {
1868 continue;
1869 }
1870 match loaded {2206 match loaded {
2207 // A later load supersedes what this one shows, but not its failure.
2208 Ok(_) if id != self.loading => {}
1871 Ok(Laid::Page(opening)) => self.opening = Some(*opening),2209 Ok(Laid::Page(opening)) => self.opening = Some(*opening),
1872 Ok(Laid::Library(library, path)) => self.adopt(library, &path)?,2210 Ok(Laid::Library(library, path)) => self.adopt(library, path.as_deref())?,
1873 Err(error) => {2211 Err(error) => {
1874 eprintln!("{error}");2212 eprintln!("{error}");
1875 platform::alert("Couldn't open", &error);2213 platform::alert("Couldn't open", &error);
...@@ -1896,11 +2234,13 @@ impl State {...@@ -1896,11 +2234,13 @@ impl State {
1896 self.persist()?;2234 self.persist()?;
1897 if let Some(session) = &self.session {2235 if let Some(session) = &self.session {
1898 let key = session.library.key(&session.tabs[session.tab].path);2236 let key = session.library.key(&session.tabs[session.tab].path);
1899 self.places.insert((key.clone(), session.space), self.view.place());2237 self.places
2238 .insert((key.clone(), session.space), self.view.place());
1900 self.last_pages.insert(key, session.space);2239 self.last_pages.insert(key, session.space);
1901 }2240 }
2241 let created = matches!(opening.loaded, Shown::Created(_));
1902 match opening.loaded {2242 match opening.loaded {
1903 Shown::Section(session) => {2243 Shown::Section(session) | Shown::Created(session) => {
1904 // The settings keep the notebook shown, not the section.2244 // The settings keep the notebook shown, not the section.
1905 let other_notebook = self2245 let other_notebook = self
1906 .session2246 .session
...@@ -1916,13 +2256,18 @@ impl State {...@@ -1916,13 +2256,18 @@ impl State {
1916 None => self.notebooks.push(Arc::clone(&session.library)),2256 None => self.notebooks.push(Arc::clone(&session.library)),
1917 }2257 }
1918 self.session = Some(*session);2258 self.session = Some(*session);
2259 self.sectionless = None;
1919 if other_notebook {2260 if other_notebook {
1920 self.save_settings();2261 self.save_settings();
1921 }2262 }
1922 }2263 }
1923 Shown::Page(space) => {2264 Shown::Page(space) | Shown::Version(space, _) => {
1924 let session = self.session.as_mut().ok_or("No section is open")?;2265 let session = self.session.as_mut().ok_or("No section is open")?;
1925 session.space = space;2266 session.space = space;
2267 session.version = match opening.loaded {
2268 Shown::Version(_, version) => Some(version),
2269 _ => None,
2270 };
1926 session.change = None;2271 session.change = None;
1927 // Pages created, moved or deleted since the list was read.2272 // Pages created, moved or deleted since the list was read.
1928 session.pages = session.section.pages()?;2273 session.pages = session.section.pages()?;
...@@ -1933,24 +2278,39 @@ impl State {...@@ -1933,24 +2278,39 @@ impl State {
1933 self.places.get(&(key, session.space)).copied()2278 self.places.get(&(key, session.space)).copied()
1934 });2279 });
1935 self.view.open(opening.editor, Some(opening.scene), place);2280 self.view.open(opening.editor, Some(opening.scene), place);
2281 self.visited();
1936 self.opened()?;2282 self.opened()?;
1937 self.refind(true)?;2283 self.refind(true)?;
1938 if self.title_focus.take().is_some_and(|space| {2284 if self.title_focus.take().is_some_and(|space| {
1939 self.session2285 self.session
1940 .as_ref()2286 .as_ref()
1941 .is_some_and(|session| session.space == space)2287 .is_some_and(|session| session.space == space)
1942 }) && let Some(title) = self.view.editor.outlines().iter().find(|outline| outline.title)2288 }) && let Some(title) = self
2289 .view
2290 .editor
2291 .outlines()
2292 .iter()
2293 .find(|outline| outline.title)
1943 {2294 {
1944 self.view.editor.focus_outline(title.id)?;2295 self.view.editor.focus_outline(title.id)?;
1945 let response = self.view.focus_text()?;2296 let response = self.view.focus_text()?;
1946 self.respond(response);2297 self.respond(response);
1947 }2298 }
2299 if created && let Some(session) = &self.session {
2300 let target = rename::Target::Entry {
2301 library: Arc::clone(&session.library),
2302 path: session.tabs[session.tab].path.clone(),
2303 in_tab: true,
2304 };
2305 self.rename(target);
2306 }
1948 Ok(())2307 Ok(())
1949 }2308 }
19502309
1951 /// Takes `library`, a notebook read again after a change, in place of the one it was;2310 /// Takes `library`, a notebook read again after a change, in place of the one it was;
1952 /// the open section, now at catalog `path` in it, stays open, reopened where it moved.2311 /// the open section, now at catalog `path` in it, stays open, reopened where it moved.
1953 fn adopt(&mut self, library: Arc<Library>, path: &str) -> Result<(), Box<dyn Error>> {2312 /// Without `path` the notebook has no sections left, and one of its shows none.
2313 fn adopt(&mut self, library: Arc<Library>, path: Option<&str>) -> Result<(), Box<dyn Error>> {
1954 match self2314 match self
1955 .notebooks2315 .notebooks
1956 .iter_mut()2316 .iter_mut()
...@@ -1959,6 +2319,23 @@ impl State {...@@ -1959,6 +2319,23 @@ impl State {
1959 Some(open) => *open = Arc::clone(&library),2319 Some(open) => *open = Arc::clone(&library),
1960 None => self.notebooks.push(Arc::clone(&library)),2320 None => self.notebooks.push(Arc::clone(&library)),
1961 }2321 }
2322 let shown = |shown: &Library| shown.location == library.location;
2323 let Some(path) = path else {
2324 if self.sectionless.as_deref().is_some_and(shown)
2325 || self
2326 .session
2327 .as_ref()
2328 .is_some_and(|session| shown(&session.library))
2329 {
2330 self.persist()?;
2331 if let Some(session) = self.session.take() {
2332 session.section.close()?;
2333 }
2334 self.sectionless = Some(library);
2335 self.title();
2336 }
2337 return Ok(());
2338 };
1962 let Some(session) = &mut self.session else {2339 let Some(session) = &mut self.session else {
1963 return Ok(());2340 return Ok(());
1964 };2341 };
...@@ -2018,8 +2395,8 @@ impl State {...@@ -2018,8 +2395,8 @@ impl State {
2018 let corner = self.ui.rect(page()).unwrap_or_default();2395 let corner = self.ui.rect(page()).unwrap_or_default();
2019 let [x0, y0, x1, y1] = self.view.caret_area()?;2396 let [x0, y0, x1, y1] = self.view.caret_area()?;
2020 self.window.set_ime_cursor_area(2397 self.window.set_ime_cursor_area(
2021 PhysicalPosition::new(x0 + corner[0] * scale, y0 + corner[1] * scale),2398 LogicalPosition::new(x0 / scale + corner[0], y0 / scale + corner[1]),
2022 PhysicalSize::new(x1 - x0, y1 - y0),2399 LogicalSize::new((x1 - x0) / scale, (y1 - y0) / scale),
2023 );2400 );
2024 let start = Instant::now();2401 let start = Instant::now();
2025 self.update_accessibility()?;2402 self.update_accessibility()?;
...@@ -2066,6 +2443,10 @@ impl State {...@@ -2066,6 +2443,10 @@ impl State {
2066 };2443 };
2067 if self.window.title() != title {2444 if self.window.title() != title {
2068 self.window.set_title(&title);2445 self.window.set_title(&title);
2446 platform::represent(
2447 &self.window,
2448 self.session.as_ref().map(|session| session.section.file()),
2449 );
2069 }2450 }
2070 }2451 }
20712452
...@@ -2153,8 +2534,8 @@ impl State {...@@ -2153,8 +2534,8 @@ impl State {
2153 if ops.is_empty() {2534 if ops.is_empty() {
2154 return Ok(());2535 return Ok(());
2155 }2536 }
2156 if session.conflict(session.space).is_some() {2537 if session.read_only() {
2157 // A conflict page is read-only: it returns to what is stored.2538 // A conflict page or a version is read-only: it returns to what is stored.
2158 self.stale = true;2539 self.stale = true;
2159 return Ok(());2540 return Ok(());
2160 }2541 }
...@@ -2267,7 +2648,19 @@ impl State {...@@ -2267,7 +2648,19 @@ impl State {
2267 let back = session.shown;2648 let back = session.shown;
2268 session.pages = session.section.pages()?;2649 session.pages = session.section.pages()?;
2269 session.refresh_conflicts()?;2650 session.refresh_conflicts()?;
2651 session.refresh_history()?;
2270 let listed = |space: ExGuid| session.pages.iter().any(|(page, ..)| *page == space);2652 let listed = |space: ExGuid| session.pages.iter().any(|(page, ..)| *page == space);
2653 let gone = session.version.is_some_and(|version| {
2654 !session
2655 .page_versions(session.space)
2656 .iter()
2657 .any(|listed| listed.context == version)
2658 });
2659 if gone && listed(session.space) {
2660 // A version deleted elsewhere returns to its page.
2661 self.commands.push(Command::OpenPage(session.space));
2662 return Ok(());
2663 }
2271 if !listed(session.space) && session.conflict(session.space).is_none() {2664 if !listed(session.space) && session.conflict(session.space).is_none() {
2272 // A page removed elsewhere, or a conflict page deleted elsewhere, which2665 // A page removed elsewhere, or a conflict page deleted elsewhere, which
2273 // returns to its page.2666 // returns to its page.
...@@ -2291,9 +2684,14 @@ impl State {...@@ -2291,9 +2684,14 @@ impl State {
2291 let mut error = None;2684 let mut error = None;
2292 let view = &self.view;2685 let view = &self.view;
2293 let corner = self.ui.rect(page()).unwrap_or_default();2686 let corner = self.ui.rect(page()).unwrap_or_default();
2294 let mut viewport = view.viewport;2687 let scale = self.ui.scale();
2295 viewport.origin[0] += corner[0] * self.ui.scale();2688 // Bounds are in the window's pixels, which `scale` renders fewer of when capped.
2296 viewport.origin[1] += corner[1] * self.ui.scale();2689 let ratio = self.window.scale_factor() as f32 / scale;
2690 let viewport = canvas::gpu::Viewport {
2691 size: view.viewport.size.map(|side| (side as f32 * ratio) as u32),
2692 scale: view.viewport.scale * ratio,
2693 origin: [0, 1].map(|axis| (view.viewport.origin[axis] + corner[axis] * scale) * ratio),
2694 };
2297 self.access_adapter.update_if_active(|| {2695 self.access_adapter.update_if_active(|| {
2298 match self.accessibility.update(2696 match self.accessibility.update(
2299 &view.editor,2697 &view.editor,
...@@ -2419,7 +2817,23 @@ impl State {...@@ -2419,7 +2817,23 @@ impl State {
2419 }2817 }
2420 };2818 };
2421 lap("acquire", start);2819 lap("acquire", start);
2422 self.paint(&frame.texture.create_view(&Default::default()))?;2820 let size = [self.config.width, self.config.height];
2821 let target = self
2822 .translucent
2823 .as_mut()
2824 .map(|translucent| translucent.target(&self.renderer.device, size));
2825 self.paint(
2826 target
2827 .as_ref()
2828 .unwrap_or(&frame.texture.create_view(&Default::default())),
2829 )?;
2830 if let Some(translucent) = &self.translucent {
2831 let window = frame.texture.create_view(&wgpu::TextureViewDescriptor {
2832 format: Some(self.config.format.remove_srgb_suffix()),
2833 ..Default::default()
2834 });
2835 translucent.present(&self.renderer.device, &self.renderer.queue, &window);
2836 }
2423 self.window.pre_present_notify();2837 self.window.pre_present_notify();
2424 let start = Instant::now();2838 let start = Instant::now();
2425 self.renderer.queue.present(frame);2839 self.renderer.queue.present(frame);
...@@ -2432,6 +2846,15 @@ impl State {...@@ -2432,6 +2846,15 @@ impl State {
2432 Ok(())2846 Ok(())
2433 }2847 }
24342848
2849 /// The window's pixels per point, lowered where the window is larger than the GPU's
2850 /// largest texture, so the surface renders scaled rather than failing.
2851 fn scale(&self) -> f32 {
2852 let size = self.window.inner_size();
2853 let most = self.renderer.device.limits().max_texture_dimension_2d as f32;
2854 let longest = size.width.max(size.height) as f32;
2855 self.window.scale_factor() as f32 * (most / longest).min(1.0)
2856 }
2857
2435 /// The paper the open page lies on: the theme's, in the page's colour.2858 /// The paper the open page lies on: the theme's, in the page's colour.
2436 fn paper(&self) -> canvas::gpu::Paper {2859 fn paper(&self) -> canvas::gpu::Paper {
2437 canvas::gpu::Paper {2860 canvas::gpu::Paper {
...@@ -2464,13 +2887,7 @@ impl State {...@@ -2464,13 +2887,7 @@ impl State {
2464 let layers: Vec<_> = interface2887 let layers: Vec<_> = interface
2465 .iter()2888 .iter()
2466 .map(|layer| match layer {2889 .map(|layer| match layer {
2467 ui::Layer::Primitives { clip, primitives } => draw::Layer {2890 ui::Layer::Primitives(primitives) => primitives.layer(scale),
2468 scale,
2469 origin: [0.0; 2],
2470 clip: clip.map(|clip| clip.map(|value| value * scale)),
2471 backdrop: None,
2472 primitives,
2473 },
2474 ui::Layer::Custom { rect, .. } => draw::Layer {2891 ui::Layer::Custom { rect, .. } => draw::Layer {
2475 scale: viewport.scale,2892 scale: viewport.scale,
2476 origin: [2893 origin: [
...@@ -2479,6 +2896,7 @@ impl State {...@@ -2479,6 +2896,7 @@ impl State {
2479 ],2896 ],
2480 clip: Some(rect.map(|value| value * scale)),2897 clip: Some(rect.map(|value| value * scale)),
2481 backdrop: Some(paper.color),2898 backdrop: Some(paper.color),
2899 motion: None,
2482 primitives: &page_primitives,2900 primitives: &page_primitives,
2483 },2901 },
2484 })2902 })
...@@ -2489,7 +2907,7 @@ impl State {...@@ -2489,7 +2907,7 @@ impl State {
2489 .draw(2907 .draw(
2490 target,2908 target,
2491 [self.config.width, self.config.height],2909 [self.config.width, self.config.height],
2492 self.ui.theme.base,2910 self.ui.theme.strip,
2493 &layers,2911 &layers,
2494 )2912 )
2495 .map_err(|error| format!("Canvas drawing failed: {error:?}"))?;2913 .map_err(|error| format!("Canvas drawing failed: {error:?}"))?;
...@@ -2583,6 +3001,23 @@ impl State {...@@ -2583,6 +3001,23 @@ impl State {
2583 if let ui::Event::Button { pressed: false, .. } = event {3001 if let ui::Event::Button { pressed: false, .. } = event {
2584 self.strip_held = false;3002 self.strip_held = false;
2585 }3003 }
3004 // Escape calls a drag off: what it held slides back.
3005 if let ui::Event::Key {
3006 key: Key::Named(NamedKey::Escape),
3007 ..
3008 } = event
3009 && let Some(drag) = self.drag.as_mut().filter(|drag| drag.live())
3010 {
3011 drag.cancelled = true;
3012 self.window.request_redraw();
3013 return;
3014 }
3015 if let ui::Event::Button { pressed: true, .. } = event
3016 && self.renaming.is_some()
3017 && self.ui.box_at(self.pointer) != Some(rename::field())
3018 {
3019 self.finish_renaming(true);
3020 }
2586 if let ui::Event::Button {3021 if let ui::Event::Button {
2587 button: MouseButton::Left,3022 button: MouseButton::Left,
2588 pressed: true,3023 pressed: true,
...@@ -2613,15 +3048,12 @@ impl State {...@@ -2613,15 +3048,12 @@ impl State {
2613 }3048 }
2614 return;3049 return;
2615 }3050 }
2616 // Settings' shortcut, which AppKit's menu takes first on macOS.3051 // On macOS the menu bar takes the chords of the items it enables first.
2617 if let ui::Event::Key {3052 if let ui::Event::Key { key, .. } = &event
2618 key: Key::Character(character),3053 && let Some(id) =
2619 ..3054 commands::find(&ui::edit_key(key), ui::edit_modifiers(self.ui.modifiers()))
2620 } = &event
2621 && character == ","
2622 && ui::edit_modifiers(self.ui.modifiers()).command
2623 {3055 {
2624 self.open_options();3056 self.choose(commands::Choice::Command(id));
2625 } else {3057 } else {
2626 self.ui.event(event);3058 self.ui.event(event);
2627 }3059 }
...@@ -2629,8 +3061,9 @@ impl State {...@@ -2629,8 +3061,9 @@ impl State {
2629 }3061 }
2630}3062}
26313063
2632/// The interface's colours in `appearance`, on white pages when `light_pages`.3064/// The interface's colours in `appearance`, on white pages when `light_pages`, and over the
2633fn theme(appearance: winit::window::Theme, light_pages: bool) -> Theme {3065/// system's material where it shows through as a `backdrop`.
3066fn theme(appearance: winit::window::Theme, light_pages: bool, backdrop: bool) -> Theme {
2634 let mut theme = match appearance {3067 let mut theme = match appearance {
2635 winit::window::Theme::Dark => Theme::dark(),3068 winit::window::Theme::Dark => Theme::dark(),
2636 winit::window::Theme::Light => Theme::light(),3069 winit::window::Theme::Light => Theme::light(),
...@@ -2639,7 +3072,11 @@ fn theme(appearance: winit::window::Theme, light_pages: bool) -> Theme {...@@ -2639,7 +3072,11 @@ fn theme(appearance: winit::window::Theme, light_pages: bool) -> Theme {
2639 let light = Theme::light();3072 let light = Theme::light();
2640 [theme.paper, theme.paper_ink] = [light.paper, light.paper_ink];3073 [theme.paper, theme.paper_ink] = [light.paper, light.paper_ink];
2641 }3074 }
2642 theme3075 if backdrop {
3076 theme.over_backdrop()
3077 } else {
3078 theme
3079 }
2643}3080}
26443081
2645/// A section's colour as linear RGBA; sections without one take OneNote's default blue.3082/// 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...@@ -2663,16 +3100,19 @@ fn conflict_bar(ui: &mut Ui, bar: Bar, sections: &[&str], steps: [bool; 2]) -> O
2663 ..Spec::default()3100 ..Spec::default()
2664 },3101 },
2665 );3102 );
2666 ui.leaf(3103 // OneNote marks conflicts, not versions.
2667 "icon",3104 if !matches!(bar, Bar::History { .. }) {
2668 Spec {3105 ui.leaf(
2669 size: [fit(), px(16.0)],3106 "icon",
2670 icon: Some(art::CONFLICT),3107 Spec {
2671 color: Some(draw::srgb(0xc0, 0x48, 0x20)),3108 size: [fit(), px(16.0)],
2672 ..Spec::default()3109 icon: Some(art::CONFLICT),
2673 },3110 color: Some(draw::srgb(0xc0, 0x48, 0x20)),
2674 );3111 ..Spec::default()
2675 // OneNote 2010's words (corpus/conflict-page/native), a sentence to a line.3112 },
3113 );
3114 }
3115 // OneNote 2010's words (corpus/conflict-page/native, corpus/page-versions/native).
2676 let [said, action] = match bar {3116 let [said, action] = match bar {
2677 Bar::Page { shown, .. } => [3117 Bar::Page { shown, .. } => [
2678 "This page has changes that could not be merged during synchronization.",3118 "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...@@ -2686,6 +3126,10 @@ fn conflict_bar(ui: &mut Ui, bar: Bar, sections: &[&str], steps: [bool; 2]) -> O
2686 "Conflicting changes are highlighted in red. This page cannot be edited, but you can copy changes to the primary page.",3126 "Conflicting changes are highlighted in red. This page cannot be edited, but you can copy changes to the primary page.",
2687 "Click here for more options.",3127 "Click here for more options.",
2688 ],3128 ],
3129 Bar::History { .. } => [
3130 "This is an earlier version of the page. It will be deleted over time.",
3131 "Click here to restore or delete this version.",
3132 ],
2689 };3133 };
2690 ui.open(3134 ui.open(
2691 "message",3135 "message",
...@@ -2699,8 +3143,9 @@ fn conflict_bar(ui: &mut Ui, bar: Bar, sections: &[&str], steps: [bool; 2]) -> O...@@ -2699,8 +3143,9 @@ fn conflict_bar(ui: &mut Ui, bar: Bar, sections: &[&str], steps: [bool; 2]) -> O
2699 ui.leaf(3143 ui.leaf(
2700 part,3144 part,
2701 Spec {3145 Spec {
2702 size: [fit(), px(18.0)],3146 size: [fill(), fit()],
2703 text: Some(text),3147 text: Some(text),
3148 overflow: ui::Overflow::Wrap,
2704 color: Some(draw::srgb(0x20, 0x20, 0x20)),3149 color: Some(draw::srgb(0x20, 0x20, 0x20)),
2705 ..Spec::default()3150 ..Spec::default()
2706 },3151 },
...@@ -2717,6 +3162,16 @@ fn conflict_bar(ui: &mut Ui, bar: Bar, sections: &[&str], steps: [bool; 2]) -> O...@@ -2717,6 +3162,16 @@ fn conflict_bar(ui: &mut Ui, bar: Bar, sections: &[&str], steps: [bool; 2]) -> O
2717 let anchor = ui::Anchor::Below([left, top, left, bottom]);3162 let anchor = ui::Anchor::Below([left, top, left, bottom]);
2718 match bar {3163 match bar {
2719 Bar::Page { page, shown } => clicked.then_some(Command::Versions { page, show: !shown }),3164 Bar::Page { page, shown } => clicked.then_some(Command::Versions { page, show: !shown }),
3165 Bar::History {
3166 page,
3167 version,
3168 grouped,
3169 } => {
3170 if clicked {
3171 ui.open_popup(menu);
3172 }
3173 history::menu(ui, anchor, [page, version], grouped, sections)
3174 }
2720 Bar::Version { page, version } => {3175 Bar::Version { page, version } => {
2721 if clicked {3176 if clicked {
2722 ui.open_popup(menu);3177 ui.open_popup(menu);
...@@ -2782,76 +3237,246 @@ struct Rows {...@@ -2782,76 +3237,246 @@ struct Rows {
2782 context: Option<(ExGuid, [f32; 2])>,3237 context: Option<(ExGuid, [f32; 2])>,
2783 /// The page held down, which a drag moves.3238 /// The page held down, which a drag moves.
2784 held: Option<ExGuid>,3239 held: Option<ExGuid>,
3240 /// A page version clicked: the page, and the version's context.
3241 version: Option<(ExGuid, ExGuid)>,
3242 /// The page whose tab was pressed twice in a row, which renames it.
3243 renamed: Option<ExGuid>,
3244 /// Whether the rename field kept or dropped the name typed, once it closes.
3245 kept: Option<bool>,
3246 /// Where each page's tab stands down the list with none dragged.
3247 places: Vec<f32>,
3248 /// The dragged tab, let go, stands in its place.
3249 settled: bool,
3250}
3251
3252/// A page's tab being dragged: the page, the top it follows the pointer to down the list
3253/// until let go, and where among the other pages it would land.
3254#[derive(Clone, Copy)]
3255struct PageDrag {
3256 space: ExGuid,
3257 top: Option<f32>,
3258 slot: Option<usize>,
3259}
3260
3261/// The page list's zero-height first box, where its rows are measured from.
3262fn page_list_top() -> &'static str {
3263 "top"
2785}3264}
27863265
2787/// The page list's rows, a page's conflict pages beneath it while shown and the pages a3266/// The page list's rows, a page's conflict pages beneath it while shown and the pages a
2788/// search `found` marked.3267/// search `found` marked. A `dragged` page's tab follows the pointer over the others, which
3268/// slide aside to open the gap it would land in; once let go it eases into its place.
3269#[allow(clippy::too_many_arguments)]
2789fn page_rows(3270fn page_rows(
2790 ui: &mut Ui,3271 ui: &mut Ui,
2791 theme: &Theme,3272 theme: &Theme,
2792 section: &ui::Section,3273 section: &ui::Section,
2793 session: &Session,3274 session: &Session,
2794 found: &HashSet<ExGuid>,3275 found: &HashSet<ExGuid>,
3276 rounding: f32,
3277 mut renaming: Option<&mut rename::Renaming>,
3278 dragged: Option<PageDrag>,
2795) -> Rows {3279) -> Rows {
2796 let mut rows = Rows::default();3280 let mut rows = Rows {
2797 for (space, title, level) in &session.pages {3281 settled: true,
2798 let versions = session.versions(*space);3282 ..Rows::default()
2799 let tab = PageTab {3283 };
2800 label: if title.is_empty() {3284 ui.leaf(page_list_top(), Spec::default());
2801 "Untitled page"3285 let from = dragged.and_then(|dragged| {
2802 } else {3286 session
2803 title3287 .pages
2804 },3288 .iter()
2805 dim: title.is_empty(),3289 .position(|(space, ..)| *space == dragged.space)
2806 indent: level.saturating_sub(1),3290 });
2807 conflicted: !versions.is_empty(),3291 let slot = dragged.and_then(|dragged| dragged.slot).or(from);
2808 found: found.contains(space),3292 // Down the list as laid out without the dragged tab, and as it stands with it.
2809 };3293 let (mut laid, mut natural, mut gap) = (0.0, 0.0, None);
2810 let selected = *space == session.space;3294 let mut others = 0;
2811 if selected {3295 let mut lifted = None;
2812 rows.open = Some(ui.id(space));3296 for (index, (space, title, level)) in session.pages.iter().enumerate() {
2813 }3297 rows.places.push(natural);
2814 let signal = page_tab(ui, theme, section, space, &tab, selected);3298 if from == Some(index) {
2815 if signal.clicked && !selected {3299 lifted = Some((space, title, level));
2816 rows.clicked = Some(*space);3300 natural += ROW;
2817 }
2818 if let Some(point) = signal.context {
2819 rows.context = Some((*space, point));
2820 }
2821 if signal.dragging {
2822 rows.held = Some(*space);
2823 }
2824 if session.shown != Some(*space) {
2825 continue;3301 continue;
2826 }3302 }
2827 // OneNote lists a page's conflict pages beneath it, each by its date and whose3303 if Some(others) == slot {
2828 // version it is.3304 gap = Some(laid);
2829 let muted = ui::Section {3305 }
2830 tab: ui::mix(section.tab, theme.chip, 0.6),3306 // Eased only while a tab is dragged: the list lays out anew once it ends.
2831 ..*section3307 let shift = match (from, slot) {
3308 (Some(_), Some(slot)) => ui.animate(
3309 ui.id((space, "slide")),
3310 if others >= slot { ROW } else { 0.0 },
3311 ),
3312 _ => 0.0,
2832 };3313 };
2833 for version in versions {3314 others += 1;
2834 let label = match version.created {3315 let height = page_row(
2835 Some(created) => format!("{} {}", platform::short_date(created), version.user),3316 ui,
2836 None => version.user.clone(),3317 theme,
2837 };3318 section,
3319 session,
3320 found,
3321 rounding,
3322 renaming.as_deref_mut(),
3323 (space, title, *level),
3324 shift,
3325 false,
3326 &mut rows,
3327 );
3328 laid += height;
3329 natural += height;
3330 }
3331 if let (Some((space, title, level)), Some(dragged)) = (lifted, dragged) {
3332 let gap = gap.unwrap_or(laid);
3333 let key = ui.id((space, "dragged"));
3334 let top = match dragged.top {
3335 Some(top) => ui.hold(key, top),
3336 None => {
3337 let top = ui.animate(key, gap);
3338 rows.settled = (top - gap).abs() < 0.5;
3339 top
3340 }
3341 };
3342 page_row(
3343 ui,
3344 theme,
3345 section,
3346 session,
3347 found,
3348 rounding,
3349 renaming,
3350 (space, title, *level),
3351 top - laid,
3352 dragged.top.is_some(),
3353 &mut rows,
3354 );
3355 }
3356 rows
3357}
3358
3359/// The tab of page `space` with its versions or conflict pages beneath it while shown,
3360/// drawn `shift` down from where they are laid out, and `lifted` as a dragged tab is:
3361/// how tall they are.
3362#[allow(clippy::too_many_arguments)]
3363fn page_row(
3364 ui: &mut Ui,
3365 theme: &Theme,
3366 section: &ui::Section,
3367 session: &Session,
3368 found: &HashSet<ExGuid>,
3369 rounding: f32,
3370 renaming: Option<&mut rename::Renaming>,
3371 (space, title, level): (&ExGuid, &String, u32),
3372 shift: f32,
3373 lifted: bool,
3374 rows: &mut Rows,
3375) -> f32 {
3376 let mut height = ROW;
3377 let versions = session.versions(*space);
3378 let tab = PageTab {
3379 label: if title.is_empty() {
3380 "Untitled page"
3381 } else {
3382 title
3383 },
3384 dim: title.is_empty(),
3385 indent: level.saturating_sub(1),
3386 conflicted: !versions.is_empty(),
3387 found: found.contains(space),
3388 renaming: renaming
3389 .filter(|renaming| renaming.page(*space))
3390 .map(|renaming| &mut renaming.name),
3391 shift,
3392 lifted,
3393 };
3394 let selected = *space == session.space && session.version.is_none();
3395 if selected && !lifted {
3396 rows.open = Some(ui.id(space));
3397 }
3398 let (signal, kept) = page_tab(ui, theme, section, space, tab, selected, rounding);
3399 rows.kept = rows.kept.or(kept);
3400 if signal.clicked && !selected {
3401 rows.clicked = Some(*space);
3402 }
3403 if signal.pressed && signal.unit != draw::edit::SelectionUnit::Grapheme {
3404 rows.renamed = Some(*space);
3405 }
3406 if let Some(point) = signal.context {
3407 rows.context = Some((*space, point));
3408 }
3409 if signal.dragging {
3410 rows.held = Some(*space);
3411 }
3412 if lifted {
3413 return height;
3414 }
3415 // OneNote lists a page's conflict pages beneath it, each by its date and whose
3416 // version it is, and its page versions alike.
3417 let muted = ui::Section {
3418 tab: ui::mix(section.tab, theme.chip, 0.6),
3419 ..*section
3420 };
3421 if session.shown_history == Some(*space) {
3422 for version in session.page_versions(*space) {
3423 let label = history::label(version);
2838 let tab = PageTab {3424 let tab = PageTab {
2839 label: &label,3425 label: &label,
2840 dim: false,3426 dim: false,
2841 indent: level.saturating_sub(1),3427 indent: level.saturating_sub(1),
2842 conflicted: false,3428 conflicted: false,
2843 found: false,3429 found: false,
3430 renaming: None,
3431 shift,
3432 lifted: false,
2844 };3433 };
2845 let selected = version.space == session.space;3434 let selected = session.version == Some(version.context);
2846 if selected {3435 if selected {
2847 rows.open = Some(ui.id(version.space));3436 rows.open = Some(ui.id(version.context));
2848 }3437 }
2849 if page_tab(ui, theme, &muted, &version.space, &tab, selected).clicked && !selected {3438 height += ROW;
2850 rows.clicked = Some(version.space);3439 if page_tab(ui, theme, &muted, &version.context, tab, selected, rounding)
3440 .0
3441 .clicked
3442 && !selected
3443 {
3444 rows.version = Some((*space, version.context));
2851 }3445 }
2852 }3446 }
2853 }3447 }
2854 rows3448 if session.shown != Some(*space) {
3449 return height;
3450 }
3451 for version in versions {
3452 let label = match version.created {
3453 Some(created) => format!("{} {}", platform::short_date(created), version.user),
3454 None => version.user.clone(),
3455 };
3456 let tab = PageTab {
3457 label: &label,
3458 dim: false,
3459 indent: level.saturating_sub(1),
3460 conflicted: false,
3461 found: false,
3462 renaming: None,
3463 shift,
3464 lifted: false,
3465 };
3466 let selected = version.space == session.space;
3467 if selected {
3468 rows.open = Some(ui.id(version.space));
3469 }
3470 height += ROW;
3471 if page_tab(ui, theme, &muted, &version.space, tab, selected, rounding)
3472 .0
3473 .clicked
3474 && !selected
3475 {
3476 rows.clicked = Some(version.space);
3477 }
3478 }
3479 height
2855}3480}
28563481
2857/// A row of the page list.3482/// A row of the page list.
...@@ -2864,23 +3489,38 @@ struct PageTab<'a> {...@@ -2864,23 +3489,38 @@ struct PageTab<'a> {
2864 conflicted: bool,3489 conflicted: bool,
2865 /// A search found the page, which OneNote marks yellow.3490 /// A search found the page, which OneNote marks yellow.
2866 found: bool,3491 found: bool,
3492 /// The name typed in the tab's rename field, while it shows one.
3493 renaming: Option<&'a mut String>,
3494 /// How far down from its place in the list it is drawn.
3495 shift: f32,
3496 /// Dragged: lifted over the others with a shadow.
3497 lifted: bool,
2867}3498}
28683499
2869/// A page's tab down the frame's right side: the open one is the page's colour and joins3500/// A page's tab down the frame's right side: the open one is the page's colour and joins
2870/// it, the others float free of it as pills.3501/// it, the others float free of it as pills rounded like the page.
2871fn page_tab(3502fn page_tab(
2872 ui: &mut Ui,3503 ui: &mut Ui,
2873 theme: &Theme,3504 theme: &Theme,
2874 section: &ui::Section,3505 section: &ui::Section,
2875 id: &ExGuid,3506 id: &ExGuid,
2876 tab: &PageTab,3507 tab: PageTab,
2877 selected: bool,3508 selected: bool,
2878) -> ui::Signal {3509 rounding: f32,
2879 let pad = 10.0 + 16.0 * tab.indent as f32;3510) -> (ui::Signal, Option<bool>) {
3511 // A rename field's text stands where the label did.
3512 let pad = 10.0 + 16.0 * tab.indent as f32
3513 - if tab.renaming.is_some() {
3514 rename::PAD
3515 } else {
3516 0.0
3517 };
2880 let spec = Spec {3518 let spec = Spec {
2881 flags: Flags::CLICKABLE,3519 flags: Flags::CLICKABLE,
2882 size: [px(PAGE_LIST), px(ROW)],3520 size: [px(PAGE_LIST), px(ROW)],
2883 pad: [pad, 0.0],3521 pad: [pad, 0.0],
3522 offset: [0.0, tab.shift],
3523 shadow: tab.lifted.then_some([0.0, 0.0, 0.0, 0.35]),
2884 ..Spec::default()3524 ..Spec::default()
2885 };3525 };
2886 let (spec, color) = if selected {3526 let (spec, color) = if selected {
...@@ -2891,15 +3531,18 @@ fn page_tab(...@@ -2891,15 +3531,18 @@ fn page_tab(
2891 };3531 };
2892 (spec, theme.paper_ink)3532 (spec, theme.paper_ink)
2893 } else {3533 } else {
2894 let fill = if tab.found {3534 // A found page's yellow lies over the tab, hovered or not.
2895 ui::mix(section.tab, draw::srgb(0xff, 0xd8, 0x30), 0.6)3535 let tint = |fill| {
2896 } else {3536 if tab.found {
2897 section.tab3537 ui::mix(fill, draw::srgb(0xff, 0xd8, 0x30), 0.6)
3538 } else {
3539 fill
3540 }
2898 };3541 };
2899 let spec = Spec {3542 let spec = Spec {
2900 fill: Some(fill),3543 fill: Some(tint(section.tab)),
2901 hover_fill: Some(ui::mix(fill, section.accent, 0.3)),3544 hover_fill: Some(tint(section.hover())),
2902 radius: ROUNDING,3545 radius: rounding,
2903 inset: [PILL_MARGIN, 0.0, 6.0, ROW_GAP],3546 inset: [PILL_MARGIN, 0.0, 6.0, ROW_GAP],
2904 // The label keeps its place as the tab opens and closes.3547 // The label keeps its place as the tab opens and closes.
2905 pad: [pad - PILL_MARGIN, 0.0],3548 pad: [pad - PILL_MARGIN, 0.0],
...@@ -2913,15 +3556,22 @@ fn page_tab(...@@ -2913,15 +3556,22 @@ fn page_tab(
2913 (spec, color)3556 (spec, color)
2914 };3557 };
2915 let row = ui.open(id, spec);3558 let row = ui.open(id, spec);
2916 ui.leaf(3559 let kept = match tab.renaming {
2917 "label",3560 Some(name) => rename::edit(ui, theme, name, ROW - ROW_GAP),
2918 Spec {3561 None => {
2919 size: [fill(), px(ROW - ROW_GAP)],3562 ui.leaf(
2920 text: Some(tab.label),3563 "label",
2921 color: Some(color),3564 Spec {
2922 ..Spec::default()3565 size: [fill(), px(ROW - ROW_GAP)],
2923 },3566 text: Some(tab.label),
2924 );3567 overflow: ui::Overflow::Ellipsis,
3568 color: Some(color),
3569 ..Spec::default()
3570 },
3571 );
3572 None
3573 }
3574 };
2925 if tab.conflicted {3575 if tab.conflicted {
2926 ui.leaf(3576 ui.leaf(
2927 "conflict",3577 "conflict",
...@@ -2935,7 +3585,60 @@ fn page_tab(...@@ -2935,7 +3585,60 @@ fn page_tab(
2935 );3585 );
2936 }3586 }
2937 ui.close();3587 ui.close();
2938 ui.signal(row)3588 (ui.signal(row), kept)
3589}
3590
3591/// Puts `item` first among `recent` picks.
3592fn remember<T: PartialEq>(recent: &mut Vec<T>, item: T) {
3593 recent.retain(|known| *known != item);
3594 recent.insert(0, item);
3595 recent.truncate(RECENT);
3596}
3597
3598/// Builds popup `id` as a gallery of a list library of `count` styles after None, under
3599/// `headings[1]`, and above it the library places `recent` under `headings[0]`, with library
3600/// place `current` outlined; `preview` builds a place's cell. Returns the place chosen, or
3601/// None for None.
3602#[allow(clippy::too_many_arguments)]
3603fn list_gallery(
3604 ui: &mut Ui,
3605 id: Id,
3606 anchor: ui::Anchor,
3607 headings: [&str; 2],
3608 (recent, count): (&[usize], usize),
3609 current: Option<usize>,
3610 size: [f32; 2],
3611 preview: impl Fn(&mut Ui, usize),
3612) -> Option<Option<usize>> {
3613 let groups = [(headings[0], recent.len()), (headings[1], count + 1)];
3614 let place = |index: usize| match index.checked_sub(recent.len()) {
3615 None => Some(recent[index]),
3616 Some(at) => at.checked_sub(1),
3617 };
3618 let chosen = ui::popup::gallery(
3619 ui,
3620 id,
3621 anchor,
3622 &groups[usize::from(recent.is_empty())..],
3623 5,
3624 size,
3625 current.map(|place| recent.len() + 1 + place),
3626 |ui, index| match place(index) {
3627 Some(place) => preview(ui, place),
3628 None => {
3629 ui.leaf(
3630 "none",
3631 Spec {
3632 size: [fill(), fill()],
3633 text: Some("None"),
3634 center: true,
3635 ..Spec::default()
3636 },
3637 );
3638 }
3639 },
3640 )?;
3641 Some(place(chosen))
2939}3642}
29403643
2941/// A toolbar group's rows, stacked.3644/// A toolbar group's rows, stacked.
...@@ -2997,6 +3700,7 @@ fn read_session(...@@ -2997,6 +3700,7 @@ fn read_session(
2997 .ok_or("The section has no pages")?;3700 .ok_or("The section has no pages")?;
2998 let page = section.page(space)?;3701 let page = section.page(space)?;
2999 let conflicts = section.conflicts()?;3702 let conflicts = section.conflicts()?;
3703 let history = section.versions()?;
3000 Ok((3704 Ok((
3001 Session {3705 Session {
3002 section,3706 section,
...@@ -3009,6 +3713,9 @@ fn read_session(...@@ -3009,6 +3713,9 @@ fn read_session(
3009 conflicts,3713 conflicts,
3010 shown: None,3714 shown: None,
3011 change: None,3715 change: None,
3716 history,
3717 shown_history: None,
3718 version: None,
3012 },3719 },
3013 page,3720 page,
3014 ))3721 ))
...@@ -3111,9 +3818,15 @@ impl ApplicationHandler<UserEvent> for App {...@@ -3111,9 +3818,15 @@ impl ApplicationHandler<UserEvent> for App {
3111 }3818 }
3112 return;3819 return;
3113 }3820 }
3114 UserEvent::Options => {3821 UserEvent::Choose(choice) => {
3115 if let Some(state) = &mut self.state {3822 if let Some(state) = &mut self.state {
3116 state.open_options();3823 state.choose(choice);
3824 }
3825 return;
3826 }
3827 UserEvent::Appearance => {
3828 if let Some(state) = &mut self.state {
3829 state.follow_color_scheme();
3117 state.window.request_redraw();3830 state.window.request_redraw();
3118 }3831 }
3119 return;3832 return;
...@@ -3132,7 +3845,7 @@ impl ApplicationHandler<UserEvent> for App {...@@ -3132,7 +3845,7 @@ impl ApplicationHandler<UserEvent> for App {
3132 Replay::Tick => {}3845 Replay::Tick => {}
3133 Replay::Appearance(appearance) => {3846 Replay::Appearance(appearance) => {
3134 state.window.set_theme(Some(appearance));3847 state.window.set_theme(Some(appearance));
3135 state.ui.theme = theme(appearance, state.light_pages);3848 state.set_appearance(appearance);
3136 }3849 }
3137 Replay::Quit => {3850 Replay::Quit => {
3138 self.close(event_loop);3851 self.close(event_loop);
...@@ -3183,7 +3896,6 @@ impl ApplicationHandler<UserEvent> for App {...@@ -3183,7 +3896,6 @@ impl ApplicationHandler<UserEvent> for App {
3183 self.proxy.clone(),3896 self.proxy.clone(),
3184 self.input.take().unwrap(),3897 self.input.take().unwrap(),
3185 &self.substitutes,3898 &self.substitutes,
3186 std::mem::take(&mut self.interface_font),
3187 self.screenshot.is_none(),3899 self.screenshot.is_none(),
3188 self.launch.take().expect("The window opens once"),3900 self.launch.take().expect("The window opens once"),
3189 )) {3901 )) {
...@@ -3230,8 +3942,9 @@ impl ApplicationHandler<UserEvent> for App {...@@ -3230,8 +3942,9 @@ impl ApplicationHandler<UserEvent> for App {
3230 WindowEvent::RedrawRequested => return state.frame(),3942 WindowEvent::RedrawRequested => return state.frame(),
3231 WindowEvent::Resized(size) => {3943 WindowEvent::Resized(size) => {
3232 if size.width > 0 && size.height > 0 {3944 if size.width > 0 && size.height > 0 {
3233 state.config.width = size.width;3945 let ratio = state.scale() / scale;
3234 state.config.height = size.height;3946 state.config.width = (size.width as f32 * ratio) as u32;
3947 state.config.height = (size.height as f32 * ratio) as u32;
3235 state3948 state
3236 .surface3949 .surface
3237 .configure(&state.renderer.device, &state.config);3950 .configure(&state.renderer.device, &state.config);
...@@ -3241,15 +3954,12 @@ impl ApplicationHandler<UserEvent> for App {...@@ -3241,15 +3954,12 @@ impl ApplicationHandler<UserEvent> for App {
3241 return state.frame();3954 return state.frame();
3242 }3955 }
3243 WindowEvent::ThemeChanged(appearance) => {3956 WindowEvent::ThemeChanged(appearance) => {
3244 let chosen = state.color_scheme.theme().unwrap_or(appearance);3957 state.set_appearance(state.color_scheme.theme().unwrap_or(appearance));
3245 state.ui.theme = theme(chosen, state.light_pages);
3246 state.window.request_redraw();3958 state.window.request_redraw();
3247 }3959 }
3248 WindowEvent::ScaleFactorChanged { scale_factor, .. } => {3960 WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
3249 state.renderer.clear_glyph_cache();3961 state.renderer.clear_glyph_cache();
3250 state.app_icon = platform::app_icon((16.0 * scale_factor).round() as u32);3962 state.app_icon = platform::app_icon((16.0 * scale_factor).round() as u32);
3251 let response = state.view.scale_factor_changed(scale_factor as f32)?;
3252 state.respond(response);
3253 state.window.request_redraw();3963 state.window.request_redraw();
3254 }3964 }
3255 WindowEvent::Focused(focused) => {3965 WindowEvent::Focused(focused) => {
...@@ -3423,7 +4133,6 @@ fn main() -> Result<(), Box<dyn Error>> {...@@ -3423,7 +4133,6 @@ fn main() -> Result<(), Box<dyn Error>> {
3423 let mut args = std::env::args_os().skip(1);4133 let mut args = std::env::args_os().skip(1);
3424 let mut positional = Vec::new();4134 let mut positional = Vec::new();
3425 let mut substitutes = Vec::new();4135 let mut substitutes = Vec::new();
3426 let mut interface_font = Vec::new();
3427 let mut reference = None;4136 let mut reference = None;
3428 let mut editable = false;4137 let mut editable = false;
3429 let mut section = None;4138 let mut section = None;
...@@ -3437,27 +4146,6 @@ fn main() -> Result<(), Box<dyn Error>> {...@@ -3437,27 +4146,6 @@ fn main() -> Result<(), Box<dyn Error>> {
3437 args.next()4146 args.next()
3438 .ok_or("Provide a font file after --substitute-font.")?,4147 .ok_or("Provide a font file after --substitute-font.")?,
3439 ));4148 ));
3440 } else if arg == "--ui-font" {
3441 let path = PathBuf::from(
3442 args.next()
3443 .ok_or("Provide a font file or folder after --ui-font.")?,
3444 );
3445 let mut files = if path.is_dir() {
3446 std::fs::read_dir(&path)?
3447 .map(|entry| entry.map(|entry| entry.path()))
3448 .collect::<Result<Vec<_>, _>>()?
3449 } else {
3450 vec![path]
3451 };
3452 files.retain(|file| {
3453 file.extension().is_some_and(|extension| {
3454 matches!(extension.to_str(), Some("otf" | "ttf" | "ttc"))
3455 })
3456 });
3457 files.sort();
3458 for file in files {
3459 interface_font.push(std::fs::read(file)?);
3460 }
3461 } else if arg == "--notebook" {4149 } else if arg == "--notebook" {
3462 notebook = Some(PathBuf::from(4150 notebook = Some(PathBuf::from(
3463 args.next()4151 args.next()
...@@ -3484,7 +4172,8 @@ fn main() -> Result<(), Box<dyn Error>> {...@@ -3484,7 +4172,8 @@ fn main() -> Result<(), Box<dyn Error>> {
3484 ));4172 ));
3485 } else if arg == "--settings" {4173 } else if arg == "--settings" {
3486 settings_file = Some(PathBuf::from(4174 settings_file = Some(PathBuf::from(
3487 args.next().ok_or("Provide a settings file after --settings.")?,4175 args.next()
4176 .ok_or("Provide a settings file after --settings.")?,
3488 ));4177 ));
3489 } else if arg == "--screenshot" {4178 } else if arg == "--screenshot" {
3490 screenshot = Some(PathBuf::from(4179 screenshot = Some(PathBuf::from(
...@@ -3525,7 +4214,7 @@ fn main() -> Result<(), Box<dyn Error>> {...@@ -3525,7 +4214,7 @@ fn main() -> Result<(), Box<dyn Error>> {
3525 }4214 }
3526 if positional.len() > 2 {4215 if positional.len() > 2 {
3527 return Err(4216 return Err(
3528 "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]..."4217 "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]..."
3529 .into(),4218 .into(),
3530 );4219 );
3531 }4220 }
...@@ -3592,7 +4281,6 @@ fn main() -> Result<(), Box<dyn Error>> {...@@ -3592,7 +4281,6 @@ fn main() -> Result<(), Box<dyn Error>> {
3592 cache,4281 cache,
3593 }),4282 }),
3594 substitutes,4283 substitutes,
3595 interface_font,
3596 screenshot,4284 screenshot,
3597 state: None,4285 state: None,
3598 startup_error: None,4286 startup_error: None,
crates/snowbound/src/manage.rs+58-11
...@@ -52,6 +52,13 @@ fn follow(current: &str, from: &str, to: &str) -> String {...@@ -52,6 +52,13 @@ fn follow(current: &str, from: &str, to: &str) -> String {
52 }52 }
53}53}
5454
55/// The section `library` shows after a change: `open` where it still lists it, else its
56/// first, or none once it has no sections.
57fn shown(library: &Library, open: Option<String>) -> Option<String> {
58 open.filter(|path| library.contains(path))
59 .or_else(|| library.first_section())
60}
61
55/// The first of `base`, `base 2`, `base 3`… that `taken` does not hold, as OneNote names62/// The first of `base`, `base 2`, `base 3`… that `taken` does not hold, as OneNote names
56/// new sections ("New Section 1", …) and groups.63/// new sections ("New Section 1", …) and groups.
57fn unused(taken: &[String], base: &str, numbered: bool) -> String {64fn unused(taken: &[String], base: &str, numbered: bool) -> String {
...@@ -162,13 +169,16 @@ impl State {...@@ -162,13 +169,16 @@ impl State {
162 /// Closes `library`: its files stay, and the sidebar and the next launch leave it out.169 /// Closes `library`: its files stay, and the sidebar and the next launch leave it out.
163 pub(crate) fn close_notebook(&mut self, library: &Arc<Library>) {170 pub(crate) fn close_notebook(&mut self, library: &Arc<Library>) {
164 self.notebooks.retain(|open| !Arc::ptr_eq(open, library));171 self.notebooks.retain(|open| !Arc::ptr_eq(open, library));
165 if self172 let shown = |shown: &Arc<Library>| shown.location == library.location;
166 .session173 if self.sectionless.as_ref().is_some_and(shown)
167 .as_ref()174 || self
168 .is_some_and(|session| Arc::ptr_eq(&session.library, library))175 .session
176 .as_ref()
177 .is_some_and(|session| shown(&session.library))
169 {178 {
170 self.persist().unwrap_or_else(|error| eprintln!("{error}"));179 self.persist().unwrap_or_else(|error| eprintln!("{error}"));
171 self.session = None;180 self.session = None;
181 self.sectionless = None;
172 self.templates = crate::templates::View::Strip;182 self.templates = crate::templates::View::Strip;
173 match self183 match self
174 .notebooks184 .notebooks
...@@ -176,7 +186,10 @@ impl State {...@@ -176,7 +186,10 @@ impl State {
176 .find_map(|open| Some((Arc::clone(open), open.first_section()?)))186 .find_map(|open| Some((Arc::clone(open), open.first_section()?)))
177 {187 {
178 Some((open, path)) => self.commands.push(Command::OpenSection(open, path)),188 Some((open, path)) => self.commands.push(Command::OpenSection(open, path)),
179 None => self.title(),189 None => {
190 self.sectionless = self.notebooks.first().cloned();
191 self.title();
192 }
180 }193 }
181 }194 }
182 self.save_settings();195 self.save_settings();
...@@ -257,20 +270,24 @@ impl State {...@@ -257,20 +270,24 @@ impl State {
257 (current, None)270 (current, None)
258 }271 }
259 };272 };
273 let fresh = created.is_some();
260 let open = created.or(followed.clone());274 let open = created.or(followed.clone());
261 let library = Arc::new(library.with(notebook));275 let library = Arc::new(library.with(notebook));
262 let path = open276 let Some(path) = shown(&library, open) else {
263 .filter(|path| library.contains(path))277 return Ok(Loaded::Library(library, None));
264 .or_else(|| library.first_section())278 };
265 .ok_or("The notebook has no sections left")?;
266 // The open section, perhaps renamed or moved, stays open: its replica is this279 // The open section, perhaps renamed or moved, stays open: its replica is this
267 // thread's to reopen only where no section holds it.280 // thread's to reopen only where no section holds it.
268 if followed.as_deref() == Some(path.as_str()) {281 if followed.as_deref() == Some(path.as_str()) {
269 return Ok(Loaded::Library(library, path));282 return Ok(Loaded::Library(library, Some(path)));
270 }283 }
271 let section = library.open(&path, notify)?;284 let section = library.open(&path, notify)?;
272 let (session, page) = read_session(section, library, path, None)?;285 let (session, page) = read_session(section, library, path, None)?;
273 Ok(Loaded::Section(Box::new(session), page))286 Ok(if fresh {
287 Loaded::Created(Box::new(session), page)
288 } else {
289 Loaded::Section(Box::new(session), page)
290 })
274 });291 });
275 }292 }
276293
...@@ -765,6 +782,36 @@ mod tests {...@@ -765,6 +782,36 @@ mod tests {
765 std::fs::remove_dir_all(&temporary).unwrap();782 std::fs::remove_dir_all(&temporary).unwrap();
766 }783 }
767784
785 /// Deleting a group's last section shows the notebook's other one; deleting that too
786 /// leaves the notebook showing no section, where it failed before.
787 #[test]
788 fn deleting_every_section_leaves_the_notebook_showing_none() {
789 let temporary =
790 std::env::temp_dir().join(format!("snowbound-empty-{}", std::process::id()));
791 let _ = std::fs::remove_dir_all(&temporary);
792 std::fs::create_dir_all(&temporary).unwrap();
793 let root = temporary.join("Emptied");
794 let location = root.to_str().unwrap();
795 let cache = temporary.join("cache");
796 let mut notebook = Notebook::create(location, &cache, NOTEBOOK_COLOR, &dated()).unwrap();
797 notebook.create_group("", "Group").unwrap();
798 notebook.create_section("Group", "Inner", &dated()).unwrap();
799
800 notebook.delete("Group/Inner.one").unwrap();
801 let library = Library::created(location, notebook, &cache);
802 assert_eq!(
803 shown(&library, Some("Group/Inner.one".into())).as_deref(),
804 Some("New Section 1.one")
805 );
806
807 let mut notebook = library.reopen().unwrap();
808 notebook.delete("New Section 1.one").unwrap();
809 let library = library.with(notebook);
810 assert_eq!(shown(&library, Some("New Section 1.one".into())), None);
811 assert!(library.tabs("").is_empty());
812 std::fs::remove_dir_all(&temporary).unwrap();
813 }
814
768 #[test]815 #[test]
769 fn moved_sections_are_followed_and_new_names_take_the_next_number() {816 fn moved_sections_are_followed_and_new_names_take_the_next_number() {
770 assert_eq!(follow("Group/Inner.one", "Group", "Kept"), "Kept/Inner.one");817 assert_eq!(follow("Group/Inner.one", "Group", "Kept"), "Kept/Inner.one");
crates/snowbound/src/menubar.rs created+407
...@@ -0,0 +1,407 @@
1//! The macOS menu bar, laid out as OneNote for Mac's: the command table's commands, and
2//! AppKit's own items where the system provides them.
3
4use crate::commands::{self, Choice, Chord, Id, cmd, named};
5use canvas::editor::{Alignment, NoteTag, Toggle};
6use draw::edit::NamedKey;
7use objc2::{
8 rc::Retained,
9 runtime::{AnyObject, Sel},
10};
11use objc2_app_kit::{NSEventModifierFlags, NSMenu, NSMenuItem};
12use objc2_foundation::{MainThreadMarker, NSString};
13
14enum Item {
15 Command(Id),
16 /// AppKit's own: its title, its action's selector and its chord.
17 System(&'static str, &'static str, Option<Chord>),
18 Separator,
19 Menu(&'static str, &'static [Item]),
20 /// The system's Services menu.
21 Services,
22 /// Each of a list's choices.
23 Fonts,
24 Sizes,
25 Highlights,
26}
27
28use Item::{Command as C, Separator as S};
29
30const MENUS: &[Item] = &[
31 Item::Menu(
32 "Snowbound",
33 &[
34 Item::System("About Snowbound", "orderFrontStandardAboutPanel:", None),
35 S,
36 C(Id::Settings),
37 S,
38 Item::Services,
39 S,
40 Item::System("Hide Snowbound", "hide:", Some(cmd('h'))),
41 Item::System(
42 "Hide Others",
43 "hideOtherApplications:",
44 Some(cmd('h').option()),
45 ),
46 Item::System("Show All", "unhideAllApplications:", None),
47 S,
48 Item::System("Quit Snowbound", "terminate:", Some(cmd('q'))),
49 ],
50 ),
51 Item::Menu(
52 "File",
53 &[
54 C(Id::NewNotebook),
55 C(Id::OpenNotebook),
56 C(Id::CloseNotebook),
57 S,
58 C(Id::NewSection),
59 C(Id::NewSectionGroup),
60 C(Id::NewPage),
61 C(Id::NewSubpage),
62 S,
63 C(Id::PageVersions),
64 C(Id::CopyPageLink),
65 C(Id::ShowNotebook),
66 ],
67 ),
68 Item::Menu(
69 "Edit",
70 &[
71 C(Id::Undo),
72 C(Id::Redo),
73 S,
74 C(Id::Cut),
75 C(Id::Copy),
76 C(Id::Paste),
77 C(Id::SelectAll),
78 S,
79 C(Id::Find),
80 C(Id::Search),
81 S,
82 Item::System("Start Dictation…", "startDictation:", None),
83 Item::System(
84 "Emoji & Symbols",
85 "orderFrontCharacterPalette:",
86 Some(named(NamedKey::Space).command().control()),
87 ),
88 ],
89 ),
90 // AppKit appends Enter Full Screen itself, and would repeat one listed here.
91 Item::Menu(
92 "View",
93 &[
94 C(Id::Back),
95 C(Id::Forward),
96 S,
97 C(Id::ZoomIn),
98 C(Id::ZoomOut),
99 C(Id::ActualSize),
100 S,
101 C(Id::Sidebar),
102 C(Id::PageList),
103 C(Id::DarkPages),
104 ],
105 ),
106 Item::Menu(
107 "Insert",
108 &[
109 C(Id::Table),
110 C(Id::Picture),
111 C(Id::Link),
112 C(Id::Equation),
113 S,
114 C(Id::Date),
115 C(Id::Time),
116 C(Id::DateTime),
117 ],
118 ),
119 Item::Menu(
120 "Format",
121 &[
122 Item::Menu("Font", &[Item::Fonts]),
123 Item::Menu("Size", &[Item::Sizes]),
124 S,
125 C(Id::Toggle(Toggle::Bold)),
126 C(Id::Toggle(Toggle::Italic)),
127 C(Id::Toggle(Toggle::Underline)),
128 C(Id::Toggle(Toggle::Strikethrough)),
129 C(Id::Toggle(Toggle::Subscript)),
130 C(Id::Toggle(Toggle::Superscript)),
131 S,
132 C(Id::Highlight),
133 Item::Menu("Highlight Color", &[Item::Highlights]),
134 C(Id::FontColor),
135 S,
136 C(Id::Bullets),
137 C(Id::Numbering),
138 S,
139 C(Id::Align(Alignment::Left)),
140 C(Id::Align(Alignment::Center)),
141 C(Id::Align(Alignment::Right)),
142 S,
143 C(Id::Indent),
144 C(Id::Outdent),
145 S,
146 C(Id::ClearFormatting),
147 ],
148 ),
149 Item::Menu(
150 "Tags",
151 &[
152 C(Id::Tag(NoteTag::ToDo)),
153 C(Id::Tag(NoteTag::Important)),
154 C(Id::Tag(NoteTag::Question)),
155 C(Id::Tag(NoteTag::RememberForLater)),
156 C(Id::Tag(NoteTag::Definition)),
157 C(Id::Tag(NoteTag::Highlight)),
158 C(Id::Tag(NoteTag::Contact)),
159 C(Id::Tag(NoteTag::Address)),
160 C(Id::Tag(NoteTag::PhoneNumber)),
161 S,
162 C(Id::RemoveTags),
163 ],
164 ),
165 Item::Menu(
166 "Window",
167 &[
168 Item::System("Minimize", "performMiniaturize:", Some(cmd('m'))),
169 Item::System("Zoom", "performZoom:", None),
170 S,
171 Item::System("Bring All to Front", "arrangeInFront:", None),
172 ],
173 ),
174 Item::Menu("Help", &[C(Id::Help)]),
175];
176
177/// The bar, and the menus AppKit fills itself.
178pub struct Menus {
179 pub bar: Retained<NSMenu>,
180 pub services: Retained<NSMenu>,
181 pub window: Retained<NSMenu>,
182 pub help: Retained<NSMenu>,
183}
184
185/// Builds the bar; the table's items send `choose:` to `target`, which validates them.
186pub fn build(mtm: MainThreadMarker, target: &AnyObject) -> Menus {
187 let services = NSMenu::new(mtm);
188 let bar = NSMenu::new(mtm);
189 let mut submenus = Vec::new();
190 for item in MENUS {
191 let Item::Menu(title, items) = item else {
192 unreachable!("The bar holds menus")
193 };
194 let menu = menu(mtm, target, title, items, &services);
195 let holder = NSMenuItem::new(mtm);
196 holder.setSubmenu(Some(&menu));
197 bar.addItem(&holder);
198 submenus.push(menu);
199 }
200 let named = |name: &str| {
201 submenus
202 .iter()
203 .find(|menu| unsafe { menu.title() }.to_string() == name)
204 .cloned()
205 .expect("The bar has the menus AppKit fills")
206 };
207 Menus {
208 window: named("Window"),
209 help: named("Help"),
210 services,
211 bar,
212 }
213}
214
215fn menu(
216 mtm: MainThreadMarker,
217 target: &AnyObject,
218 title: &str,
219 items: &[Item],
220 services: &NSMenu,
221) -> Retained<NSMenu> {
222 let menu = unsafe { NSMenu::initWithTitle(mtm.alloc(), &NSString::from_str(title)) };
223 let choose = |choice: &Choice, title: &str, chord: Option<Chord>| {
224 let item = entry(mtm, title, Some(objc2::sel!(choose:)), chord);
225 // The application validates and runs the table's items whatever holds focus.
226 unsafe {
227 item.setTarget(Some(target));
228 item.setTag(commands::index(choice) as isize);
229 }
230 menu.addItem(&item);
231 };
232 let list = |pick: fn(&Choice) -> bool| {
233 for choice in commands::choices().filter(pick) {
234 choose(&choice, &commands::title(choice.clone()), None);
235 }
236 };
237 for item in items {
238 match item {
239 Item::Command(id) => {
240 let command = commands::command(*id);
241 choose(
242 &Choice::Command(*id),
243 command.title,
244 command.mac.first().copied(),
245 );
246 }
247 Item::System(title, action, chord) => {
248 menu.addItem(&entry(mtm, title, Some(Sel::register(action)), *chord));
249 }
250 Item::Separator => menu.addItem(&NSMenuItem::separatorItem(mtm)),
251 Item::Menu(title, items) => {
252 let holder = entry(mtm, title, None, None);
253 holder.setSubmenu(Some(&self::menu(mtm, target, title, items, services)));
254 menu.addItem(&holder);
255 }
256 Item::Services => {
257 let holder = entry(mtm, "Services", None, None);
258 holder.setSubmenu(Some(services));
259 menu.addItem(&holder);
260 }
261 Item::Fonts => list(|choice| matches!(choice, Choice::Font(_))),
262 Item::Sizes => list(|choice| matches!(choice, Choice::Size(_))),
263 Item::Highlights => list(|choice| matches!(choice, Choice::Highlight(_))),
264 }
265 }
266 menu
267}
268
269fn entry(
270 mtm: MainThreadMarker,
271 title: &str,
272 action: Option<Sel>,
273 chord: Option<Chord>,
274) -> Retained<NSMenuItem> {
275 let (key, shift) = chord.map_or((String::new(), false), Chord::equivalent);
276 let item = unsafe {
277 NSMenuItem::initWithTitle_action_keyEquivalent(
278 mtm.alloc(),
279 &NSString::from_str(title),
280 action,
281 &NSString::from_str(&key),
282 )
283 };
284 if let Some(chord) = chord {
285 let mut mask = NSEventModifierFlags(0);
286 for (held, flag) in [
287 (shift, NSEventModifierFlags::NSEventModifierFlagShift),
288 (
289 chord.option,
290 NSEventModifierFlags::NSEventModifierFlagOption,
291 ),
292 (
293 chord.control,
294 NSEventModifierFlags::NSEventModifierFlagControl,
295 ),
296 (
297 chord.command,
298 NSEventModifierFlags::NSEventModifierFlagCommand,
299 ),
300 ] {
301 if held {
302 mask.0 |= flag.0;
303 }
304 }
305 item.setKeyEquivalentModifierMask(mask);
306 }
307 item
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313 use draw::edit::Platform;
314
315 /// The menu tree as the bar shows it: titles, chords, and each table item's choice.
316 fn dump(menu: &NSMenu, depth: usize, out: &mut String, chords: &mut Vec<String>) {
317 for index in 0..unsafe { menu.numberOfItems() } {
318 let item = unsafe { menu.itemAtIndex(index) }.expect("Items within the count");
319 let indent = " ".repeat(depth);
320 if unsafe { item.isSeparatorItem() } {
321 out.push_str(&format!("{indent}---\n"));
322 continue;
323 }
324 let mask = unsafe { item.keyEquivalentModifierMask() }.0;
325 let key = unsafe { item.keyEquivalent() }.to_string();
326 let chord = if key.is_empty() {
327 String::new()
328 } else {
329 let symbols = [
330 (NSEventModifierFlags::NSEventModifierFlagControl, "⌃"),
331 (NSEventModifierFlags::NSEventModifierFlagOption, "⌥"),
332 (NSEventModifierFlags::NSEventModifierFlagShift, "⇧"),
333 (NSEventModifierFlags::NSEventModifierFlagCommand, "⌘"),
334 ];
335 let held: String = symbols
336 .iter()
337 .filter(|(flag, _)| mask & flag.0 != 0)
338 .map(|(_, symbol)| *symbol)
339 .collect();
340 let key = match key.as_str() {
341 " " => "Space".to_owned(),
342 "\u{f702}" => "←".to_owned(),
343 "\u{f703}" => "→".to_owned(),
344 key => key.to_uppercase(),
345 };
346 format!("{held}{key}")
347 };
348 if !chord.is_empty() {
349 chords.push(chord.clone());
350 }
351 let action = unsafe { item.action() }
352 .filter(|action| *action != objc2::sel!(submenuAction:))
353 .map_or(String::new(), |action| {
354 if action == objc2::sel!(choose:) {
355 let tag = unsafe { item.tag() } as usize;
356 format!(
357 "{:?}",
358 commands::choices().nth(tag).expect("Tags index choices")
359 )
360 } else {
361 action.name().to_owned()
362 }
363 });
364 let submenu = unsafe { item.submenu() };
365 // The bar's items show their menu's title.
366 let title = match &submenu {
367 Some(submenu) if depth == 0 => unsafe { submenu.title() }.to_string(),
368 _ => unsafe { item.title() }.to_string(),
369 };
370 out.push_str(format!("{indent}{title:<24} {chord:<8} {action}").trim_end());
371 out.push('\n');
372 if let Some(submenu) = submenu {
373 dump(&submenu, depth + 1, out, chords);
374 }
375 }
376 }
377
378 #[test]
379 fn menu_bar_offers_each_chord_once_as_the_keyboard_does() {
380 // Built off the main thread for the test only; nothing shows or runs.
381 let mtm = unsafe { MainThreadMarker::new_unchecked() };
382 let target: Retained<AnyObject> =
383 unsafe { objc2::msg_send_id![objc2::class!(NSObject), new] };
384 let menus = build(mtm, &target);
385 let (mut tree, mut chords) = (String::new(), Vec::new());
386 dump(&menus.bar, 0, &mut tree, &mut chords);
387 println!("{tree}");
388 for (at, chord) in chords.iter().enumerate() {
389 assert!(!chords[at + 1..].contains(chord), "{chord} is on two items");
390 }
391 // The keyboard's first chord for each command is on its menu item.
392 for command in commands::COMMANDS {
393 let Some(chord) = command.mac.first() else {
394 continue;
395 };
396 let line = tree
397 .lines()
398 .find(|line| line.ends_with(&format!("Command({:?})", command.id)))
399 .unwrap_or_else(|| panic!("{} has a chord and no menu item", command.title));
400 assert!(
401 line.contains(&chord.label(Platform::MacOs)),
402 "{line} shows {}",
403 chord.label(Platform::MacOs)
404 );
405 }
406 }
407}
crates/snowbound/src/menus.rs+267-128
...@@ -7,14 +7,45 @@ use std::sync::Arc;...@@ -7,14 +7,45 @@ use std::sync::Arc;
7use ui::{Anchor, Id, popup::Item};7use ui::{Anchor, Id, popup::Item};
88
9/// What a drag holds.9/// What a drag holds.
10pub enum Drag {10pub enum Dragged {
11 Page(ExGuid),11 Page(ExGuid),
12 /// A sidebar row.12 /// A sidebar row, where it would land, and where it came from.
13 Entry(crate::sidebar::Entry),13 Entry {
14 entry: crate::sidebar::Entry,
15 landing: crate::sidebar::Landing,
16 home: crate::sidebar::Landing,
17 },
14 /// A section tab, by index.18 /// A section tab, by index.
15 Tab(usize),19 Tab(usize),
16}20}
1721
22/// Something held down to drag: where the pointer took it, whether it has moved far enough
23/// to lift, and for rows, the index in their list where the gap it would land in opens.
24pub struct Drag {
25 pub what: Dragged,
26 /// The pointer when pressed, and its distance from the box's corner.
27 from: [f32; 2],
28 grab: [f32; 2],
29 pub lifted: bool,
30 /// Escape called it off; it ends when the button comes up.
31 pub cancelled: bool,
32 /// Let go: what it held eases into its place, then the drag ends.
33 pub released: bool,
34 pub slot: Option<usize>,
35}
36
37impl Drag {
38 /// Whether it follows the pointer: lifted, held, and not called off.
39 pub fn live(&self) -> bool {
40 self.lifted && !self.cancelled && !self.released
41 }
42
43 /// Where the dragged box's corner follows the pointer at `pointer`.
44 pub fn corner(&self, pointer: [f32; 2]) -> [f32; 2] {
45 [pointer[0] - self.grab[0], pointer[1] - self.grab[1]]
46 }
47}
48
18/// What a context menu was opened on.49/// What a context menu was opened on.
19pub enum Target {50pub enum Target {
20 Page(ExGuid),51 Page(ExGuid),
...@@ -28,12 +59,72 @@ pub fn id() -> Id {...@@ -28,12 +59,72 @@ pub fn id() -> Id {
28}59}
2960
30/// The catalog folder holding `path`.61/// The catalog folder holding `path`.
31fn folder(path: &str) -> String {62pub(crate) fn folder(path: &str) -> String {
32 path.rsplit_once('/')63 path.rsplit_once('/')
33 .map_or(String::new(), |(folder, _)| folder.to_owned())64 .map_or(String::new(), |(folder, _)| folder.to_owned())
34}65}
3566
36impl State {67impl State {
68 /// Follows a drag of what `held` names, pressed in its box's rectangle, this frame:
69 /// starts one, lifts it once the pointer has moved a few pixels as a click's doesn't,
70 /// and says when it was let go while it followed the pointer. `mine` tells this list's
71 /// drags from others'. A drag let go stays, as what it held eases into place, until
72 /// `settle` ends it.
73 pub(crate) fn follow_drag(
74 &mut self,
75 held: Option<(Dragged, [f32; 4])>,
76 mine: impl Fn(&Dragged) -> bool,
77 ) -> bool {
78 let pointer = self.ui.pointer();
79 match (held, &mut self.drag) {
80 (Some(_), Some(drag)) if mine(&drag.what) && !drag.released => {
81 if let Some([x, y]) = pointer {
82 drag.lifted |= (x - drag.from[0]).hypot(y - drag.from[1]) > 4.0;
83 }
84 false
85 }
86 (Some((what, rect)), None | Some(Drag { released: true, .. })) => {
87 self.drag = pointer.map(|pointer| Drag {
88 what,
89 from: pointer,
90 grab: [pointer[0] - rect[0], pointer[1] - rect[1]],
91 lifted: false,
92 cancelled: false,
93 released: false,
94 slot: None,
95 });
96 false
97 }
98 (None, Some(drag)) if mine(&drag.what) && !drag.released => {
99 let dropped = drag.live();
100 drag.released = true;
101 if !drag.lifted {
102 self.drag = None;
103 }
104 dropped
105 }
106 _ => false,
107 }
108 }
109
110 /// Whether something dragged is lifted, still following the pointer or easing into place,
111 /// so letting go of it isn't a click.
112 pub(crate) fn dragged(&self) -> bool {
113 self.drag.as_ref().is_some_and(|drag| drag.lifted)
114 }
115
116 /// Ends this list's drag, let go, once what it held stands in its place.
117 pub(crate) fn settle(&mut self, mine: impl Fn(&Dragged) -> bool, placed: bool) {
118 if placed
119 && self
120 .drag
121 .as_ref()
122 .is_some_and(|drag| drag.released && mine(&drag.what))
123 {
124 self.drag = None;
125 }
126 }
127
37 /// The open context menu, and what its choice does.128 /// The open context menu, and what its choice does.
38 pub(crate) fn context_menu(&mut self) {129 pub(crate) fn context_menu(&mut self) {
39 let Some((target, point)) = &self.menu else {130 let Some((target, point)) = &self.menu else {
...@@ -58,8 +149,18 @@ impl State {...@@ -58,8 +149,18 @@ impl State {
58 let above = at149 let above = at
59 .and_then(|at| at.checked_sub(1))150 .and_then(|at| at.checked_sub(1))
60 .map_or(0, |above| pages[above].2);151 .map_or(0, |above| pages[above].2);
152 let (versions, shown) = self.session.as_ref().map_or((false, false), |session| {
153 (
154 !session.page_versions(*space).is_empty(),
155 session.shown_history == Some(*space),
156 )
157 });
61 vec![158 vec![
62 item("Delete"),159 item("Delete"),
160 Item {
161 separated: true,
162 ..item("Copy Link to Page")
163 },
63 Item {164 Item {
64 separated: true,165 separated: true,
65 ..item("New Page")166 ..item("New Page")
...@@ -75,6 +176,15 @@ impl State {...@@ -75,6 +176,15 @@ impl State {
75 disabled: level <= 1,176 disabled: level <= 1,
76 ..item("Promote Subpage")177 ..item("Promote Subpage")
77 },178 },
179 Item {
180 separated: true,
181 disabled: !versions,
182 ..item(if shown {
183 "Hide Page Versions"
184 } else {
185 "Show Page Versions"
186 })
187 },
78 ]188 ]
79 }189 }
80 Target::Section { .. } | Target::Group { .. } => vec![190 Target::Section { .. } | Target::Group { .. } => vec![
...@@ -104,8 +214,19 @@ impl State {...@@ -104,8 +214,19 @@ impl State {
104 };214 };
105 let command = match (target, items[chosen].text) {215 let command = match (target, items[chosen].text) {
106 (Target::Page(space), "Delete") => Some(Command::DeletePages(vec![space])),216 (Target::Page(space), "Delete") => Some(Command::DeletePages(vec![space])),
217 (Target::Page(space), "Copy Link to Page") => {
218 let copied = self
219 .page_link(space, None)
220 .and_then(|link| Ok(self.clipboard.set_text(link)?));
221 if let Err(error) = copied {
222 eprintln!("Copying the link failed: {error}");
223 }
224 None
225 }
107 (Target::Page(_), "New Page") => Some(Command::NewPage { under: None }),226 (Target::Page(_), "New Page") => Some(Command::NewPage { under: None }),
108 (Target::Page(space), "New Subpage") => Some(Command::NewPage { under: Some(space) }),227 (Target::Page(space), "New Subpage") => Some(Command::NewPage { under: Some(space) }),
228 (Target::Page(page), "Show Page Versions") => Some(Command::History { page, show: true }),
229 (Target::Page(page), "Hide Page Versions") => Some(Command::History { page, show: false }),
109 (Target::Page(space), text) => self.session.as_ref().and_then(|session| {230 (Target::Page(space), text) => self.session.as_ref().and_then(|session| {
110 let level = session231 let level = session
111 .pages232 .pages
...@@ -123,13 +244,11 @@ impl State {...@@ -123,13 +244,11 @@ impl State {
123 Target::Section { library, path } | Target::Group { library, path },244 Target::Section { library, path } | Target::Group { library, path },
124 "Rename",245 "Rename",
125 ) => {246 ) => {
126 let name = path.rsplit('/').next().unwrap_or_default();247 self.rename(crate::rename::Target::Entry {
127 self.renaming = Some(crate::sidebar::Renaming {
128 library,248 library,
129 name: name.strip_suffix(".one").unwrap_or(name).to_owned(),
130 path,249 path,
250 in_tab: false,
131 });251 });
132 self.sidebar = true;
133 None252 None
134 }253 }
135 (Target::Section { library, path }, "Delete") => platform::confirm(254 (Target::Section { library, path }, "Delete") => platform::confirm(
...@@ -166,8 +285,18 @@ impl State {...@@ -166,8 +285,18 @@ impl State {
166 /// The section tab, of those built as `row`, under the pointer while a page is dragged285 /// The section tab, of those built as `row`, under the pointer while a page is dragged
167 /// there, which a drop moves the page into.286 /// there, which a drop moves the page into.
168 pub(crate) fn page_drop(&self, row: Id) -> Option<usize> {287 pub(crate) fn page_drop(&self, row: Id) -> Option<usize> {
169 let (Some(Drag::Page(_)), Some(session), Some([x, y])) =288 let (
170 (&self.drag, &self.session, self.ui.pointer())289 Some(Drag {
290 what: Dragged::Page(_),
291 ..
292 }),
293 Some(session),
294 Some([x, y]),
295 ) = (
296 self.drag.as_ref().filter(|drag| drag.live()),
297 &self.session,
298 self.ui.pointer(),
299 )
171 else {300 else {
172 return None;301 return None;
173 };302 };
...@@ -180,156 +309,166 @@ impl State {...@@ -180,156 +309,166 @@ impl State {
180 })309 })
181 }310 }
182311
183 /// Moves a page by dragging its tab: while `held`, a line shows where it would go; let312 /// The page tab dragged, as the page list, built as the current box, shows it.
184 /// go, it moves there at its level, or under a page at most one level above it. Onto a313 pub(crate) fn dragged_page(&self) -> Option<crate::PageDrag> {
185 /// section tab of those built as `row`, it moves to the end of that section, as314 let drag = self.drag.as_ref().filter(|drag| drag.lifted)?;
186 /// OneNote moves a page dropped on a section's tab. `panel` is the page list's rectangle.315 let Dragged::Page(space) = drag.what else {
316 return None;
317 };
318 let origin = self.ui.rect(self.ui.id(crate::page_list_top()))?[1];
319 Some(crate::PageDrag {
320 space,
321 top: drag.live().then(|| drag.corner(self.pointer)[1] - origin),
322 slot: drag.slot,
323 })
324 }
325
326 /// Moves a page by dragging its tab, `held` in the page list whose tabs stand at
327 /// `places`: let go, it moves where the gap the other tabs opened for it is, at its
328 /// level, or under a page at most one level above it. Onto a section tab of those built
329 /// as `row`, it moves to the end of that section, as OneNote moves a page dropped on a
330 /// section's tab.
187 pub(crate) fn drag_pages(331 pub(crate) fn drag_pages(
188 &mut self,332 &mut self,
189 held: Option<ExGuid>,333 held: Option<ExGuid>,
190 section: &ui::Section,334 places: &[f32],
191 panel: [f32; 4],335 settled: bool,
192 row: Id,336 row: Id,
193 ) {337 ) {
194 let Some(session) = &self.session else {338 let mine = |what: &Dragged| matches!(what, Dragged::Page(_));
339 self.settle(mine, settled);
340 let held =
341 held.and_then(|space| Some((Dragged::Page(space), self.ui.rect(self.ui.id(space))?)));
342 let target = self.page_drop(row);
343 let origin = self.ui.rect(self.ui.id(crate::page_list_top()));
344 let dropped = self.follow_drag(held, mine);
345 let (
346 Some(session),
347 Some(
348 drag @ Drag {
349 what: Dragged::Page(_),
350 ..
351 },
352 ),
353 ) = (&self.session, &mut self.drag)
354 else {
195 return;355 return;
196 };356 };
197 let dropped = match (&self.drag, held) {357 let Dragged::Page(space) = drag.what else {
198 (_, Some(space)) => Some(space),358 return;
199 (Some(Drag::Page(space)), None) => Some(*space),
200 _ => None,
201 };359 };
202 let target = self.page_drop(row);360 let Some(from) = session
203 if let Some(space) = held {361 .pages
204 self.drag = Some(Drag::Page(space));362 .iter()
205 } else if matches!(self.drag, Some(Drag::Page(_))) {363 .position(|(listed, ..)| *listed == space)
364 else {
365 // The page left the section, dropped on another's tab.
206 self.drag = None;366 self.drag = None;
207 }
208 let (Some(space), Some([_, y])) = (dropped, self.ui.pointer()) else {
209 return;367 return;
210 };368 };
369 if drag.cancelled {
370 drag.slot = None;
371 } else if drag.live()
372 && let Some(origin) = origin
373 {
374 let middle = drag.corner(self.pointer)[1] - origin[1] + crate::ROW / 2.0;
375 let spans: Vec<[f32; 2]> = places.iter().map(|top| [*top, crate::ROW]).collect();
376 drag.slot = Some(if target.is_some() {
377 from
378 } else {
379 ui::drop_slot(&spans, from, middle)
380 });
381 }
382 if !dropped {
383 return;
384 }
385 let slot = drag.slot.take();
211 if let Some(tab) = target {386 if let Some(tab) = target {
212 if held.is_none() {387 let path = session.tabs[tab].path.clone();
213 let path = session.tabs[tab].path.clone();388 self.commands.push(Command::MovePage { space, path });
214 self.commands.push(Command::MovePage { space, path });
215 }
216 return;389 return;
217 }390 }
218 // Rows other than the dragged one, with where each lies.391 let Some(slot) = slot.filter(|slot| *slot != from) else {
219 let rows: Vec<(ExGuid, u32, [f32; 4])> = session392 return;
393 };
394 let others: Vec<_> = session
220 .pages395 .pages
221 .iter()396 .iter()
222 .filter(|(listed, ..)| *listed != space)397 .filter(|(listed, ..)| *listed != space)
223 .filter_map(|(listed, _, level)| {
224 Some((*listed, *level, self.ui.rect(self.ui.id(listed))?))
225 })
226 .collect();398 .collect();
227 let Some(level) = session399 let before = others.get(slot).map(|(listed, ..)| *listed);
228 .pages400 let level = slot
229 .iter()
230 .find(|(listed, ..)| *listed == space)
231 .map(|(.., level)| *level)
232 else {
233 return;
234 };
235 let at = rows
236 .iter()
237 .position(|(.., rect)| y < (rect[1] + rect[3]) / 2.0)
238 .unwrap_or(rows.len());
239 let before = rows.get(at).map(|(listed, ..)| *listed);
240 let level = at
241 .checked_sub(1)401 .checked_sub(1)
242 .map_or(1, |above| level.min(rows[above].1 + 1));402 .map_or(1, |above| session.pages[from].2.min(others[above].2 + 1));
243 let stays = session403 if let Ok(edit) = PageEdit::move_to(space, before, level) {
244 .pages
245 .iter()
246 .skip_while(|(listed, ..)| *listed != space)
247 .nth(1)
248 .map(|(listed, ..)| *listed)
249 == before;
250 if stays {
251 return;
252 }
253 if held.is_some() {
254 // The line lies between the rows the page would go between.
255 let line = match (at.checked_sub(1).map(|above| rows[above].2), rows.get(at)) {
256 (_, Some((.., below))) => below[1],
257 (Some(above), None) => above[3],
258 (None, None) => return,
259 } - panel[1];
260 let indent = 10.0 + 16.0 * (level - 1) as f32;
261 self.ui.mark(
262 [indent, line - 1.5, crate::PAGE_LIST - 6.0, line + 1.5],
263 section.accent,
264 1.5,
265 );
266 } else if let Ok(edit) = PageEdit::move_to(space, before, level) {
267 self.commands.push(Command::Pages(vec![edit]));404 self.commands.push(Command::Pages(vec![edit]));
268 }405 }
269 }406 }
270407
271 /// Reorders section tabs by dragging one: while `held`, a line shows where it would go408 /// The section tab dragged, as the tabs built as `row` show it: its index, and its
272 /// among the tabs built as `row` in the tab row `bar`; let go, the folder takes that409 /// leading edge following the pointer along the row.
273 /// order.410 pub(crate) fn dragged_tab(&self, row: Id) -> Option<ui::shell::Dragged> {
411 let drag = self.drag.as_ref().filter(|drag| drag.lifted)?;
412 let Dragged::Tab(index) = drag.what else {
413 return None;
414 };
415 let start = drag.corner(self.pointer)[0] - self.ui.rect(row)?[0];
416 Some(ui::shell::Dragged {
417 index,
418 start: drag.live().then_some(start),
419 })
420 }
421
422 /// Reorders section tabs by dragging one, as browser tabs reorder: let go, the tab
423 /// `held` of those built as `row` lands at `slot`, and the folder takes that order. The
424 /// tabs take it at once, so none slides back while the notebook changes.
274 pub(crate) fn drag_tabs(425 pub(crate) fn drag_tabs(
275 &mut self,426 &mut self,
276 held: Option<usize>,427 held: Option<usize>,
428 slot: Option<usize>,
429 settled: bool,
277 row: Id,430 row: Id,
278 bar: [f32; 4],
279 accent: [f32; 4],
280 ) {431 ) {
281 let Some(session) = &self.session else {432 let mine = |what: &Dragged| matches!(what, Dragged::Tab(_));
282 return;433 self.settle(mine, settled);
283 };434 let held = held.and_then(|tab| {
284 let dragged = match (&self.drag, held) {435 Some((
285 (_, Some(tab)) => Some(tab),436 Dragged::Tab(tab),
286 (Some(Drag::Tab(tab)), None) => Some(*tab),437 self.ui.rect(ui::shell::tab_id(row, tab))?,
287 _ => None,438 ))
288 };439 });
289 if let Some(tab) = held {440 let dropped = self.follow_drag(held, mine);
290 self.drag = Some(Drag::Tab(tab));441 let (
291 } else if matches!(self.drag, Some(Drag::Tab(_))) {442 true,
292 self.drag = None;443 Some(Drag {
293 }444 what: Dragged::Tab(tab),
294 let (Some(tab), Some([x, _])) = (dragged, self.ui.pointer()) else {445 ..
446 }),
447 Some(slot),
448 Some(session),
449 ) = (dropped, &mut self.drag, slot, &mut self.session)
450 else {
295 return;451 return;
296 };452 };
297 let others: Vec<(usize, [f32; 4])> = (0..session.tabs.len())453 // It eases into its new place from where it was let go.
298 .filter(|other| *other != tab)454 let tab = std::mem::replace(tab, slot);
299 .filter_map(|other| Some((other, self.ui.rect(ui::shell::tab_id(row, other))?)))455 if slot == tab {
300 .collect();
301 let at = others
302 .iter()
303 .position(|(_, rect)| x < (rect[0] + rect[2]) / 2.0)
304 .unwrap_or(others.len());
305 let mut order: Vec<usize> = others.iter().map(|(other, _)| *other).collect();
306 order.insert(at, tab);
307 if order.iter().copied().eq(0..session.tabs.len()) {
308 return;456 return;
309 }457 }
310 if held.is_some() {458 let open = session.tabs[session.tab].path.clone();
311 let edge = match (459 let moved = session.tabs.remove(tab);
312 at.checked_sub(1).map(|before| others[before].1),460 session.tabs.insert(slot, moved);
313 others.get(at),461 session.tab = session
314 ) {462 .tabs
315 (_, Some((_, after))) => after[0],463 .iter()
316 (Some(before), None) => before[2],464 .position(|tab| tab.path == open)
317 (None, None) => return,465 .unwrap_or_default();
318 } - bar[0];466 let folder = folder(&open);
319 self.ui467 let paths = session.tabs.iter().map(|tab| tab.path.clone()).collect();
320 .mark([edge - 1.5, 4.0, edge + 1.5, bar[3] - bar[1]], accent, 1.5);468 self.commands.push(Command::Structure(
321 } else {469 Arc::clone(&session.library),
322 let path = &session.tabs[tab].path;470 Structure::Reorder { folder, paths },
323 let folder = folder(path);471 ));
324 let paths = order
325 .into_iter()
326 .map(|index| session.tabs[index].path.clone())
327 .collect();
328 self.commands.push(Command::Structure(
329 Arc::clone(&session.library),
330 Structure::Reorder { folder, paths },
331 ));
332 }
333 }472 }
334}473}
335474
crates/snowbound/src/navigation.rs created+175
...@@ -0,0 +1,175 @@
1//! Back and Forward through the pages visited, across sections and notebooks, as OneNote
2//! 2010's Quick Access Toolbar offers them.
3
4use super::*;
5
6/// A page shown: its notebook's location, its section's catalog path and the page.
7#[derive(Clone, Debug, PartialEq)]
8pub struct Place {
9 pub notebook: String,
10 pub section: String,
11 pub page: ExGuid,
12}
13
14/// Pages visited before and after the one shown.
15#[derive(Default)]
16pub struct Trail {
17 back: Vec<Place>,
18 here: Option<Place>,
19 forward: Vec<Place>,
20}
21
22/// Places Back keeps, at most.
23const KEPT: usize = 100;
24
25impl Trail {
26 /// Notes `place` shown. Arriving anywhere but where Back or Forward went drops the pages
27 /// ahead, as a browser does.
28 pub fn visit(&mut self, place: Place) {
29 if self.here.as_ref() == Some(&place) {
30 return;
31 }
32 if let Some(here) = self.here.replace(place) {
33 self.back.push(here);
34 if self.back.len() > KEPT {
35 self.back.remove(0);
36 }
37 }
38 self.forward.clear();
39 }
40
41 /// Steps back, or `forward`, to the nearest place `exists` accepts, dropping those it
42 /// refuses; the place to show.
43 pub fn step(&mut self, forward: bool, exists: impl Fn(&Place) -> bool) -> Option<Place> {
44 let (from, to) = if forward {
45 (&mut self.forward, &mut self.back)
46 } else {
47 (&mut self.back, &mut self.forward)
48 };
49 let place = std::iter::from_fn(|| from.pop()).find(|place| exists(place))?;
50 if let Some(here) = self.here.replace(place.clone()) {
51 to.push(here);
52 }
53 Some(place)
54 }
55
56 /// Whether Back and Forward have somewhere to go.
57 pub fn open(&self) -> [bool; 2] {
58 [!self.back.is_empty(), !self.forward.is_empty()]
59 }
60}
61
62impl State {
63 /// Notes the page just shown for Back and Forward.
64 pub(crate) fn visited(&mut self) {
65 if let Some(session) = &self.session {
66 self.trail.visit(Place {
67 notebook: session.library.location.clone(),
68 section: session.tabs[session.tab].path.clone(),
69 page: session.space,
70 });
71 }
72 }
73
74 /// Shows the page visited before the one shown, or after it going `forward`. A page
75 /// deleted from the open section since is passed over.
76 pub(crate) fn travel(&mut self, forward: bool) {
77 let session = self.session.as_ref();
78 let exists = |place: &Place| {
79 session.is_none_or(|session| {
80 session.library.location != place.notebook
81 || session.tabs[session.tab].path != place.section
82 || session.pages.iter().any(|(page, ..)| *page == place.page)
83 })
84 };
85 let Some(place) = self.trail.step(forward, exists) else {
86 return;
87 };
88 if let Some(session) = &self.session
89 && session.library.location == place.notebook
90 && session.tabs[session.tab].path == place.section
91 {
92 self.commands.push(Command::OpenPage(place.page));
93 return;
94 }
95 let Some(library) = self
96 .notebooks
97 .iter()
98 .find(|library| library.location == place.notebook)
99 else {
100 return;
101 };
102 let library = Arc::clone(library);
103 self.last_pages
104 .insert(library.key(&place.section), place.page);
105 self.commands
106 .push(Command::OpenSection(library, place.section));
107 }
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113
114 fn place(section: &str, page: u32) -> Place {
115 Place {
116 notebook: "/notebooks/Personal".into(),
117 section: section.into(),
118 page: ExGuid {
119 guid: [7; 16],
120 n: page,
121 },
122 }
123 }
124
125 #[test]
126 fn back_and_forward_retrace_visits_across_sections() {
127 let mut trail = Trail::default();
128 assert_eq!(trail.open(), [false, false]);
129 for (section, page) in [("A.one", 1), ("A.one", 2), ("B.one", 1)] {
130 trail.visit(place(section, page));
131 }
132 // Showing the same page again, as a reload does, is no visit.
133 trail.visit(place("B.one", 1));
134 let all = |_: &Place| true;
135 assert_eq!(trail.step(false, all), Some(place("A.one", 2)));
136 // Arriving where Back went is not a new visit.
137 trail.visit(place("A.one", 2));
138 assert_eq!(trail.open(), [true, true]);
139 assert_eq!(trail.step(false, all), Some(place("A.one", 1)));
140 assert_eq!(trail.step(false, all), None);
141 assert_eq!(trail.step(true, all), Some(place("A.one", 2)));
142 assert_eq!(trail.step(true, all), Some(place("B.one", 1)));
143 assert_eq!(trail.step(true, all), None);
144 // A page opened after going back drops the pages ahead.
145 trail.step(false, all);
146 trail.visit(place("C.one", 5));
147 assert_eq!(trail.open(), [true, false]);
148 assert_eq!(trail.step(false, all), Some(place("A.one", 2)));
149 }
150
151 #[test]
152 fn back_passes_over_pages_since_deleted() {
153 let mut trail = Trail::default();
154 for page in 1..=3 {
155 trail.visit(place("A.one", page));
156 }
157 let kept = |place: &Place| place.page.n != 2;
158 assert_eq!(trail.step(false, kept), Some(place("A.one", 1)));
159 assert_eq!(trail.step(true, kept), Some(place("A.one", 3)));
160 assert_eq!(trail.open(), [true, false]);
161 }
162
163 #[test]
164 fn back_keeps_its_last_hundred_places() {
165 let mut trail = Trail::default();
166 for page in 0..150 {
167 trail.visit(place("A.one", page));
168 }
169 let mut steps = 0;
170 while trail.step(false, |_| true).is_some() {
171 steps += 1;
172 }
173 assert_eq!(steps, KEPT);
174 }
175}
crates/snowbound/src/options.rs+10-16
...@@ -77,12 +77,6 @@ impl State {...@@ -77,12 +77,6 @@ impl State {
77 }77 }
78 let theme = ui.theme.clone();78 let theme = ui.theme.clone();
79 let row = theme.font_size * 2.0;79 let row = theme.font_size * 2.0;
80 let window = ui.rect(Id::ROOT).unwrap_or_default();
81 let height = ui.rect(id()).map_or(HEIGHT, |rect| rect[3] - rect[1]);
82 let [left, top] = [
83 ((window[2] - WIDTH) / 2.0).max(0.0),
84 ((window[3] - height) / 2.0).max(0.0),
85 ];
86 let entered = ui::popup::navigation(ui, &[user_name()], &[NamedKey::Enter])80 let entered = ui::popup::navigation(ui, &[user_name()], &[NamedKey::Enter])
87 .contains(&NamedKey::Enter);81 .contains(&NamedKey::Enter);
88 ui.open_as(82 ui.open_as(
...@@ -95,7 +89,7 @@ impl State {...@@ -95,7 +89,7 @@ impl State {
95 shadow: Some(theme.shadow),89 shadow: Some(theme.shadow),
96 radius: 8.0,90 radius: 8.0,
97 pad: [6.0, 6.0],91 pad: [6.0, 6.0],
98 anchor: Some(Anchor::Below([left, top, left, top])),92 anchor: Some(Anchor::Dialog),
99 ..Spec::default()93 ..Spec::default()
100 },94 },
101 );95 );
...@@ -189,8 +183,13 @@ impl State {...@@ -189,8 +183,13 @@ impl State {
189 Page::Display => {183 Page::Display => {
190 heading(ui, &theme, "Display");184 heading(ui, &theme, "Display");
191 let dark = !options.light_pages;185 let dark = !options.light_pages;
192 if ui::check_box(ui, "dark-pages", "Pages appear dark in the Dark color scheme", dark)186 if ui::check_box(
193 .clicked187 ui,
188 "dark-pages",
189 "Pages appear dark in the Dark color scheme",
190 dark,
191 )
192 .clicked
194 {193 {
195 options.light_pages = dark;194 options.light_pages = dark;
196 }195 }
...@@ -247,12 +246,7 @@ impl State {...@@ -247,12 +246,7 @@ impl State {
247 self.author = name.to_owned();246 self.author = name.to_owned();
248 self.color_scheme = options.color_scheme;247 self.color_scheme = options.color_scheme;
249 self.light_pages = options.light_pages;248 self.light_pages = options.light_pages;
250 self.window.set_theme(self.color_scheme.theme());249 self.follow_color_scheme();
251 let appearance = self
252 .color_scheme
253 .theme()
254 .unwrap_or_else(|| platform::appearance(&self.window));
255 self.ui.theme = crate::theme(appearance, self.light_pages);
256 self.save_settings();250 self.save_settings();
257 } else if !cancel {251 } else if !cancel {
258 return;252 return;
...@@ -270,7 +264,7 @@ fn heading(ui: &mut Ui, theme: &Theme, text: &str) {...@@ -270,7 +264,7 @@ fn heading(ui: &mut Ui, theme: &Theme, text: &str) {
270 size: [fill(), px(theme.font_size * 1.8)],264 size: [fill(), px(theme.font_size * 1.8)],
271 text: Some(text),265 text: Some(text),
272 bold: true,266 bold: true,
273 fill: Some(theme.strip),267 fill: Some(ui::mix(theme.panel, theme.chip, 0.4)),
274 radius: 4.0,268 radius: 4.0,
275 pad: [8.0, 0.0],269 pad: [8.0, 0.0],
276 ..Spec::default()270 ..Spec::default()
crates/snowbound/src/rename.rs created+201
...@@ -0,0 +1,201 @@
1//! Renaming in place, as VS Code renames a file and OneNote 2010 a section's tab: a field
2//! over the name with it all selected, where Enter or a press elsewhere keeps the name typed
3//! and Escape the old one.
4
5use crate::{Command, Library, State, manage::Structure};
6use onestore::{
7 ExGuid,
8 op::{Edit, Op, PageOp},
9 page::PageObject,
10};
11use std::{error::Error, sync::Arc};
12use ui::{Event, Id, Spec, Theme, Ui, fill, px};
13use winit::keyboard::{Key, NamedKey};
14
15/// Room between the field's edge and its text.
16pub const PAD: f32 = 3.0;
17
18/// What is being renamed, and the name typed so far.
19pub struct Renaming {
20 pub target: Target,
21 pub name: String,
22}
23
24pub enum Target {
25 /// A section or group by catalog path, in its sidebar row or, `in_tab`, its tab.
26 Entry {
27 library: Arc<Library>,
28 path: String,
29 in_tab: bool,
30 },
31 /// A page of the open section, by its title in its tab.
32 Page(ExGuid),
33}
34
35impl Renaming {
36 /// Whether the sidebar row or tab of the section or group at `path` shows the field.
37 pub fn entry(&self, library: &Arc<Library>, path: &str, tab: bool) -> bool {
38 matches!(&self.target, Target::Entry { library: renamed, path: at, in_tab }
39 if Arc::ptr_eq(renamed, library) && at == path && *in_tab == tab)
40 }
41
42 pub fn page(&self, space: ExGuid) -> bool {
43 matches!(self.target, Target::Page(renamed) if renamed == space)
44 }
45}
46
47pub fn field() -> Id {
48 Id::ROOT.child("rename")
49}
50
51/// The rename field, `height` tall, editing `name`: whether Enter kept or Escape dropped
52/// the name typed.
53pub fn edit(ui: &mut Ui, theme: &Theme, name: &mut String, height: f32) -> Option<bool> {
54 let signal = ui::text_field(
55 ui,
56 field(),
57 name,
58 "",
59 Spec {
60 size: [fill(), px(height)],
61 fill: Some(theme.base),
62 border: Some(theme.accent),
63 radius: 2.0,
64 pad: [PAD, 0.0],
65 ..Spec::default()
66 },
67 );
68 signal.events.iter().find_map(|event| match event {
69 Event::Key {
70 key: Key::Named(key @ (NamedKey::Enter | NamedKey::Escape)),
71 ..
72 } => Some(*key == NamedKey::Enter),
73 _ => None,
74 })
75}
76
77impl State {
78 /// Opens the rename field on `target` with its name selected.
79 pub(crate) fn rename(&mut self, target: Target) {
80 let name = match &target {
81 Target::Entry { path, in_tab, .. } => {
82 self.sidebar |= !in_tab;
83 let name = path.rsplit('/').next().unwrap_or_default();
84 name.strip_suffix(".one").unwrap_or(name).to_owned()
85 }
86 Target::Page(space) => self
87 .session
88 .as_ref()
89 .and_then(|session| session.pages.iter().find(|(page, ..)| page == space))
90 .map(|(_, title, _)| title.clone())
91 .unwrap_or_default(),
92 };
93 self.renaming = Some(Renaming { target, name });
94 self.ui.focus_all(field());
95 }
96
97 /// Closes the rename field, renaming what it was on to the name typed where `keep`.
98 pub(crate) fn finish_renaming(&mut self, keep: bool) {
99 let Some(Renaming { target, name }) = self.renaming.take() else {
100 return;
101 };
102 self.ui.set_focus(Some(crate::page()));
103 let name = name.trim().to_owned();
104 if !keep || name.is_empty() {
105 return;
106 }
107 match target {
108 Target::Entry { library, path, .. } => {
109 let old = path.rsplit('/').next().unwrap_or_default();
110 if name != old.strip_suffix(".one").unwrap_or(old) {
111 self.commands.push(Command::Structure(
112 library,
113 Structure::Rename { path, name },
114 ));
115 }
116 }
117 Target::Page(space) => {
118 if let Err(error) = self.retitle(space, name) {
119 crate::platform::alert("Couldn't rename the page", &error.to_string());
120 }
121 }
122 }
123 }
124
125 /// The rename field over the section tab being renamed, of those built as `row` in the
126 /// tab row `bar`, where the tab's label stands.
127 pub(crate) fn tab_rename_field(&mut self, theme: &Theme, row: Id, bar: Id) {
128 let (Some(session), Some(renaming)) = (&self.session, &mut self.renaming) else {
129 return;
130 };
131 let Some(tab) = session
132 .tabs
133 .iter()
134 .position(|tab| renaming.entry(&session.library, &tab.path, true))
135 else {
136 return;
137 };
138 let (Some(rect), Some(bar)) =
139 (self.ui.rect(ui::shell::tab_id(row, tab)), self.ui.rect(bar))
140 else {
141 return;
142 };
143 let width = self.ui.measure(&renaming.name)[0] + 4.0 + 2.0 * PAD;
144 self.ui.open(
145 "rename",
146 Spec {
147 flags: ui::Flags::FLOAT,
148 size: [px(width.max(48.0)), px(rect[3] - rect[1] - 6.0)],
149 position: [
150 rect[0] + ui::shell::TAB_PAD - PAD - bar[0],
151 rect[1] + 3.0 - bar[1],
152 ],
153 ..Spec::default()
154 },
155 );
156 let kept = edit(
157 &mut self.ui,
158 theme,
159 &mut renaming.name,
160 rect[3] - rect[1] - 6.0,
161 );
162 self.ui.close();
163 if let Some(keep) = kept {
164 self.finish_renaming(keep);
165 }
166 }
167
168 /// Gives page `space` of the open section the title `name`.
169 fn retitle(&mut self, space: ExGuid, name: String) -> Result<(), Box<dyn Error>> {
170 self.persist()?;
171 let session = self.session.as_mut().ok_or("No section is open")?;
172 let page = session.section.page(space)?;
173 let title = page
174 .objects
175 .iter()
176 .find_map(|object| match object {
177 PageObject::Title(title) => title.outlines.first()?.paragraphs.first()?.text(),
178 _ => None,
179 })
180 .ok_or("The page has no title to rename")?;
181 let length = title.text.utf16_offset(title.text.text().len())?;
182 session.section.apply(
183 &self.author,
184 Edit {
185 at: crate::filetime(),
186 ops: vec![Op::Page {
187 space,
188 op: PageOp::Text {
189 text: title.id,
190 range: 0..length,
191 with: name,
192 },
193 }],
194 },
195 )?;
196 session.status = "Saving";
197 session.pages = session.section.pages()?;
198 self.edited(vec![space]);
199 self.refresh()
200 }
201}
crates/snowbound/src/screenshot.rs+23-13
...@@ -5,7 +5,8 @@...@@ -5,7 +5,8 @@
5use super::*;5use super::*;
6use winit::window::Theme as Appearance;6use winit::window::Theme as Appearance;
77
8/// The window in points, drawn at `SCALE` pixels per point.8/// The window in points, unless `SNOWBOUND_SCREENSHOT_SIZE` gives it as `WIDTHxHEIGHT`,
9/// drawn at `SCALE` pixels per point or fewer where the GPU caps textures.
9const SIZE: [f32; 2] = [1440.0, 900.0];10const SIZE: [f32; 2] = [1440.0, 900.0];
10const SCALE: f32 = 2.0;11const SCALE: f32 = 2.0;
11/// A standard window's close, minimize and zoom buttons as AppKit places them beside a12/// 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] = [...@@ -20,10 +21,19 @@ const LIGHTS: [(f32, [u8; 3], [u8; 3]); 3] = [
20impl State {21impl State {
21 /// Writes `PREFIX-light.png` and `PREFIX-dark.png`, the page unfocused so no caret shows.22 /// Writes `PREFIX-light.png` and `PREFIX-dark.png`, the page unfocused so no caret shows.
22 pub(crate) fn screenshot(&mut self, prefix: &Path) -> Result<(), Box<dyn Error>> {23 pub(crate) fn screenshot(&mut self, prefix: &Path) -> Result<(), Box<dyn Error>> {
23 [self.config.width, self.config.height] = SIZE.map(|side| (side * SCALE) as u32);24 let size = match std::env::var("SNOWBOUND_SCREENSHOT_SIZE") {
25 Ok(size) => size
26 .split_once('x')
27 .and_then(|(width, height)| Some([width.parse().ok()?, height.parse().ok()?]))
28 .ok_or("SNOWBOUND_SCREENSHOT_SIZE must be WIDTHxHEIGHT in points.")?,
29 Err(_) => SIZE,
30 };
31 let most = self.renderer.device.limits().max_texture_dimension_2d as f32;
32 let scale = SCALE.min(most / size[0].max(size[1]));
33 [self.config.width, self.config.height] = size.map(|side| (side * scale).max(1.0) as u32);
24 self.renderer.clear_glyph_cache();34 self.renderer.clear_glyph_cache();
25 self.app_icon = platform::app_icon((16.0 * SCALE) as u32);35 self.app_icon = platform::app_icon((16.0 * scale) as u32);
26 let response = self.view.scale_factor_changed(SCALE)?;36 let response = self.view.scale_factor_changed(scale)?;
27 self.respond(response);37 self.respond(response);
28 self.ui.set_focus(None);38 self.ui.set_focus(None);
29 if let Some(title) = self39 if let Some(title) = self
...@@ -37,9 +47,9 @@ impl State {...@@ -37,9 +47,9 @@ impl State {
37 }47 }
38 for (name, appearance) in [("light", Appearance::Light), ("dark", Appearance::Dark)] {48 for (name, appearance) in [("light", Appearance::Light), ("dark", Appearance::Dark)] {
39 self.window.set_theme(Some(appearance));49 self.window.set_theme(Some(appearance));
40 self.ui.theme = theme(appearance, self.light_pages);50 self.set_appearance(appearance);
41 // The page's box sizes the view whose pictures settle.51 // The page's box sizes the view whose pictures settle.
42 self.layout(SIZE, SCALE)?;52 self.layout(size, scale)?;
43 let paper = self.paper();53 let paper = self.paper();
44 if let Some((scene, _)) = &mut self.view.scene {54 if let Some((scene, _)) = &mut self.view.scene {
45 scene.settle(Some(&self.view.editor), self.view.viewport.scale, paper);55 scene.settle(Some(&self.view.editor), self.view.viewport.scale, paper);
...@@ -47,7 +57,7 @@ impl State {...@@ -47,7 +57,7 @@ impl State {
47 // Later frames lay out with earlier frames' measurements while colours ease.57 // Later frames lay out with earlier frames' measurements while colours ease.
48 let deadline = Instant::now() + std::time::Duration::from_secs(2);58 let deadline = Instant::now() + std::time::Duration::from_secs(2);
49 loop {59 loop {
50 self.layout(SIZE, SCALE)?;60 self.layout(size, scale)?;
51 if !self.ui.wants_frame() || Instant::now() > deadline {61 if !self.ui.wants_frame() || Instant::now() > deadline {
52 break;62 break;
53 }63 }
...@@ -56,7 +66,7 @@ impl State {...@@ -56,7 +66,7 @@ impl State {
56 let path = PathBuf::from(format!("{}-{name}.png", prefix.display()));66 let path = PathBuf::from(format!("{}-{name}.png", prefix.display()));
57 self.snapshot(&path)?;67 self.snapshot(&path)?;
58 #[cfg(target_os = "macos")]68 #[cfg(target_os = "macos")]
59 window_frame(&path, appearance)?;69 window_frame(&path, appearance, scale)?;
60 }70 }
61 Ok(())71 Ok(())
62 }72 }
...@@ -65,7 +75,7 @@ impl State {...@@ -65,7 +75,7 @@ impl State {
65/// Paints the traffic lights into the PNG at `path`, rounds its corners to transparency75/// Paints the traffic lights into the PNG at `path`, rounds its corners to transparency
66/// and edges them with the window's hairline.76/// and edges them with the window's hairline.
67#[cfg(target_os = "macos")]77#[cfg(target_os = "macos")]
68fn window_frame(path: &Path, appearance: Appearance) -> Result<(), Box<dyn Error>> {78fn window_frame(path: &Path, appearance: Appearance, scale: f32) -> Result<(), Box<dyn Error>> {
69 let mut reader =79 let mut reader =
70 png::Decoder::new(std::io::BufReader::new(std::fs::File::open(path)?)).read_info()?;80 png::Decoder::new(std::io::BufReader::new(std::fs::File::open(path)?)).read_info()?;
71 let mut pixels = vec![0; reader.output_buffer_size().ok_or("Snapshot too large")?];81 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<dyn Error...@@ -84,21 +94,21 @@ fn window_frame(path: &Path, appearance: Appearance) -> Result<(), Box<dyn Error
84 }94 }
85 };95 };
86 let [width, height] = [info.width as f32, info.height as f32];96 let [width, height] = [info.width as f32, info.height as f32];
87 let radius = platform::CORNER_RADIUS * SCALE;97 let radius = platform::CORNER_RADIUS * scale;
88 for (index, pixel) in pixels.chunks_exact_mut(4).enumerate() {98 for (index, pixel) in pixels.chunks_exact_mut(4).enumerate() {
89 let x = (index as u32 % info.width) as f32 + 0.5;99 let x = (index as u32 % info.width) as f32 + 0.5;
90 let y = (index as u32 / info.width) as f32 + 0.5;100 let y = (index as u32 / info.width) as f32 + 0.5;
91 for (centre, fill, rim) in LIGHTS {101 for (centre, fill, rim) in LIGHTS {
92 let distance = (x - centre * SCALE).hypot(y - TITLE / 2.0 * SCALE);102 let distance = (x - centre * scale).hypot(y - TITLE / 2.0 * scale);
93 blend(103 blend(
94 pixel,104 pixel,
95 [rim[0], rim[1], rim[2], 255],105 [rim[0], rim[1], rim[2], 255],
96 inside(distance - 6.0 * SCALE),106 inside(distance - 6.0 * scale),
97 );107 );
98 blend(108 blend(
99 pixel,109 pixel,
100 [fill[0], fill[1], fill[2], 255],110 [fill[0], fill[1], fill[2], 255],
101 inside(distance - 5.5 * SCALE),111 inside(distance - 5.5 * scale),
102 );112 );
103 }113 }
104 // Signed distance to the window's rounded rectangle.114 // Signed distance to the window's rounded rectangle.
crates/snowbound/src/search.rs+35-46
...@@ -2,7 +2,7 @@...@@ -2,7 +2,7 @@
2//! results dropping down from it, and Find on This Page. The index is kept on a thread of2//! results dropping down from it, and Find on This Page. The index is kept on a thread of
3//! its own, from the section files and the open section's replica.3//! its own, from the section files and the open section's replica.
44
5use crate::{Command, Library, State, Theme, art, lap, page};5use crate::{Command, Library, State, Theme, art, commands, lap, page};
6use canvas::search::{Entry, Found, Index, Query, page_matches};6use canvas::search::{Entry, Found, Index, Query, page_matches};
7use notebook::Replica;7use notebook::Replica;
8use onestore::ExGuid;8use onestore::ExGuid;
...@@ -43,16 +43,6 @@ impl Scope {...@@ -43,16 +43,6 @@ impl Scope {
43 }43 }
44}44}
4545
46const SEARCH_KEY: &str = if cfg!(target_os = "macos") {
47 "⌘E"
48} else {
49 "Ctrl+E"
50};
51const FIND_KEY: &str = if cfg!(target_os = "macos") {
52 "⌘F"
53} else {
54 "Ctrl+F"
55};
56/// The results list's rows, the headings above their groups, and its width.46/// The results list's rows, the headings above their groups, and its width.
57const RESULT: f32 = 42.0;47const RESULT: f32 = 42.0;
58const HEADING: f32 = 24.0;48const HEADING: f32 = 24.0;
...@@ -71,7 +61,7 @@ fn scope_menu() -> Id {...@@ -71,7 +61,7 @@ fn scope_menu() -> Id {
71}61}
7262
73/// The field of the box, and of the results over it.63/// The field of the box, and of the results over it.
74fn field() -> Id {64pub(crate) fn field() -> Id {
75 Id::ROOT.child("search-field")65 Id::ROOT.child("search-field")
76}66}
7767
...@@ -274,7 +264,7 @@ fn sections(library: &Library) -> Vec<String> {...@@ -274,7 +264,7 @@ fn sections(library: &Library) -> Vec<String> {
274 folder264 folder
275 .groups265 .groups
276 .iter()266 .iter()
277 .filter(|group| !crate::library::recycle_bin(group)),267 .filter(|group| !crate::library::recycle_bin(&group.path)),
278 );268 );
279 }269 }
280 paths270 paths
...@@ -678,7 +668,11 @@ impl State {...@@ -678,7 +668,11 @@ impl State {
678 },668 },
679 );669 );
680 } else {670 } else {
681 let placeholder = format!("Search {} ({SEARCH_KEY})", self.search.scope.name());671 let placeholder = format!(
672 "Search {} ({})",
673 self.search.scope.name(),
674 commands::shortcut(commands::Id::Search)
675 );
682 let signal = ui::text_field(676 let signal = ui::text_field(
683 &mut self.ui,677 &mut self.ui,
684 field(),678 field(),
...@@ -705,15 +699,6 @@ impl State {...@@ -705,15 +699,6 @@ impl State {
705 Ok(())699 Ok(())
706 }700 }
707701
708 /// Whether `signal`, a field's, holds the command key with `letter`.
709 fn shortcut(&self, signal: &ui::Signal, letter: &str) -> bool {
710 self.view.modifiers().command
711 && signal.events.iter().any(|event| {
712 matches!(event, ui::Event::Key { key: winit::keyboard::Key::Character(key), .. }
713 if key.eq_ignore_ascii_case(letter))
714 })
715 }
716
717 fn scope_button(&mut self, theme: &Theme) {702 fn scope_button(&mut self, theme: &Theme) {
718 let button = self.ui.id("scope");703 let button = self.ui.id("scope");
719 if ui::shell::tool_button(&mut self.ui, "scope", art::SEARCH, theme.text_dim, false).pressed704 if ui::shell::tool_button(&mut self.ui, "scope", art::SEARCH, theme.text_dim, false).pressed
...@@ -727,6 +712,7 @@ impl State {...@@ -727,6 +712,7 @@ impl State {
727 /// OneNote's "Search In:" menu.712 /// OneNote's "Search In:" menu.
728 fn scope_popup(&mut self) {713 fn scope_popup(&mut self) {
729 let current = self.search.scope;714 let current = self.search.scope;
715 let find = commands::shortcut(commands::Id::Find);
730 let mut items = vec![716 let mut items = vec![
731 ui::popup::Item {717 ui::popup::Item {
732 text: "Search In:",718 text: "Search In:",
...@@ -734,8 +720,8 @@ impl State {...@@ -734,8 +720,8 @@ impl State {
734 ..Default::default()720 ..Default::default()
735 },721 },
736 ui::popup::Item {722 ui::popup::Item {
737 text: "Find on This Page",723 text: commands::command(commands::Id::Find).title,
738 shortcut: FIND_KEY,724 shortcut: &find,
739 ..Default::default()725 ..Default::default()
740 },726 },
741 ];727 ];
...@@ -800,30 +786,31 @@ impl State {...@@ -800,30 +786,31 @@ impl State {
800 self.ui.set_focus(Some(field()));786 self.ui.set_focus(Some(field()));
801 }787 }
802 let [left, top, right, bottom] = rect;788 let [left, top, right, bottom] = rect;
789 let anchor = Anchor::Over([left - 4.0, top - 4.0, right + 4.0, bottom + 4.0]);
790 let width = RESULTS.max(right - left);
791 // The box takes the toolbar's box's place at once and widens as the dropdown comes in.
792 let open = self.ui.opening(results(), anchor).unwrap_or(1.0);
793 let field_width = right - left + (width - 8.0 - (right - left)) * open;
803 self.ui.open_as(794 self.ui.open_as(
804 results(),795 results(),
805 Spec {796 Spec {
806 axis: Axis::Y,797 axis: Axis::Y,
807 size: [px(RESULTS.max(right - left)), children()],798 size: [px(width), children()],
808 fill: Some(theme.popup),799 fill: Some(theme.popup),
809 border: Some(theme.chip),800 border: Some(theme.chip),
810 shadow: Some(theme.shadow),801 shadow: Some(theme.shadow),
811 radius: 6.0,802 radius: 6.0,
812 pad: [4.0, 4.0],803 pad: [4.0, 4.0],
813 gap: 4.0,804 gap: 4.0,
814 anchor: Some(Anchor::Over([805 anchor: Some(anchor),
815 left - 4.0,
816 top - 4.0,
817 right + 4.0,
818 bottom + 4.0,
819 ])),
820 ..Spec::default()806 ..Spec::default()
821 },807 },
822 );808 );
823 self.ui.open(809 self.ui.open(
824 "box",810 "box",
825 Spec {811 Spec {
826 size: [fill(), px(ui::shell::TOOL)],812 flags: Flags::STILL,
813 size: [px(field_width), px(ui::shell::TOOL)],
827 fill: Some(theme.base),814 fill: Some(theme.base),
828 border: Some(theme.accent),815 border: Some(theme.accent),
829 radius: 4.0,816 radius: 4.0,
...@@ -832,8 +819,12 @@ impl State {...@@ -832,8 +819,12 @@ impl State {
832 ..Spec::default()819 ..Spec::default()
833 },820 },
834 );821 );
835 let placeholder = format!("Search {} ({SEARCH_KEY})", self.search.scope.name());822 let placeholder = format!(
836 let signal = ui::text_field(823 "Search {} ({})",
824 self.search.scope.name(),
825 commands::shortcut(commands::Id::Search)
826 );
827 ui::text_field(
837 &mut self.ui,828 &mut self.ui,
838 field(),829 field(),
839 &mut self.search.query,830 &mut self.search.query,
...@@ -846,10 +837,6 @@ impl State {...@@ -846,10 +837,6 @@ impl State {
846 );837 );
847 self.scope_button(theme);838 self.scope_button(theme);
848 self.ui.close();839 self.ui.close();
849 if self.shortcut(&signal, "f") {
850 self.ui.close();
851 return self.start_find(true);
852 }
853 let query = self.search.query.trim().to_owned();840 let query = self.search.query.trim().to_owned();
854 let found = &self.search.found;841 let found = &self.search.found;
855 let titled = found.iter().take_while(|found| found.in_title).count();842 let titled = found.iter().take_while(|found| found.in_title).count();
...@@ -916,7 +903,10 @@ impl State {...@@ -916,7 +903,10 @@ impl State {
916 "find",903 "find",
917 Spec {904 Spec {
918 size: [fit(), px(22.0)],905 size: [fit(), px(22.0)],
919 text: Some(&format!("Find on page: {FIND_KEY}")),906 text: Some(&format!(
907 "Find on page: {}",
908 commands::shortcut(commands::Id::Find)
909 )),
920 color: Some(theme.text_dim),910 color: Some(theme.text_dim),
921 ..Spec::default()911 ..Spec::default()
922 },912 },
...@@ -1018,7 +1008,10 @@ impl State {...@@ -1018,7 +1008,10 @@ impl State {
1018 let label = if self.search.query.trim().is_empty() {1008 let label = if self.search.query.trim().is_empty() {
1019 "Find on page".to_owned()1009 "Find on page".to_owned()
1020 } else if count == 0 {1010 } else if count == 0 {
1021 format!("No matches. Try {SEARCH_KEY}")1011 format!(
1012 "No matches. Try {}",
1013 commands::shortcut(commands::Id::Search)
1014 )
1022 } else {1015 } else {
1023 format!(1016 format!(
1024 "Match {} of {count}",1017 "Match {} of {count}",
...@@ -1051,7 +1044,7 @@ impl State {...@@ -1051,7 +1044,7 @@ impl State {
1051 step = Some(true);1044 step = Some(true);
1052 }1045 }
1053 }1046 }
1054 let signal = ui::text_field(1047 ui::text_field(
1055 &mut self.ui,1048 &mut self.ui,
1056 find_field(),1049 find_field(),
1057 &mut self.search.query,1050 &mut self.search.query,
...@@ -1065,10 +1058,6 @@ impl State {...@@ -1065,10 +1058,6 @@ impl State {
1065 if std::mem::take(&mut self.search.claim) {1058 if std::mem::take(&mut self.search.claim) {
1066 self.ui.set_focus(Some(find_field()));1059 self.ui.set_focus(Some(find_field()));
1067 }1060 }
1068 if self.shortcut(&signal, "e") {
1069 self.start_search();
1070 return Ok(());
1071 }
1072 if ui::shell::tool_button(&mut self.ui, "close", art::CLOSE, theme.text_dim, false).clicked1061 if ui::shell::tool_button(&mut self.ui, "close", art::CLOSE, theme.text_dim, false).clicked
1073 {1062 {
1074 self.end_search();1063 self.end_search();
crates/snowbound/src/settings.rs+38
...@@ -15,6 +15,7 @@ pub struct Settings {...@@ -15,6 +15,7 @@ pub struct Settings {
15 pub sidebar: bool,15 pub sidebar: bool,
16 /// Fonts picked from the font box, latest first.16 /// Fonts picked from the font box, latest first.
17 pub recent_fonts: Vec<String>,17 pub recent_fonts: Vec<String>,
18 pub toolbar: Toolbar,
18 /// The name edits are stored under, OneNote's User name; the account's full name until19 /// The name edits are stored under, OneNote's User name; the account's full name until
19 /// first saved.20 /// first saved.
20 pub user_name: Option<String>,21 pub user_name: Option<String>,
...@@ -25,6 +26,31 @@ pub struct Settings {...@@ -25,6 +26,31 @@ pub struct Settings {
25 pub search_scope: crate::search::Scope,26 pub search_scope: crate::search::Scope,
26}27}
2728
29/// What the toolbar's buttons apply from their menus' last picks.
30#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
31#[serde(default)]
32pub struct Toolbar {
33 /// COLORREFs the highlighter and the font colour button apply; `None` removes the
34 /// highlight, or makes the colour automatic.
35 pub highlight: Option<u32>,
36 pub font_color: Option<u32>,
37 /// Places in OneNote's bullet and numbering libraries picked lately, latest first.
38 pub bullets: Vec<usize>,
39 pub numbering: Vec<usize>,
40}
41
42impl Default for Toolbar {
43 /// Office's yellow highlighter and red font colour.
44 fn default() -> Self {
45 Self {
46 highlight: Some(0x00ffff),
47 font_color: Some(0x0000ff),
48 bullets: Vec::new(),
49 numbering: Vec::new(),
50 }
51 }
52}
53
28/// The interface's colours: the system's appearance, or one chosen in Options.54/// The interface's colours: the system's appearance, or one chosen in Options.
29#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]55#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
30#[serde(rename_all = "lowercase")]56#[serde(rename_all = "lowercase")]
...@@ -97,6 +123,7 @@ impl crate::State {...@@ -97,6 +123,7 @@ impl crate::State {
97 .map(|library| library.location.clone()),123 .map(|library| library.location.clone()),
98 sidebar: self.sidebar,124 sidebar: self.sidebar,
99 recent_fonts: self.recent_fonts.clone(),125 recent_fonts: self.recent_fonts.clone(),
126 toolbar: self.toolbar.clone(),
100 user_name: Some(self.author.clone()),127 user_name: Some(self.author.clone()),
101 color_scheme: self.color_scheme,128 color_scheme: self.color_scheme,
102 light_pages: self.light_pages,129 light_pages: self.light_pages,
...@@ -140,6 +167,12 @@ mod tests {...@@ -140,6 +167,12 @@ mod tests {
140 current: Some("/notebooks/Personal".into()),167 current: Some("/notebooks/Personal".into()),
141 sidebar: true,168 sidebar: true,
142 recent_fonts: vec!["Georgia".into()],169 recent_fonts: vec!["Georgia".into()],
170 toolbar: Toolbar {
171 highlight: None,
172 font_color: Some(0x00ff00),
173 bullets: vec![3, 0],
174 numbering: vec![16],
175 },
143 user_name: Some("Snowbound Test".into()),176 user_name: Some("Snowbound Test".into()),
144 color_scheme: ColorScheme::Dark,177 color_scheme: ColorScheme::Dark,
145 light_pages: true,178 light_pages: true,
...@@ -155,6 +188,11 @@ mod tests {...@@ -155,6 +188,11 @@ mod tests {
155 ..Settings::default()188 ..Settings::default()
156 }189 }
157 );190 );
191 // A picked "No color" stays picked; what was never picked keeps Office's default.
192 std::fs::write(&path, br#"{"toolbar": {"highlight": null}}"#).unwrap();
193 let toolbar = Settings::load(&path).toolbar;
194 assert_eq!(toolbar.highlight, None);
195 assert_eq!(toolbar.font_color, Toolbar::default().font_color);
158 std::fs::remove_dir_all(&directory).unwrap();196 std::fs::remove_dir_all(&directory).unwrap();
159 }197 }
160}198}
crates/snowbound/src/sidebar.rs+617-292
...@@ -1,26 +1,25 @@...@@ -1,26 +1,25 @@
1//! OneNote 2010's navigation bar: the open notebooks with their sections and section1//! OneNote 2010's navigation bar: the open notebooks with their sections and section
2//! groups, down the window's left, beside the section tabs.2//! groups, down the window's left, beside the section tabs.
33
4use crate::{art, library::Library, menus::Target, section_color};4use crate::{
5 art,
6 library::Library,
7 menus::{Dragged, Target},
8 rename, section_color,
9};
5use notebook::discover::{Folder, SectionState};10use notebook::discover::{Folder, SectionState};
6use std::{collections::HashSet, sync::Arc};11use std::{collections::HashSet, sync::Arc};
7use ui::{Axis, Event, Flags, Id, Signal, Spec, Theme, Ui, fill, fit, px};12use ui::{Axis, Flags, Id, Signal, Spec, Theme, Ui, fill, fit, px};
8use winit::keyboard::{Key, NamedKey};
913
10pub const WIDTH: f32 = 220.0;14pub const WIDTH: f32 = 220.0;
11/// The collapsed sidebar: a rail as wide as its button, which stays where it is as the15/// The square the sidebar's button stands in at the start of the section tabs.
12/// sidebar opens.16const RAIL: f32 = crate::TAB_ROW;
13pub const RAIL: f32 = crate::FRAME + ui::shell::TOOL + 2.0;
14const ROW: f32 = 24.0;17const ROW: f32 = 24.0;
15/// How far each level of the tree sits inside its parent.18/// How far each level of the tree sits inside its parent.
16const INDENT: f32 = 16.0;19const INDENT: f32 = 16.0;
1720
18/// What the sidebar was asked to do.21/// What the sidebar was asked to do.
19pub enum Action {22pub enum Action {
20 /// Expands or collapses the sidebar.
21 Toggle,
22 /// Shows a notebook where it was left.
23 Notebook(usize),
24 /// Opens a notebook's section by catalog path.23 /// Opens a notebook's section by catalog path.
25 Open {24 Open {
26 notebook: usize,25 notebook: usize,
...@@ -36,18 +35,8 @@ pub enum Action {...@@ -36,18 +35,8 @@ pub enum Action {
36 Menu(Target, [f32; 2]),35 Menu(Target, [f32; 2]),
37 /// Ends renaming, with the name typed or without.36 /// Ends renaming, with the name typed or without.
38 Renamed(bool),37 Renamed(bool),
39}38 /// Explains why a section or group can't be read: its name and the reason.
4039 Unavailable(String, String),
41/// A section or group being renamed in place, and the name typed so far.
42pub struct Renaming {
43 pub library: Arc<Library>,
44 pub path: String,
45 pub name: String,
46}
47
48/// The field a row being renamed shows.
49pub fn rename_field() -> Id {
50 Id::ROOT.child("rename")
51}40}
5241
53/// Names a notebook's or group's rows for folding them.42/// Names a notebook's or group's rows for folding them.
...@@ -61,12 +50,35 @@ pub struct Tree<'a> {...@@ -61,12 +50,35 @@ pub struct Tree<'a> {
61 /// The open section's notebook index and catalog path.50 /// The open section's notebook index and catalog path.
62 open: Option<(usize, &'a str)>,51 open: Option<(usize, &'a str)>,
63 folded: &'a HashSet<String>,52 folded: &'a HashSet<String>,
64 renaming: Option<&'a mut Renaming>,53 renaming: Option<&'a mut rename::Renaming>,
65 action: Option<Action>,54 action: Option<Action>,
66 /// Each section and group row: where it lies, and what it is.55 /// Each section and group row: where it lies, and what it is.
67 rows: Vec<Entry>,56 rows: Vec<Entry>,
68 /// The section or group held down, which a drag moves.57 /// The section or group held down, which a drag moves.
69 held: Option<Entry>,58 held: Option<Entry>,
59 /// The row dragged, which the tree leaves out for a gap where it would land.
60 lifted: Option<(&'a Entry, &'a Landing)>,
61 /// The dragged row as it was built, for drawing it over the others.
62 ghost: Option<Ghost>,
63 /// Where the gap the dragged row would land in stands.
64 gap: Option<[f32; 4]>,
65}
66
67/// Where a dragged row would land: before a row of a folder or at its end, or into a group.
68#[derive(Clone, PartialEq)]
69pub struct Landing {
70 pub folder: String,
71 pub before: Option<String>,
72 pub into: bool,
73}
74
75/// The dragged row's look, kept to draw it over the others.
76struct Ghost {
77 label: String,
78 icon: Leading,
79 depth: u32,
80 dim: bool,
81 selected: Option<[f32; 4]>,
70}82}
7183
72impl<'a> Tree<'a> {84impl<'a> Tree<'a> {
...@@ -74,7 +86,7 @@ impl<'a> Tree<'a> {...@@ -74,7 +86,7 @@ impl<'a> Tree<'a> {
74 theme: &'a Theme,86 theme: &'a Theme,
75 open: Option<(usize, &'a str)>,87 open: Option<(usize, &'a str)>,
76 folded: &'a HashSet<String>,88 folded: &'a HashSet<String>,
77 renaming: Option<&'a mut Renaming>,89 renaming: Option<&'a mut rename::Renaming>,
78 ) -> Self {90 ) -> Self {
79 Self {91 Self {
80 theme,92 theme,
...@@ -84,6 +96,9 @@ impl<'a> Tree<'a> {...@@ -84,6 +96,9 @@ impl<'a> Tree<'a> {
84 action: None,96 action: None,
85 rows: Vec::new(),97 rows: Vec::new(),
86 held: None,98 held: None,
99 lifted: None,
100 ghost: None,
101 gap: None,
87 }102 }
88 }103 }
89}104}
...@@ -95,18 +110,26 @@ pub struct Entry {...@@ -95,18 +110,26 @@ pub struct Entry {
95 pub path: String,110 pub path: String,
96 pub group: bool,111 pub group: bool,
97 pub rect: [f32; 4],112 pub rect: [f32; 4],
113 /// The row's box, which a drag draws over the others.
114 pub id: Id,
98}115}
99116
100impl Entry {117impl Entry {
101 fn folder(&self) -> &str {118 fn folder(&self) -> &str {
102 self.path.rsplit_once('/').map_or("", |(folder, _)| folder)119 self.path.rsplit_once('/').map_or("", |(folder, _)| folder)
103 }120 }
121
122 /// Whether this is the row `lifted`, or one inside it.
123 fn within(&self, lifted: &Entry) -> bool {
124 self.notebook == lifted.notebook
125 && (self.path == lifted.path || self.path.starts_with(&format!("{}/", lifted.path)))
126 }
104}127}
105128
106/// The sidebar's header row, `header` tall, then with `rows` the tree of `notebooks` with129/// The sidebar's header row, `header` tall, then with `rows` the tree of `notebooks` with
107/// the open section marked.130/// the open section marked.
108fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc<Library>], header: f32, rows: bool) {131fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc<Library>], header: f32, rows: bool) {
109 let (theme, open, folded) = (tree.theme, tree.open, tree.folded);132 let (theme, folded) = (tree.theme, tree.folded);
110 ui.open(133 ui.open(
111 "header",134 "header",
112 Spec {135 Spec {
...@@ -116,9 +139,14 @@ fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc<Library>], header: f32...@@ -116,9 +139,14 @@ fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc<Library>], header: f32
116 ..Spec::default()139 ..Spec::default()
117 },140 },
118 );141 );
119 if ui::shell::tool_button(ui, "toggle", art::NOTEBOOK, theme.text, false).clicked {142 // The button's square, which floats over this place from the section tabs.
120 tree.action = Some(Action::Toggle);143 ui.leaf(
121 }144 "toggle",
145 Spec {
146 size: [px(RAIL - crate::FRAME), px(ui::shell::TOOL)],
147 ..Spec::default()
148 },
149 );
122 ui.leaf(150 ui.leaf(
123 "title",151 "title",
124 Spec {152 Spec {
...@@ -148,7 +176,7 @@ fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc<Library>], header: f32...@@ -148,7 +176,7 @@ fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc<Library>], header: f32
148 let (row, fold) = tree_row(176 let (row, fold) = tree_row(
149 ui,177 ui,
150 tree,178 tree,
151 ("notebook", index),179 ui.id(("notebook", index)),
152 Row {180 Row {
153 label: &library.name,181 label: &library.name,
154 icon: Leading::Icon(art::NOTEBOOK),182 icon: Leading::Icon(art::NOTEBOOK),
...@@ -159,16 +187,18 @@ fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc<Library>], header: f32...@@ -159,16 +187,18 @@ fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc<Library>], header: f32
159 renamed: false,187 renamed: false,
160 },188 },
161 );189 );
162 if fold {190 if row.clicked || fold {
163 tree.action = Some(Action::Fold(key));191 tree.action = Some(Action::Fold(key));
164 } else if row.clicked && open.is_none_or(|(notebook, _)| notebook != index) {
165 tree.action = Some(Action::Notebook(index));
166 }192 }
167 if let Some(point) = row.context {193 if let Some(point) = row.context {
168 tree.action = Some(Action::Menu(Target::Notebook(Arc::clone(library)), point));194 tree.action = Some(Action::Menu(Target::Notebook(Arc::clone(library)), point));
169 }195 }
170 if unfolded && let Some(catalog) = library.catalog() {196 if let Some(catalog) = library.catalog()
197 && unfolding(ui, ("notebook rows", index), unfolded)
198 {
171 folder(ui, tree, library, index, catalog, 1);199 folder(ui, tree, library, index, catalog, 1);
200 ui.close();
201 ui.close();
172 }202 }
173 }203 }
174 ui.close();204 ui.close();
...@@ -192,7 +222,7 @@ fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc<Library>], header: f32...@@ -192,7 +222,7 @@ fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc<Library>], header: f32
192 let (row, _) = tree_row(222 let (row, _) = tree_row(
193 ui,223 ui,
194 tree,224 tree,
195 part,225 ui.id(part),
196 Row {226 Row {
197 label,227 label,
198 icon: Leading::Icon(icon),228 icon: Leading::Icon(icon),
...@@ -224,9 +254,9 @@ fn folder(...@@ -224,9 +254,9 @@ fn folder(
224 .filter(|(index, _)| *index == notebook)254 .filter(|(index, _)| *index == notebook)
225 .map(|(_, path)| path);255 .map(|(_, path)| path);
226 let renamed = |tree: &Tree, path: &str| {256 let renamed = |tree: &Tree, path: &str| {
227 tree.renaming.as_ref().is_some_and(|renaming| {257 tree.renaming
228 Arc::ptr_eq(&renaming.library, library) && renaming.path == path258 .as_ref()
229 })259 .is_some_and(|renaming| renaming.entry(library, path, false))
230 };260 };
231 for section in &folder.sections {261 for section in &folder.sections {
232 let (name, color, readable) = match &section.state {262 let (name, color, readable) = match &section.state {
...@@ -241,28 +271,29 @@ fn folder(...@@ -241,28 +271,29 @@ fn folder(
241 false,271 false,
242 ),272 ),
243 };273 };
274 gap(ui, tree, &folder.path, Some(&section.path));
244 let renaming = renamed(tree, &section.path);275 let renaming = renamed(tree, &section.path);
245 let (row, _) = tree_row(276 let id = ui.id(("section", notebook, &section.path));
246 ui,277 let row = Row {
247 tree,278 label: &name,
248 ("section", notebook, &section.path),279 icon: Leading::Section(section_color(color)),
249 Row {280 depth,
250 label: &name,281 dim: !readable,
251 icon: Leading::Swatch(section_color(color)),282 fold: None,
252 depth,283 selected: (open == Some(section.path.as_str())).then_some(section_color(color)),
253 dim: !readable,284 renamed: renaming,
254 fold: None,285 };
255 selected: (open == Some(section.path.as_str())).then_some(section_color(color)),286 if lifts(tree, notebook, &section.path, &row) {
256 renamed: renaming,287 continue;
257 },288 }
258 );289 let (row, _) = tree_row(ui, tree, id, row);
259 if row.clicked && readable && open != Some(section.path.as_str()) {290 if row.clicked && readable && open != Some(section.path.as_str()) {
260 tree.action = Some(Action::Open {291 tree.action = Some(Action::Open {
261 notebook,292 notebook,
262 path: section.path.clone(),293 path: section.path.clone(),
263 });294 });
264 }295 }
265 entry(ui, tree, notebook, &section.path, false, &row);296 entry(ui, tree, notebook, &section.path, id, false, &row);
266 if let Some(point) = row.context {297 if let Some(point) = row.context {
267 let target = Target::Section {298 let target = Target::Section {
268 library: Arc::clone(library),299 library: Arc::clone(library),
...@@ -273,50 +304,200 @@ fn folder(...@@ -273,50 +304,200 @@ fn folder(
273 }304 }
274 for group in &folder.groups {305 for group in &folder.groups {
275 // OneNote lists deleted sections under its own command, not as a group.306 // OneNote lists deleted sections under its own command, not as a group.
276 if crate::library::recycle_bin(group) {307 if crate::library::recycle_bin(&group.path) {
277 continue;308 continue;
278 }309 }
279 let key = fold_key(library, &group.path);310 let key = fold_key(library, &group.path);
280 let unfolded = !tree.folded.contains(&key);311 let unfolded = !tree.folded.contains(&key);
281 let name = group.path.rsplit('/').next().unwrap_or_default();312 let name = group.path.rsplit('/').next().unwrap_or_default();
313 gap(ui, tree, &folder.path, Some(&group.path));
282 let renaming = renamed(tree, &group.path);314 let renaming = renamed(tree, &group.path);
283 let (row, fold) = tree_row(315 let id = ui.id(("group", notebook, &group.path));
316 let row = Row {
317 label: name,
318 icon: Leading::Icon(art::SECTION_GROUP),
319 depth,
320 dim: false,
321 fold: Some(unfolded),
322 selected: None,
323 renamed: renaming,
324 };
325 // A dragged group's rows fold away beneath it.
326 let lifted = lifts(tree, notebook, &group.path, &row);
327 if !lifted {
328 let (row, fold) = tree_row(ui, tree, id, row);
329 if row.clicked || fold {
330 tree.action = Some(Action::Fold(key));
331 }
332 entry(ui, tree, notebook, &group.path, id, true, &row);
333 if let Some(point) = row.context {
334 let target = Target::Group {
335 library: Arc::clone(library),
336 path: group.path.clone(),
337 };
338 tree.action = Some(Action::Menu(target, point));
339 }
340 }
341 if unfolding(
342 ui,
343 ("group rows", notebook, &group.path),
344 unfolded && !lifted,
345 ) {
346 self::folder(ui, tree, library, notebook, group, depth + 1);
347 ui.close();
348 ui.close();
349 }
350 }
351 gap(ui, tree, &folder.path, None);
352 for entry in &folder.unavailable {
353 if crate::library::recycle_bin(&entry.path) {
354 continue;
355 }
356 let name = entry.path.rsplit('/').next().unwrap_or_default();
357 let name = if entry.group {
358 name.to_owned()
359 } else {
360 crate::library::section_name(&entry.path, &None)
361 };
362 let (row, _) = tree_row(
284 ui,363 ui,
285 tree,364 tree,
286 ("group", notebook, &group.path),365 ui.id(("unavailable", notebook, &entry.path)),
287 Row {366 Row {
288 label: name,367 label: &name,
289 icon: Leading::Icon(art::SECTION_GROUP),368 icon: if entry.group {
369 Leading::Icon(art::SECTION_GROUP)
370 } else {
371 Leading::Section(section_color(None))
372 },
290 depth,373 depth,
291 dim: false,374 dim: true,
292 fold: Some(unfolded),375 fold: None,
293 selected: None,376 selected: None,
294 renamed: renaming,377 renamed: false,
295 },378 },
296 );379 );
297 if row.clicked || fold {380 if row.clicked {
298 tree.action = Some(Action::Fold(key));381 tree.action = Some(Action::Unavailable(name, entry.error.clone()));
299 }
300 entry(ui, tree, notebook, &group.path, true, &row);
301 if let Some(point) = row.context {
302 let target = Target::Group {
303 library: Arc::clone(library),
304 path: group.path.clone(),
305 };
306 tree.action = Some(Action::Menu(target, point));
307 }
308 if unfolded {
309 self::folder(ui, tree, library, notebook, group, depth + 1);
310 }382 }
311 }383 }
312}384}
313385
314/// Records a section or group row for dragging.386/// Where the row `lifted` lands with the pointer at height `y` among `rows`: onto a
315fn entry(ui: &Ui, tree: &mut Tree, notebook: usize, path: &str, group: bool, row: &Signal) {387/// group's middle, into it; above or below a row's middle, before or after it in its
316 let Some(rect) = ui388/// folder; into another folder, at its end.
317 .rect(ui.id(("section", notebook, path)))389fn landing_at(rows: &[Entry], lifted: &Entry, y: f32) -> Option<Landing> {
318 .or_else(|| ui.rect(ui.id(("group", notebook, path))))390 let candidates: Vec<&Entry> = rows
319 else {391 .iter()
392 .filter(|row| row.notebook == lifted.notebook && !row.within(lifted))
393 .collect();
394 let under = candidates
395 .iter()
396 .find(|row| y >= row.rect[1] && y < row.rect[3])
397 .or_else(|| candidates.last().filter(|row| y >= row.rect[3]))?;
398 let [top, bottom] = [under.rect[1], under.rect[3]];
399 let quarter = (bottom - top) / 4.0;
400 if under.group && y > top + quarter && y < bottom - quarter {
401 return Some(Landing {
402 folder: under.path.clone(),
403 before: None,
404 into: true,
405 });
406 }
407 let folder = under.folder();
408 let before = if y < (top + bottom) / 2.0 {
409 Some(under.path.clone())
410 } else {
411 candidates
412 .iter()
413 .skip_while(|row| row.path != under.path)
414 .skip(1)
415 .find(|row| !row.within(under))
416 .filter(|row| row.folder() == folder)
417 .map(|row| row.path.clone())
418 };
419 Some(Landing {
420 folder: folder.to_owned(),
421 before: before.filter(|_| folder == lifted.folder()),
422 into: false,
423 })
424}
425
426/// Whether the row of `path` in `notebook` is the one dragged, which the tree leaves out,
427/// keeping its look to draw it over the others.
428fn lifts(tree: &mut Tree, notebook: usize, path: &str, row: &Row) -> bool {
429 let lifted = tree
430 .lifted
431 .is_some_and(|(lifted, _)| lifted.notebook == notebook && lifted.path == path);
432 if lifted {
433 tree.ghost = Some(Ghost {
434 label: row.label.to_owned(),
435 icon: row.icon,
436 depth: row.depth,
437 dim: row.dim,
438 selected: row.selected,
439 });
440 }
441 lifted
442}
443
444/// The gap before the row of `before` in `folder`, or at its end, which opens where a
445/// dragged row would land and closes where it no longer would.
446fn gap(ui: &mut Ui, tree: &mut Tree, folder: &str, before: Option<&str>) {
447 let Some((_, landing)) = tree.lifted else {
448 return;
449 };
450 let here = !landing.into && landing.folder == folder && landing.before.as_deref() == before;
451 let id = ui.id(("gap", before));
452 let height = ui.animate(id, if here { ROW } else { 0.0 });
453 if here {
454 tree.gap = ui.rect(id);
455 }
456 if height > 0.0 {
457 ui.leaf(
458 ("gap", before),
459 Spec {
460 size: [fill(), px(height)],
461 ..Spec::default()
462 },
463 );
464 }
465}
466
467/// Opens the boxes a notebook's or group's rows ease open and shut in, where they show:
468/// the caller builds the rows and closes both.
469fn unfolding(ui: &mut Ui, part: impl std::hash::Hash, unfolded: bool) -> bool {
470 let outer = ui.id(part);
471 let rows = ui
472 .rect(outer.child("rows"))
473 .map_or(0.0, |rect| rect[3] - rect[1]);
474 let height = ui.animate(outer, if unfolded { rows } else { 0.0 });
475 if !unfolded && height == 0.0 {
476 return false;
477 }
478 ui.open_as(
479 outer,
480 Spec {
481 flags: Flags::CLIP,
482 axis: Axis::Y,
483 size: [fill(), px(height)],
484 ..Spec::default()
485 },
486 );
487 ui.open(
488 "rows",
489 Spec {
490 axis: Axis::Y,
491 size: [fill(), ui::children()],
492 ..Spec::default()
493 },
494 );
495 true
496}
497
498/// Records a section or group row, built as `id`, for dragging.
499fn entry(ui: &Ui, tree: &mut Tree, notebook: usize, path: &str, id: Id, group: bool, row: &Signal) {
500 let Some(rect) = ui.rect(id) else {
320 return;501 return;
321 };502 };
322 let entry = Entry {503 let entry = Entry {
...@@ -324,6 +505,7 @@ fn entry(ui: &Ui, tree: &mut Tree, notebook: usize, path: &str, group: bool, row...@@ -324,6 +505,7 @@ fn entry(ui: &Ui, tree: &mut Tree, notebook: usize, path: &str, group: bool, row
324 path: path.to_owned(),505 path: path.to_owned(),
325 group,506 group,
326 rect,507 rect,
508 id,
327 };509 };
328 if row.dragging {510 if row.dragging {
329 tree.held = Some(entry.clone());511 tree.held = Some(entry.clone());
...@@ -331,10 +513,11 @@ fn entry(ui: &Ui, tree: &mut Tree, notebook: usize, path: &str, group: bool, row...@@ -331,10 +513,11 @@ fn entry(ui: &Ui, tree: &mut Tree, notebook: usize, path: &str, group: bool, row
331 tree.rows.push(entry);513 tree.rows.push(entry);
332}514}
333515
516#[derive(Clone, Copy)]
334enum Leading {517enum Leading {
335 Icon(&'static [&'static str]),518 Icon(&'static [&'static str]),
336 /// A section's colour, as OneNote's section icon shows it.519 /// A section's tab, drawn in its colour.
337 Swatch([f32; 4]),520 Section([f32; 4]),
338}521}
339522
340struct Row<'a> {523struct Row<'a> {
...@@ -351,13 +534,13 @@ struct Row<'a> {...@@ -351,13 +534,13 @@ struct Row<'a> {
351}534}
352535
353/// One row of the tree: its signal, and whether its fold arrow was clicked.536/// One row of the tree: its signal, and whether its fold arrow was clicked.
354fn tree_row(ui: &mut Ui, tree: &mut Tree, part: impl std::hash::Hash, row: Row) -> (Signal, bool) {537fn tree_row(ui: &mut Ui, tree: &mut Tree, id: Id, row: Row) -> (Signal, bool) {
355 let theme = tree.theme;538 let theme = tree.theme;
356 let lit = row539 let lit = row
357 .selected540 .selected
358 .map(|color| ui::mix(theme.section(color).tab, theme.base, 0.35));541 .map(|color| ui::mix(theme.section(color).tab, theme.base, 0.35));
359 let id = ui.open(542 ui.open_as(
360 part,543 id,
361 Spec {544 Spec {
362 flags: Flags::CLICKABLE,545 flags: Flags::CLICKABLE,
363 size: [fill(), px(ROW)],546 size: [fill(), px(ROW)],
...@@ -365,69 +548,31 @@ fn tree_row(ui: &mut Ui, tree: &mut Tree, part: impl std::hash::Hash, row: Row)...@@ -365,69 +548,31 @@ fn tree_row(ui: &mut Ui, tree: &mut Tree, part: impl std::hash::Hash, row: Row)
365 hover_fill: Some(ui::mix(lit.unwrap_or(theme.strip), theme.hover(), 0.6)),548 hover_fill: Some(ui::mix(lit.unwrap_or(theme.strip), theme.hover(), 0.6)),
366 radius: 4.0,549 radius: 4.0,
367 pad: [6.0 + INDENT * row.depth as f32, 0.0],550 pad: [6.0 + INDENT * row.depth as f32, 0.0],
368 gap: 6.0,551 // The field's text stands where the label did.
552 gap: if row.renamed { 6.0 - rename::PAD } else { 6.0 },
369 ..Spec::default()553 ..Spec::default()
370 },554 },
371 );555 );
372 let color = if row.dim { theme.text_dim } else { theme.text };556 let color = if row.dim { theme.text_dim } else { theme.text };
373 match row.icon {557 let (icon, [red, green, blue, _]) = match row.icon {
374 Leading::Icon(icon) => {558 Leading::Icon(icon) => (icon, theme.text),
375 ui.leaf(559 Leading::Section(section) => (art::SECTION, theme.section(section).accent),
376 "icon",560 };
377 Spec {561 // Coloured art keeps its colours, so a dim row fades its icon.
378 size: [px(16.0), px(ROW)],562 let alpha = if row.dim { 0.5 } else { 1.0 };
379 icon: Some(icon),563 ui.leaf(
380 color: Some(color),564 "icon",
381 ..Spec::default()565 Spec {
382 },566 size: [px(16.0), px(ROW)],
383 );567 icon: Some(icon),
384 }568 color: Some([red, green, blue, alpha]),
385 Leading::Swatch(swatch) => {569 ..Spec::default()
386 ui.open(570 },
387 "swatch",571 );
388 Spec {
389 size: [px(16.0), px(ROW)],
390 pad: [3.0, 6.0],
391 ..Spec::default()
392 },
393 );
394 ui.leaf(
395 "color",
396 Spec {
397 size: [fill(), fill()],
398 fill: Some(theme.section(swatch).frame[0]),
399 border: Some(theme.section(swatch).edge),
400 radius: 2.0,
401 ..Spec::default()
402 },
403 );
404 ui.close();
405 }
406 }
407 match (row.renamed, tree.renaming.as_deref_mut()) {572 match (row.renamed, tree.renaming.as_deref_mut()) {
408 (true, Some(renaming)) => {573 (true, Some(renaming)) => {
409 let field = ui::text_field(574 if let Some(keep) = rename::edit(ui, theme, &mut renaming.name, ROW - 4.0) {
410 ui,575 tree.action = Some(Action::Renamed(keep));
411 rename_field(),
412 &mut renaming.name,
413 "",
414 Spec {
415 size: [fill(), px(ROW - 4.0)],
416 fill: Some(theme.base),
417 border: Some(theme.accent),
418 radius: 3.0,
419 pad: [4.0, 0.0],
420 ..Spec::default()
421 },
422 );
423 for event in &field.events {
424 if let Event::Key {
425 key: Key::Named(key @ (NamedKey::Enter | NamedKey::Escape)),
426 ..
427 } = event
428 {
429 tree.action = Some(Action::Renamed(*key == NamedKey::Enter));
430 }
431 }576 }
432 }577 }
433 _ => {578 _ => {
...@@ -464,14 +609,14 @@ fn tree_row(ui: &mut Ui, tree: &mut Tree, part: impl std::hash::Hash, row: Row)...@@ -464,14 +609,14 @@ fn tree_row(ui: &mut Ui, tree: &mut Tree, part: impl std::hash::Hash, row: Row)
464}609}
465610
466impl crate::State {611impl crate::State {
467 /// The sidebar left of the section tabs, easing open and shut.612 /// The sidebar left of the section tabs, easing open and shut; returns its width.
468 pub(crate) fn sidebar(&mut self, theme: &Theme) {613 pub(crate) fn sidebar(&mut self, theme: &Theme) -> f32 {
469 if self.temporary {614 if self.temporary {
470 return;615 return 0.0;
471 }616 }
472 let width = self.ui.animate(617 let width = self.ui.animate(
473 self.ui.id("sidebar"),618 self.ui.id("sidebar"),
474 if self.sidebar { WIDTH } else { RAIL },619 if self.sidebar { WIDTH } else { 0.0 },
475 );620 );
476 self.ui.open(621 self.ui.open(
477 "sidebar",622 "sidebar",
...@@ -500,39 +645,43 @@ impl crate::State {...@@ -500,39 +645,43 @@ impl crate::State {
500 Some((index, session.tabs[session.tab].path.as_str()))645 Some((index, session.tabs[session.tab].path.as_str()))
501 });646 });
502 let mut tree = Tree::new(theme, open, &self.folded, self.renaming.as_mut());647 let mut tree = Tree::new(theme, open, &self.folded, self.renaming.as_mut());
503 // The collapsed rail shows only its button.648 tree.lifted = self
649 .drag
650 .as_ref()
651 .filter(|drag| drag.lifted)
652 .and_then(|drag| match &drag.what {
653 Dragged::Entry { entry, landing, .. } => Some((entry, landing)),
654 _ => None,
655 });
504 sidebar(656 sidebar(
505 &mut self.ui,657 &mut self.ui,
506 &mut tree,658 &mut tree,
507 &self.notebooks,659 &self.notebooks,
508 crate::TAB_ROW,660 crate::TAB_ROW,
509 width > RAIL + 0.5,661 width > 0.5,
510 );662 );
511 let Tree {663 let Tree {
512 action, rows, held, ..664 action,
665 rows,
666 mut held,
667 ghost,
668 gap,
669 ..
513 } = tree;670 } = tree;
514 let corner = self.ui.rect(rows_id).unwrap_or_default();671 let corner = self.ui.rect(rows_id).unwrap_or_default();
515 self.drag_entries(held, &rows, corner, theme);672 let found = ghost.is_some();
673 let (ghost_held, settled) = self.lifted_row(theme, ghost, gap, &rows, corner);
674 held = held.or(ghost_held);
675 self.drag_entries(held, &rows, gap, settled || !found, corner, theme);
516 self.ui.close();676 self.ui.close();
517 self.ui.close();677 self.ui.close();
518 match action {678 match action {
519 Some(Action::Toggle) => {
520 self.sidebar = !self.sidebar;
521 self.save_settings();
522 }
523 Some(Action::Fold(key)) => {679 Some(Action::Fold(key)) => {
524 if !self.folded.remove(&key) {680 if !self.folded.remove(&key) {
525 self.folded.insert(key);681 self.folded.insert(key);
526 }682 }
527 }683 }
528 Some(Action::Notebook(index)) => {684 Some(Action::Open { notebook, path }) if !self.dragged() => {
529 let library = Arc::clone(&self.notebooks[index]);
530 if let Some(path) = library.first_section() {
531 self.commands
532 .push(crate::Command::OpenSection(library, path));
533 }
534 }
535 Some(Action::Open { notebook, path }) => {
536 let library = Arc::clone(&self.notebooks[notebook]);685 let library = Arc::clone(&self.notebooks[notebook]);
537 self.commands686 self.commands
538 .push(crate::Command::OpenSection(library, path));687 .push(crate::Command::OpenSection(library, path));
...@@ -541,120 +690,208 @@ impl crate::State {...@@ -541,120 +690,208 @@ impl crate::State {
541 self.menu = Some((target, point));690 self.menu = Some((target, point));
542 self.ui.open_popup(crate::menus::id());691 self.ui.open_popup(crate::menus::id());
543 }692 }
544 Some(Action::Renamed(keep)) => {693 Some(Action::Renamed(keep)) => self.finish_renaming(keep),
545 if let Some(renaming) = self.renaming.take()
546 && keep
547 {
548 let name = renaming.name.trim().to_owned();
549 let old = renaming.path.rsplit('/').next().unwrap_or_default();
550 if !name.is_empty() && name != old.strip_suffix(".one").unwrap_or(old) {
551 self.commands.push(crate::Command::Structure(
552 renaming.library,
553 crate::manage::Structure::Rename {
554 path: renaming.path,
555 name,
556 },
557 ));
558 }
559 }
560 self.ui.set_focus(Some(crate::page()));
561 }
562 Some(Action::NewNotebook) => self.commands.push(crate::Command::NewNotebook),694 Some(Action::NewNotebook) => self.commands.push(crate::Command::NewNotebook),
563 Some(Action::OpenNotebook) => self.commands.push(crate::Command::OpenNotebook),695 Some(Action::OpenNotebook) => self.commands.push(crate::Command::OpenNotebook),
564 Some(Action::Options) => self.open_options(),696 Some(Action::Options) => self.open_options(),
565 None => {}697 Some(Action::Unavailable(name, error)) => crate::platform::alert(
698 &format!("Can't read \u{201c}{name}\u{201d}"),
699 &format!("{error}\n\nIt appears here once you have access."),
700 ),
701 Some(Action::Open { .. }) | None => {}
702 }
703 width
704 }
705
706 /// The sidebar's button in its square at the window's edge, and the room the section
707 /// tabs, standing `width` from that edge, leave it; the button stays put as the sidebar
708 /// eases open and shut.
709 pub(crate) fn sidebar_button(&mut self, theme: &Theme, width: f32) {
710 if self.temporary {
711 return;
566 }712 }
713 // Past the tab row's padding and the frame's corner, the tabs start where the square
714 // ends; the room shrinks on the sidebar's own easing, so the tabs ease with it.
715 let room = RAIL - crate::FRAME - self.rounding();
716 self.ui.leaf(
717 "rail",
718 Spec {
719 size: [px(room * (1.0 - width / WIDTH)), px(1.0)],
720 ..Spec::default()
721 },
722 );
723 self.ui.open(
724 "toggle",
725 Spec {
726 flags: Flags::FLOAT,
727 size: [px(RAIL), px(RAIL)],
728 position: [-width, 0.0],
729 pad: [(RAIL - ui::shell::TOOL) / 2.0; 2],
730 ..Spec::default()
731 },
732 );
733 if ui::shell::tool_button(&mut self.ui, "button", art::NOTEBOOK, theme.text, false).clicked
734 {
735 self.sidebar = !self.sidebar;
736 self.save_settings();
737 }
738 self.ui.close();
739 }
740
741 /// The dragged row, `ghost`, drawn over the others in the rows' box `corner`: following
742 /// the pointer, or once let go easing into the `gap` it lands in, or onto the group it
743 /// lands in among `rows`. Returns it while held, and whether it stands in its place.
744 fn lifted_row(
745 &mut self,
746 theme: &Theme,
747 ghost: Option<Ghost>,
748 gap: Option<[f32; 4]>,
749 rows: &[Entry],
750 corner: [f32; 4],
751 ) -> (Option<Entry>, bool) {
752 let (
753 Some(ghost),
754 Some(
755 drag @ crate::menus::Drag {
756 what: Dragged::Entry { entry, landing, .. },
757 ..
758 },
759 ),
760 ) = (ghost, &self.drag)
761 else {
762 return (None, true);
763 };
764 let key = self.ui.id("lifted");
765 let (top, settled) = if drag.live() {
766 let top = drag.corner(self.pointer)[1] - corner[1];
767 (self.ui.hold(key, top), false)
768 } else {
769 let place = gap
770 .or_else(|| {
771 rows.iter()
772 .find(|row| landing.into && row.path == landing.folder)
773 .map(|row| row.rect)
774 })
775 .unwrap_or(entry.rect);
776 let target = place[1] - corner[1];
777 let top = self.ui.animate(key, target);
778 (top, (top - target).abs() < 0.5)
779 };
780 let entry = entry.clone();
781 self.ui.open(
782 "lifted",
783 Spec {
784 flags: Flags::FLOAT,
785 size: [px(entry.rect[2] - entry.rect[0]), px(ROW)],
786 position: [entry.rect[0] - corner[0], top],
787 fill: Some(theme.base),
788 shadow: Some([0.0, 0.0, 0.0, 0.3]),
789 radius: 4.0,
790 ..Spec::default()
791 },
792 );
793 let mut tree = Tree::new(theme, None, &self.folded, None);
794 let (row, _) = tree_row(
795 &mut self.ui,
796 &mut tree,
797 entry.id,
798 Row {
799 label: &ghost.label,
800 icon: ghost.icon,
801 depth: ghost.depth,
802 dim: ghost.dim,
803 fold: None,
804 selected: ghost.selected,
805 renamed: false,
806 },
807 );
808 self.ui.close();
809 (row.dragging.then_some(entry), settled)
567 }810 }
568811
569 /// Moves a section or group by dragging its row: onto a group's middle it moves into the812 /// Moves a section or group by dragging its row, `held`, among `rows` in the rows' box
570 /// group, as OneNote moves one dropped on a group's tab; between rows of its folder it813 /// `corner`: the others open a `gap` where it would land. Onto a group's middle it moves
571 /// takes that place; between rows of another folder it moves there, last. `corner` is814 /// into the group, as OneNote moves one dropped on a group's tab; between rows of its
572 /// the rows' box.815 /// folder it takes that place; into another folder it moves there, last. The drag ends
816 /// once the row, let go, is `settled`.
573 fn drag_entries(817 fn drag_entries(
574 &mut self,818 &mut self,
575 held: Option<Entry>,819 held: Option<Entry>,
576 rows: &[Entry],820 rows: &[Entry],
821 gap: Option<[f32; 4]>,
822 settled: bool,
577 corner: [f32; 4],823 corner: [f32; 4],
578 theme: &Theme,824 theme: &Theme,
579 ) {825 ) {
580 let dragged = match (&self.drag, &held) {826 let mine = |what: &Dragged| matches!(what, Dragged::Entry { .. });
581 (_, Some(entry)) => Some(entry.clone()),827 self.settle(mine, settled);
582 (Some(crate::menus::Drag::Entry(entry)), None) => Some(entry.clone()),828 let held = held.map(|entry| {
583 _ => None,829 let rect = entry.rect;
584 };830 let home = Landing {
585 if let Some(entry) = &held {831 folder: entry.folder().to_owned(),
586 self.drag = Some(crate::menus::Drag::Entry(entry.clone()));832 before: rows
587 } else if matches!(self.drag, Some(crate::menus::Drag::Entry(_))) {833 .iter()
588 self.drag = None;834 .skip_while(|row| row.path != entry.path)
589 }835 .skip(1)
590 let (Some(dragged), Some([_, y])) = (dragged, self.ui.pointer()) else {836 .find(|row| !row.within(&entry) && row.notebook == entry.notebook)
837 .filter(|row| row.folder() == entry.folder())
838 .map(|row| row.path.clone()),
839 into: false,
840 };
841 let what = Dragged::Entry {
842 landing: home.clone(),
843 home,
844 entry,
845 };
846 (what, rect)
847 });
848 let dropped = self.follow_drag(held, mine);
849 let pointer = self.pointer;
850 let Some(
851 drag @ crate::menus::Drag {
852 what: Dragged::Entry { .. },
853 ..
854 },
855 ) = &mut self.drag
856 else {
591 return;857 return;
592 };858 };
593 let inside = |entry: &Entry| {859 let live = drag.live();
594 entry.path == dragged.path || entry.path.starts_with(&format!("{}/", dragged.path))860 let middle = drag.corner(pointer)[1] + ROW / 2.0;
595 };861 let Dragged::Entry {
596 let candidates: Vec<&Entry> = rows862 entry,
597 .iter()863 landing,
598 .filter(|entry| entry.notebook == dragged.notebook && !inside(entry))864 home,
599 .collect();865 } = &mut drag.what
600 let Some(under) = candidates
601 .iter()
602 .find(|entry| y >= entry.rect[1] && y < entry.rect[3])
603 .or_else(|| candidates.last().filter(|entry| y >= entry.rect[3]))
604 else {866 else {
605 return;867 return;
606 };868 };
607 let [top, bottom] = [under.rect[1], under.rect[3]];869 if drag.cancelled {
608 let quarter = (bottom - top) / 4.0;870 *landing = home.clone();
609 let into = under.group && y > top + quarter && y < bottom - quarter;871 } else if live {
610 let (folder, before) = if into {872 // Where the rows stand with no gap open, so the gap follows the dragged row
611 (under.path.as_str(), None)873 // rather than the rows it pushed aside.
612 } else if y < (top + bottom) / 2.0 {874 let closed: Vec<Entry> = rows
613 (under.folder(), Some(under.path.as_str()))
614 } else {
615 let next = candidates
616 .iter()875 .iter()
617 .skip_while(|entry| entry.path != under.path)876 .map(|row| {
618 .nth(1)877 let mut row = row.clone();
619 .filter(|entry| entry.folder() == under.folder());878 if let Some([_, top, _, bottom]) = gap
620 (under.folder(), next.map(|entry| entry.path.as_str()))879 && row.rect[1] >= bottom - 0.5
621 };880 {
622 let change = if folder == dragged.folder() {881 row.rect[1] -= bottom - top;
623 if into {882 row.rect[3] -= bottom - top;
624 return;883 }
625 }884 row
626 // The folder's order with the dragged entry where it was dropped.885 })
627 let mut paths: Vec<String> = candidates
628 .iter()
629 .filter(|entry| entry.folder() == folder)
630 .map(|entry| entry.path.clone())
631 .collect();
632 let at = before
633 .and_then(|before| paths.iter().position(|path| path == before))
634 .unwrap_or(paths.len());
635 paths.insert(at, dragged.path.clone());
636 let current: Vec<&str> = rows
637 .iter()
638 .filter(|entry| entry.notebook == dragged.notebook && entry.folder() == folder)
639 .map(|entry| entry.path.as_str())
640 .collect();886 .collect();
641 if paths.iter().map(String::as_str).eq(current) {887 if let Some(found) = landing_at(&closed, entry, middle) {
642 return;888 *landing = found;
643 }
644 crate::manage::Structure::Reorder {
645 folder: folder.to_owned(),
646 paths,
647 }
648 } else {
649 crate::manage::Structure::Move {
650 path: dragged.path.clone(),
651 folder: folder.to_owned(),
652 }889 }
653 };890 }
654 if held.is_some() {891 if live && landing.into {
655 let accent = theme.accent;892 let accent = theme.accent;
656 if into {893 if let Some(group) = rows.iter().find(|row| row.path == landing.folder) {
657 let [left, top, right, bottom] = under.rect;894 let [left, top, right, bottom] = group.rect;
658 self.ui.mark(895 self.ui.mark(
659 [896 [
660 left - corner[0],897 left - corner[0],
...@@ -665,28 +902,38 @@ impl crate::State {...@@ -665,28 +902,38 @@ impl crate::State {
665 [accent[0], accent[1], accent[2], 0.3],902 [accent[0], accent[1], accent[2], 0.3],
666 4.0,903 4.0,
667 );904 );
668 } else {
669 let line = if before == Some(under.path.as_str()) {
670 top
671 } else {
672 bottom
673 } - corner[1];
674 self.ui.mark(
675 [
676 under.rect[0] - corner[0],
677 line - 1.5,
678 under.rect[2] - corner[0],
679 line + 1.5,
680 ],
681 accent,
682 1.5,
683 );
684 }905 }
685 } else {
686 let library = std::sync::Arc::clone(&self.notebooks[dragged.notebook]);
687 self.commands
688 .push(crate::Command::Structure(library, change));
689 }906 }
907 if !dropped || landing == home {
908 return;
909 }
910 let change = if landing.into || landing.folder != entry.folder() {
911 crate::manage::Structure::Move {
912 path: entry.path.clone(),
913 folder: landing.folder.clone(),
914 }
915 } else {
916 // The folder's order with the dragged entry where it was dropped.
917 let mut paths: Vec<String> = rows
918 .iter()
919 .filter(|row| row.notebook == entry.notebook && row.folder() == landing.folder)
920 .filter(|row| !row.within(entry))
921 .map(|row| row.path.clone())
922 .collect();
923 let at = landing
924 .before
925 .as_ref()
926 .and_then(|before| paths.iter().position(|path| path == before))
927 .unwrap_or(paths.len());
928 paths.insert(at, entry.path.clone());
929 crate::manage::Structure::Reorder {
930 folder: landing.folder.clone(),
931 paths,
932 }
933 };
934 let library = Arc::clone(&self.notebooks[entry.notebook]);
935 self.commands
936 .push(crate::Command::Structure(library, change));
690 }937 }
691938
692 /// Opens what the open panel chose: a notebook folder, a notebook's table of contents,939 /// Opens what the open panel chose: a notebook folder, a notebook's table of contents,
...@@ -745,13 +992,64 @@ impl crate::State {...@@ -745,13 +992,64 @@ impl crate::State {
745 /// The window below the title bar on the first run, or once every notebook is closed:992 /// The window below the title bar on the first run, or once every notebook is closed:
746 /// what is missing, and the two ways to start.993 /// what is missing, and the two ways to start.
747 pub(crate) fn welcome(&mut self, theme: &Theme) {994 pub(crate) fn welcome(&mut self, theme: &Theme) {
995 let id = self.ui.id("welcome");
996 self.notice(
997 theme,
998 id,
999 theme.base,
1000 "No notebooks open",
1001 vec![
1002 (
1003 "new",
1004 art::PLUS,
1005 "New Notebook",
1006 crate::Command::NewNotebook,
1007 ),
1008 (
1009 "open",
1010 art::NOTEBOOK,
1011 "Open Existing",
1012 crate::Command::OpenNotebook,
1013 ),
1014 ],
1015 );
1016 }
1017
1018 /// The page's place while notebook `library` has no sections, as OneNote 2010 shows
1019 /// it, with a button to add one.
1020 pub(crate) fn no_sections(&mut self, theme: &Theme, library: Arc<Library>) {
1021 let new = crate::Command::Structure(
1022 library,
1023 crate::manage::Structure::NewSection {
1024 folder: String::new(),
1025 },
1026 );
1027 self.notice(
1028 theme,
1029 crate::page(),
1030 theme.strip,
1031 "No sections in this notebook",
1032 vec![("new", art::PLUS, "New Section", new)],
1033 );
1034 }
1035
1036 /// A box `id` filled with `background`, showing `title` above `buttons` in its middle: each
1037 /// a part, an icon, a label and what it does.
1038 fn notice(
1039 &mut self,
1040 theme: &Theme,
1041 id: ui::Id,
1042 background: [f32; 4],
1043 title: &str,
1044 buttons: Vec<(&str, &'static [&'static str], &str, crate::Command)>,
1045 ) {
748 const BUTTON: [f32; 2] = [200.0, 32.0];1046 const BUTTON: [f32; 2] = [200.0, 32.0];
749 let [left, top, right, bottom] = self.ui.rect(self.ui.id("welcome")).unwrap_or_default();1047 let [left, top, right, bottom] = self.ui.rect(id).unwrap_or_default();
750 self.ui.open(1048 self.ui.open_as(
751 "welcome",1049 id,
752 Spec {1050 Spec {
753 size: [fill(), fill()],1051 size: [fill(), fill()],
754 fill: Some(theme.base),1052 fill: Some(background),
755 ..Spec::default()1053 ..Spec::default()
756 },1054 },
757 );1055 );
...@@ -773,26 +1071,13 @@ impl crate::State {...@@ -773,26 +1071,13 @@ impl crate::State {
773 "title",1071 "title",
774 Spec {1072 Spec {
775 size: [fill(), px(28.0)],1073 size: [fill(), px(28.0)],
776 text: Some("No notebooks open"),1074 text: Some(title),
777 center: true,1075 center: true,
778 ..Spec::default()1076 ..Spec::default()
779 },1077 },
780 );1078 );
781 let mut chosen = None;1079 let mut chosen = None;
782 for (part, icon, label, command) in [1080 for (part, icon, label, command) in buttons {
783 (
784 "new",
785 art::PLUS,
786 "New Notebook",
787 crate::Command::NewNotebook,
788 ),
789 (
790 "open",
791 art::NOTEBOOK,
792 "Open Existing",
793 crate::Command::OpenNotebook,
794 ),
795 ] {
796 let button = self.ui.leaf(1081 let button = self.ui.leaf(
797 part,1082 part,
798 Spec {1083 Spec {
...@@ -817,3 +1102,43 @@ impl crate::State {...@@ -817,3 +1102,43 @@ impl crate::State {
817 self.ui.close();1102 self.ui.close();
818 }1103 }
819}1104}
1105
1106#[cfg(test)]
1107mod tests {
1108 use super::*;
1109
1110 fn row(path: &str, group: bool, top: f32) -> Entry {
1111 Entry {
1112 notebook: 0,
1113 path: path.to_owned(),
1114 group,
1115 rect: [0.0, top, 200.0, top + ROW],
1116 id: Id::ROOT.child(path),
1117 }
1118 }
1119
1120 /// A dragged row lands before or after a row by its middle, into a group by the group's
1121 /// middle half, and at the end of another folder.
1122 #[test]
1123 fn a_dragged_row_lands_between_rows_into_groups_and_last_in_other_folders() {
1124 let rows = [
1125 row("A.one", false, 0.0),
1126 row("B.one", false, 24.0),
1127 row("G", true, 48.0),
1128 row("G/C.one", false, 72.0),
1129 ];
1130 let lifted = row("Z.one", false, 200.0);
1131 let landing = |y| landing_at(&rows, &lifted, y).unwrap();
1132 let at = |folder: &str, before: Option<&str>, into| Landing {
1133 folder: folder.to_owned(),
1134 before: before.map(str::to_owned),
1135 into,
1136 };
1137 assert!(landing(4.0) == at("", Some("A.one"), false));
1138 assert!(landing(20.0) == at("", Some("B.one"), false));
1139 assert!(landing(60.0) == at("G", None, true));
1140 assert!(landing(50.0) == at("", Some("G"), false));
1141 assert!(landing(80.0) == at("G", None, false));
1142 assert!(landing(500.0) == at("G", None, false));
1143 }
1144}
crates/snowbound/src/templates.rs+1-1
...@@ -418,7 +418,7 @@ impl crate::State {...@@ -418,7 +418,7 @@ impl crate::State {
418 .chain(editor.caret_outline())418 .chain(editor.caret_outline())
419 .filter(|outline| !outline.title)419 .filter(|outline| !outline.title)
420 .all(canvas::editor::TextOutline::is_empty);420 .all(canvas::editor::TextOutline::is_empty);
421 if !blank || session.conflict(space).is_some() || self.dismissed.contains(&space) {421 if !blank || session.read_only() || self.dismissed.contains(&space) {
422 self.templates = View::Strip;422 self.templates = View::Strip;
423 return;423 return;
424 }424 }
crates/ui/examples/popups.rs+194-13
...@@ -65,10 +65,176 @@ fn main() {...@@ -65,10 +65,176 @@ fn main() {
65 render(panels, 5, &path);65 render(panels, 5, &path);
66 println!("{path}");66 println!("{path}");
67 }67 }
68 let items = font_items();
69 let fonts = Id::ROOT.child("fonts");
70 let context = Id::ROOT.child("context");
71 let options = Id::ROOT.child("options");
72 let search = Id::ROOT.child("search");
73 let strips: [(&str, Id, &Build<'_>); 4] = [
74 ("menu", context, &|ui, _| {
75 ui::popup::menu(ui, context, Anchor::Point([140.0, 70.0]), &items[..8], None);
76 }),
77 ("combo", fonts, &|ui, [combo, _]| {
78 let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default());
79 ui::popup::menu(ui, fonts, anchor, &items, Some("Calibri"));
80 }),
81 ("search", search, &|ui, [combo, _]| {
82 results(ui, search, combo)
83 }),
84 ("dialog", options, &|ui, _| dialog(ui, options)),
85 ];
86 for (motion, id, build) in strips {
87 let panels = opening_and_closing(&theme, &device, &queue, &mut renderer, id, build);
88 let path = format!("/tmp/ui-motion-{motion}-{name}.png");
89 render(panels, 6, &path);
90 println!("{path}");
91 }
68 }92 }
69 measure();93 measure();
70}94}
7195
96/// Builds a frame's popup, given the ids of the combo and the split button's arrow.
97type Build<'a> = dyn Fn(&mut Ui, [Id; 2]) + 'a;
98
99/// Popup `id` as `build` builds it, every 16 ms from the frame it opens until it has
100/// closed again; no frame is asked for once each settles.
101fn opening_and_closing(
102 theme: &Theme,
103 device: &wgpu::Device,
104 queue: &wgpu::Queue,
105 renderer: &mut draw::Renderer,
106 id: Id,
107 build: &Build<'_>,
108) -> Vec<Vec<u8>> {
109 let mut scene = Scene {
110 ui: Ui::new(theme.clone(), Duration::from_millis(500)),
111 start: Instant::now(),
112 frames: 0,
113 };
114 scene.frame(|_, _| {});
115 scene.frame(|ui, ids| {
116 ui.open_popup(id);
117 build(ui, ids);
118 });
119 let mut panels = vec![paint(device, queue, renderer, &scene.ui)];
120 for _ in 0..17 {
121 scene.frame(build);
122 panels.push(paint(device, queue, renderer, &scene.ui));
123 }
124 assert!(!scene.ui.wants_frame(), "settled open");
125 scene.ui.close_popup(id);
126 for _ in 0..12 {
127 scene.frame(build);
128 panels.push(paint(device, queue, renderer, &scene.ui));
129 }
130 assert!(!scene.ui.wants_frame(), "settled closed");
131 panels
132}
133
134/// A search's dropdown over the box at `combo`, as the app's search box opens: its field
135/// holds the box's place and widens while the panel comes in around it.
136fn results(ui: &mut Ui, id: Id, combo: Id) {
137 if !ui.popup_open(id) {
138 return;
139 }
140 let theme = ui.theme.clone();
141 let [left, top, right, bottom] = ui.rect(combo).unwrap_or_default();
142 let anchor = Anchor::Over([left - 4.0, top - 4.0, right + 4.0, bottom + 4.0]);
143 let width = 300.0;
144 let open = ui.opening(id, anchor).unwrap_or(1.0);
145 ui.open_as(
146 id,
147 Spec {
148 axis: ui::Axis::Y,
149 size: [px(width), ui::children()],
150 fill: Some(theme.popup),
151 border: Some(theme.chip),
152 shadow: Some(theme.shadow),
153 radius: 6.0,
154 pad: [4.0, 4.0],
155 gap: 4.0,
156 anchor: Some(anchor),
157 ..Spec::default()
158 },
159 );
160 ui.leaf(
161 "box",
162 Spec {
163 flags: ui::Flags::STILL,
164 size: [
165 px(right - left + (width - 8.0 - (right - left)) * open),
166 px(bottom - top),
167 ],
168 text: Some("Search All Notebooks (⌘E)"),
169 color: Some(theme.text_dim),
170 fill: Some(theme.base),
171 border: Some(theme.accent),
172 radius: 4.0,
173 pad: [6.0, 0.0],
174 ..Spec::default()
175 },
176 );
177 for (index, page) in ["Meeting notes", "Quarterly planning", "Reading list"]
178 .iter()
179 .enumerate()
180 {
181 ui.leaf(
182 index,
183 Spec {
184 size: [fill(), px(22.0)],
185 text: Some(page),
186 pad: [8.0, 0.0],
187 ..Spec::default()
188 },
189 );
190 }
191 ui.close();
192}
193
194/// A small dialog, as Options opens over the window.
195fn dialog(ui: &mut Ui, id: Id) {
196 if !ui.popup_open(id) {
197 return;
198 }
199 let theme = ui.theme.clone();
200 ui.open_as(
201 id,
202 Spec {
203 axis: ui::Axis::Y,
204 size: [px(260.0), ui::children()],
205 fill: Some(theme.popup),
206 border: Some(theme.chip),
207 shadow: Some(theme.shadow),
208 radius: 8.0,
209 pad: [14.0, 12.0],
210 gap: 8.0,
211 anchor: Some(Anchor::Dialog),
212 ..Spec::default()
213 },
214 );
215 ui.leaf(
216 "title",
217 Spec {
218 size: [fill(), ui::fit()],
219 text: Some("Options"),
220 bold: true,
221 ..Spec::default()
222 },
223 );
224 ui.leaf(
225 "body",
226 Spec {
227 size: [fill(), ui::fit()],
228 text: Some("These choices apply to every notebook, and are kept when OK is chosen."),
229 overflow: ui::Overflow::Wrap,
230 color: Some(theme.text_dim),
231 ..Spec::default()
232 },
233 );
234 ui::button(ui, "ok", "OK");
235 ui.close();
236}
237
72/// Frames a change shows at: before it, then each of the 150 ms it takes.238/// Frames a change shows at: before it, then each of the 150 ms it takes.
73const FRAMES: [u32; 9] = [1, 2, 3, 4, 5, 6, 7, 8, 10];239const FRAMES: [u32; 9] = [1, 2, 3, 4, 5, 6, 7, 8, 10];
74240
...@@ -344,6 +510,7 @@ impl Scene {...@@ -344,6 +510,7 @@ impl Scene {
344 art!("icons/font-color"),510 art!("icons/font-color"),
345 Some(draw::srgb(0xe8, 0x3a, 0x30)),511 Some(draw::srgb(0xe8, 0x3a, 0x30)),
346 false,512 false,
513 Id::ROOT.child("font color"),
347 );514 );
348 ui.close();515 ui.close();
349 ui.close();516 ui.close();
...@@ -568,7 +735,7 @@ fn highlight(scene: &mut Scene) {...@@ -568,7 +735,7 @@ fn highlight(scene: &mut Scene) {
568 let id = Id::ROOT.child("highlight");735 let id = Id::ROOT.child("highlight");
569 scene.open(id, vec![key(NamedKey::ArrowDown)], |ui, [_, split]| {736 scene.open(id, vec![key(NamedKey::ArrowDown)], |ui, [_, split]| {
570 let anchor = below(ui, split);737 let anchor = below(ui, split);
571 ui::popup::colors(ui, id, anchor, "No colour", &swatches, 5);738 ui::popup::colors(ui, id, anchor, "No Color", &swatches, 5);
572 });739 });
573}740}
574741
...@@ -601,14 +768,34 @@ fn palette(scene: &mut Scene) {...@@ -601,14 +768,34 @@ fn palette(scene: &mut Scene) {
601fn tags(scene: &mut Scene) {768fn tags(scene: &mut Scene) {
602 let tags = [769 let tags = [
603 ("To Do", None::<&'static [&'static str]>, "⌘1"),770 ("To Do", None::<&'static [&'static str]>, "⌘1"),
604 ("Important", Some(&[include_str!("../../canvas/assets/tags/star.svg")]), "⌘2"),771 (
772 "Important",
773 Some(&[include_str!("../../canvas/assets/tags/star.svg")]),
774 "⌘2",
775 ),
605 ("Question", None, "⌘3"),776 ("Question", None, "⌘3"),
606 ("Remember for later", Some(art!("tags/remember")), "⌘4"),777 ("Remember for later", Some(art!("tags/remember")), "⌘4"),
607 ("Definition", Some(art!("tags/definition")), "⌘5"),778 ("Definition", Some(art!("tags/definition")), "⌘5"),
608 ("Highlight", Some(&[include_str!("../../canvas/assets/tags/highlight.svg")]), "⌘6"),779 (
609 ("Contact", Some(&[include_str!("../../canvas/assets/tags/contact.svg")]), "⌘7"),780 "Highlight",
610 ("Address", Some(&[include_str!("../../canvas/assets/tags/address.svg")]), "⌘8"),781 Some(&[include_str!("../../canvas/assets/tags/highlight.svg")]),
611 ("Phone number", Some(&[include_str!("../../canvas/assets/tags/phone.svg")]), "⌘9"),782 "⌘6",
783 ),
784 (
785 "Contact",
786 Some(&[include_str!("../../canvas/assets/tags/contact.svg")]),
787 "⌘7",
788 ),
789 (
790 "Address",
791 Some(&[include_str!("../../canvas/assets/tags/address.svg")]),
792 "⌘8",
793 ),
794 (
795 "Phone number",
796 Some(&[include_str!("../../canvas/assets/tags/phone.svg")]),
797 "⌘9",
798 ),
612 ("Web site to visit", None, ""),799 ("Web site to visit", None, ""),
613 ("Idea", None, ""),800 ("Idea", None, ""),
614 ("Password", None, ""),801 ("Password", None, ""),
...@@ -686,13 +873,7 @@ fn paint(...@@ -686,13 +873,7 @@ fn paint(
686 let layers: Vec<_> = interface873 let layers: Vec<_> = interface
687 .iter()874 .iter()
688 .filter_map(|layer| match layer {875 .filter_map(|layer| match layer {
689 ui::Layer::Primitives { clip, primitives } => Some(draw::Layer {876 ui::Layer::Primitives(primitives) => Some(primitives.layer(SCALE)),
690 scale: SCALE,
691 origin: [0.0; 2],
692 clip: clip.map(|clip| clip.map(|value| value * SCALE)),
693 backdrop: None,
694 primitives,
695 }),
696 ui::Layer::Custom { .. } => None,877 ui::Layer::Custom { .. } => None,
697 })878 })
698 .collect();879 .collect();
crates/ui/src/layout.rs+40-6
...@@ -1,12 +1,22 @@...@@ -1,12 +1,22 @@
1use crate::{Axis, Built, Flags, Id, Size, State};1use crate::{Axis, Built, Flags, ICON, ICON_GAP, Id, Overflow, Size, State, text::Texts};
2use std::collections::HashMap;2use std::collections::HashMap;
33
4/// Sizes each box on both axes, then places it: standalone sizes, sizes taken from4/// Sizes each box on both axes, then places it: standalone sizes, sizes taken from
5/// ancestors (pre-order), sizes summed from children (post-order), overflow shared out5/// ancestors (pre-order), sizes summed from children (post-order), overflow shared out
6/// by strictness, and positions along each parent's flow. Boxes are in build order, so6/// by strictness, and positions along each parent's flow. Labels too wide for their
7/// solved width wrap or shorten before heights are solved. Boxes are in build order, so
7/// index order is pre-order.8/// index order is pre-order.
8pub(crate) fn solve(nodes: &mut [Built], states: &HashMap<Id, State>, scale: f32) {9pub(crate) fn solve(
10 nodes: &mut [Built],
11 states: &HashMap<Id, State>,
12 scale: f32,
13 texts: &mut Texts,
14 frame: u64,
15) {
9 for axis in 0..2 {16 for axis in 0..2 {
17 if axis == 1 {
18 fit_labels(nodes, texts, frame);
19 }
10 for node in nodes.iter_mut() {20 for node in nodes.iter_mut() {
11 node.computed[axis] = match node.size[axis].size {21 node.computed[axis] = match node.size[axis].size {
12 Size::Pixels(pixels) => pixels,22 Size::Pixels(pixels) => pixels,
...@@ -47,7 +57,7 @@ pub(crate) fn solve(nodes: &mut [Built], states: &HashMap<Id, State>, scale: f32...@@ -47,7 +57,7 @@ pub(crate) fn solve(nodes: &mut [Built], states: &HashMap<Id, State>, scale: f32
47 if node.children.is_empty() || (axis == 1 && node.flags.contains(Flags::SCROLL)) {57 if node.children.is_empty() || (axis == 1 && node.flags.contains(Flags::SCROLL)) {
48 continue;58 continue;
49 }59 }
50 let room = node.computed[axis] - 2.0 * node.pad[axis];60 let room = (node.computed[axis] - 2.0 * node.pad[axis]).max(0.0);
51 let children: Vec<_> = in_flow(nodes, index).collect();61 let children: Vec<_> = in_flow(nodes, index).collect();
52 if along(node, axis) {62 if along(node, axis) {
53 let excess = flow(nodes, index, axis) - room;63 let excess = flow(nodes, index, axis) - room;
...@@ -110,8 +120,9 @@ pub(crate) fn solve(nodes: &mut [Built], states: &HashMap<Id, State>, scale: f32...@@ -110,8 +120,9 @@ pub(crate) fn solve(nodes: &mut [Built], states: &HashMap<Id, State>, scale: f32
110 } else {120 } else {
111 0.0121 0.0
112 };122 };
113 let x = parent.rect[0] + nodes[index].relative[0];123 let [dx, dy] = nodes[index].offset;
114 let y = parent.rect[1] + nodes[index].relative[1] - scroll;124 let x = parent.rect[0] + nodes[index].relative[0] + dx;
125 let y = parent.rect[1] + nodes[index].relative[1] - scroll + dy;
115 nodes[index].rect = [126 nodes[index].rect = [
116 snap(x),127 snap(x),
117 snap(y),128 snap(y),
...@@ -145,3 +156,26 @@ fn flow(nodes: &[Built], index: usize, axis: usize) -> f32 {...@@ -145,3 +156,26 @@ fn flow(nodes: &[Built], index: usize, axis: usize) -> f32 {
145 sizes.fold(0.0, f32::max)156 sizes.fold(0.0, f32::max)
146 }157 }
147}158}
159
160fn fit_labels(nodes: &mut [Built], texts: &mut Texts, frame: u64) {
161 for node in nodes.iter_mut() {
162 let Some(label) = node.label.clone() else {
163 continue;
164 };
165 let icon = if node.icon.is_some() || node.image.is_some() {
166 ICON + ICON_GAP
167 } else {
168 0.0
169 };
170 let [left, _, right, _] = node.inset;
171 let width = (node.computed[0] - left - right - 2.0 * node.pad[0] - icon).max(0.0);
172 if label.size[0] <= width {
173 continue;
174 }
175 node.label = match node.overflow {
176 Overflow::Clip => continue,
177 Overflow::Wrap => Some(label.wrapped(width, node.center)),
178 Overflow::Ellipsis => Some(texts.ellipsis(&label, width, frame)),
179 };
180 }
181}
crates/ui/src/lib.rs+351-51
...@@ -43,6 +43,9 @@ const POPUP_SHADOW: [f32; 2] = [12.0, 4.0];...@@ -43,6 +43,9 @@ const POPUP_SHADOW: [f32; 2] = [12.0, 4.0];
43/// Logical size of a box's icon, and its distance from the label.43/// Logical size of a box's icon, and its distance from the label.
44const ICON: f32 = 16.0;44const ICON: f32 = 16.0;
45const ICON_GAP: f32 = 6.0;45const ICON_GAP: f32 = 6.0;
46/// Seconds a dialog and other popups take to open and to close.
47const DIALOG: [f32; 2] = [0.24, 0.16];
48const POPUP: [f32; 2] = [0.16, 0.12];
4649
47/// A box's identity across frames: its parent's id combined with a builder-chosen part.50/// A box's identity across frames: its parent's id combined with a builder-chosen part.
48#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]51#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
...@@ -136,6 +139,9 @@ impl Flags {...@@ -136,6 +139,9 @@ impl Flags {
136 pub const FLOAT: Flags = Flags(16);139 pub const FLOAT: Flags = Flags(16);
137 /// The host paints it and receives the events routed to it.140 /// The host paints it and receives the events routed to it.
138 pub const CUSTOM: Flags = Flags(32);141 pub const CUSTOM: Flags = Flags(32);
142 /// Paints in place while the popup around it opens, as a field standing in for the one
143 /// it opened from.
144 pub const STILL: Flags = Flags(64);
139145
140 pub(crate) fn contains(self, other: Flags) -> bool {146 pub(crate) fn contains(self, other: Flags) -> bool {
141 self.0 & other.0 == other.0147 self.0 & other.0 == other.0
...@@ -165,6 +171,18 @@ pub enum Shape {...@@ -165,6 +171,18 @@ pub enum Shape {
165 Tab { lean: f32 },171 Tab { lean: f32 },
166}172}
167173
174/// What a label does where it is wider than its box.
175#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
176pub enum Overflow {
177 /// Runs on as one line, cut at the box's edge.
178 #[default]
179 Clip,
180 /// Breaks into lines across the box, which grows to their height when sized by its text.
181 Wrap,
182 /// Stays one line, ending in an ellipsis where it is cut.
183 Ellipsis,
184}
185
168/// Where a popup opens, flipping to the far side of its anchor where the window ends first.186/// Where a popup opens, flipping to the far side of its anchor where the window ends first.
169#[derive(Clone, Copy, Debug, PartialEq)]187#[derive(Clone, Copy, Debug, PartialEq)]
170pub enum Anchor {188pub enum Anchor {
...@@ -174,8 +192,10 @@ pub enum Anchor {...@@ -174,8 +192,10 @@ pub enum Anchor {
174 Right([f32; 4]),192 Right([f32; 4]),
175 /// Over the rectangle from its corner, as a combo box opens into its own list.193 /// Over the rectangle from its corner, as a combo box opens into its own list.
176 Over([f32; 4]),194 Over([f32; 4]),
177 /// At a point, as a context menu opens: at once, without fading in.195 /// At a point, as a context menu opens.
178 Point([f32; 2]),196 Point([f32; 2]),
197 /// Centred in the window over the interface, which dims, as a dialog opens.
198 Dialog,
179}199}
180200
181impl Anchor {201impl Anchor {
...@@ -187,6 +207,7 @@ impl Anchor {...@@ -187,6 +207,7 @@ impl Anchor {
187 Anchor::Right(rect) => (rect, Some(0)),207 Anchor::Right(rect) => (rect, Some(0)),
188 Anchor::Over(rect) => (rect, None),208 Anchor::Over(rect) => (rect, None),
189 Anchor::Point([x, y]) => ([x, y, x, y], Some(1)),209 Anchor::Point([x, y]) => ([x, y, x, y], Some(1)),
210 Anchor::Dialog => return ((room - size) / 2.0).max(0.0),
190 };211 };
191 let [low, high] = [rect[axis], rect[axis + 2]];212 let [low, high] = [rect[axis], rect[axis + 2]];
192 let (first, second) = if along == Some(axis) {213 let (first, second) = if along == Some(axis) {
...@@ -202,6 +223,51 @@ impl Anchor {...@@ -202,6 +223,51 @@ impl Anchor {
202 first.min(room - size).max(0.0)223 first.min(room - size).max(0.0)
203 }224 }
204 }225 }
226
227 /// How a popup laid out at `rect` beside the anchor shows `open` of the way open: it
228 /// grows out of the anchor as it fades in, and a dialog swings up into place.
229 fn motion(self, rect: [f32; 4], open: f32) -> Motion {
230 let pivot = match self {
231 Anchor::Below(anchor) => [anchor[0], anchor[3]],
232 Anchor::Right(anchor) => [anchor[2], anchor[1]],
233 Anchor::Over(anchor) => [anchor[0], anchor[1]],
234 Anchor::Point(point) => point,
235 Anchor::Dialog => [(rect[0] + rect[2]) / 2.0, rect[1]],
236 };
237 let (from, tilt) = match self {
238 Anchor::Dialog => (0.95, 0.4),
239 Anchor::Over(_) => (0.94, 0.25),
240 _ => (0.94, 0.0),
241 };
242 Motion {
243 zoom: from + (1.0 - from) * open,
244 pivot: [
245 pivot[0].clamp(rect[0], rect[2]),
246 pivot[1].clamp(rect[1], rect[3]),
247 ],
248 tilt: tilt * (1.0 - open),
249 opacity: open,
250 }
251 }
252
253 /// Seconds the popup takes to open and to close.
254 fn durations(self) -> [f32; 2] {
255 if self == Anchor::Dialog {
256 DIALOG
257 } else {
258 POPUP
259 }
260 }
261}
262
263/// A box and what it holds scaled by `zoom` about `pivot`, leaned back `tilt` radians about
264/// the line through it, and faded; logical pixels.
265#[derive(Clone, Copy, Debug, PartialEq)]
266struct Motion {
267 zoom: f32,
268 pivot: [f32; 2],
269 tilt: f32,
270 opacity: f32,
205}271}
206272
207/// What a box is this frame. Colours are linear RGBA.273/// What a box is this frame. Colours are linear RGBA.
...@@ -218,6 +284,7 @@ pub struct Spec<'a> {...@@ -218,6 +284,7 @@ pub struct Spec<'a> {
218 /// Shapes the label semibold.284 /// Shapes the label semibold.
219 pub bold: bool,285 pub bold: bool,
220 pub font: Option<&'a str>,286 pub font: Option<&'a str>,
287 pub overflow: Overflow,
221 /// The label's colour; the theme's text colour otherwise.288 /// The label's colour; the theme's text colour otherwise.
222 pub color: Option<[f32; 4]>,289 pub color: Option<[f32; 4]>,
223 pub fill: Option<[f32; 4]>,290 pub fill: Option<[f32; 4]>,
...@@ -246,6 +313,9 @@ pub struct Spec<'a> {...@@ -246,6 +313,9 @@ pub struct Spec<'a> {
246 pub center: bool,313 pub center: bool,
247 /// For floating boxes, the offset from the parent's corner.314 /// For floating boxes, the offset from the parent's corner.
248 pub position: [f32; 2],315 pub position: [f32; 2],
316 /// How far the box and its children draw and take the pointer from where they are laid
317 /// out, the boxes around them keeping their places, as a row sliding aside does.
318 pub offset: [f32; 2],
249 pub cursor: Option<CursorIcon>,319 pub cursor: Option<CursorIcon>,
250 /// Floats the box over all others beside a rectangle in the window, as a popup320 /// Floats the box over all others beside a rectangle in the window, as a popup
251 /// `Ui::open_popup` opened; boxes beneath take no input while one is open.321 /// `Ui::open_popup` opened; boxes beneath take no input while one is open.
...@@ -291,13 +361,9 @@ pub struct Signal {...@@ -291,13 +361,9 @@ pub struct Signal {
291 pub events: Vec<Event>,361 pub events: Vec<Event>,
292}362}
293363
294/// A frame's painting, in order: interface primitives under a clip, or a host-painted box.364/// A frame's painting, in order: interface primitives, or a host-painted box.
295pub enum Layer<'a> {365pub enum Layer<'a> {
296 Primitives {366 Primitives(Primitives<'a>),
297 /// Logical pixels.
298 clip: Option<[f32; 4]>,
299 primitives: Vec<Primitive<'a>>,
300 },
301 Custom {367 Custom {
302 id: Id,368 id: Id,
303 /// The box's visible logical rectangle.369 /// The box's visible logical rectangle.
...@@ -305,6 +371,52 @@ pub enum Layer<'a> {...@@ -305,6 +371,52 @@ pub enum Layer<'a> {
305 },371 },
306}372}
307373
374/// Interface primitives under a clip, in logical pixels, perhaps part of the way through
375/// opening or closing.
376pub struct Primitives<'a> {
377 pub clip: Option<[f32; 4]>,
378 motion: Option<Motion>,
379 pub primitives: Vec<Primitive<'a>>,
380}
381
382impl Primitives<'_> {
383 /// The layer the renderer paints at `scale` device pixels per logical pixel.
384 pub fn layer(&self, scale: f32) -> draw::Layer<'_> {
385 let Motion {
386 zoom,
387 pivot,
388 tilt,
389 opacity,
390 } = self.motion.unwrap_or(Motion {
391 zoom: 1.0,
392 pivot: [0.0; 2],
393 tilt: 0.0,
394 opacity: 1.0,
395 });
396 let device = |point: [f32; 2]| point.map(|value| value * scale);
397 let moved = |point: [f32; 2]| {
398 device(std::array::from_fn(|axis| {
399 pivot[axis] + (point[axis] - pivot[axis]) * zoom
400 }))
401 };
402 draw::Layer {
403 scale: scale * zoom,
404 origin: moved([0.0; 2]),
405 clip: self.clip.map(|[left, top, right, bottom]| {
406 let [[left, top], [right, bottom]] = [moved([left, top]), moved([right, bottom])];
407 [left, top, right, bottom]
408 }),
409 backdrop: None,
410 motion: self.motion.map(|_| draw::Motion {
411 opacity,
412 tilt,
413 pivot: device(pivot),
414 }),
415 primitives: &self.primitives,
416 }
417 }
418}
419
308struct Built {420struct Built {
309 id: Id,421 id: Id,
310 parent: usize,422 parent: usize,
...@@ -313,6 +425,7 @@ struct Built {...@@ -313,6 +425,7 @@ struct Built {
313 size: [Extent; 2],425 size: [Extent; 2],
314 axis: Axis,426 axis: Axis,
315 label: Option<Rc<Label>>,427 label: Option<Rc<Label>>,
428 overflow: Overflow,
316 color: [f32; 4],429 color: [f32; 4],
317 fill: Option<[f32; 4]>,430 fill: Option<[f32; 4]>,
318 gradient: Option<[f32; 4]>,431 gradient: Option<[f32; 4]>,
...@@ -329,8 +442,10 @@ struct Built {...@@ -329,8 +442,10 @@ struct Built {
329 gap: f32,442 gap: f32,
330 center: bool,443 center: bool,
331 position: [f32; 2],444 position: [f32; 2],
445 offset: [f32; 2],
332 cursor: Option<CursorIcon>,446 cursor: Option<CursorIcon>,
333 anchor: Option<Anchor>,447 anchor: Option<Anchor>,
448 motion: Option<Motion>,
334 /// Rectangles relative to the box, painted over its fill.449 /// Rectangles relative to the box, painted over its fill.
335 marks: Vec<([f32; 4], [f32; 4], f32)>,450 marks: Vec<([f32; 4], [f32; 4], f32)>,
336 computed: [f32; 2],451 computed: [f32; 2],
...@@ -353,6 +468,8 @@ struct State {...@@ -353,6 +468,8 @@ struct State {
353 /// A text field's selection, and the selection and unit of the press a drag extends.468 /// A text field's selection, and the selection and unit of the press a drag extends.
354 selection: Selection,469 selection: Selection,
355 press: (Selection, SelectionUnit),470 press: (Selection, SelectionUnit),
471 /// The field selects all its text when next built.
472 select_all: bool,
356 /// Where a dragged scrollbar thumb was taken, from its start.473 /// Where a dragged scrollbar thumb was taken, from its start.
357 grab: f32,474 grab: f32,
358 /// An animated value and its target, for `Ui::animate`.475 /// An animated value and its target, for `Ui::animate`.
...@@ -369,6 +486,16 @@ struct Popup {...@@ -369,6 +486,16 @@ struct Popup {
369 highlight: Option<u64>,486 highlight: Option<u64>,
370 /// The height its results ease from and to, and the seconds since they set out.487 /// The height its results ease from and to, and the seconds since they set out.
371 height: Option<[f32; 3]>,488 height: Option<[f32; 3]>,
489 opened: Instant,
490}
491
492/// A popup's painting as it last showed, fading out since it closed.
493struct Closing {
494 id: Id,
495 display: Vec<Display>,
496 anchor: Anchor,
497 rect: [f32; 4],
498 closed: Instant,
372}499}
373500
374struct Hit {501struct Hit {
...@@ -378,8 +505,10 @@ struct Hit {...@@ -378,8 +505,10 @@ struct Hit {
378 cursor: Option<CursorIcon>,505 cursor: Option<CursorIcon>,
379}506}
380507
508#[derive(Clone)]
381enum Display {509enum Display {
382 Clip(Option<[f32; 4]>),510 Clip(Option<[f32; 4]>),
511 Motion(Option<Motion>),
383 Rect {512 Rect {
384 rect: [f32; 4],513 rect: [f32; 4],
385 fill: [f32; 4],514 fill: [f32; 4],
...@@ -425,6 +554,8 @@ enum Display {...@@ -425,6 +554,8 @@ enum Display {
425554
426pub struct Ui {555pub struct Ui {
427 pub theme: Theme,556 pub theme: Theme,
557 /// The colours icons' slots take.
558 pub icon_palette: draw::Palette,
428 /// Whether the window has keyboard focus; without it a field hides its caret and dims559 /// Whether the window has keyboard focus; without it a field hides its caret and dims
429 /// its selection.560 /// its selection.
430 pub window_focused: bool,561 pub window_focused: bool,
...@@ -456,6 +587,10 @@ pub struct Ui {...@@ -456,6 +587,10 @@ pub struct Ui {
456 display: Vec<Display>,587 display: Vec<Display>,
457 /// Where the popups' painting starts in `display`, for edges drawn beneath them.588 /// Where the popups' painting starts in `display`, for edges drawn beneath them.
458 popups_painted: usize,589 popups_painted: usize,
590 /// Each popup painted, its anchor and rectangle, and its painting's place after
591 /// `popups_painted`.
592 painted: Vec<(Id, Anchor, [f32; 4], std::ops::Range<usize>)>,
593 closing: Vec<Closing>,
459 animating: bool,594 animating: bool,
460 /// This frame routed input, whose effects the builder may only have seen after boxes595 /// This frame routed input, whose effects the builder may only have seen after boxes
461 /// built before it read their state; one more frame shows them.596 /// built before it read their state; one more frame shows them.
...@@ -471,6 +606,7 @@ impl Ui {...@@ -471,6 +606,7 @@ impl Ui {
471 pub fn new(theme: Theme, double_click: Duration) -> Self {606 pub fn new(theme: Theme, double_click: Duration) -> Self {
472 Self {607 Self {
473 theme,608 theme,
609 icon_palette: draw::Palette::default(),
474 window_focused: true,610 window_focused: true,
475 frame: 0,611 frame: 0,
476 now: Instant::now(),612 now: Instant::now(),
...@@ -495,6 +631,8 @@ impl Ui {...@@ -495,6 +631,8 @@ impl Ui {
495 clicks: Clicks::new(double_click),631 clicks: Clicks::new(double_click),
496 display: Vec::new(),632 display: Vec::new(),
497 popups_painted: 0,633 popups_painted: 0,
634 painted: Vec::new(),
635 closing: Vec::new(),
498 animating: false,636 animating: false,
499 routed: false,637 routed: false,
500 caret: None,638 caret: None,
...@@ -538,6 +676,15 @@ impl Ui {...@@ -538,6 +676,15 @@ impl Ui {
538 self.focus = id;676 self.focus = id;
539 }677 }
540678
679 /// Focuses text field `id` with all its text selected once it is built, as a field
680 /// opening to rename something does.
681 pub fn focus_all(&mut self, id: Id) {
682 let state = self.states.entry(id).or_default();
683 state.touched = self.frame;
684 state.select_all = true;
685 self.focus = Some(id);
686 }
687
541 /// The clickable or custom box at `point` in the latest layout.688 /// The clickable or custom box at `point` in the latest layout.
542 pub fn box_at(&self, point: [f32; 2]) -> Option<Id> {689 pub fn box_at(&self, point: [f32; 2]) -> Option<Id> {
543 self.hit(point, Flags::CLICKABLE | Flags::CUSTOM)690 self.hit(point, Flags::CLICKABLE | Flags::CUSTOM)
...@@ -767,6 +914,7 @@ impl Ui {...@@ -767,6 +914,7 @@ impl Ui {
767 query: String::new(),914 query: String::new(),
768 highlight: None,915 highlight: None,
769 height: None,916 height: None,
917 opened: self.now,
770 });918 });
771 self.focus = Some(id);919 self.focus = Some(id);
772 self.states.entry(id).or_default().touched = self.frame;920 self.states.entry(id).or_default().touched = self.frame;
...@@ -838,11 +986,10 @@ impl Ui {...@@ -838,11 +986,10 @@ impl Ui {
838 /// Opens a box under an id chosen outside the hierarchy, so the host can name it986 /// Opens a box under an id chosen outside the hierarchy, so the host can name it
839 /// wherever it is built.987 /// wherever it is built.
840 pub fn open_as(&mut self, id: Id, spec: Spec<'_>) -> Id {988 pub fn open_as(&mut self, id: Id, spec: Spec<'_>) -> Id {
841 let label = spec989 let label = spec.text.map(|text| {
842 .text
843 .map(|text| {
844 let size = spec.font_size.unwrap_or(self.theme.font_size);990 let size = spec.font_size.unwrap_or(self.theme.font_size);
845 self.texts.label(text, size, spec.bold, spec.font, self.frame)991 self.texts
992 .label(text, size, spec.bold, spec.font, self.frame)
846 });993 });
847 // Popups hang from the root, outside the clips and flow of where they are built.994 // Popups hang from the root, outside the clips and flow of where they are built.
848 let parent = if spec.anchor.is_some() {995 let parent = if spec.anchor.is_some() {
...@@ -872,6 +1019,15 @@ impl Ui {...@@ -872,6 +1019,15 @@ impl Ui {
872 }1019 }
8731020
874 /// Eases towards `target` over the next frames, starting there the first time `id` asks.1021 /// Eases towards `target` over the next frames, starting there the first time `id` asks.
1022 /// Holds animated value `id` at `value` without easing, as a dragged box follows the
1023 /// pointer; it eases from there once animated again.
1024 pub fn hold(&mut self, id: Id, value: f32) -> f32 {
1025 let state = self.states.entry(id).or_default();
1026 state.touched = self.frame;
1027 state.tween = Some([value; 2]);
1028 value
1029 }
1030
875 pub fn animate(&mut self, id: Id, target: f32) -> f32 {1031 pub fn animate(&mut self, id: Id, target: f32) -> f32 {
876 let state = self.states.entry(id).or_default();1032 let state = self.states.entry(id).or_default();
877 state.touched = self.frame;1033 state.touched = self.frame;
...@@ -881,12 +1037,6 @@ impl Ui {...@@ -881,12 +1037,6 @@ impl Ui {
881 *value1037 *value
882 }1038 }
8831039
884 /// Shapes labels in the family the font `files` define, in place of the system's
885 /// interface font. Returns the family's name, or None when the files hold no font.
886 pub fn use_fonts(&mut self, files: impl IntoIterator<Item = Vec<u8>>) -> Option<String> {
887 self.texts.use_fonts(files)
888 }
889
890 /// Previews `family` in the font `data` holds, as a font menu shows the substitute a1040 /// Previews `family` in the font `data` holds, as a font menu shows the substitute a
891 /// missing family lays out in.1041 /// missing family lays out in.
892 pub fn preview_font(&mut self, data: parley::fontique::Blob<u8>, family: &str) {1042 pub fn preview_font(&mut self, data: parley::fontique::Blob<u8>, family: &str) {
...@@ -937,7 +1087,13 @@ impl Ui {...@@ -937,7 +1087,13 @@ impl Ui {
937 /// Solves this frame's layout and prepares its painting.1087 /// Solves this frame's layout and prepares its painting.
938 pub fn end(&mut self) {1088 pub fn end(&mut self) {
939 debug_assert_eq!(self.stack.len(), 1, "open without close");1089 debug_assert_eq!(self.stack.len(), 1, "open without close");
940 layout::solve(&mut self.nodes, &self.states, self.scale);1090 layout::solve(
1091 &mut self.nodes,
1092 &self.states,
1093 self.scale,
1094 &mut self.texts,
1095 self.frame,
1096 );
941 for node in &self.nodes {1097 for node in &self.nodes {
942 let state = self.states.entry(node.id).or_default();1098 let state = self.states.entry(node.id).or_default();
943 state.rect = node.rect;1099 state.rect = node.rect;
...@@ -960,16 +1116,74 @@ impl Ui {...@@ -960,16 +1116,74 @@ impl Ui {
960 {1116 {
961 self.close_from(gone);1117 self.close_from(gone);
962 }1118 }
963 self.display.clear();1119 let last = std::mem::take(&mut self.display);
1120 let last_popups = self.popups_painted;
964 self.hits.clear();1121 self.hits.clear();
965 self.paint(0, None);1122 self.paint(0, None, None);
966 self.popups_painted = self.display.len();1123 self.popups_painted = self.display.len();
967 let beneath = self.hits.len();1124 let beneath = self.hits.len();
1125 let mut painted = Vec::new();
968 for index in self.nodes[0].children.clone() {1126 for index in self.nodes[0].children.clone() {
969 if self.nodes[index].anchor.is_some() {1127 let node = &self.nodes[index];
970 self.paint(index, None);1128 let Some(anchor) = node.anchor else {
1129 continue;
1130 };
1131 let (id, rect) = (node.id, node.rect);
1132 if let Some(open) = self.opening(id, anchor) {
1133 self.scrim(anchor, open);
1134 self.nodes[index].motion = Some(anchor.motion(rect, open));
971 }1135 }
1136 let from = self.display.len() - self.popups_painted;
1137 self.paint(index, None, None);
1138 painted.push((
1139 id,
1140 anchor,
1141 rect,
1142 from..self.display.len() - self.popups_painted,
1143 ));
972 }1144 }
1145 for (id, anchor, rect, range) in std::mem::replace(&mut self.painted, painted) {
1146 if self.painted.iter().any(|(shown, ..)| *shown == id) {
1147 continue;
1148 }
1149 // What held still while it opened goes at once, uncovering what it stood in for.
1150 let mut still = false;
1151 let display = last[last_popups + range.start..last_popups + range.end]
1152 .iter()
1153 .filter(|item| match item {
1154 Display::Motion(motion) => {
1155 still = motion.is_none();
1156 false
1157 }
1158 _ => !still,
1159 })
1160 .cloned()
1161 .collect();
1162 self.closing.push(Closing {
1163 id,
1164 display,
1165 anchor,
1166 rect,
1167 closed: self.now,
1168 });
1169 }
1170 let closing = std::mem::take(&mut self.closing);
1171 for closing in &closing {
1172 let shut = self.progress(closing.closed, closing.anchor.durations()[1]);
1173 let open = (1.0 - shut).powi(3);
1174 self.scrim(closing.anchor, open);
1175 let motion = closing.anchor.motion(closing.rect, open);
1176 self.display.push(Display::Motion(Some(motion)));
1177 self.display.extend(closing.display.iter().cloned());
1178 self.display.push(Display::Motion(None));
1179 }
1180 self.closing = closing
1181 .into_iter()
1182 .filter(|closing| {
1183 self.progress(closing.closed, closing.anchor.durations()[1]) < 1.0
1184 && !self.painted.iter().any(|(shown, ..)| *shown == closing.id)
1185 })
1186 .collect();
973 self.modal = if self.popups.is_empty() { 0 } else { beneath };1187 self.modal = if self.popups.is_empty() { 0 } else { beneath };
974 for id in [&mut self.hover, &mut self.active, &mut self.focus] {1188 for id in [&mut self.hover, &mut self.active, &mut self.focus] {
975 if id.is_some_and(|id| !self.states.contains_key(&id)) {1189 if id.is_some_and(|id| !self.states.contains_key(&id)) {
...@@ -978,19 +1192,50 @@ impl Ui {...@@ -978,19 +1192,50 @@ impl Ui {
978 }1192 }
979 }1193 }
9801194
981 fn paint(&mut self, index: usize, clip: Option<[f32; 4]>) {1195 /// How far open popup `id` beside `anchor` shows this frame, from 0 to 1, while open.
1196 pub fn opening(&mut self, id: Id, anchor: Anchor) -> Option<f32> {
1197 let opened = self.popups.iter().find(|popup| popup.id == id)?.opened;
1198 Some(1.0 - (1.0 - self.progress(opened, anchor.durations()[0])).powi(3))
1199 }
1200
1201 /// How far through an animation `duration` seconds long that began at `start` this
1202 /// frame is, asking for frames until it ends.
1203 fn progress(&mut self, start: Instant, duration: f32) -> f32 {
1204 let progress =
1205 (self.now.saturating_duration_since(start).as_secs_f32() / duration).min(1.0);
1206 self.animating |= progress < 1.0;
1207 progress
1208 }
1209
1210 /// Dims the window beneath a dialog `open` of the way open.
1211 fn scrim(&mut self, anchor: Anchor, open: f32) {
1212 if anchor == Anchor::Dialog {
1213 self.display.push(Display::Rect {
1214 rect: self.nodes[0].rect,
1215 fill: [0.0, 0.0, 0.0, self.theme.shadow[3] * 0.5 * open],
1216 shade: None,
1217 border: None,
1218 radius: 0.0,
1219 });
1220 }
1221 }
1222
1223 fn paint(&mut self, index: usize, clip: Option<[f32; 4]>, motion: Option<Motion>) {
982 let node = &self.nodes[index];1224 let node = &self.nodes[index];
1225 // The motion the box and its children paint with, where it changes it.
1226 let own = if node.flags.contains(Flags::STILL) {
1227 Some(None)
1228 } else {
1229 node.motion.map(Some)
1230 };
1231 if let Some(own) = own {
1232 self.display.push(Display::Motion(own));
1233 }
983 let state = &self.states[&node.id];1234 let state = &self.states[&node.id];
984 let rect = node.rect;1235 let rect = node.rect;
985 let visible = clip.map_or(Some(rect), |clip| intersect(rect, clip));1236 let visible = clip.map_or(Some(rect), |clip| intersect(rect, clip));
986 // Without a colour of its own, a box fades the hover colour in rather than
987 // blending from transparent black.
988 let blend = |base: Option<[f32; 4]>, hover: Option<[f32; 4]>| match (base, hover) {1237 let blend = |base: Option<[f32; 4]>, hover: Option<[f32; 4]>| match (base, hover) {
989 (base, Some(hover)) => Some(mix(1238 (base, Some(hover)) => Some(mix(base.unwrap_or_default(), hover, state.hot)),
990 base.unwrap_or([hover[0], hover[1], hover[2], 0.0]),
991 hover,
992 state.hot,
993 )),
994 (base, None) => base,1239 (base, None) => base,
995 };1240 };
996 let fill = blend(node.fill, node.hover_fill);1241 let fill = blend(node.fill, node.hover_fill);
...@@ -1085,7 +1330,12 @@ impl Ui {...@@ -1085,7 +1330,12 @@ impl Ui {
1085 let rect = if node.label.is_some() {1330 let rect = if node.label.is_some() {
1086 [x, top, x + ICON, top + ICON]1331 [x, top, x + ICON, top + ICON]
1087 } else {1332 } else {
1088 [inner[0], painted[1] + node.pad[1], inner[2], painted[3] - node.pad[1]]1333 [
1334 inner[0],
1335 painted[1] + node.pad[1],
1336 inner[2],
1337 painted[3] - node.pad[1],
1338 ]
1089 };1339 };
1090 self.display.push(Display::Image {1340 self.display.push(Display::Image {
1091 image: image.clone(),1341 image: image.clone(),
...@@ -1130,30 +1380,47 @@ impl Ui {...@@ -1130,30 +1380,47 @@ impl Ui {
1130 }1380 }
1131 for child in node.children.clone() {1381 for child in node.children.clone() {
1132 if self.nodes[child].anchor.is_none() {1382 if self.nodes[child].anchor.is_none() {
1133 self.paint(child, inner_clip);1383 self.paint(child, inner_clip, own.unwrap_or(motion));
1134 }1384 }
1135 }1385 }
1136 if inner_clip != clip {1386 if inner_clip != clip {
1137 self.display.push(Display::Clip(clip));1387 self.display.push(Display::Clip(clip));
1138 }1388 }
1389 if own.is_some() {
1390 self.display.push(Display::Motion(motion));
1391 }
1139 }1392 }
11401393
1141 /// This frame's painting in order, in logical pixels.1394 /// This frame's painting in order, in logical pixels.
1142 pub fn layers(&self) -> Vec<Layer<'_>> {1395 pub fn layers(&self) -> Vec<Layer<'_>> {
1143 let mut layers = Vec::new();1396 let mut layers = Vec::new();
1144 let mut clip = None;1397 let mut clip = None;
1398 let mut motion = None;
1145 let mut primitives = Vec::new();1399 let mut primitives = Vec::new();
1400 fn flush<'a>(
1401 layers: &mut Vec<Layer<'a>>,
1402 clip: Option<[f32; 4]>,
1403 motion: Option<Motion>,
1404 primitives: &mut Vec<Primitive<'a>>,
1405 ) {
1406 if !primitives.is_empty() {
1407 layers.push(Layer::Primitives(Primitives {
1408 clip,
1409 motion,
1410 primitives: std::mem::take(primitives),
1411 }));
1412 }
1413 }
1146 for item in &self.display {1414 for item in &self.display {
1147 match item {1415 match item {
1148 Display::Clip(next) => {1416 Display::Clip(next) => {
1149 if !primitives.is_empty() {1417 flush(&mut layers, clip, motion, &mut primitives);
1150 layers.push(Layer::Primitives {
1151 clip,
1152 primitives: std::mem::take(&mut primitives),
1153 });
1154 }
1155 clip = *next;1418 clip = *next;
1156 }1419 }
1420 Display::Motion(next) => {
1421 flush(&mut layers, clip, motion, &mut primitives);
1422 motion = *next;
1423 }
1157 Display::Rect {1424 Display::Rect {
1158 rect,1425 rect,
1159 fill,1426 fill,
...@@ -1221,6 +1488,7 @@ impl Ui {...@@ -1221,6 +1488,7 @@ impl Ui {
1221 origin: *origin,1488 origin: *origin,
1222 size: ICON,1489 size: ICON,
1223 tint: *tint,1490 tint: *tint,
1491 palette: self.icon_palette,
1224 }),1492 }),
1225 Display::Text {1493 Display::Text {
1226 label,1494 label,
...@@ -1234,12 +1502,7 @@ impl Ui {...@@ -1234,12 +1502,7 @@ impl Ui {
1234 ink: *color,1502 ink: *color,
1235 }),1503 }),
1236 Display::Custom { id, rect } => {1504 Display::Custom { id, rect } => {
1237 if !primitives.is_empty() {1505 flush(&mut layers, clip, motion, &mut primitives);
1238 layers.push(Layer::Primitives {
1239 clip,
1240 primitives: std::mem::take(&mut primitives),
1241 });
1242 }
1243 layers.push(Layer::Custom {1506 layers.push(Layer::Custom {
1244 id: *id,1507 id: *id,
1245 rect: *rect,1508 rect: *rect,
...@@ -1247,9 +1510,7 @@ impl Ui {...@@ -1247,9 +1510,7 @@ impl Ui {
1247 }1510 }
1248 }1511 }
1249 }1512 }
1250 if !primitives.is_empty() {1513 flush(&mut layers, clip, motion, &mut primitives);
1251 layers.push(Layer::Primitives { clip, primitives });
1252 }
1253 layers1514 layers
1254 }1515 }
12551516
...@@ -1257,9 +1518,16 @@ impl Ui {...@@ -1257,9 +1518,16 @@ impl Ui {
1257 (&mut self.texts, self.frame)1518 (&mut self.texts, self.frame)
1258 }1519 }
12591520
1260 pub(crate) fn field(&mut self, id: Id) -> (&mut Selection, &mut (Selection, SelectionUnit)) {1521 pub(crate) fn field(
1522 &mut self,
1523 id: Id,
1524 ) -> (&mut Selection, &mut (Selection, SelectionUnit), &mut bool) {
1261 let state = self.states.entry(id).or_default();1525 let state = self.states.entry(id).or_default();
1262 (&mut state.selection, &mut state.press)1526 (
1527 &mut state.selection,
1528 &mut state.press,
1529 &mut state.select_all,
1530 )
1263 }1531 }
12641532
1265 pub(crate) fn grab(&mut self, id: Id) -> &mut f32 {1533 pub(crate) fn grab(&mut self, id: Id) -> &mut f32 {
...@@ -1293,6 +1561,7 @@ impl Built {...@@ -1293,6 +1561,7 @@ impl Built {
1293 size: spec.size,1561 size: spec.size,
1294 axis: spec.axis,1562 axis: spec.axis,
1295 label,1563 label,
1564 overflow: spec.overflow,
1296 color: spec.color.unwrap_or(text),1565 color: spec.color.unwrap_or(text),
1297 fill: spec.fill,1566 fill: spec.fill,
1298 gradient: spec.gradient,1567 gradient: spec.gradient,
...@@ -1309,8 +1578,10 @@ impl Built {...@@ -1309,8 +1578,10 @@ impl Built {
1309 gap: spec.gap,1578 gap: spec.gap,
1310 center: spec.center,1579 center: spec.center,
1311 position: spec.position,1580 position: spec.position,
1581 offset: spec.offset,
1312 cursor: spec.cursor,1582 cursor: spec.cursor,
1313 anchor: spec.anchor,1583 anchor: spec.anchor,
1584 motion: None,
1314 marks: Vec::new(),1585 marks: Vec::new(),
1315 computed: [0.0; 2],1586 computed: [0.0; 2],
1316 relative: [0.0; 2],1587 relative: [0.0; 2],
...@@ -1407,9 +1678,38 @@ impl Corner {...@@ -1407,9 +1678,38 @@ impl Corner {
1407 }1678 }
1408}1679}
14091680
1410/// `a` moved `t` of the way to `b`.1681/// Where a box dragged from index `from` of a list laid out at `spans`, each a start and a
1682/// length along it, lands with its middle at `middle`: after every other box whose middle it
1683/// has passed, as browser tabs reorder. The index is in the list's new order.
1684pub fn drop_slot(spans: &[[f32; 2]], from: usize, middle: f32) -> usize {
1685 spans
1686 .iter()
1687 .enumerate()
1688 .filter(|(index, [start, length])| *index != from && start + length / 2.0 < middle)
1689 .count()
1690}
1691
1692/// How far box `index` of a list slides aside while the box at `from`, `length` long, is
1693/// dragged to land at `to`.
1694pub fn slide(index: usize, from: usize, to: usize, length: f32) -> f32 {
1695 if from < index && index <= to {
1696 -length
1697 } else if to <= index && index < from {
1698 length
1699 } else {
1700 0.0
1701 }
1702}
1703
1704/// `a` moved `t` of the way to `b`, mixed as premultiplied colours, so from transparent
1705/// `b` fades in with its own hue.
1411pub fn mix(a: [f32; 4], b: [f32; 4], t: f32) -> [f32; 4] {1706pub fn mix(a: [f32; 4], b: [f32; 4], t: f32) -> [f32; 4] {
1412 std::array::from_fn(|i| a[i] + (b[i] - a[i]) * t)1707 let alpha = a[3] + (b[3] - a[3]) * t;
1708 if alpha == 0.0 {
1709 return [0.0; 4];
1710 }
1711 let channel = |i: usize| (a[i] * a[3] + (b[i] * b[3] - a[i] * a[3]) * t) / alpha;
1712 [channel(0), channel(1), channel(2), alpha]
1413}1713}
14141714
1415fn contains(rect: [f32; 4], point: [f32; 2]) -> bool {1715fn contains(rect: [f32; 4], point: [f32; 2]) -> bool {
crates/ui/src/list.rs+1-26
...@@ -4,8 +4,6 @@...@@ -4,8 +4,6 @@
4use crate::{Axis, Event, Flags, HALF_LIFE, Id, Size, Spec, Ui, fill, mix, px, scrollbar};4use crate::{Axis, Event, Flags, HALF_LIFE, Id, Size, Spec, Ui, fill, mix, px, scrollbar};
5use winit::keyboard::NamedKey;5use winit::keyboard::NamedKey;
66
7/// How far rows fade out under a scrolled list's edges.
8const EDGE: f64 = 10.0;
9/// Room beside a scrolling list's rows for its scrollbar.7/// Room beside a scrolling list's rows for its scrollbar.
10pub(crate) const GUTTER: f32 = 12.0;8pub(crate) const GUTTER: f32 = 12.0;
119
...@@ -191,8 +189,6 @@ pub fn list<R: Rows>(...@@ -191,8 +189,6 @@ pub fn list<R: Rows>(
191 let scroll = state.scroll;189 let scroll = state.scroll;
192 ui.animating |= scroll != state.target;190 ui.animating |= scroll != state.target;
193191
194 let theme = ui.theme.clone();
195 let background = spec.fill.unwrap_or(theme.base);
196 ui.open_as(192 ui.open_as(
197 id,193 id,
198 Spec {194 Spec {
...@@ -235,28 +231,7 @@ pub fn list<R: Rows>(...@@ -235,28 +231,7 @@ pub fn list<R: Rows>(
235 ui.close();231 ui.close();
236 shown.push((key, place));232 shown.push((key, place));
237 }233 }
238 // Rows fade out under the list's edges rather than end at a cut.234 let thumb = mix(ui.theme.text_dim, ui.theme.chip, 0.5);
239 let clear = [background[0], background[1], background[2], 0.0];
240 let edges = [
241 (scroll > 0.0, 0.0, [background, clear]),
242 (scroll < most, view - EDGE, [clear, background]),
243 ];
244 for (index, (shown, at, [top, bottom])) in edges.into_iter().enumerate() {
245 if shown {
246 ui.leaf(
247 ("edge", index),
248 Spec {
249 flags: Flags::FLOAT,
250 size: [width, px(EDGE as f32)],
251 position: [0.0, at as f32],
252 fill: Some(top),
253 gradient: Some(bottom),
254 ..Spec::default()
255 },
256 );
257 }
258 }
259 let thumb = mix(theme.text_dim, theme.chip, 0.5);
260 if let Some(offset) = scrollbar(235 if let Some(offset) = scrollbar(
261 ui,236 ui,
262 "bar",237 "bar",
crates/ui/src/popup.rs+149-18
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1//! Controls built on the popup layer: menus and filterable lists, a colour grid and a1//! Controls built on the popup layer: menus and filterable lists, a colour grid, a gallery
2//! command palette. Each builds while its popup is open and returns what was chosen the2//! and a command palette. Each builds while its popup is open and returns what was chosen the
3//! frame it is, closing the popup.3//! frame it is, closing the popup.
44
5use crate::{5use crate::{
...@@ -16,7 +16,13 @@ use winit::keyboard::{Key, NamedKey};...@@ -16,7 +16,13 @@ use winit::keyboard::{Key, NamedKey};
16pub(crate) const CHECK: &[&str] = &[include_str!("../assets/check.svg")];16pub(crate) const CHECK: &[&str] = &[include_str!("../assets/check.svg")];
17/// Inset of a popup's contents, and its distance from what it drops down from.17/// Inset of a popup's contents, and its distance from what it drops down from.
18const PAD: f32 = 4.0;18const PAD: f32 = 4.0;
19/// A palette's rows and filter field, and a menu's more compact rows.
19const ROW: f32 = 26.0;20const ROW: f32 = 26.0;
21pub(crate) const MENU_ROW: f32 = 22.0;
22/// Inset of a menu row's icon and text.
23const ROW_PAD: f32 = 8.0;
24/// How far a menu's icons sit inside its leading edge.
25pub(crate) const ICON_INSET: f32 = PAD + ROW_PAD;
20/// Rows a list shows before it scrolls.26/// Rows a list shows before it scrolls.
21const ROWS: f32 = 12.0;27const ROWS: f32 = 12.0;
22/// Height of the rule between groups.28/// Height of the rule between groups.
...@@ -108,6 +114,7 @@ pub fn menu(...@@ -108,6 +114,7 @@ pub fn menu(
108 items,114 items,
109 filter,115 filter,
110 width.max(least).max(NARROWEST),116 width.max(least).max(NARROWEST),
117 MENU_ROW,
111 )118 )
112}119}
113120
...@@ -128,6 +135,7 @@ pub fn palette(ui: &mut Ui, id: Id, commands: &[Item]) -> Option<usize> {...@@ -128,6 +135,7 @@ pub fn palette(ui: &mut Ui, id: Id, commands: &[Item]) -> Option<usize> {
128 commands,135 commands,
129 Some("Search commands"),136 Some("Search commands"),
130 width,137 width,
138 ROW,
131 )139 )
132}140}
133141
...@@ -138,6 +146,7 @@ fn choose(...@@ -138,6 +146,7 @@ fn choose(
138 items: &[Item],146 items: &[Item],
139 filter: Option<&str>,147 filter: Option<&str>,
140 width: f32,148 width: f32,
149 row: f32,
141) -> Option<usize> {150) -> Option<usize> {
142 let field = id.child("filter");151 let field = id.child("filter");
143 if filter.is_some() && ui.focus == Some(id) {152 if filter.is_some() && ui.focus == Some(id) {
...@@ -148,21 +157,31 @@ fn choose(...@@ -148,21 +157,31 @@ fn choose(
148 let mut query = std::mem::take(&mut popup.query);157 let mut query = std::mem::take(&mut popup.query);
149 let mut highlight = popup.highlight;158 let mut highlight = popup.highlight;
150 let theme = ui.theme.clone();159 let theme = ui.theme.clone();
160 // Over a combo box, the field takes the box's place at once and widens as the popup
161 // comes in around it.
162 let (flags, size) = match anchor {
163 Anchor::Over(rect) => {
164 let open = ui.opening(id, anchor).unwrap_or(1.0);
165 let from = rect[2] - rect[0];
166 let to = width - 2.0 * PAD;
167 (
168 Flags::STILL,
169 [px(from + (to - from) * open), px(rect[3] - rect[1])],
170 )
171 }
172 _ => (Flags::default(), [fill(), px(ROW)]),
173 };
151 surface(ui, id, anchor, width);174 surface(ui, id, anchor, width);
152 if let Some(placeholder) = filter {175 if let Some(placeholder) = filter {
153 let before = query.clone();176 let before = query.clone();
154 // Over a combo box, the field takes the box's place.
155 let height = match anchor {
156 Anchor::Over(rect) => rect[3] - rect[1],
157 _ => ROW,
158 };
159 text_field(177 text_field(
160 ui,178 ui,
161 field,179 field,
162 &mut query,180 &mut query,
163 placeholder,181 placeholder,
164 Spec {182 Spec {
165 size: [fill(), px(height)],183 flags,
184 size,
166 fill: Some(theme.base),185 fill: Some(theme.base),
167 border: Some(theme.accent),186 border: Some(theme.accent),
168 radius: 4.0,187 radius: 4.0,
...@@ -178,7 +197,10 @@ fn choose(...@@ -178,7 +197,10 @@ fn choose(
178 // Unfiltered, the current or checked item starts highlighted; filtered, the best match.197 // Unfiltered, the current or checked item starts highlighted; filtered, the best match.
179 highlight = highlight.or_else(|| {198 highlight = highlight.or_else(|| {
180 let first = if query.is_empty() {199 let first = if query.is_empty() {
181 matches.order.iter().position(|index| items[*index].current || items[*index].checked)200 matches
201 .order
202 .iter()
203 .position(|index| items[*index].current || items[*index].checked)
182 } else {204 } else {
183 (0..matches.count()).find(|row| matches.selectable(*row))205 (0..matches.count()).find(|row| matches.selectable(*row))
184 };206 };
...@@ -186,10 +208,10 @@ fn choose(...@@ -186,10 +208,10 @@ fn choose(
186 });208 });
187 let window = ui.rect(Id::ROOT).map_or(0.0, |window| window[3]);209 let window = ui.rect(Id::ROOT).map_or(0.0, |window| window[3]);
188 let field = if filter.is_some() { ROW + PAD } else { 0.0 };210 let field = if filter.is_some() { ROW + PAD } else { 0.0 };
189 let content = matches.count() as f32 * ROW + matches.space_before(matches.count());211 let content = matches.count() as f32 * row + matches.space_before(matches.count());
190 let view = content212 let view = content
191 .max(ROW)213 .max(row)
192 .min((ROWS * ROW).min(window - 4.0 * PAD - field).max(ROW));214 .min((ROWS * row).min(window - 4.0 * PAD - field).max(row));
193 // The popup eases to its new height as the results change; the rows do not move.215 // The popup eases to its new height as the results change; the rows do not move.
194 let dt = ui.dt;216 let dt = ui.dt;
195 let [from, to, elapsed] = state(ui, id).height.get_or_insert([view, view, RESIZE]);217 let [from, to, elapsed] = state(ui, id).height.get_or_insert([view, view, RESIZE]);
...@@ -212,7 +234,7 @@ fn choose(...@@ -212,7 +234,7 @@ fn choose(
212 ui.leaf(234 ui.leaf(
213 "empty",235 "empty",
214 Spec {236 Spec {
215 size: [fill(), px(ROW)],237 size: [fill(), px(row)],
216 text: Some("No matches"),238 text: Some("No matches"),
217 color: Some(theme.text_dim),239 color: Some(theme.text_dim),
218 pad: [8.0, 0.0],240 pad: [8.0, 0.0],
...@@ -223,7 +245,7 @@ fn choose(...@@ -223,7 +245,7 @@ fn choose(
223 } else {245 } else {
224 let list = List {246 let list = List {
225 rows: &matches,247 rows: &matches,
226 row: ROW,248 row,
227 keys: &keys,249 keys: &keys,
228 hover_selects: true,250 hover_selects: true,
229 };251 };
...@@ -285,7 +307,7 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) {...@@ -285,7 +307,7 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) {
285 size: [fill(), fill()],307 size: [fill(), fill()],
286 fill: row.selected.then(|| theme.hover()),308 fill: row.selected.then(|| theme.hover()),
287 radius: 4.0,309 radius: 4.0,
288 pad: [8.0, 0.0],310 pad: [ROW_PAD, 0.0],
289 gap: ICON_GAP,311 gap: ICON_GAP,
290 ..Spec::default()312 ..Spec::default()
291 },313 },
...@@ -299,7 +321,7 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) {...@@ -299,7 +321,7 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) {
299 ui.leaf(321 ui.leaf(
300 "icon",322 "icon",
301 Spec {323 Spec {
302 size: [px(ICON), px(ROW)],324 size: [px(ICON), fill()],
303 icon: if item.checked { Some(CHECK) } else { item.icon },325 icon: if item.checked { Some(CHECK) } else { item.icon },
304 color: Some(if item.disabled {326 color: Some(if item.disabled {
305 mix(tint, theme.popup, 0.5)327 mix(tint, theme.popup, 0.5)
...@@ -313,7 +335,7 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) {...@@ -313,7 +335,7 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) {
313 ui.leaf(335 ui.leaf(
314 "text",336 "text",
315 Spec {337 Spec {
316 size: [fill(), px(ROW)],338 size: [fill(), fill()],
317 text: Some(item.text),339 text: Some(item.text),
318 font: item.font,340 font: item.font,
319 font_size: item.heading.then_some(theme.font_size - 2.0),341 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) {...@@ -326,7 +348,7 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) {
326 ui.leaf(348 ui.leaf(
327 "shortcut",349 "shortcut",
328 Spec {350 Spec {
329 size: [fit(), px(ROW)],351 size: [fit(), fill()],
330 text: Some(item.shortcut),352 text: Some(item.shortcut),
331 color: Some(theme.text_dim),353 color: Some(theme.text_dim),
332 ..Spec::default()354 ..Spec::default()
...@@ -451,6 +473,115 @@ pub fn colors(...@@ -451,6 +473,115 @@ pub fn colors(
451 None473 None
452}474}
453475
476/// Builds popup `id` as a gallery beside `anchor` while it is open: `groups` of cells, each
477/// a heading and a count, in rows of `columns` cells `size` large, with cell `current`
478/// outlined. `cell` builds a cell's contents from its index, counted through the groups.
479/// Returns the index of the cell chosen.
480#[allow(clippy::too_many_arguments)]
481pub fn gallery(
482 ui: &mut Ui,
483 id: Id,
484 anchor: Anchor,
485 groups: &[(&str, usize)],
486 columns: usize,
487 size: [f32; 2],
488 current: Option<usize>,
489 mut cell: impl FnMut(&mut Ui, usize),
490) -> Option<usize> {
491 if !ui.popup_open(id) {
492 return None;
493 }
494 let cell_id = |index: usize| id.child(("cell", index));
495 let count: usize = groups.iter().map(|(_, count)| count).sum();
496 let keys = navigation(
497 ui,
498 &[id],
499 &[
500 NamedKey::ArrowLeft,
501 NamedKey::ArrowRight,
502 NamedKey::ArrowUp,
503 NamedKey::ArrowDown,
504 NamedKey::Enter,
505 ],
506 );
507 let mut highlight = state(ui, id).highlight.map(|cell| cell as usize);
508 let mut chosen = None;
509 for index in 0..count {
510 let signal = ui.signal(cell_id(index));
511 if signal.hovered && ui.moved {
512 highlight = Some(index);
513 }
514 if signal.clicked {
515 chosen = Some(index);
516 }
517 }
518 for key in keys {
519 let at = highlight.or(current).unwrap_or(0);
520 highlight = Some(match key {
521 NamedKey::Enter => {
522 chosen = chosen.or(highlight);
523 continue;
524 }
525 NamedKey::ArrowLeft => at.saturating_sub(1),
526 NamedKey::ArrowRight => (at + 1).min(count - 1),
527 NamedKey::ArrowUp => at.saturating_sub(columns),
528 _ => (at + columns).min(count - 1),
529 });
530 }
531 if let Some(index) = chosen {
532 ui.close_popup(id);
533 return Some(index);
534 }
535 let theme = ui.theme.clone();
536 surface(ui, id, anchor, columns as f32 * size[0] + 2.0 * PAD);
537 let mut index = 0;
538 for (group, (heading, cells)) in groups.iter().enumerate() {
539 ui.leaf(
540 ("heading", group),
541 Spec {
542 size: [fill(), px(MENU_ROW)],
543 text: Some(heading),
544 font_size: Some(theme.font_size - 2.0),
545 bold: true,
546 color: Some(theme.text_dim),
547 pad: [PAD, 0.0],
548 ..Spec::default()
549 },
550 );
551 for row in 0..cells.div_ceil(columns) {
552 ui.open(
553 ("row", group, row),
554 Spec {
555 size: [fill(), px(size[1])],
556 ..Spec::default()
557 },
558 );
559 for _ in 0..columns.min(cells - row * columns) {
560 ui.open_as(
561 cell_id(index),
562 Spec {
563 flags: Flags::CLICKABLE,
564 axis: Axis::Y,
565 size: [px(size[0]), px(size[1])],
566 fill: (highlight == Some(index)).then(|| theme.hover()),
567 border: (current == Some(index)).then_some(theme.accent),
568 radius: 4.0,
569 pad: [PAD, PAD],
570 ..Spec::default()
571 },
572 );
573 cell(ui, index);
574 ui.close();
575 index += 1;
576 }
577 ui.close();
578 }
579 }
580 ui.close();
581 state(ui, id).highlight = highlight.map(|cell| cell as u64);
582 None
583}
584
454/// Opens popup `id`'s panel `width` wide beside `anchor`; the caller closes it.585/// Opens popup `id`'s panel `width` wide beside `anchor`; the caller closes it.
455fn surface(ui: &mut Ui, id: Id, anchor: Anchor, width: f32) {586fn surface(ui: &mut Ui, id: Id, anchor: Anchor, width: f32) {
456 let anchor = match anchor {587 let anchor = match anchor {
crates/ui/src/shell.rs+198-41
...@@ -1,10 +1,10 @@...@@ -1,10 +1,10 @@
1//! Controls a notebook window's chrome is built from: OneNote's section tabs and the1//! Controls a notebook window's chrome is built from: OneNote's section tabs and the
2//! toolbar's compact buttons.2//! toolbar's compact buttons.
33
4use draw::PathStyle;4use draw::{PathStyle, edit::SelectionUnit};
55
6use crate::{6use crate::{
7 Corner, Display, Flags, Id, Section, Shape, Signal, Spec, Ui, children, fill, mix, px,7 Anchor, Corner, Display, Flags, Id, Section, Shape, Signal, Spec, Ui, children, fill, mix, px,
8};8};
9use std::hash::Hash;9use std::hash::Hash;
1010
...@@ -14,17 +14,25 @@ pub const CHEVRON: &[&str] = &[include_str!("../assets/chevron-down.svg")];...@@ -14,17 +14,25 @@ pub const CHEVRON: &[&str] = &[include_str!("../assets/chevron-down.svg")];
14const TAB_ROUNDING: f32 = 4.0;14const TAB_ROUNDING: f32 = 4.0;
15/// Side of a toolbar button.15/// Side of a toolbar button.
16pub const TOOL: f32 = 22.0;16pub const TOOL: f32 = 22.0;
17/// Width of a menu arrow beside a toolbar button.
18const ARROW: f32 = 10.0;
19/// Where a section tab's label starts inside it.
20pub const TAB_PAD: f32 = 10.0;
1721
18/// Section tabs in a row `height` tall, as OneNote draws them: each leans over the next22/// Section tabs in a row `height` tall, as OneNote draws them: each leans over the next
19/// at 45°, and the open one lies on top in `section`'s colours, rising to meet the frame23/// at 45°, and the open one lies on top in `section`'s colours, rising to meet the frame
20/// below as its outline and shadow fade in. `tabs` are names and section colours; `lit`24/// below as its outline and shadow fade in. `tabs` are names and section colours; `lit`
21/// shows as hovered, as a tab something is dragged onto.25/// shows as hovered, as a tab something is dragged onto. A `dragged` tab follows the
26/// pointer, lifted over the others, which slide aside to open the gap it would land in;
27/// each eases to its place once let go.
28#[allow(clippy::too_many_arguments)]
22pub fn section_tabs(29pub fn section_tabs(
23 ui: &mut Ui,30 ui: &mut Ui,
24 part: impl Hash,31 part: impl Hash,
25 tabs: &[(&str, [f32; 4])],32 tabs: &[(&str, [f32; 4])],
26 active: usize,33 active: usize,
27 lit: Option<usize>,34 lit: Option<usize>,
35 dragged: Option<Dragged>,
28 section: &Section,36 section: &Section,
29 height: f32,37 height: f32,
30) -> Tabs {38) -> Tabs {
...@@ -38,25 +46,60 @@ pub fn section_tabs(...@@ -38,25 +46,60 @@ pub fn section_tabs(
38 },46 },
39 );47 );
40 let [low, tallest] = [height - 6.0, height - 2.0];48 let [low, tallest] = [height - 6.0, height - 2.0];
41 let mut left = 0.0;49 // Room for the first tab's outline and shadow, which the row would clip.
50 let mut left = crate::SHADOW[0];
42 let placed: Vec<_> = tabs51 let placed: Vec<_> = tabs
43 .iter()52 .iter()
44 .map(|(name, _)| {53 .map(|(name, _)| {
45 let width = 10.0 + ui.measure(name)[0] + 4.0 + lean(height);54 let width = TAB_PAD + ui.measure(name)[0] + 4.0 + lean(height);
46 left += width;55 left += width;
47 (left - width, width)56 (left - width, width)
48 })57 })
49 .collect();58 .collect();
50 let (mut clicked, mut context, mut held) = (None, None, None);59 let (mut clicked, mut context, mut held, mut renamed) = (None, None, None, None);
51 // Earlier tabs lie over later ones; the open tab over all.60 let spans: Vec<[f32; 2]> = placed.iter().map(|(x, width)| [*x, *width]).collect();
61 let slot = dragged.and_then(|dragged| {
62 let width = placed[dragged.index].1;
63 Some(crate::drop_slot(
64 &spans,
65 dragged.index,
66 dragged.start? + width / 2.0,
67 ))
68 });
69 let lifted = dragged.map(|dragged| dragged.index);
70 let mut settled = true;
71 // Earlier tabs lie over later ones; the open tab over them, and a dragged one over all.
52 let order = (0..tabs.len())72 let order = (0..tabs.len())
53 .rev()73 .rev()
54 .filter(|index| *index != active)74 .filter(|index| *index != active && Some(*index) != lifted)
55 .chain((active < tabs.len()).then_some(active));75 .chain((active < tabs.len() && Some(active) != lifted).then_some(active))
76 .chain(lifted);
56 for index in order {77 for index in order {
57 let (name, color) = tabs[index];78 let (name, color) = tabs[index];
58 let (x, width) = placed[index];79 let (x, width) = placed[index];
59 let open = ui.animate(ui.id(("open", index)), f32::from(u8::from(index == active)));80 // Keyed by name, so a tab eases from where it stood when the tabs are reordered.
81 let key = ui.id(("x", name));
82 let x = match (dragged, slot) {
83 (
84 Some(Dragged {
85 start: Some(start), ..
86 }),
87 _,
88 ) if lifted == Some(index) => ui.hold(key, start),
89 (Some(dragged), Some(slot)) => ui.animate(
90 key,
91 x + crate::slide(index, dragged.index, slot, placed[dragged.index].1),
92 ),
93 _ => {
94 let eased = ui.animate(key, x);
95 settled &= lifted != Some(index) || (eased - x).abs() < 0.5;
96 eased
97 }
98 };
99 let lift = f32::from(u8::from(lifted == Some(index)));
100 let open = ui
101 .animate(ui.id(("open", name)), f32::from(u8::from(index == active)))
102 .max(lift);
60 // Whole device pixels, so a rising tab reuses its rasterized outlines.103 // Whole device pixels, so a rising tab reuses its rasterized outlines.
61 let tall = ((low + (tallest - low) * open) * ui.scale()).round() / ui.scale();104 let tall = ((low + (tallest - low) * open) * ui.scale()).round() / ui.scale();
62 let colors = theme.section(color);105 let colors = theme.section(color);
...@@ -77,11 +120,12 @@ pub fn section_tabs(...@@ -77,11 +120,12 @@ pub fn section_tabs(
77 }),120 }),
78 gradient: Some(fill),121 gradient: Some(fill),
79 hover_fill: (index != active).then(|| mix(colors.frame[0], [1.0; 4], 0.08)),122 hover_fill: (index != active).then(|| mix(colors.frame[0], [1.0; 4], 0.08)),
80 border: Some(fade(section.edge, open)),123 // A tab not open is outlined in its own hue, fainter than the open one's.
81 shadow: (open > 0.0).then(|| fade([0.0, 0.0, 0.0, 0.35], open)),124 border: Some(mix(mix(colors.tab, colors.edge, 0.55), section.edge, open)),
125 shadow: (open > 0.0).then(|| fade([0.0, 0.0, 0.0, 0.35 + 0.15 * lift], open)),
82 radius: TAB_ROUNDING,126 radius: TAB_ROUNDING,
83 shape: Shape::Tab { lean: lean(height) },127 shape: Shape::Tab { lean: lean(height) },
84 pad: [10.0, 0.0],128 pad: [TAB_PAD, 0.0],
85 ..Spec::default()129 ..Spec::default()
86 },130 },
87 );131 );
...@@ -94,6 +138,9 @@ pub fn section_tabs(...@@ -94,6 +138,9 @@ pub fn section_tabs(
94 if signal.dragging {138 if signal.dragging {
95 held = Some(index);139 held = Some(index);
96 }140 }
141 if signal.pressed && signal.unit != SelectionUnit::Grapheme {
142 renamed = Some(index);
143 }
97 }144 }
98 let open_tab = ui.id(("tab", active));145 let open_tab = ui.id(("tab", active));
99 ui.close();146 ui.close();
...@@ -101,10 +148,21 @@ pub fn section_tabs(...@@ -101,10 +148,21 @@ pub fn section_tabs(
101 clicked,148 clicked,
102 context,149 context,
103 held,150 held,
151 renamed,
152 slot,
153 settled,
104 open: open_tab,154 open: open_tab,
105 }155 }
106}156}
107157
158/// A section tab being dragged: its index, and its leading edge along the row while it
159/// follows the pointer; none once let go, as it eases into its place over the others.
160#[derive(Clone, Copy, Debug)]
161pub struct Dragged {
162 pub index: usize,
163 pub start: Option<f32>,
164}
165
108/// The id of tab `index` of the section tabs built as `row`.166/// The id of tab `index` of the section tabs built as `row`.
109pub fn tab_id(row: Id, index: usize) -> Id {167pub fn tab_id(row: Id, index: usize) -> Id {
110 row.child(("tab", index))168 row.child(("tab", index))
...@@ -117,6 +175,12 @@ pub struct Tabs {...@@ -117,6 +175,12 @@ pub struct Tabs {
117 pub context: Option<(usize, [f32; 2])>,175 pub context: Option<(usize, [f32; 2])>,
118 /// The tab held down, which a drag moves.176 /// The tab held down, which a drag moves.
119 pub held: Option<usize>,177 pub held: Option<usize>,
178 /// The tab pressed twice in a row, which OneNote renames in place.
179 pub renamed: Option<usize>,
180 /// Where the dragged tab would land, as an index in the tabs' new order.
181 pub slot: Option<usize>,
182 /// The dragged tab, let go, stands in its place.
183 pub settled: bool,
120 /// The open tab's id, for `tab_base`.184 /// The open tab's id, for `tab_base`.
121 pub open: Id,185 pub open: Id,
122}186}
...@@ -158,34 +222,62 @@ pub fn tool_button(...@@ -158,34 +222,62 @@ pub fn tool_button(
158 )222 )
159}223}
160224
161/// A button showing `icon`, lit while `on`, with a menu arrow beside it, and optionally225/// A button showing `icon`, lit while `on`, joined to an arrow that opens popup `menu`,
162/// the colour it applies as a bar under the icon. Returns the button's and the arrow's226/// with the colour it applies as a bar under the icon when given. Hovering the button fills
163/// signals.227/// it alone; hovering the arrow, or its menu being open, outlines both as one control.
228/// Returns the button's signal.
164pub fn split_button(229pub fn split_button(
165 ui: &mut Ui,230 ui: &mut Ui,
166 part: impl Hash,231 part: impl Hash,
167 icon: &'static [&'static str],232 icon: &'static [&'static str],
168 bar: Option<[f32; 4]>,233 bar: Option<[f32; 4]>,
169 on: bool,234 on: bool,
170) -> [Signal; 2] {235 menu: Id,
171 let theme = ui.theme.clone();236) -> Signal {
172 ui.open(237 const RADIUS: f32 = 4.0;
173 part,238 let hover = ui.theme.hover();
239 let fade =
240 |alpha: f32| (alpha > 0.0).then_some([hover[0], hover[1], hover[2], hover[3] * alpha]);
241 let split = ui.id(part);
242 let [button, arrow] = [split.child("button"), split.child("menu")];
243 let lit = ui.animate(
244 split.child("lit"),
245 f32::from(u8::from(on || ui.hover == Some(button))),
246 );
247 let outlined = ui.hover == Some(arrow) || ui.popup_open(menu);
248 let ring = ui.animate(split.child("ring"), f32::from(u8::from(outlined)));
249 ui.open_as(
250 split,
174 Spec {251 Spec {
175 size: [children(), px(TOOL)],252 size: [children(), px(TOOL)],
176 ..Spec::default()253 ..Spec::default()
177 },254 },
178 );255 );
179 let button = ui.open(256 ui.open_as(
180 "button",257 button,
258 Spec {
259 flags: Flags::CLICKABLE | Flags::CLIP,
260 size: [px(TOOL), px(TOOL)],
261 ..Spec::default()
262 },
263 );
264 // Rounded past the clip, so the fill meets the arrow square.
265 ui.leaf(
266 "face",
267 Spec {
268 flags: Flags::FLOAT,
269 size: [px(TOOL + RADIUS), px(TOOL)],
270 fill: fade(lit),
271 radius: RADIUS,
272 ..Spec::default()
273 },
274 );
275 ui.open(
276 "icon",
181 Spec {277 Spec {
182 flags: Flags::CLICKABLE,
183 size: [px(TOOL), px(TOOL)],278 size: [px(TOOL), px(TOOL)],
184 icon: Some(icon),279 icon: Some(icon),
185 color: Some(theme.text),280 color: Some(ui.theme.text),
186 fill: on.then(|| theme.hover()),
187 hover_fill: Some(theme.hover()),
188 radius: 4.0,
189 center: true,281 center: true,
190 ..Spec::default()282 ..Spec::default()
191 },283 },
...@@ -196,21 +288,79 @@ pub fn split_button(...@@ -196,21 +288,79 @@ pub fn split_button(
196 ui.mark([top, top + 13.0, top + 16.0, top + 16.0], color, 0.0);288 ui.mark([top, top + 13.0, top + 16.0, top + 16.0], color, 0.0);
197 }289 }
198 ui.close();290 ui.close();
199 let menu = ui.leaf(291 ui.close();
200 "menu",292 if ui
293 .leaf(
294 "menu",
295 Spec {
296 flags: Flags::CLICKABLE,
297 size: [px(ARROW), px(TOOL)],
298 icon: Some(CHEVRON),
299 color: Some(ui.theme.text_dim),
300 center: true,
301 ..Spec::default()
302 },
303 )
304 .pressed
305 {
306 ui.open_popup(menu);
307 }
308 ui.leaf(
309 "ring",
310 Spec {
311 flags: Flags::FLOAT,
312 size: [px(TOOL + ARROW), px(TOOL)],
313 border: fade(ring),
314 radius: RADIUS,
315 ..Spec::default()
316 },
317 );
318 ui.close();
319 ui.signal(button)
320}
321
322/// A button showing `icon` and a menu arrow, one control that opens popup `menu`. Returns
323/// where the menu opens so its icons line up under the button's.
324pub fn menu_button(
325 ui: &mut Ui,
326 part: impl Hash,
327 icon: &'static [&'static str],
328 menu: Id,
329) -> Anchor {
330 let theme = ui.theme.clone();
331 let id = ui.open(
332 part,
201 Spec {333 Spec {
202 flags: Flags::CLICKABLE,334 flags: Flags::CLICKABLE,
203 size: [px(10.0), px(TOOL)],335 size: [px(TOOL + ARROW), px(TOOL)],
204 icon: Some(CHEVRON),336 fill: ui.popup_open(menu).then(|| theme.hover()),
205 color: Some(theme.text_dim),
206 hover_fill: Some(theme.hover()),337 hover_fill: Some(theme.hover()),
207 radius: 4.0,338 radius: 4.0,
208 center: true,
209 ..Spec::default()339 ..Spec::default()
210 },340 },
211 );341 );
342 for (part, icon, width, color) in [
343 ("icon", icon, TOOL, theme.text),
344 ("arrow", CHEVRON, ARROW, theme.text_dim),
345 ] {
346 ui.leaf(
347 part,
348 Spec {
349 size: [px(width), px(TOOL)],
350 icon: Some(icon),
351 color: Some(color),
352 center: true,
353 ..Spec::default()
354 },
355 );
356 }
212 ui.close();357 ui.close();
213 [ui.signal(button), menu]358 if ui.signal(id).pressed {
359 ui.open_popup(menu);
360 }
361 let [left, top, right, bottom] = ui.rect(id).unwrap_or_default();
362 let shift = crate::popup::ICON_INSET - (TOOL - crate::ICON) / 2.0;
363 Anchor::Below([left - shift, top, right - shift, bottom])
214}364}
215365
216/// A drop-down box `width` wide showing `text`.366/// A drop-down box `width` wide showing `text`.
...@@ -295,7 +445,8 @@ impl Ui {...@@ -295,7 +445,8 @@ impl Ui {
295445
296 /// Rounds the corners of the clockwise outline `points`, as `border` does: paints446 /// Rounds the corners of the clockwise outline `points`, as `border` does: paints
297 /// `outside(y)` beyond each outward corner at height `y`, and `inside` into each447 /// `outside(y)` beyond each outward corner at height `y`, and `inside` into each
298 /// inward one. Called between `end` and `layers`.448 /// inward one; a transparent colour cuts the corner out to the window's backdrop.
449 /// Called between `end` and `layers`.
299 pub fn round_corners(450 pub fn round_corners(
300 &mut self,451 &mut self,
301 points: &[([f32; 2], f32)],452 points: &[([f32; 2], f32)],
...@@ -311,6 +462,16 @@ impl Ui {...@@ -311,6 +462,16 @@ impl Ui {
311 corner.start[0] - corner.point[0],462 corner.start[0] - corner.point[0],
312 corner.start[1] - corner.point[1],463 corner.start[1] - corner.point[1],
313 ];464 ];
465 let color = if corner.outward {
466 outside(corner.point[1])
467 } else {
468 inside
469 };
470 let (style, color) = if color[3] == 0.0 {
471 (PathStyle::Erase, [0.0, 0.0, 0.0, 1.0])
472 } else {
473 (PathStyle::Fill, color)
474 };
314 self.beneath_popups(Display::Path {475 self.beneath_popups(Display::Path {
315 data: format!(476 data: format!(
316 "M{} {}{}L0 0Z",477 "M{} {}{}L0 0Z",
...@@ -319,12 +480,8 @@ impl Ui {...@@ -319,12 +480,8 @@ impl Ui {
319 corner.curve(corner.point)480 corner.curve(corner.point)
320 ),481 ),
321 origin: corner.point,482 origin: corner.point,
322 style: PathStyle::Fill,483 style,
323 colors: [if corner.outward {484 colors: [color; 2],
324 outside(corner.point[1])
325 } else {
326 inside
327 }; 2],
328 });485 });
329 }486 }
330 }487 }
crates/ui/src/tests.rs+605-10
...@@ -61,6 +61,36 @@ fn fill_yields_to_strict_siblings_along_the_flow() {...@@ -61,6 +61,36 @@ fn fill_yields_to_strict_siblings_along_the_flow() {
61 assert_eq!(ui.rect(ids[1]), Some([280.0, 0.0, 400.0, 100.0]));61 assert_eq!(ui.rect(ids[1]), Some([280.0, 0.0, 400.0, 100.0]));
62}62}
6363
64#[test]
65fn padding_wider_than_its_box_leaves_loose_children_empty_not_negative() {
66 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
67 let mut child = None;
68 frame(&mut ui, |ui| {
69 ui.open(
70 "cramped",
71 Spec {
72 size: [px(10.0), px(10.0)],
73 pad: [8.0, 8.0],
74 ..Spec::default()
75 },
76 );
77 let loose = Extent {
78 size: Size::Pixels(20.0),
79 strictness: 0.0,
80 };
81 child = Some(ui.open(
82 "child",
83 Spec {
84 size: [loose; 2],
85 ..Spec::default()
86 },
87 ));
88 ui.close();
89 ui.close();
90 });
91 assert_eq!(ui.rect(child.unwrap()), Some([8.0, 8.0, 8.0, 8.0]));
92}
93
64#[test]94#[test]
65fn children_sum_with_gaps_and_padding() {95fn children_sum_with_gaps_and_padding() {
66 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);96 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
...@@ -327,10 +357,10 @@ fn wheel_scrolls_within_the_content_and_clips_children() {...@@ -327,10 +357,10 @@ fn wheel_scrolls_within_the_content_and_clips_children() {
327 let layers = ui.layers();357 let layers = ui.layers();
328 assert!(matches!(358 assert!(matches!(
329 layers[..],359 layers[..],
330 [Layer::Primitives {360 [Layer::Primitives(Primitives {
331 clip: Some([0.0, 0.0, 100.0, 100.0]),361 clip: Some([0.0, 0.0, 100.0, 100.0]),
332 ..362 ..
333 }] | []363 })] | []
334 ));364 ));
335}365}
336366
...@@ -424,6 +454,20 @@ fn text_fields_edit_with_keys_and_selection() {...@@ -424,6 +454,20 @@ fn text_fields_edit_with_keys_and_selection() {
424 assert_eq!(text, "");454 assert_eq!(text, "");
425}455}
426456
457#[test]
458fn a_field_focused_whole_selects_its_text_once_for_typing_over() {
459 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
460 let mut text = "New Section 1".to_owned();
461 ui.focus_all(field());
462 field_frame(&mut ui, &mut text);
463 ui.event(typed("K"));
464 field_frame(&mut ui, &mut text);
465 ui.event(typed("i"));
466 let signal = field_frame(&mut ui, &mut text);
467 assert!(signal.focused);
468 assert_eq!(text, "Ki");
469}
470
427#[test]471#[test]
428#[cfg_attr(not(target_vendor = "apple"), ignore = "macOS editing conventions")]472#[cfg_attr(not(target_vendor = "apple"), ignore = "macOS editing conventions")]
429fn text_field_carets_fade_on_appkit_s_blink_and_restart_when_moved() {473fn 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() {...@@ -448,7 +492,7 @@ fn text_field_carets_fade_on_appkit_s_blink_and_restart_when_moved() {
448 .layers()492 .layers()
449 .iter()493 .iter()
450 .flat_map(|layer| match layer {494 .flat_map(|layer| match layer {
451 Layer::Primitives { primitives, .. } => primitives.as_slice(),495 Layer::Primitives(layer) => layer.primitives.as_slice(),
452 Layer::Custom { .. } => &[],496 Layer::Custom { .. } => &[],
453 })497 })
454 .find_map(|primitive| match primitive {498 .find_map(|primitive| match primitive {
...@@ -467,9 +511,16 @@ fn text_field_carets_fade_on_appkit_s_blink_and_restart_when_moved() {...@@ -467,9 +511,16 @@ fn text_field_carets_fade_on_appkit_s_blink_and_restart_when_moved() {
467 assert_eq!(at(&mut ui, 700).0, faded);511 assert_eq!(at(&mut ui, 700).0, faded);
468 assert_eq!(at(&mut ui, 900).0, 0.0);512 assert_eq!(at(&mut ui, 900).0, 0.0);
469 ui.event(key(NamedKey::ArrowRight));513 ui.event(key(NamedKey::ArrowRight));
470 assert_eq!(at(&mut ui, 910), (1.0, Some(Duration::from_micros(1_597_500))));514 assert_eq!(
515 at(&mut ui, 910),
516 (1.0, Some(Duration::from_micros(1_597_500)))
517 );
471 ui.window_focused = false;518 ui.window_focused = false;
472 assert_eq!(at(&mut ui, 920), (0.0, None), "an inactive window shows no caret");519 assert_eq!(
520 at(&mut ui, 920),
521 (0.0, None),
522 "an inactive window shows no caret"
523 );
473}524}
474525
475#[test]526#[test]
...@@ -1397,8 +1448,9 @@ fn a_filtered_menu_eases_to_its_new_height_while_its_rows_appear_in_place() {...@@ -1397,8 +1448,9 @@ fn a_filtered_menu_eases_to_its_new_height_while_its_rows_appear_in_place() {
1397 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);1448 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
1398 build(&mut ui);1449 build(&mut ui);
1399 ui.open_popup(menu_id());1450 ui.open_popup(menu_id());
1400 build(&mut ui);1451 for _ in 0..12 {
1401 build(&mut ui);1452 build(&mut ui);
1453 }
1402 let results = menu_id().child("results");1454 let results = menu_id().child("results");
1403 let height = |ui: &Ui| {1455 let height = |ui: &Ui| {
1404 let rect = ui.rect(results).unwrap();1456 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() {...@@ -1406,7 +1458,7 @@ fn a_filtered_menu_eases_to_its_new_height_while_its_rows_appear_in_place() {
1406 };1458 };
1407 let tall = height(&ui);1459 let tall = height(&ui);
1408 assert!(1460 assert!(
1409 !ui.wants_frame() && tall > 5.0 * 26.0,1461 !ui.wants_frame() && tall > 5.0 * popup::MENU_ROW,
1410 "open at its full height"1462 "open at its full height"
1411 );1463 );
1412 ui.event(typed("tnr"));1464 ui.event(typed("tnr"));
...@@ -1419,13 +1471,556 @@ fn a_filtered_menu_eases_to_its_new_height_while_its_rows_appear_in_place() {...@@ -1419,13 +1471,556 @@ fn a_filtered_menu_eases_to_its_new_height_while_its_rows_appear_in_place() {
1419 "Times New Roman at the top"1471 "Times New Roman at the top"
1420 );1472 );
1421 let easing = height(&ui);1473 let easing = height(&ui);
1422 assert!(easing > 26.0 && easing < tall, "{easing}");1474 assert!(easing > popup::MENU_ROW && easing < tall, "{easing}");
1423 let mut frames = 0;1475 let mut frames = 0;
1424 while ui.wants_frame() {1476 while ui.wants_frame() {
1425 build(&mut ui);1477 build(&mut ui);
1426 frames += 1;1478 frames += 1;
1427 assert_eq!(ui.rect(menu_id().child("rows").child(42_u64)), Some(row));1479 assert_eq!(ui.rect(menu_id().child("rows").child(42_u64)), Some(row));
1428 }1480 }
1429 assert_eq!(height(&ui), 26.0);1481 assert_eq!(height(&ui), popup::MENU_ROW);
1430 assert!(frames <= 10, "settles in {frames} frames");1482 assert!(frames <= 10, "settles in {frames} frames");
1431}1483}
1484
1485/// A column `width` wide holding one label: its box, and the label as laid out.
1486fn fitted(
1487 ui: &mut Ui,
1488 text: &str,
1489 width: f32,
1490 overflow: Overflow,
1491 center: bool,
1492) -> ([f32; 4], Rc<Label>) {
1493 let mut id = None;
1494 frame(ui, |ui| {
1495 ui.open(
1496 "column",
1497 Spec {
1498 axis: Axis::Y,
1499 size: [px(width), children()],
1500 ..Spec::default()
1501 },
1502 );
1503 id = Some(ui.open(
1504 "label",
1505 Spec {
1506 size: [fill(), fit()],
1507 text: Some(text),
1508 overflow,
1509 center,
1510 pad: [4.0, 2.0],
1511 ..Spec::default()
1512 },
1513 ));
1514 ui.close();
1515 ui.close();
1516 });
1517 let node = ui.nodes.iter().find(|node| Some(node.id) == id).unwrap();
1518 (node.rect, node.label.clone().unwrap())
1519}
1520
1521fn advances(label: &Label) -> Vec<f32> {
1522 label
1523 .layout
1524 .lines()
1525 .map(|line| line.metrics().advance)
1526 .collect()
1527}
1528
1529#[test]
1530fn wrapped_labels_break_to_their_width_and_the_box_grows_to_their_height() {
1531 let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK);
1532 let text = "Conflicting changes are highlighted in red. This page cannot be edited.";
1533 let (_, label) = fitted(&mut ui, text, 1000.0, Overflow::Wrap, false);
1534 assert_eq!(
1535 advances(&label).len(),
1536 1,
1537 "a label that fits stays one line"
1538 );
1539 let line = label.size[1];
1540 let mut previous = 2;
1541 for width in [320.0, 200.0, 120.0, 60.0] {
1542 let (rect, label) = fitted(&mut ui, text, width, Overflow::Wrap, false);
1543 let lines = advances(&label);
1544 assert!(lines.len() >= previous, "{width}: {lines:?}");
1545 assert!(
1546 lines.iter().all(|line| *line <= width - 8.0 + 0.01),
1547 "{width}: {lines:?}"
1548 );
1549 assert!((rect[3] - rect[1] - 4.0 - label.size[1]).abs() <= 0.5);
1550 assert!((label.size[1] / line - lines.len() as f32).abs() < 0.1);
1551 assert_eq!(rect[2] - rect[0], width);
1552 previous = lines.len();
1553 }
1554}
1555
1556#[test]
1557fn wrapping_breaks_cjk_emoji_and_words_longer_than_the_line() {
1558 let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK);
1559 for text in [
1560 "東京都の天気は晴れのち曇り、明日は雨が降るでしょう",
1561 "🎉🎈🎂🎁🎊🎉🎈🎂🎁🎊🎉🎈🎂🎁🎊",
1562 "Supercalifragilisticexpialidocious-antidisestablishmentarianism",
1563 ] {
1564 let lines = advances(&fitted(&mut ui, text, 90.0, Overflow::Wrap, false).1);
1565 assert!(lines.len() > 2, "{text}: {lines:?}");
1566 assert!(lines.iter().all(|line| *line <= 82.01), "{text}: {lines:?}");
1567 }
1568}
1569
1570#[test]
1571fn centred_wrapped_lines_centre_across_the_box() {
1572 let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK);
1573 let (_, label) = fitted(
1574 &mut ui,
1575 "A long line that wraps, then short",
1576 150.0,
1577 Overflow::Wrap,
1578 true,
1579 );
1580 let last = label.layout.lines().last().unwrap();
1581 let Some(parley::PositionedLayoutItem::GlyphRun(run)) = last.items().next() else {
1582 panic!("no glyphs");
1583 };
1584 let room = 150.0 - 8.0 - last.metrics().advance;
1585 assert!(
1586 room > 20.0 && (run.offset() - room / 2.0).abs() < 1.0,
1587 "{}",
1588 run.offset()
1589 );
1590}
1591
1592#[test]
1593fn ellipsis_shortens_a_label_to_its_box_and_leaves_one_that_fits() {
1594 let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK);
1595 let text = "Meeting notes from the quarterly planning review";
1596 let (whole, label) = fitted(&mut ui, text, 1000.0, Overflow::Ellipsis, false);
1597 assert_eq!(label.key.0, text);
1598 for width in [200.0, 90.0, 30.0] {
1599 let (rect, label) = fitted(&mut ui, text, width, Overflow::Ellipsis, false);
1600 let (shown, lines) = (&label.key.0, advances(&label));
1601 assert!(shown.ends_with('…') && !shown.ends_with(" …"), "{shown}");
1602 assert!(text.starts_with(shown.trim_end_matches('…')));
1603 assert_eq!(lines.len(), 1);
1604 assert!(lines[0] <= width - 8.0 + 0.01, "{width}: {shown} {lines:?}");
1605 assert_eq!(rect[3] - rect[1], whole[3] - whole[1]);
1606 }
1607 let (_, label) = fitted(
1608 &mut ui,
1609 "東京都の天気は晴れのち曇り",
1610 80.0,
1611 Overflow::Ellipsis,
1612 false,
1613 );
1614 assert!(
1615 label.key.0.ends_with('…') && label.key.0.chars().count() > 1,
1616 "{}",
1617 label.key.0
1618 );
1619}
1620
1621#[test]
1622fn popups_ease_open_and_closed_then_stop_asking_for_frames() {
1623 let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK);
1624 let id = Id::ROOT.child("dialog");
1625 let build = |ui: &mut Ui| {
1626 if ui.popup_open(id) {
1627 ui.leaf(
1628 "dialog",
1629 Spec {
1630 size: [px(100.0), px(50.0)],
1631 fill: Some([1.0; 4]),
1632 anchor: Some(Anchor::Dialog),
1633 ..Spec::default()
1634 },
1635 );
1636 }
1637 };
1638 let motion = |ui: &Ui| {
1639 ui.layers().iter().find_map(|layer| match layer {
1640 Layer::Primitives(primitives) => primitives.motion,
1641 Layer::Custom { .. } => None,
1642 })
1643 };
1644 frame(&mut ui, |ui| {
1645 ui.open_popup(id);
1646 build(ui);
1647 });
1648 let first = motion(&ui).unwrap();
1649 assert!(first.opacity < 0.1 && first.zoom < 0.96 && first.tilt > 0.3);
1650 assert_eq!(
1651 first.pivot,
1652 [200.0, 125.0],
1653 "the dialog swings from its top centre"
1654 );
1655 for _ in 0..16 {
1656 frame(&mut ui, build);
1657 }
1658 assert_eq!(motion(&ui).map(|motion| motion.opacity), Some(1.0));
1659 assert!(!ui.wants_frame());
1660 ui.close_popup(id);
1661 frame(&mut ui, build);
1662 frame(&mut ui, build);
1663 let closing = motion(&ui).unwrap();
1664 assert!(closing.opacity < 1.0 && closing.opacity > 0.0);
1665 assert!(ui.wants_frame());
1666 for _ in 0..10 {
1667 frame(&mut ui, build);
1668 }
1669 assert_eq!(motion(&ui), None);
1670 assert!(!ui.wants_frame());
1671}
1672
1673#[test]
1674fn a_dragged_box_lands_past_the_middles_it_crossed_and_the_rest_slide_aside() {
1675 let spans = [[0.0, 40.0], [40.0, 60.0], [100.0, 20.0], [120.0, 50.0]];
1676 // Still over its own place, box 1 stays; past box 2's middle, it lands after it.
1677 assert_eq!(drop_slot(&spans, 1, 70.0), 1);
1678 assert_eq!(drop_slot(&spans, 1, 111.0), 2);
1679 assert_eq!(drop_slot(&spans, 1, 500.0), 3);
1680 assert_eq!(drop_slot(&spans, 3, -10.0), 0);
1681 assert_eq!(
1682 (0..4)
1683 .map(|index| slide(index, 1, 3, 60.0))
1684 .collect::<Vec<_>>(),
1685 [0.0, 0.0, -60.0, -60.0]
1686 );
1687 assert_eq!(
1688 (0..4)
1689 .map(|index| slide(index, 3, 1, 50.0))
1690 .collect::<Vec<_>>(),
1691 [0.0, 50.0, 50.0, 0.0]
1692 );
1693}
1694
1695/// Clicks the middle of `id`'s box as laid out now, where it must be hit.
1696fn click_box(ui: &mut Ui, id: Id) {
1697 let rect = ui.rect(id).unwrap();
1698 let middle = [(rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0];
1699 assert_eq!(
1700 ui.box_at(middle),
1701 Some(id),
1702 "the box is hit where it is laid out"
1703 );
1704 click(ui, middle);
1705}
1706
1707/// What a dialog's controls reported in a frame.
1708#[derive(Default)]
1709struct Reported {
1710 checked: bool,
1711 ok: bool,
1712 scheme: Option<usize>,
1713}
1714
1715/// A dialog holding a check box, a button, a field and a combo opening a menu of schemes.
1716fn dialog_frame(ui: &mut Ui, text: &mut String) -> Reported {
1717 let mut reported = Reported::default();
1718 frame(ui, |ui| {
1719 let dialog = Id::ROOT.child("dialog");
1720 if !ui.popup_open(dialog) {
1721 return;
1722 }
1723 ui.open_as(
1724 dialog,
1725 Spec {
1726 axis: Axis::Y,
1727 size: [px(240.0), children()],
1728 fill: Some([1.0; 4]),
1729 pad: [8.0, 8.0],
1730 anchor: Some(Anchor::Dialog),
1731 ..Spec::default()
1732 },
1733 );
1734 reported.checked = check_box(ui, "check", "Dark pages", false).clicked;
1735 reported.ok = button(ui, "ok", "OK").clicked;
1736 text_field(
1737 ui,
1738 Id::ROOT.child("name"),
1739 text,
1740 "",
1741 Spec {
1742 size: [fill(), px(26.0)],
1743 ..Spec::default()
1744 },
1745 );
1746 let combo = ui.id("combo");
1747 if shell::combo(ui, "combo", "Light", 120.0).pressed {
1748 ui.open_popup(Id::ROOT.child("schemes"));
1749 }
1750 let items = ["System", "Light", "Dark"].map(|text| popup::Item {
1751 text,
1752 ..popup::Item::default()
1753 });
1754 let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default());
1755 reported.scheme = popup::menu(ui, Id::ROOT.child("schemes"), anchor, &items, None);
1756 ui.close();
1757 });
1758 reported
1759}
1760
1761#[test]
1762fn every_control_in_an_open_dialog_takes_clicks_where_it_rests() {
1763 let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK);
1764 let mut text = String::new();
1765 let dialog = Id::ROOT.child("dialog");
1766 dialog_frame(&mut ui, &mut text);
1767 ui.open_popup(dialog);
1768 let settle = |ui: &mut Ui, text: &mut String| {
1769 for _ in 0..20 {
1770 dialog_frame(ui, text);
1771 }
1772 assert!(!ui.wants_frame());
1773 };
1774 settle(&mut ui, &mut text);
1775 click_box(&mut ui, dialog.child("check"));
1776 assert!(dialog_frame(&mut ui, &mut text).checked);
1777 click_box(&mut ui, dialog.child("ok"));
1778 assert!(dialog_frame(&mut ui, &mut text).ok);
1779 click_box(&mut ui, Id::ROOT.child("name"));
1780 dialog_frame(&mut ui, &mut text);
1781 assert_eq!(ui.focused(), Some(Id::ROOT.child("name")));
1782 ui.event(typed("a"));
1783 dialog_frame(&mut ui, &mut text);
1784 assert_eq!(text, "a");
1785 click_box(&mut ui, dialog.child("combo"));
1786 dialog_frame(&mut ui, &mut text);
1787 let schemes = Id::ROOT.child("schemes");
1788 assert!(ui.popup_open(schemes) && ui.popup_open(dialog));
1789 settle(&mut ui, &mut text);
1790 click_box(&mut ui, schemes.child("rows").child(2_u64));
1791 assert_eq!(dialog_frame(&mut ui, &mut text).scheme, Some(2));
1792 assert!(!ui.popup_open(schemes) && ui.popup_open(dialog));
1793}
1794
1795#[test]
1796fn a_combo_s_field_holds_its_place_as_its_list_opens_and_its_rows_take_clicks() {
1797 let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK);
1798 let fonts = fonts();
1799 let combo = Id::ROOT.child("combo");
1800 let mut chosen = None;
1801 let mut build = |ui: &mut Ui| {
1802 frame(ui, |ui| {
1803 shell::combo(ui, "combo", "Calibri", 120.0);
1804 let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default());
1805 chosen = popup::menu(ui, menu_id(), anchor, &fonts, Some("Font"));
1806 });
1807 chosen
1808 };
1809 build(&mut ui);
1810 ui.open_popup(menu_id());
1811 build(&mut ui);
1812 let field = menu_id().child("filter");
1813 let [box_rect, first] = [ui.rect(combo).unwrap(), ui.rect(field).unwrap()];
1814 assert_eq!(first[..2], box_rect[..2], "the field starts on the box");
1815 assert!(first[2] - first[0] < box_rect[2] - box_rect[0] + 20.0);
1816 let moving: Vec<_> = ui
1817 .layers()
1818 .iter()
1819 .filter_map(|layer| match layer {
1820 Layer::Primitives(layer) => Some(layer.motion.is_some()),
1821 Layer::Custom { .. } => None,
1822 })
1823 .collect();
1824 let panel = moving.iter().position(|moving| *moving).unwrap();
1825 assert!(
1826 moving[panel..].contains(&false),
1827 "the field paints without the popup's motion"
1828 );
1829 for _ in 0..20 {
1830 build(&mut ui);
1831 }
1832 assert!(!ui.wants_frame());
1833 let [panel, last] = [ui.rect(menu_id()).unwrap(), ui.rect(field).unwrap()];
1834 assert_eq!(last[2], panel[2] - 4.0, "the field widens across the popup");
1835 click_box(&mut ui, menu_id().child("rows").child(3_u64));
1836 assert_eq!(build(&mut ui), Some(3));
1837}
1838
1839fn built(ui: &Ui, id: Id) -> &Built {
1840 ui.nodes.iter().find(|node| node.id == id).unwrap()
1841}
1842
1843/// A split button at (100, 100) opening `split_menu()`; its signal.
1844fn split_frame(ui: &mut Ui) -> Signal {
1845 let mut signal = Signal::default();
1846 frame(ui, |ui| {
1847 ui.open(
1848 "bar",
1849 Spec {
1850 flags: Flags::FLOAT,
1851 size: [px(200.0), px(shell::TOOL)],
1852 position: [100.0, 100.0],
1853 ..Spec::default()
1854 },
1855 );
1856 signal = shell::split_button(ui, "split", CHECK_ICON, None, false, split_menu());
1857 let item = popup::Item {
1858 text: "Item",
1859 ..Default::default()
1860 };
1861 popup::menu(ui, split_menu(), BELOW, &[item], None);
1862 ui.close();
1863 });
1864 signal
1865}
1866
1867const CHECK_ICON: &[&str] = popup::CHECK;
1868
1869fn split_menu() -> Id {
1870 Id::ROOT.child("split menu")
1871}
1872
1873fn split_part(part: &str) -> Id {
1874 Id::ROOT.child("bar").child("split").child(part)
1875}
1876
1877fn center(rect: [f32; 4]) -> [f32; 2] {
1878 [(rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0]
1879}
1880
1881#[test]
1882fn a_split_button_fills_its_button_alone_and_outlines_both_halves_from_its_arrow() {
1883 let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK);
1884 split_frame(&mut ui);
1885 let [button, arrow] = [split_part("button"), split_part("menu")];
1886 let face = button.child("face");
1887 let ring = split_part("ring");
1888 let settle = |ui: &mut Ui| {
1889 for _ in 0..60 {
1890 split_frame(ui);
1891 }
1892 };
1893 ui.event(Event::PointerMoved(center(ui.rect(button).unwrap())));
1894 settle(&mut ui);
1895 // The fill rounds past the button's clip, so it meets the arrow square.
1896 let [left, top, right, bottom] = ui.rect(button).unwrap();
1897 assert!(built(&ui, face).fill.is_some());
1898 assert_eq!(ui.rect(face), Some([left, top, right + 4.0, bottom]));
1899 assert!(built(&ui, button).flags.contains(Flags::CLIP));
1900 assert!(built(&ui, ring).border.is_none());
1901 ui.event(Event::PointerMoved(center(ui.rect(arrow).unwrap())));
1902 settle(&mut ui);
1903 assert!(built(&ui, face).fill.is_none());
1904 assert!(built(&ui, ring).border.is_some());
1905 assert_eq!(
1906 ui.rect(ring),
1907 Some([left, top, ui.rect(arrow).unwrap()[2], bottom])
1908 );
1909 // The arrow opens the menu, and the outline stays while it is open.
1910 let point = center(ui.rect(arrow).unwrap());
1911 click(&mut ui, point);
1912 assert!(!split_frame(&mut ui).clicked);
1913 assert!(ui.popup_open(split_menu()));
1914 ui.event(Event::PointerMoved([390.0, 290.0]));
1915 settle(&mut ui);
1916 assert!(built(&ui, ring).border.is_some());
1917 ui.close_popup(split_menu());
1918 settle(&mut ui);
1919 let point = center(ui.rect(button).unwrap());
1920 click(&mut ui, point);
1921 assert!(split_frame(&mut ui).clicked);
1922 assert!(!ui.popup_open(split_menu()));
1923}
1924
1925#[test]
1926fn a_menu_button_opens_its_menu_with_the_icons_under_its_own() {
1927 let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK);
1928 let items = [
1929 popup::Item {
1930 text: "Align left",
1931 icon: Some(CHECK_ICON),
1932 ..Default::default()
1933 },
1934 popup::Item {
1935 text: "Center",
1936 icon: Some(CHECK_ICON),
1937 current: true,
1938 ..Default::default()
1939 },
1940 ];
1941 let build = |ui: &mut Ui| {
1942 frame(ui, |ui| {
1943 ui.open(
1944 "bar",
1945 Spec {
1946 flags: Flags::FLOAT,
1947 size: [px(200.0), px(shell::TOOL)],
1948 position: [100.0, 100.0],
1949 ..Spec::default()
1950 },
1951 );
1952 let anchor = shell::menu_button(ui, "align", CHECK_ICON, menu_id());
1953 popup::menu(ui, menu_id(), anchor, &items, None);
1954 ui.close();
1955 });
1956 };
1957 build(&mut ui);
1958 let button = ui.rect(Id::ROOT.child("bar").child("align")).unwrap();
1959 // One target: the arrow opens the menu as the icon does.
1960 click(&mut ui, [button[2] - 3.0, center(button)[1]]);
1961 for _ in 0..40 {
1962 build(&mut ui);
1963 }
1964 assert!(ui.popup_open(menu_id()));
1965 let icon = ui
1966 .rect(
1967 menu_id()
1968 .child("rows")
1969 .child(0_usize)
1970 .child("item")
1971 .child("icon"),
1972 )
1973 .unwrap();
1974 assert_eq!(icon[0], button[0] + (shell::TOOL - ICON) / 2.0);
1975}
1976
1977#[test]
1978fn galleries_choose_across_their_groups_by_keys_and_clicks() {
1979 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
1980 let gallery = Id::ROOT.child("gallery");
1981 let groups = [("Recent", 2), ("Library", 5)];
1982 let build = |ui: &mut Ui| {
1983 let mut chosen = None;
1984 frame(ui, |ui| {
1985 chosen = popup::gallery(
1986 ui,
1987 gallery,
1988 BELOW,
1989 &groups,
1990 3,
1991 [30.0, 30.0],
1992 Some(3),
1993 |ui, index| {
1994 ui.leaf(
1995 "label",
1996 Spec {
1997 size: [fill(), fill()],
1998 text: Some(["a", "b", "c", "d", "e", "f", "g"][index]),
1999 ..Spec::default()
2000 },
2001 );
2002 },
2003 );
2004 });
2005 chosen
2006 };
2007 build(&mut ui);
2008 ui.open_popup(gallery);
2009 build(&mut ui);
2010 // Keys start from the current cell, the library's second.
2011 for named in [NamedKey::ArrowDown, NamedKey::ArrowLeft, NamedKey::Enter] {
2012 ui.event(key(named));
2013 }
2014 assert_eq!(build(&mut ui), Some(5));
2015 ui.open_popup(gallery);
2016 for _ in 0..40 {
2017 build(&mut ui);
2018 }
2019 let cell = |index: usize| ui.rect(gallery.child(("cell", index))).unwrap();
2020 // The library starts a row of its own under its heading.
2021 assert_eq!(cell(2)[0], cell(0)[0]);
2022 assert!(cell(2)[1] > cell(0)[3]);
2023 let point = center(cell(1));
2024 click(&mut ui, point);
2025 assert_eq!(build(&mut ui), Some(1));
2026}
crates/ui/src/text.rs+54-21
...@@ -1,14 +1,15 @@...@@ -1,14 +1,15 @@
1use draw::{GlyphRun, Glyphs, RenderError};1use draw::{GlyphRun, Glyphs, RenderError};
2use parley::{2use parley::{
3 FontContext, FontFamily, FontFamilyName, GenericFamily, Layout, LayoutContext,3 Alignment, AlignmentOptions, Cluster, FontContext, FontFamily, FontFamilyName, GenericFamily,
4 PositionedLayoutItem, StyleProperty, fontique::Blob,4 Layout, LayoutContext, OverflowWrap, PositionedLayoutItem, StyleProperty, fontique::Blob,
5};5};
6use std::{borrow::Cow, collections::HashMap, rc::Rc, sync::Arc};6use std::{borrow::Cow, collections::HashMap, rc::Rc};
77
8/// A shaped single line of interface text, in logical pixels.8/// Shaped interface text, in logical pixels: a single line unless fitted to a width.
9pub(crate) struct Label {9pub(crate) struct Label {
10 pub layout: Layout<()>,10 pub layout: Layout<()>,
11 pub size: [f32; 2],11 pub size: [f32; 2],
12 pub key: LabelKey,
12}13}
1314
14type LabelKey = (String, u32, bool, Option<String>);15type LabelKey = (String, u32, bool, Option<String>);
...@@ -18,8 +19,6 @@ type LabelKey = (String, u32, bool, Option<String>);...@@ -18,8 +19,6 @@ type LabelKey = (String, u32, bool, Option<String>);
18pub(crate) struct Texts {19pub(crate) struct Texts {
19 fonts: FontContext,20 fonts: FontContext,
20 context: LayoutContext<()>,21 context: LayoutContext<()>,
21 /// A family registered in place of the system's interface font.
22 family: Option<String>,
23 /// By text, size, weight and preferred family, with the frame each was last used.22 /// By text, size, weight and preferred family, with the frame each was last used.
24 cache: HashMap<LabelKey, (Rc<Label>, u64)>,23 cache: HashMap<LabelKey, (Rc<Label>, u64)>,
25}24}
...@@ -34,7 +33,12 @@ impl Texts {...@@ -34,7 +33,12 @@ impl Texts {
34 font: Option<&str>,33 font: Option<&str>,
35 frame: u64,34 frame: u64,
36 ) -> Rc<Label> {35 ) -> Rc<Label> {
37 let key = (text.to_owned(), size.to_bits(), bold, font.map(str::to_owned));36 let key = (
37 text.to_owned(),
38 size.to_bits(),
39 bold,
40 font.map(str::to_owned),
41 );
38 if let Some((label, touched)) = self.cache.get_mut(&key) {42 if let Some((label, touched)) = self.cache.get_mut(&key) {
39 *touched = frame;43 *touched = frame;
40 return label.clone();44 return label.clone();
...@@ -47,7 +51,6 @@ impl Texts {...@@ -47,7 +51,6 @@ impl Texts {
47 [GenericFamily::SystemUi, GenericFamily::SansSerif].map(FontFamilyName::Generic);51 [GenericFamily::SystemUi, GenericFamily::SansSerif].map(FontFamilyName::Generic);
48 let families: Vec<_> = font52 let families: Vec<_> = font
49 .into_iter()53 .into_iter()
50 .chain(self.family.as_deref())
51 .map(FontFamilyName::named)54 .map(FontFamilyName::named)
52 .chain(system)55 .chain(system)
53 .collect();56 .collect();
...@@ -55,29 +58,40 @@ impl Texts {...@@ -55,29 +58,40 @@ impl Texts {
55 families,58 families,
56 ))));59 ))));
57 builder.push_default(StyleProperty::FontSize(size));60 builder.push_default(StyleProperty::FontSize(size));
61 builder.push_default(StyleProperty::OverflowWrap(OverflowWrap::Anywhere));
58 if bold {62 if bold {
59 builder.push_default(StyleProperty::FontWeight(parley::FontWeight::SEMI_BOLD));63 builder.push_default(StyleProperty::FontWeight(parley::FontWeight::SEMI_BOLD));
60 }64 }
61 let mut layout = builder.build(text);65 let mut layout = builder.build(text);
62 layout.break_all_lines(None);66 layout.break_all_lines(None);
63 let size = [layout.full_width(), layout.height()];67 let size = [layout.full_width(), layout.height()];
64 let label = Rc::new(Label { layout, size });68 let label = Rc::new(Label {
69 layout,
70 size,
71 key: key.clone(),
72 });
65 self.cache.insert(key, (label.clone(), frame));73 self.cache.insert(key, (label.clone(), frame));
66 label74 label
67 }75 }
6876
69 /// Shapes labels in the family `files` define, falling back to the system's77 /// `label` cut short to fit `width` with an ellipsis.
70 /// interface font; returns the family's name, or None when the files hold no font.78 pub fn ellipsis(&mut self, label: &Label, width: f32, frame: u64) -> Rc<Label> {
71 pub fn use_fonts(&mut self, files: impl IntoIterator<Item = Vec<u8>>) -> Option<String> {79 let (text, size, bold, font) = &label.key;
72 let collection = &mut self.fonts.collection;80 let (size, font) = (f32::from_bits(*size), font.as_deref());
73 let families: Vec<_> = files81 let room = width - self.label("…", size, *bold, font, frame).size[0];
74 .into_iter()82 let mut end = 0;
75 .flat_map(|file| collection.register_fonts(Blob::new(Arc::new(file)), None))83 let mut advance = 0.0;
76 .collect();84 let mut cluster = Cluster::from_byte_index(&label.layout, 0);
77 let family = collection.family_name(families.first()?.0)?.to_owned();85 while let Some(next) = cluster {
78 self.family = Some(family.clone());86 advance += next.advance();
79 self.cache.clear();87 if advance > room {
80 Some(family)88 break;
89 }
90 end = next.text_range().end;
91 cluster = next.next_logical();
92 }
93 let cut = format!("{}…", text[..end].trim_end());
94 self.label(&cut, size, *bold, font, frame)
81 }95 }
8296
83 pub fn preview_font(&mut self, data: Blob<u8>, family: &str) {97 pub fn preview_font(&mut self, data: Blob<u8>, family: &str) {
...@@ -96,6 +110,25 @@ impl Texts {...@@ -96,6 +110,25 @@ impl Texts {
96 }110 }
97}111}
98112
113impl Label {
114 /// The text broken into lines no wider than `width` and aligned across it.
115 pub fn wrapped(&self, width: f32, center: bool) -> Rc<Label> {
116 let mut layout = self.layout.clone();
117 layout.break_all_lines(Some(width));
118 let alignment = if center {
119 Alignment::Center
120 } else {
121 Alignment::Start
122 };
123 layout.align(alignment, AlignmentOptions::default());
124 Rc::new(Label {
125 size: [width, layout.height()],
126 layout,
127 key: self.key.clone(),
128 })
129 }
130}
131
99impl Glyphs for Label {132impl Glyphs for Label {
100 fn runs(133 fn runs(
101 &self,134 &self,
crates/ui/src/theme.rs+119-1
...@@ -8,7 +8,8 @@ pub struct Theme {...@@ -8,7 +8,8 @@ pub struct Theme {
8 pub base: [f32; 4],8 pub base: [f32; 4],
9 /// Side panels.9 /// Side panels.
10 pub panel: [f32; 4],10 pub panel: [f32; 4],
11 /// The window's title bar, toolbar and tab row.11 /// The window's title bar, toolbar, tab row and sidebar; transparent where the system's
12 /// backdrop shows through them.
12 pub strip: [f32; 4],13 pub strip: [f32; 4],
13 pub accent: [f32; 4],14 pub accent: [f32; 4],
14 /// The text caret, and selected text's fill with and without keyboard focus; the15 /// The text caret, and selected text's fill with and without keyboard focus; the
...@@ -65,6 +66,14 @@ pub struct Section {...@@ -65,6 +66,14 @@ pub struct Section {
65 pub accent: [f32; 4],66 pub accent: [f32; 4],
66}67}
6768
69impl Section {
70 /// A tab under the pointer: most of the way to the accent, which lies past the frame's
71 /// lightness from the tabs in both themes, so it stands out from both.
72 pub fn hover(&self) -> [f32; 4] {
73 crate::mix(self.tab, self.accent, 0.7)
74 }
75}
76
68impl Theme {77impl Theme {
69 /// After File Pilot's default dark scheme.78 /// After File Pilot's default dark scheme.
70 pub fn dark() -> Self {79 pub fn dark() -> Self {
...@@ -123,11 +132,61 @@ impl Theme {...@@ -123,11 +132,61 @@ impl Theme {
123 }132 }
124 }133 }
125134
135 /// The theme over a system material that shows through the strip: dim text, controls
136 /// and fields take the text colour at part opacity, as vibrancy draws them.
137 pub fn over_backdrop(self) -> Self {
138 let text = |alpha| [self.text[0], self.text[1], self.text[2], alpha];
139 Self {
140 strip: [0.0; 4],
141 text_dim: text(0.5),
142 chip: text(0.1),
143 base: text(0.05),
144 ..self
145 }
146 }
147
126 /// Hovered controls: the accent over the base.148 /// Hovered controls: the accent over the base.
127 pub fn hover(&self) -> [f32; 4] {149 pub fn hover(&self) -> [f32; 4] {
128 crate::mix(self.base, self.accent, 0.35)150 crate::mix(self.base, self.accent, 0.35)
129 }151 }
130152
153 /// Colours for icons' slots under a section of `accent`, with `highlight` the colour
154 /// the highlighter applies. The accent keeps its hue but stands as clear of the toolbar's
155 /// lightness as text must; the badge takes the first of green, blue and orange whose hue
156 /// stands clear of the accent's.
157 pub fn icon_palette(&self, accent: [f32; 4], highlight: [f32; 4]) -> draw::Palette {
158 // Over a system material the strip is clear, and the panel has its lightness.
159 let behind = if self.strip[3] == 1.0 {
160 self.strip
161 } else {
162 self.panel
163 };
164 let [under, ..] = draw::oklab(behind);
165 let [lightness, a, b] = draw::oklab(accent);
166 let lightness = if under < 0.5 {
167 lightness.max(under + ICON_CONTRAST)
168 } else {
169 lightness.min(under - ICON_CONTRAST)
170 };
171 let [r, g, b] = draw::from_oklab([lightness, a, b]);
172 let accent = [r, g, b, 1.0];
173 let apart = |a: f32, b: f32| {
174 let turn = (a - b).rem_euclid(360.0);
175 turn.min(360.0 - turn)
176 };
177 let accent_hue = hue(accent);
178 let badge = [135.0, 215.0, 30.0]
179 .into_iter()
180 .find(|badge| apart(*badge, accent_hue) >= 60.0)
181 .expect("Three hues this far apart leave one clear of any other");
182 let rgb = |[r, g, b, _]: [f32; 4]| [r, g, b];
183 draw::Palette {
184 accent: Some(rgb(accent)),
185 highlight: Some(rgb(highlight)),
186 badge: Some(rgb(hsl(badge, 0.65, 0.45))),
187 }
188 }
189
131 /// The colours a section takes from the hue of `color`, linear RGBA.190 /// The colours a section takes from the hue of `color`, linear RGBA.
132 pub fn section(&self, color: [f32; 4]) -> Section {191 pub fn section(&self, color: [f32; 4]) -> Section {
133 let hue = hue(color);192 let hue = hue(color);
...@@ -147,6 +206,9 @@ impl Theme {...@@ -147,6 +206,9 @@ impl Theme {
147 }206 }
148}207}
149208
209/// Least OKLab lightness difference between icons' accent marks and the toolbar.
210const ICON_CONTRAST: f32 = 0.4;
211
150/// The hue in degrees of a linear colour, as it looks in sRGB.212/// The hue in degrees of a linear colour, as it looks in sRGB.
151fn hue(color: [f32; 4]) -> f32 {213fn hue(color: [f32; 4]) -> f32 {
152 let [r, g, b] = [color[0], color[1], color[2]].map(|value| {214 let [r, g, b] = [color[0], color[1], color[2]].map(|value| {
...@@ -195,4 +257,60 @@ mod tests {...@@ -195,4 +257,60 @@ mod tests {
195 assert_eq!(hsl(0.0, 1.0, 0.5), srgb(0xff, 0, 0));257 assert_eq!(hsl(0.0, 1.0, 0.5), srgb(0xff, 0, 0));
196 assert_eq!(hsl(240.0, 0.0, 1.0), srgb(0xff, 0xff, 0xff));258 assert_eq!(hsl(240.0, 0.0, 1.0), srgb(0xff, 0xff, 0xff));
197 }259 }
260
261 #[test]
262 fn icon_badges_stand_clear_of_the_accent() {
263 let badge = |accent_hue: f32| {
264 let palette = Theme::light().icon_palette(hsl(accent_hue, 0.6, 0.45), [1.0; 4]);
265 let [r, g, b] = palette.badge.unwrap();
266 hue([r, g, b, 1.0]).round()
267 };
268 assert_eq!(badge(210.0), 135.0);
269 assert_eq!(badge(120.0), 215.0);
270 assert_eq!(badge(170.0), 30.0);
271 }
272
273 #[test]
274 fn icon_accents_stand_clear_of_the_toolbar() {
275 for theme in [Theme::light(), Theme::dark()] {
276 let [under, ..] = draw::oklab(theme.strip);
277 for hue in (0..360).step_by(15) {
278 let accent = theme.section(hsl(hue as f32, 0.6, 0.6)).accent;
279 let [r, g, b] = theme.icon_palette(accent, [1.0; 4]).accent.unwrap();
280 let [lightness, ..] = draw::oklab([r, g, b, 1.0]);
281 assert!(
282 (lightness - under).abs() >= ICON_CONTRAST - 0.02,
283 "{hue}: {lightness} on {under}"
284 );
285 }
286 }
287 }
288
289 #[test]
290 fn a_hovered_tab_stands_out_from_the_frame_and_its_neighbours() {
291 let lightness = |[r, g, b, _]: [f32; 4]| {
292 let y = 0.2126 * r + 0.7152 * g + 0.0722 * b;
293 116.0 * y.cbrt() - 16.0
294 };
295 for theme in [Theme::light(), Theme::dark()] {
296 for hue in (0..360).step_by(5) {
297 let section = theme.section(hsl(hue as f32, 0.6, 0.6));
298 let hover = lightness(section.hover());
299 for other in [section.frame[0], section.frame[1], section.tab] {
300 assert!((hover - lightness(other)).abs() > 5.0, "{hue}");
301 }
302 }
303 }
304 }
305
306 #[test]
307 fn colours_mix_as_premultiplied_so_transparency_fades_a_colour_in() {
308 let [white, black] = [[1.0; 4], [0.0, 0.0, 0.0, 1.0]];
309 assert_eq!(crate::mix(white, black, 0.25), [0.75, 0.75, 0.75, 1.0]);
310 let accent = Theme::light().accent;
311 let faded = crate::mix([0.0; 4], accent, 0.6);
312 assert!((0..3).all(|channel| (faded[channel] - accent[channel]).abs() < 1e-6));
313 assert!((faded[3] - 0.6).abs() < 1e-6);
314 assert_eq!(crate::mix([0.0; 4], [0.0; 4], 0.5), [0.0; 4]);
315 }
198}316}
crates/ui/src/widgets.rs+24-9
...@@ -44,14 +44,15 @@ pub fn check_box(ui: &mut Ui, part: impl Hash, label: &str, checked: bool) -> Si...@@ -44,14 +44,15 @@ pub fn check_box(ui: &mut Ui, part: impl Hash, label: &str, checked: bool) -> Si
44 ..Spec::default()44 ..Spec::default()
45 },45 },
46 );46 );
47 let hovered = ui.signal(id).hovered;47 // A box's signal is taken once, so its hover and click are read together.
48 let signal = ui.signal(id);
48 ui.leaf(49 ui.leaf(
49 "box",50 "box",
50 Spec {51 Spec {
51 size: [px(side), px(height)],52 size: [px(side), px(height)],
52 inset: [0.0, margin, 0.0, margin],53 inset: [0.0, margin, 0.0, margin],
53 fill: Some(if checked { theme.accent } else { theme.base }),54 fill: Some(if checked { theme.accent } else { theme.base }),
54 border: Some(if checked || hovered {55 border: Some(if checked || signal.hovered {
55 theme.accent56 theme.accent
56 } else {57 } else {
57 theme.chip58 theme.chip
...@@ -71,7 +72,7 @@ pub fn check_box(ui: &mut Ui, part: impl Hash, label: &str, checked: bool) -> Si...@@ -71,7 +72,7 @@ pub fn check_box(ui: &mut Ui, part: impl Hash, label: &str, checked: bool) -> Si
71 },72 },
72 );73 );
73 ui.close();74 ui.close();
74 ui.signal(id)75 signal
75}76}
7677
77/// An overlay scrollbar along the far edge of the current box, for content whose scroll78/// An overlay scrollbar along the far edge of the current box, for content whose scroll
...@@ -151,9 +152,9 @@ pub fn text_field(...@@ -151,9 +152,9 @@ pub fn text_field(
151 .zip(ui.rect(id))152 .zip(ui.rect(id))
152 .map(|(pointer, rect)| pointer[0] - rect[0] - pad)153 .map(|(pointer, rect)| pointer[0] - rect[0] - pad)
153 .filter(|_| signal.pressed || signal.dragging);154 .filter(|_| signal.pressed || signal.dragging);
154 let (mut selection, mut press) = {155 let (mut selection, mut press, select_all) = {
155 let (selection, press) = ui.field(id);156 let (selection, press, select_all) = ui.field(id);
156 (*selection, *press)157 (*selection, *press, std::mem::take(select_all))
157 };158 };
158 let before = [selection.anchor(), selection.focus()];159 let before = [selection.anchor(), selection.focus()];
159 let (texts, frame) = ui.texts();160 let (texts, frame) = ui.texts();
...@@ -181,7 +182,14 @@ pub fn text_field(...@@ -181,7 +182,14 @@ pub fn text_field(
181 Selection::new(anchor, focus)182 Selection::new(anchor, focus)
182 };183 };
183 }184 }
184 selection = selection.refresh(&label.layout);185 selection = if select_all {
186 Selection::new(
187 Cursor::from_byte_index(&label.layout, 0, Affinity::Downstream),
188 Cursor::from_byte_index(&label.layout, usize::MAX, Affinity::Upstream),
189 )
190 } else {
191 selection.refresh(&label.layout)
192 };
185 for event in &signal.events {193 for event in &signal.events {
186 let layout = &label.layout;194 let layout = &label.layout;
187 let (range, inserted) = match event {195 let (range, inserted) = match event {
...@@ -192,7 +200,14 @@ pub fn text_field(...@@ -192,7 +200,14 @@ pub fn text_field(
192 // page keys to scroll.200 // page keys to scroll.
193 let command = match (Command::from_key(&key, modifiers), &key) {201 let command = match (Command::from_key(&key, modifiers), &key) {
194 (Some(Command::Kill), _) => Some(Command::DeleteTo(Movement::LineEnd)),202 (Some(Command::Kill), _) => Some(Command::DeleteTo(Movement::LineEnd)),
195 (Some(Command::ScrollPage { .. } | Command::MovePage { .. }), _) => None,203 (
204 Some(
205 Command::ScrollPage { .. }
206 | Command::MovePage { .. }
207 | Command::MoveParagraphs { .. },
208 ),
209 _,
210 ) => None,
196 (None, edit::Key::Named(edit::NamedKey::Home)) => {211 (None, edit::Key::Named(edit::NamedKey::Home)) => {
197 Some(Command::Move(Movement::DocumentStart))212 Some(Command::Move(Movement::DocumentStart))
198 }213 }
...@@ -254,7 +269,7 @@ pub fn text_field(...@@ -254,7 +269,7 @@ pub fn text_field(
254 }269 }
255 let moved = signal.pressed || before != [selection.anchor(), selection.focus()];270 let moved = signal.pressed || before != [selection.anchor(), selection.focus()];
256 {271 {
257 let (stored, stored_press) = ui.field(id);272 let (stored, stored_press, _) = ui.field(id);
258 *stored = selection;273 *stored = selection;
259 *stored_press = press;274 *stored_press = press;
260 }275 }
vendor/sctk-adwaita/CHANGELOG.md created+67
...@@ -0,0 +1,67 @@
1## [Unreleased]
2
3## 0.10.1
4- Panic hardening of ab-glyph (#64)
5
6## 0.10.0
7- Bump sctk to 0.19
8
9## 0.9.0
10- Bump crossfont to 0.8.0 (#60)
11
12## 0.8.1
13- Fix `ab_glyph` renderer panicking with integer scale factor 3 (#50)
14- Improved roundness of headerbar (#51)
15
16## 0.8.0
17- **Breaking:** `AdwaitaFrame::new` now takes `Arc<CompositorState>` as an argument
18- Fix leftmost title pixel sometimes being cut off (#45)
19- Fix transparency in ab_glyph renderer (#44)
20- Extended resize corners (#47)
21- Center maximize icon (#46)
22- Window shadows (#43)
23
24### Dependencies updates
25- Bump crossfont to 0.6.0 (#52)
26
27## 0.7.0
28- **Breaking:** `wayland-csd-frame` is now used as a part of the public interface.
29
30## 0.6.1
31- Bump tiny-skia to v0.11 (#32)
32- cleanup: Remove debug println (#29)
33- Support custom header buttons layouts (#30)
34- The double click threshold value was raised to `400ms`
35
36## 0.6.0
37- Update the `smithay-client-toolkit` to `0.17.0`
38- Don't use tiny-skia's default features
39
40## 0.5.4
41- Timeout dbus call to settings portal (100ms)
42
43## 0.5.3
44- `ab_glyph` titles will read the system title font using memory mapped buffers instead of reading to heap.
45 Lowers RAM usage.
46- Improve titlebar-font config parsing to correctly handle more font names.
47
48## 0.5.2
49- `ab_glyph` & `crossfont` titles will use gnome "titlebar-font" config if available.
50- `ab_glyph` titles are now more consistent with `crossfont` titles both using system sans
51 if no better font config is available.
52- Rounded corners are now disabled on maximized and tiled windows.
53- Double click interval is now 400ms (as previous 1s interval was caused by bug fixed in 0.5.1)
54
55## 0.5.1
56- Use dbus org.freedesktop.portal.Settings to automatically choose light or dark theming.
57- Double click detection fix.
58- Apply button click on release instead of press.
59
60## 0.5.0
61- `title` feature got removed
62- `ab_glyph` default feature got added (for `ab_glyph` based title rendering)
63- `crossfont` feature got added (for `crossfont` based title rendering)
64 - Can be enable like this:
65 ```toml
66 sctk-adwaita = { default-features = false, features = ["crossfont"] }
67 ```
vendor/sctk-adwaita/Cargo.toml created+61
...@@ -0,0 +1,61 @@
1# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
2#
3# When uploading crates to the registry Cargo will automatically
4# "normalize" Cargo.toml files for maximal compatibility
5# with all versions of Cargo and also rewrite `path` dependencies
6# to registry (e.g., crates.io) dependencies.
7#
8# If you are reading this file be aware that the original Cargo.toml
9# will likely look very different (and much more reasonable).
10# See Cargo.toml.orig for the original contents.
11
12[package]
13edition = "2021"
14name = "sctk-adwaita"
15version = "0.10.1"
16authors = ["Poly <marynczak.bartlomiej@gmail.com>"]
17description = "Adwaita-like SCTK Frame"
18documentation = "https://docs.rs/sctk-adwaita"
19readme = "README.md"
20keywords = ["sctk"]
21license = "MIT"
22repository = "https://github.com/PolyMeilex/sctk-adwaita"
23
24[dependencies.ab_glyph]
25version = "0.2.17"
26optional = true
27
28[dependencies.crossfont]
29version = "0.8.0"
30optional = true
31
32[dependencies.log]
33version = "0.4"
34
35[dependencies.memmap2]
36version = "0.9.0"
37optional = true
38
39[dependencies.smithay-client-toolkit]
40version = "0.19.0"
41default_features = false
42
43[dependencies.tiny-skia]
44version = "0.11"
45features = [
46 "std",
47 "simd",
48]
49default-features = false
50
51[features]
52ab_glyph = [
53 "dep:ab_glyph",
54 "memmap2",
55]
56crossfont = ["dep:crossfont"]
57default = ["ab_glyph"]
58
59# Upstream predates this lint; a path dependency's warnings are not capped.
60[lints.rust]
61mismatched_lifetime_syntaxes = "allow"
vendor/sctk-adwaita/LICENSE created+21
...@@ -0,0 +1,21 @@
1MIT License
2
3Copyright (c) 2022 Bartłomiej Maryńczak
4
5Permission is hereby granted, free of charge, to any person obtaining a copy
6of this software and associated documentation files (the "Software"), to deal
7in the Software without restriction, including without limitation the rights
8to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9copies of the Software, and to permit persons to whom the Software is
10furnished to do so, subject to the following conditions:
11
12The above copyright notice and this permission notice shall be included in all
13copies or substantial portions of the Software.
14
15THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21SOFTWARE.
vendor/sctk-adwaita/README.md created+19
...@@ -0,0 +1,19 @@
1# Adwaita-like SCTK Frame
2
3| | |
4|---|---|
5|![active](https://i.imgur.com/WdO8e0i.png)|![hover](https://i.imgur.com/TkUq2WF.png)|
6![inactive](https://i.imgur.com/MTFdSjK.png)|
7
8### Dark mode:
9![image](https://user-images.githubusercontent.com/20758186/169424673-3b9fa022-f112-4928-8360-305a714ba979.png)
10
11## Title text: ab_glyph
12By default title text is drawn with _ab_glyph_ crate. This can be disabled by disabling default features.
13
14## Title text: crossfont
15Alternatively title text may be drawn with _crossfont_ crate. This adds a requirement on _freetype_.
16
17```toml
18sctk-adwaita = { default-features = false, features = ["crossfont"] }
19```
vendor/sctk-adwaita/SNOWBOUND.md created+34
...@@ -0,0 +1,34 @@
1# sctk-adwaita, vendored
2
3Upstream: [sctk-adwaita](https://github.com/PolyMeilex/sctk-adwaita) 0.10.1 from
4crates.io (the version winit resolves to in `Cargo.lock`), copied without its
5`examples/`, `Cargo.lock` and CI files. The workspace `Cargo.toml` points
6`[patch.crates-io]` here.
7
8`Cargo.toml` gains a `[lints.rust]` table silencing a lint newer than the
9release, since Cargo does not cap a path dependency's warnings. The one source
10change drops the 1 px border line under the header bar, so the title
11bar flows into Snowbound's toolbar as it does in a GTK 4 app:
12
13```diff
14--- a/src/lib.rs
15+++ b/src/lib.rs
16@@ fn draw_headerbar_bg(
17 None,
18 );
19
20- pixmap.fill_rect(
21- Rect::from_xywh(0., h - 1., w, h)?,
22- &colors.border_paint(),
23- Transform::identity(),
24- None,
25- );
26-
27 Some(())
28 }
29```
30
31To bump: copy the new release's `src/`, `Cargo.toml`, `LICENSE`, `README.md`
32and `CHANGELOG.md` over this folder, reapply the diff, and update the version
33above. The patch applies only while the version here satisfies winit's
34requirement; `cargo tree -i sctk-adwaita` shows the path source when it does.
vendor/sctk-adwaita/src/buttons.rs created+382
...@@ -0,0 +1,382 @@
1use log::{debug, warn};
2use smithay_client_toolkit::reexports::csd_frame::{WindowManagerCapabilities, WindowState};
3use tiny_skia::{FillRule, PathBuilder, PixmapMut, Rect, Stroke, Transform};
4
5use crate::{theme::ColorMap, Location, SkiaResult};
6
7/// The size of the button on the header bar in logical points.
8const BUTTON_SIZE: f32 = 24.;
9const BUTTON_MARGIN: f32 = 5.;
10const BUTTON_SPACING: f32 = 13.;
11
12#[derive(Debug)]
13pub(crate) struct Buttons {
14 // Sorted by order vec of buttons for the left and right sides
15 buttons_left: Vec<Button>,
16 buttons_right: Vec<Button>,
17 layout_config: Option<(String, String)>,
18}
19
20type ButtonLayout = (Vec<Button>, Vec<Button>);
21
22impl Default for Buttons {
23 fn default() -> Self {
24 let (buttons_left, buttons_right) = Buttons::get_default_buttons_layout();
25
26 Self {
27 buttons_left,
28 buttons_right,
29 layout_config: None,
30 }
31 }
32}
33
34impl Buttons {
35 pub fn new(layout_config: Option<(String, String)>) -> Self {
36 match Buttons::parse_button_layout(layout_config.clone()) {
37 Some((buttons_left, buttons_right)) => Self {
38 buttons_left,
39 buttons_right,
40 layout_config,
41 },
42 _ => Self::default(),
43 }
44 }
45
46 /// Rearrange the buttons with the new width.
47 pub fn arrange(&mut self, width: u32, margin_h: f32) {
48 let mut left_x = BUTTON_MARGIN + margin_h;
49 let mut right_x = width as f32 - BUTTON_MARGIN;
50
51 for button in &mut self.buttons_left {
52 button.offset = left_x;
53
54 // Add the button size plus spacing
55 left_x += BUTTON_SIZE + BUTTON_SPACING;
56 }
57
58 for button in &mut self.buttons_right {
59 // Subtract the button size.
60 right_x -= BUTTON_SIZE;
61
62 // Update it
63 button.offset = right_x;
64
65 // Subtract spacing for the next button.
66 right_x -= BUTTON_SPACING;
67 }
68 }
69
70 /// Find the coordinate of the button.
71 pub fn find_button(&self, x: f64, y: f64) -> Location {
72 let x = x as f32;
73 let y = y as f32;
74 let buttons = self.buttons_left.iter().chain(self.buttons_right.iter());
75
76 for button in buttons {
77 if button.contains(x, y) {
78 return Location::Button(button.kind);
79 }
80 }
81
82 Location::Head
83 }
84
85 pub fn update_wm_capabilities(&mut self, wm_capabilites: WindowManagerCapabilities) {
86 let supports_maximize = wm_capabilites.contains(WindowManagerCapabilities::MAXIMIZE);
87 let supports_minimize = wm_capabilites.contains(WindowManagerCapabilities::MINIMIZE);
88
89 self.update_buttons(supports_maximize, supports_minimize);
90 }
91
92 pub fn update_buttons(&mut self, supports_maximize: bool, supports_minimize: bool) {
93 let is_supported = |button: &Button| match button.kind {
94 ButtonKind::Close => true,
95 ButtonKind::Maximize => supports_maximize,
96 ButtonKind::Minimize => supports_minimize,
97 };
98
99 let (buttons_left, buttons_right) =
100 Buttons::parse_button_layout(self.layout_config.clone())
101 .unwrap_or_else(Buttons::get_default_buttons_layout);
102
103 self.buttons_left = buttons_left.into_iter().filter(is_supported).collect();
104 self.buttons_right = buttons_right.into_iter().filter(is_supported).collect();
105 }
106
107 pub fn right_buttons_start_x(&self) -> Option<f32> {
108 self.buttons_right.last().map(|button| button.x())
109 }
110
111 pub fn left_buttons_end_x(&self) -> Option<f32> {
112 self.buttons_left.last().map(|button| button.end_x())
113 }
114
115 #[allow(clippy::too_many_arguments)]
116 pub fn draw(
117 &self,
118 start_x: f32,
119 end_x: f32,
120 scale: f32,
121 colors: &ColorMap,
122 mouse_location: Location,
123 pixmap: &mut PixmapMut,
124 resizable: bool,
125 state: &WindowState,
126 ) {
127 let left_buttons_right_limit =
128 self.right_buttons_start_x().unwrap_or(end_x).min(end_x) - BUTTON_SPACING;
129 let buttons_left = self.buttons_left.iter().map(|x| (x, Side::Left));
130 let buttons_right = self.buttons_right.iter().map(|x| (x, Side::Right));
131
132 for (button, side) in buttons_left.chain(buttons_right) {
133 let is_visible = button.x() > start_x && button.end_x() < end_x
134 // If we have buttons from both sides and they overlap, prefer the right side
135 && (side == Side::Right || button.end_x() < left_buttons_right_limit);
136
137 if is_visible {
138 button.draw(scale, colors, mouse_location, pixmap, resizable, state);
139 }
140 }
141 }
142
143 fn parse_button_layout(sides: Option<(String, String)>) -> Option<ButtonLayout> {
144 let Some((left_side, right_side)) = sides else {
145 return None;
146 };
147
148 let buttons_left = Buttons::parse_button_layout_side(left_side, Side::Left);
149 let buttons_right = Buttons::parse_button_layout_side(right_side, Side::Right);
150
151 if buttons_left.is_empty() && buttons_right.is_empty() {
152 warn!("No valid buttons found in configuration");
153 return None;
154 }
155
156 Some((buttons_left, buttons_right))
157 }
158
159 fn parse_button_layout_side(config: String, side: Side) -> Vec<Button> {
160 let mut buttons: Vec<Button> = vec![];
161
162 for button in config.split(',').take(3) {
163 let button_kind = match button {
164 "close" => ButtonKind::Close,
165 "maximize" => ButtonKind::Maximize,
166 "minimize" => ButtonKind::Minimize,
167 "appmenu" => {
168 debug!("Ignoring \"appmenu\" button");
169 continue;
170 }
171 _ => {
172 warn!("Ignoring unknown button type: {button}");
173 continue;
174 }
175 };
176
177 buttons.push(Button::new(button_kind));
178 }
179
180 // For the right side, we need to revert the order
181 if side == Side::Right {
182 buttons.into_iter().rev().collect()
183 } else {
184 buttons
185 }
186 }
187
188 fn get_default_buttons_layout() -> ButtonLayout {
189 (
190 vec![],
191 vec![
192 Button::new(ButtonKind::Close),
193 Button::new(ButtonKind::Maximize),
194 Button::new(ButtonKind::Minimize),
195 ],
196 )
197 }
198}
199
200#[derive(Debug, Clone)]
201pub(crate) struct Button {
202 /// The button offset into the header bar canvas.
203 offset: f32,
204 /// The kind of the button.
205 kind: ButtonKind,
206}
207
208impl Button {
209 pub fn new(kind: ButtonKind) -> Self {
210 Self { offset: 0., kind }
211 }
212
213 pub fn radius(&self) -> f32 {
214 BUTTON_SIZE / 2.0
215 }
216
217 pub fn x(&self) -> f32 {
218 self.offset
219 }
220
221 pub fn center_x(&self) -> f32 {
222 self.offset + self.radius()
223 }
224
225 pub fn center_y(&self) -> f32 {
226 BUTTON_MARGIN + self.radius()
227 }
228
229 pub fn end_x(&self) -> f32 {
230 self.offset + BUTTON_SIZE
231 }
232
233 fn contains(&self, x: f32, y: f32) -> bool {
234 x > self.offset
235 && x < self.offset + BUTTON_SIZE
236 && y > BUTTON_MARGIN
237 && y < BUTTON_MARGIN + BUTTON_SIZE
238 }
239
240 pub fn draw(
241 &self,
242 scale: f32,
243 colors: &ColorMap,
244 mouse_location: Location,
245 pixmap: &mut PixmapMut,
246 resizable: bool,
247 state: &WindowState,
248 ) -> SkiaResult {
249 let button_bg = if mouse_location == Location::Button(self.kind)
250 && (resizable || self.kind != ButtonKind::Maximize)
251 {
252 colors.button_hover_paint()
253 } else {
254 colors.button_idle_paint()
255 };
256
257 // Convert to pixels.
258 let x = self.center_x() * scale;
259 let y = self.center_y() * scale;
260 let radius = self.radius() * scale;
261
262 // Draw the button background.
263 let circle = PathBuilder::from_circle(x, y, radius)?;
264 pixmap.fill_path(
265 &circle,
266 &button_bg,
267 FillRule::Winding,
268 Transform::identity(),
269 None,
270 );
271
272 let mut button_icon_paint = colors.button_icon_paint();
273 // Do AA only for diagonal lines.
274 button_icon_paint.anti_alias = self.kind == ButtonKind::Close;
275
276 // Draw the icon.
277 match self.kind {
278 ButtonKind::Close => {
279 let x_icon = {
280 let size = 3.5 * scale;
281 let mut pb = PathBuilder::new();
282
283 {
284 let sx = x - size;
285 let sy = y - size;
286 let ex = x + size;
287 let ey = y + size;
288
289 pb.move_to(sx, sy);
290 pb.line_to(ex, ey);
291 pb.close();
292 }
293
294 {
295 let sx = x - size;
296 let sy = y + size;
297 let ex = x + size;
298 let ey = y - size;
299
300 pb.move_to(sx, sy);
301 pb.line_to(ex, ey);
302 pb.close();
303 }
304
305 pb.finish()?
306 };
307
308 pixmap.stroke_path(
309 &x_icon,
310 &button_icon_paint,
311 &Stroke {
312 width: 1.1 * scale,
313 ..Default::default()
314 },
315 Transform::identity(),
316 None,
317 );
318 }
319 ButtonKind::Maximize => {
320 let path2 = {
321 let size = 8.0 * scale;
322 let hsize = size / 2.0;
323 let mut pb = PathBuilder::new();
324
325 let x = x - hsize;
326 let y = y - hsize;
327 if state.contains(WindowState::MAXIMIZED) {
328 let offset = 2.0 * scale;
329 if let Some(rect) =
330 Rect::from_xywh(x, y + offset, size - offset, size - offset)
331 {
332 pb.push_rect(rect);
333 pb.move_to(rect.left() + offset, rect.top() - offset);
334 pb.line_to(rect.right() + offset, rect.top() - offset);
335 pb.line_to(rect.right() + offset, rect.bottom() - offset + 0.5);
336 }
337 } else if let Some(rect) = Rect::from_xywh(x, y, size, size) {
338 pb.push_rect(rect);
339 }
340
341 pb.finish()?
342 };
343
344 pixmap.stroke_path(
345 &path2,
346 &button_icon_paint,
347 &Stroke {
348 width: 1.0 * scale,
349 ..Default::default()
350 },
351 Transform::identity(),
352 None,
353 );
354 }
355 ButtonKind::Minimize => {
356 let len = 8.0 * scale;
357 let hlen = len / 2.0;
358 pixmap.fill_rect(
359 Rect::from_xywh(x - hlen, y + hlen, len, scale)?,
360 &button_icon_paint,
361 Transform::identity(),
362 None,
363 );
364 }
365 }
366
367 Some(())
368 }
369}
370
371#[derive(Debug, Copy, Clone, PartialEq, Eq)]
372pub enum ButtonKind {
373 Close,
374 Maximize,
375 Minimize,
376}
377
378#[derive(Debug, Copy, Clone, PartialEq, Eq)]
379pub enum Side {
380 Left,
381 Right,
382}
vendor/sctk-adwaita/src/config.rs created+56
...@@ -0,0 +1,56 @@
1//! System configuration.
2use std::process::Command;
3
4/// Query system to see if dark theming should be preferred.
5pub(crate) fn prefer_dark() -> bool {
6 // outputs something like: `variant variant uint32 1`
7 let stdout = Command::new("dbus-send")
8 .arg("--reply-timeout=100")
9 .arg("--print-reply=literal")
10 .arg("--dest=org.freedesktop.portal.Desktop")
11 .arg("/org/freedesktop/portal/desktop")
12 .arg("org.freedesktop.portal.Settings.Read")
13 .arg("string:org.freedesktop.appearance")
14 .arg("string:color-scheme")
15 .output()
16 .ok()
17 .and_then(|out| String::from_utf8(out.stdout).ok());
18
19 if matches!(stdout, Some(ref s) if s.is_empty()) {
20 log::error!("XDG Settings Portal did not return response in time: timeout: 100ms, key: color-scheme");
21 }
22
23 matches!(stdout, Some(s) if s.trim().ends_with("uint32 1"))
24}
25
26/// Query system configuration for buttons layout.
27/// Should be updated to use standard xdg-desktop-portal specs once available
28/// https://github.com/flatpak/xdg-desktop-portal/pull/996
29pub(crate) fn get_button_layout_config() -> Option<(String, String)> {
30 let config_string = Command::new("dbus-send")
31 .arg("--reply-timeout=100")
32 .arg("--print-reply=literal")
33 .arg("--dest=org.freedesktop.portal.Desktop")
34 .arg("/org/freedesktop/portal/desktop")
35 .arg("org.freedesktop.portal.Settings.Read")
36 .arg("string:org.gnome.desktop.wm.preferences")
37 .arg("string:button-layout")
38 .output()
39 .ok()
40 .and_then(|out| String::from_utf8(out.stdout).ok())?;
41
42 let sides_split: Vec<_> = config_string
43 // Taking last word
44 .rsplit(' ')
45 .next()?
46 // Split by left/right side
47 .split(':')
48 // Only two sides
49 .take(2)
50 .collect();
51
52 match sides_split.as_slice() {
53 [left, right] => Some((left.to_string(), right.to_string())),
54 _ => None,
55 }
56}
vendor/sctk-adwaita/src/lib.rs created+773
...@@ -0,0 +1,773 @@
1use std::error::Error;
2use std::mem;
3use std::num::NonZeroU32;
4use std::sync::Arc;
5use std::time::Duration;
6
7use tiny_skia::{
8 Color, FillRule, Mask, Path, PathBuilder, Pixmap, PixmapMut, PixmapPaint, Point, Rect,
9 Transform,
10};
11
12use smithay_client_toolkit::reexports::client::backend::ObjectId;
13use smithay_client_toolkit::reexports::client::protocol::wl_shm;
14use smithay_client_toolkit::reexports::client::protocol::wl_subsurface::WlSubsurface;
15use smithay_client_toolkit::reexports::client::protocol::wl_surface::WlSurface;
16use smithay_client_toolkit::reexports::client::{Dispatch, Proxy, QueueHandle};
17use smithay_client_toolkit::reexports::csd_frame::{
18 CursorIcon, DecorationsFrame, FrameAction, FrameClick, WindowManagerCapabilities, WindowState,
19};
20
21use smithay_client_toolkit::compositor::{CompositorState, Region, SurfaceData};
22use smithay_client_toolkit::shell::WaylandSurface;
23use smithay_client_toolkit::shm::{slot::SlotPool, Shm};
24use smithay_client_toolkit::subcompositor::SubcompositorState;
25use smithay_client_toolkit::subcompositor::SubsurfaceData;
26
27mod buttons;
28mod config;
29mod parts;
30mod pointer;
31mod shadow;
32pub mod theme;
33mod title;
34mod wl_typed;
35
36use crate::theme::{
37 ColorMap, ColorTheme, BORDER_SIZE, CORNER_RADIUS, HEADER_SIZE, RESIZE_HANDLE_CORNER_SIZE,
38 VISIBLE_BORDER_SIZE,
39};
40
41use buttons::Buttons;
42use config::get_button_layout_config;
43use parts::DecorationParts;
44use pointer::{Location, MouseState};
45use shadow::Shadow;
46use title::TitleText;
47use wl_typed::WlTyped;
48
49/// XXX this is not result, so `must_use` when needed.
50type SkiaResult = Option<()>;
51
52/// A simple set of decorations
53#[derive(Debug)]
54pub struct AdwaitaFrame<State> {
55 /// The base surface used to create the window.
56 base_surface: WlTyped<WlSurface, SurfaceData>,
57
58 compositor: Arc<CompositorState>,
59
60 /// Subcompositor to create/drop subsurfaces ondemand.
61 subcompositor: Arc<SubcompositorState>,
62
63 /// Queue handle to perform object creation.
64 queue_handle: QueueHandle<State>,
65
66 /// The drawable decorations, `None` when hidden.
67 decorations: Option<DecorationParts>,
68
69 /// Memory pool to allocate the buffers for the decorations.
70 pool: SlotPool,
71
72 /// Whether the frame should be redrawn.
73 dirty: bool,
74
75 /// Whether the drawing should be synced with the main surface.
76 should_sync: bool,
77
78 /// Scale factor used for the surface.
79 scale_factor: u32,
80
81 /// Wether the frame is resizable.
82 resizable: bool,
83
84 buttons: Buttons,
85 state: WindowState,
86 wm_capabilities: WindowManagerCapabilities,
87 mouse: MouseState,
88 theme: ColorTheme,
89 title: Option<String>,
90 title_text: Option<TitleText>,
91 shadow: Shadow,
92}
93
94impl<State> AdwaitaFrame<State>
95where
96 State: Dispatch<WlSurface, SurfaceData> + Dispatch<WlSubsurface, SubsurfaceData> + 'static,
97{
98 pub fn new(
99 base_surface: &impl WaylandSurface,
100 shm: &Shm,
101 compositor: Arc<CompositorState>,
102 subcompositor: Arc<SubcompositorState>,
103 queue_handle: QueueHandle<State>,
104 frame_config: FrameConfig,
105 ) -> Result<Self, Box<dyn Error>> {
106 let base_surface = WlTyped::wrap::<State>(base_surface.wl_surface().clone());
107
108 let pool = SlotPool::new(1, shm)?;
109
110 let decorations = Some(DecorationParts::new(
111 &base_surface,
112 &subcompositor,
113 &queue_handle,
114 ));
115
116 let theme = frame_config.theme;
117
118 Ok(AdwaitaFrame {
119 base_surface,
120 decorations,
121 pool,
122 compositor,
123 subcompositor,
124 queue_handle,
125 dirty: true,
126 scale_factor: 1,
127 should_sync: true,
128 title: None,
129 title_text: TitleText::new(theme.active.font_color),
130 theme,
131 buttons: Buttons::new(get_button_layout_config()),
132 mouse: Default::default(),
133 state: WindowState::empty(),
134 wm_capabilities: WindowManagerCapabilities::all(),
135 resizable: true,
136 shadow: Shadow::default(),
137 })
138 }
139
140 /// Update the current frame config.
141 pub fn set_config(&mut self, config: FrameConfig) {
142 self.theme = config.theme;
143 self.dirty = true;
144 }
145
146 fn precise_location(
147 &self,
148 location: Location,
149 decoration: &DecorationParts,
150 x: f64,
151 y: f64,
152 ) -> Location {
153 let header_width = decoration.header().surface_rect.width;
154 let side_height = decoration.side_height();
155
156 let left_corner_x = BORDER_SIZE + RESIZE_HANDLE_CORNER_SIZE;
157 let right_corner_x = (header_width + BORDER_SIZE).saturating_sub(RESIZE_HANDLE_CORNER_SIZE);
158 let top_corner_y = RESIZE_HANDLE_CORNER_SIZE;
159 let bottom_corner_y = side_height.saturating_sub(RESIZE_HANDLE_CORNER_SIZE);
160 match location {
161 Location::Head | Location::Button(_) => self.buttons.find_button(x, y),
162 Location::Top | Location::TopLeft | Location::TopRight => {
163 if x <= f64::from(left_corner_x) {
164 Location::TopLeft
165 } else if x >= f64::from(right_corner_x) {
166 Location::TopRight
167 } else {
168 Location::Top
169 }
170 }
171 Location::Bottom | Location::BottomLeft | Location::BottomRight => {
172 if x <= f64::from(left_corner_x) {
173 Location::BottomLeft
174 } else if x >= f64::from(right_corner_x) {
175 Location::BottomRight
176 } else {
177 Location::Bottom
178 }
179 }
180 Location::Left => {
181 if y <= f64::from(top_corner_y) {
182 Location::TopLeft
183 } else if y >= f64::from(bottom_corner_y) {
184 Location::BottomLeft
185 } else {
186 Location::Left
187 }
188 }
189 Location::Right => {
190 if y <= f64::from(top_corner_y) {
191 Location::TopRight
192 } else if y >= f64::from(bottom_corner_y) {
193 Location::BottomRight
194 } else {
195 Location::Right
196 }
197 }
198 other => other,
199 }
200 }
201
202 fn redraw_inner(&mut self) -> Option<bool> {
203 let decorations = self.decorations.as_mut()?;
204
205 // Reset the dirty bit.
206 self.dirty = false;
207 let should_sync = mem::take(&mut self.should_sync);
208
209 // Don't draw borders if the frame explicitly hidden or fullscreened.
210 if self.state.contains(WindowState::FULLSCREEN) {
211 decorations.hide();
212 return Some(true);
213 }
214
215 let colors = if self.state.contains(WindowState::ACTIVATED) {
216 &self.theme.active
217 } else {
218 &self.theme.inactive
219 };
220
221 let draw_borders = if self.state.contains(WindowState::MAXIMIZED) {
222 // Don't draw the borders.
223 decorations.hide_borders();
224 false
225 } else {
226 true
227 };
228 let border_paint = colors.border_paint();
229
230 // Draw the borders.
231 for (idx, part) in decorations
232 .parts()
233 .filter(|(idx, _)| *idx == DecorationParts::HEADER || draw_borders)
234 {
235 let scale = self.scale_factor;
236
237 let mut rect = part.surface_rect;
238 // XXX to perfectly align the visible borders we draw them with
239 // the header, otherwise rounded corners won't look 'smooth' at the
240 // start. To achieve that, we enlargen the width of the header by
241 // 2 * `VISIBLE_BORDER_SIZE`, and move `x` by `VISIBLE_BORDER_SIZE`
242 // to the left.
243 if idx == DecorationParts::HEADER && draw_borders {
244 rect.width += 2 * VISIBLE_BORDER_SIZE;
245 rect.x -= VISIBLE_BORDER_SIZE as i32;
246 }
247
248 rect.width *= scale;
249 rect.height *= scale;
250
251 let (buffer, canvas) = match self.pool.create_buffer(
252 rect.width as i32,
253 rect.height as i32,
254 rect.width as i32 * 4,
255 wl_shm::Format::Argb8888,
256 ) {
257 Ok((buffer, canvas)) => (buffer, canvas),
258 Err(_) => continue,
259 };
260
261 // Create the pixmap and fill with transparent color.
262 let mut pixmap = PixmapMut::from_bytes(canvas, rect.width, rect.height)?;
263
264 // Fill everything with transparent background, since we draw rounded corners and
265 // do invisible borders to enlarge the input zone.
266 pixmap.fill(Color::TRANSPARENT);
267
268 if !self.state.intersects(WindowState::TILED) {
269 self.shadow.draw(
270 &mut pixmap,
271 scale,
272 self.state.contains(WindowState::ACTIVATED),
273 idx,
274 );
275 }
276
277 match idx {
278 DecorationParts::HEADER => {
279 if let Some(title_text) = self.title_text.as_mut() {
280 title_text.update_scale(scale);
281 title_text.update_color(colors.font_color);
282 }
283
284 draw_headerbar(
285 &mut pixmap,
286 self.title_text.as_ref().map(|t| t.pixmap()).unwrap_or(None),
287 scale as f32,
288 self.resizable,
289 &self.state,
290 &self.theme,
291 &self.buttons,
292 self.mouse.location,
293 );
294 }
295 border => {
296 // The visible border is one pt.
297 let visible_border_size = VISIBLE_BORDER_SIZE * scale;
298
299 // XXX we do all the match using integral types and then convert to f32 in the
300 // end to ensure that result is finite.
301 let border_rect = match border {
302 DecorationParts::LEFT => {
303 let x = (rect.x.unsigned_abs() * scale) - visible_border_size;
304 let y = rect.y.unsigned_abs() * scale;
305 Rect::from_xywh(
306 x as f32,
307 y as f32,
308 visible_border_size as f32,
309 (rect.height - y) as f32,
310 )
311 }
312 DecorationParts::RIGHT => {
313 let y = rect.y.unsigned_abs() * scale;
314 Rect::from_xywh(
315 0.,
316 y as f32,
317 visible_border_size as f32,
318 (rect.height - y) as f32,
319 )
320 }
321 // We draw small visible border only bellow the window surface, no need to
322 // handle `TOP`.
323 DecorationParts::BOTTOM => {
324 let x = (rect.x.unsigned_abs() * scale) - visible_border_size;
325 Rect::from_xywh(
326 x as f32,
327 0.,
328 (rect.width - 2 * x) as f32,
329 visible_border_size as f32,
330 )
331 }
332 _ => None,
333 };
334
335 // Fill the visible border, if present.
336 if let Some(border_rect) = border_rect {
337 pixmap.fill_rect(border_rect, &border_paint, Transform::identity(), None);
338 }
339 }
340 };
341
342 if should_sync {
343 part.subsurface.set_sync();
344 } else {
345 part.subsurface.set_desync();
346 }
347
348 part.surface.set_buffer_scale(scale as i32);
349
350 part.subsurface.set_position(rect.x, rect.y);
351 buffer.attach_to(&part.surface).ok()?;
352
353 if part.surface.version() >= 4 {
354 part.surface.damage_buffer(0, 0, i32::MAX, i32::MAX);
355 } else {
356 part.surface.damage(0, 0, i32::MAX, i32::MAX);
357 }
358
359 if let Some(input_rect) = part.input_rect {
360 let input_region = Region::new(&*self.compositor).ok()?;
361 input_region.add(
362 input_rect.x,
363 input_rect.y,
364 input_rect.width as i32,
365 input_rect.height as i32,
366 );
367
368 part.surface
369 .set_input_region(Some(input_region.wl_region()));
370 }
371
372 part.surface.commit();
373 }
374
375 Some(should_sync)
376 }
377}
378
379impl<State> DecorationsFrame for AdwaitaFrame<State>
380where
381 State: Dispatch<WlSurface, SurfaceData> + Dispatch<WlSubsurface, SubsurfaceData> + 'static,
382{
383 fn update_state(&mut self, state: WindowState) {
384 let difference = self.state.symmetric_difference(state);
385 self.state = state;
386 self.dirty |= difference.intersects(
387 WindowState::ACTIVATED
388 | WindowState::FULLSCREEN
389 | WindowState::MAXIMIZED
390 | WindowState::TILED,
391 );
392 }
393
394 fn update_wm_capabilities(&mut self, wm_capabilities: WindowManagerCapabilities) {
395 self.dirty |= self.wm_capabilities != wm_capabilities;
396 self.wm_capabilities = wm_capabilities;
397 self.buttons.update_wm_capabilities(wm_capabilities);
398 }
399
400 fn set_hidden(&mut self, hidden: bool) {
401 if hidden {
402 self.dirty = false;
403 let _ = self.pool.resize(1);
404 self.decorations = None;
405 } else if self.decorations.is_none() {
406 self.decorations = Some(DecorationParts::new(
407 &self.base_surface,
408 &self.subcompositor,
409 &self.queue_handle,
410 ));
411 self.dirty = true;
412 self.should_sync = true;
413 }
414 }
415
416 fn set_resizable(&mut self, resizable: bool) {
417 self.dirty |= self.resizable != resizable;
418 self.resizable = resizable;
419 }
420
421 fn resize(&mut self, width: NonZeroU32, height: NonZeroU32) {
422 let Some(decorations) = self.decorations.as_mut() else {
423 log::error!("trying to resize the hidden frame.");
424 return;
425 };
426
427 decorations.resize(width.get(), height.get());
428 self.buttons
429 .arrange(width.get(), get_margin_h_lp(&self.state));
430 self.dirty = true;
431 self.should_sync = true;
432 }
433
434 fn draw(&mut self) -> bool {
435 self.redraw_inner().unwrap_or(true)
436 }
437
438 fn subtract_borders(
439 &self,
440 width: NonZeroU32,
441 height: NonZeroU32,
442 ) -> (Option<NonZeroU32>, Option<NonZeroU32>) {
443 if self.decorations.is_none() || self.state.contains(WindowState::FULLSCREEN) {
444 (Some(width), Some(height))
445 } else {
446 (
447 Some(width),
448 NonZeroU32::new(height.get().saturating_sub(HEADER_SIZE)),
449 )
450 }
451 }
452
453 fn add_borders(&self, width: u32, height: u32) -> (u32, u32) {
454 if self.decorations.is_none() || self.state.contains(WindowState::FULLSCREEN) {
455 (width, height)
456 } else {
457 (width, height + HEADER_SIZE)
458 }
459 }
460
461 fn location(&self) -> (i32, i32) {
462 if self.decorations.is_none() || self.state.contains(WindowState::FULLSCREEN) {
463 (0, 0)
464 } else {
465 (0, -(HEADER_SIZE as i32))
466 }
467 }
468
469 fn set_title(&mut self, title: impl Into<String>) {
470 let new_title = title.into();
471 if let Some(title_text) = self.title_text.as_mut() {
472 title_text.update_title(new_title.clone());
473 }
474
475 self.title = Some(new_title);
476 self.dirty = true;
477 }
478
479 fn on_click(
480 &mut self,
481 timestamp: Duration,
482 click: FrameClick,
483 pressed: bool,
484 ) -> Option<FrameAction> {
485 match click {
486 FrameClick::Normal => self.mouse.click(
487 timestamp,
488 pressed,
489 self.resizable,
490 &self.state,
491 &self.wm_capabilities,
492 ),
493 FrameClick::Alternate => self.mouse.alternate_click(pressed, &self.wm_capabilities),
494 _ => None,
495 }
496 }
497
498 fn set_scaling_factor(&mut self, scale_factor: f64) {
499 // NOTE: Clamp it just in case to some ok-ish range.
500 self.scale_factor = scale_factor.clamp(0.1, 64.).ceil() as u32;
501 self.dirty = true;
502 self.should_sync = true;
503 }
504
505 fn click_point_moved(
506 &mut self,
507 _timestamp: Duration,
508 surface: &ObjectId,
509 x: f64,
510 y: f64,
511 ) -> Option<CursorIcon> {
512 let decorations = self.decorations.as_ref()?;
513 let location = decorations.find_surface(surface);
514 if location == Location::None {
515 return None;
516 }
517
518 let old_location = self.mouse.location;
519
520 let location = self.precise_location(location, decorations, x, y);
521 let new_cursor = self.mouse.moved(location, x, y, self.resizable);
522
523 // Set dirty if we moved the cursor between the buttons.
524 self.dirty |= (matches!(old_location, Location::Button(_))
525 || matches!(self.mouse.location, Location::Button(_)))
526 && old_location != self.mouse.location;
527
528 Some(new_cursor)
529 }
530
531 fn click_point_left(&mut self) {
532 self.mouse.left()
533 }
534
535 fn is_dirty(&self) -> bool {
536 self.dirty
537 }
538
539 fn is_hidden(&self) -> bool {
540 self.decorations.is_none()
541 }
542}
543
544/// The configuration for the [`AdwaitaFrame`] frame.
545#[derive(Debug, Clone)]
546pub struct FrameConfig {
547 pub theme: ColorTheme,
548}
549
550impl FrameConfig {
551 /// Create the new configuration with the given `theme`.
552 pub fn new(theme: ColorTheme) -> Self {
553 Self { theme }
554 }
555
556 /// This is equivalent of calling `FrameConfig::new(ColorTheme::auto())`.
557 ///
558 /// For details see [`ColorTheme::auto`].
559 pub fn auto() -> Self {
560 Self {
561 theme: ColorTheme::auto(),
562 }
563 }
564
565 /// This is equivalent of calling `FrameConfig::new(ColorTheme::light())`.
566 ///
567 /// For details see [`ColorTheme::light`].
568 pub fn light() -> Self {
569 Self {
570 theme: ColorTheme::light(),
571 }
572 }
573
574 /// This is equivalent of calling `FrameConfig::new(ColorTheme::dark())`.
575 ///
576 /// For details see [`ColorTheme::dark`].
577 pub fn dark() -> Self {
578 Self {
579 theme: ColorTheme::dark(),
580 }
581 }
582}
583
584#[allow(clippy::too_many_arguments)]
585fn draw_headerbar(
586 pixmap: &mut PixmapMut,
587 text_pixmap: Option<&Pixmap>,
588 scale: f32,
589 resizable: bool,
590 state: &WindowState,
591 theme: &ColorTheme,
592 buttons: &Buttons,
593 mouse: Location,
594) {
595 let colors = theme.for_state(state.contains(WindowState::ACTIVATED));
596
597 let _ = draw_headerbar_bg(pixmap, scale, colors, state);
598
599 // Horizontal margin.
600 let margin_h = get_margin_h_lp(state) * 2.0;
601
602 let canvas_w = pixmap.width() as f32;
603 let canvas_h = pixmap.height() as f32;
604
605 let header_w = canvas_w - margin_h * 2.0;
606 let header_h = canvas_h;
607
608 if let Some(text_pixmap) = text_pixmap {
609 const TEXT_OFFSET: f32 = 10.;
610 let offset_x = TEXT_OFFSET * scale;
611
612 let text_w = text_pixmap.width() as f32;
613 let text_h = text_pixmap.height() as f32;
614
615 let x = margin_h + header_w / 2. - text_w / 2.;
616 let y = header_h / 2. - text_h / 2.;
617
618 let left_buttons_end_x = buttons.left_buttons_end_x().unwrap_or(0.0) * scale;
619 let right_buttons_start_x =
620 buttons.right_buttons_start_x().unwrap_or(header_w / scale) * scale;
621
622 {
623 // We have enough space to center text
624 let (x, y, text_canvas_start_x) = if (x + text_w < right_buttons_start_x - offset_x)
625 && (x > left_buttons_end_x + offset_x)
626 {
627 let text_canvas_start_x = x;
628
629 (x, y, text_canvas_start_x)
630 } else {
631 let x = left_buttons_end_x + offset_x;
632 let text_canvas_start_x = left_buttons_end_x + offset_x;
633
634 (x, y, text_canvas_start_x)
635 };
636
637 let text_canvas_end_x = right_buttons_start_x - x - offset_x;
638 // Ensure that text start within the bounds.
639 let x = x.max(margin_h + offset_x);
640
641 if let Some(clip) =
642 Rect::from_xywh(text_canvas_start_x, 0., text_canvas_end_x, canvas_h)
643 {
644 if let Some(mut mask) = Mask::new(canvas_w as u32, canvas_h as u32) {
645 mask.fill_path(
646 &PathBuilder::from_rect(clip),
647 FillRule::Winding,
648 false,
649 Transform::identity(),
650 );
651 pixmap.draw_pixmap(
652 x.round() as i32,
653 y as i32,
654 text_pixmap.as_ref(),
655 &PixmapPaint::default(),
656 Transform::identity(),
657 Some(&mask),
658 );
659 } else {
660 log::error!(
661 "Invalid mask width and height: w: {}, h: {}",
662 canvas_w as u32,
663 canvas_h as u32
664 );
665 }
666 }
667 }
668 }
669
670 // Draw the buttons.
671 buttons.draw(
672 margin_h, header_w, scale, colors, mouse, pixmap, resizable, state,
673 );
674}
675
676#[must_use]
677fn draw_headerbar_bg(
678 pixmap: &mut PixmapMut,
679 scale: f32,
680 colors: &ColorMap,
681 state: &WindowState,
682) -> SkiaResult {
683 let w = pixmap.width() as f32;
684 let h = pixmap.height() as f32;
685
686 let radius = if state.intersects(WindowState::MAXIMIZED | WindowState::TILED) {
687 0.
688 } else {
689 CORNER_RADIUS as f32 * scale
690 };
691
692 let bg = rounded_headerbar_shape(0., 0., w, h, radius)?;
693
694 pixmap.fill_path(
695 &bg,
696 &colors.headerbar_paint(),
697 FillRule::Winding,
698 Transform::identity(),
699 None,
700 );
701
702 Some(())
703}
704
705fn rounded_headerbar_shape(x: f32, y: f32, width: f32, height: f32, radius: f32) -> Option<Path> {
706 // https://stackoverflow.com/a/27863181
707 let cubic_bezier_circle = 0.552_284_8 * radius;
708
709 let mut pb = PathBuilder::new();
710 let mut cursor = Point::from_xy(x, y);
711
712 // !!!
713 // This code is heavily "inspired" by https://gitlab.com/snakedye/snui/
714 // So technically it should be licensed under MPL-2.0, sorry about that 🥺 👉👈
715 // !!!
716
717 // Positioning the cursor
718 cursor.y += radius;
719 pb.move_to(cursor.x, cursor.y);
720
721 // Drawing the outline
722 let next = Point::from_xy(cursor.x + radius, cursor.y - radius);
723 pb.cubic_to(
724 cursor.x,
725 cursor.y - cubic_bezier_circle,
726 next.x - cubic_bezier_circle,
727 next.y,
728 next.x,
729 next.y,
730 );
731 cursor = next;
732 pb.line_to(
733 {
734 cursor.x = x + width - radius;
735 cursor.x
736 },
737 cursor.y,
738 );
739 let next = Point::from_xy(cursor.x + radius, cursor.y + radius);
740 pb.cubic_to(
741 cursor.x + cubic_bezier_circle,
742 cursor.y,
743 next.x,
744 next.y - cubic_bezier_circle,
745 next.x,
746 next.y,
747 );
748 cursor = next;
749 pb.line_to(cursor.x, {
750 cursor.y = y + height;
751 cursor.y
752 });
753 pb.line_to(
754 {
755 cursor.x = x;
756 cursor.x
757 },
758 cursor.y,
759 );
760
761 pb.close();
762
763 pb.finish()
764}
765
766// returns horizontal margin, logical points
767fn get_margin_h_lp(state: &WindowState) -> f32 {
768 if state.intersects(WindowState::MAXIMIZED | WindowState::TILED) {
769 0.
770 } else {
771 VISIBLE_BORDER_SIZE as f32
772 }
773}
vendor/sctk-adwaita/src/parts.rs created+260
...@@ -0,0 +1,260 @@
1use smithay_client_toolkit::reexports::client::{
2 backend::ObjectId,
3 protocol::{wl_subsurface::WlSubsurface, wl_surface::WlSurface},
4 Dispatch, Proxy, QueueHandle,
5};
6
7use smithay_client_toolkit::{
8 compositor::SurfaceData,
9 subcompositor::{SubcompositorState, SubsurfaceData},
10};
11
12use crate::theme::{BORDER_SIZE, HEADER_SIZE, RESIZE_HANDLE_SIZE};
13use crate::{pointer::Location, wl_typed::WlTyped};
14
15/// The decoration's 'parts'.
16#[derive(Debug)]
17pub struct DecorationParts {
18 parts: [Part; 5],
19}
20
21impl DecorationParts {
22 // XXX keep in sync with `Self;:new`.
23 // Order is important. The lower the number, the earlier the part gets drawn.
24 // Because the header can overlap other parts, we draw it last.
25 pub const TOP: usize = 0;
26 pub const LEFT: usize = 1;
27 pub const RIGHT: usize = 2;
28 pub const BOTTOM: usize = 3;
29 pub const HEADER: usize = 4;
30
31 pub fn new<State>(
32 base_surface: &WlTyped<WlSurface, SurfaceData>,
33 subcompositor: &SubcompositorState,
34 queue_handle: &QueueHandle<State>,
35 ) -> Self
36 where
37 State: Dispatch<WlSurface, SurfaceData> + Dispatch<WlSubsurface, SubsurfaceData> + 'static,
38 {
39 // XXX the order must be in sync with associated constants.
40 let parts = [
41 // Top.
42 Part::new(
43 base_surface,
44 subcompositor,
45 queue_handle,
46 Rect {
47 x: -(BORDER_SIZE as i32),
48 y: -(HEADER_SIZE as i32 + BORDER_SIZE as i32),
49 width: 0, // Defined by `Self::resize`.
50 height: BORDER_SIZE,
51 },
52 Some(Rect {
53 x: BORDER_SIZE as i32 - RESIZE_HANDLE_SIZE as i32,
54 y: BORDER_SIZE as i32 - RESIZE_HANDLE_SIZE as i32,
55 width: 0, // Defined by `Self::resize`.
56 height: RESIZE_HANDLE_SIZE,
57 }),
58 ),
59 // Left.
60 Part::new(
61 base_surface,
62 subcompositor,
63 queue_handle,
64 Rect {
65 x: -(BORDER_SIZE as i32),
66 y: -(HEADER_SIZE as i32),
67 width: BORDER_SIZE,
68 height: 0, // Defined by `Self::resize`.
69 },
70 Some(Rect {
71 x: BORDER_SIZE as i32 - RESIZE_HANDLE_SIZE as i32,
72 y: 0,
73 width: RESIZE_HANDLE_SIZE,
74 height: 0, // Defined by `Self::resize`.
75 }),
76 ),
77 // Right.
78 Part::new(
79 base_surface,
80 subcompositor,
81 queue_handle,
82 Rect {
83 x: 0, // Defined by `Self::resize`.
84 y: -(HEADER_SIZE as i32),
85 width: BORDER_SIZE,
86 height: 0, // Defined by `Self::resize`.
87 },
88 Some(Rect {
89 x: 0,
90 y: 0,
91 width: RESIZE_HANDLE_SIZE,
92 height: 0, // Defined by `Self::resize`.
93 }),
94 ),
95 // Bottom.
96 Part::new(
97 base_surface,
98 subcompositor,
99 queue_handle,
100 Rect {
101 x: -(BORDER_SIZE as i32),
102 y: 0, // Defined by `Self::resize`.
103 width: 0, // Defined by `Self::resize`.
104 height: BORDER_SIZE,
105 },
106 Some(Rect {
107 x: BORDER_SIZE as i32 - RESIZE_HANDLE_SIZE as i32,
108 y: 0,
109 width: 0, // Defined by `Self::resize`,
110 height: RESIZE_HANDLE_SIZE,
111 }),
112 ),
113 // Header.
114 Part::new(
115 base_surface,
116 subcompositor,
117 queue_handle,
118 Rect {
119 x: 0,
120 y: -(HEADER_SIZE as i32),
121 width: 0, // Defined by `Self::resize`.
122 height: HEADER_SIZE,
123 },
124 None,
125 ),
126 ];
127
128 Self { parts }
129 }
130
131 pub fn parts(&self) -> std::iter::Enumerate<std::slice::Iter<Part>> {
132 self.parts.iter().enumerate()
133 }
134
135 pub fn hide(&self) {
136 for part in self.parts.iter() {
137 part.subsurface.set_sync();
138 part.surface.attach(None, 0, 0);
139 part.surface.commit();
140 }
141 }
142
143 pub fn hide_borders(&self) {
144 for (_, part) in self.parts().filter(|(idx, _)| *idx != Self::HEADER) {
145 part.surface.attach(None, 0, 0);
146 part.surface.commit();
147 }
148 }
149
150 // These unwraps are guaranteed to succeed because the affected options are filled above
151 // and then never emptied afterwards.
152 #[allow(clippy::unwrap_used)]
153 pub fn resize(&mut self, width: u32, height: u32) {
154 self.parts[Self::HEADER].surface_rect.width = width;
155
156 self.parts[Self::BOTTOM].surface_rect.width = width + 2 * BORDER_SIZE;
157 self.parts[Self::BOTTOM].surface_rect.y = height as i32;
158 self.parts[Self::BOTTOM].input_rect.as_mut().unwrap().width =
159 self.parts[Self::BOTTOM].surface_rect.width - (BORDER_SIZE * 2)
160 + (RESIZE_HANDLE_SIZE * 2);
161
162 self.parts[Self::TOP].surface_rect.width = self.parts[Self::BOTTOM].surface_rect.width;
163 self.parts[Self::TOP].input_rect.as_mut().unwrap().width =
164 self.parts[Self::TOP].surface_rect.width - (BORDER_SIZE * 2) + (RESIZE_HANDLE_SIZE * 2);
165
166 self.parts[Self::LEFT].surface_rect.height = height + HEADER_SIZE;
167 self.parts[Self::LEFT].input_rect.as_mut().unwrap().height =
168 self.parts[Self::LEFT].surface_rect.height;
169
170 self.parts[Self::RIGHT].surface_rect.height = self.parts[Self::LEFT].surface_rect.height;
171 self.parts[Self::RIGHT].surface_rect.x = width as i32;
172 self.parts[Self::RIGHT].input_rect.as_mut().unwrap().height =
173 self.parts[Self::RIGHT].surface_rect.height;
174 }
175
176 pub fn header(&self) -> &Part {
177 &self.parts[Self::HEADER]
178 }
179
180 pub fn side_height(&self) -> u32 {
181 self.parts[Self::LEFT].surface_rect.height
182 }
183
184 pub fn find_surface(&self, surface: &ObjectId) -> Location {
185 let pos = match self
186 .parts
187 .iter()
188 .position(|part| &part.surface.id() == surface)
189 {
190 Some(pos) => pos,
191 None => return Location::None,
192 };
193
194 match pos {
195 Self::HEADER => Location::Head,
196 Self::TOP => Location::Top,
197 Self::BOTTOM => Location::Bottom,
198 Self::LEFT => Location::Left,
199 Self::RIGHT => Location::Right,
200 _ => unreachable!(),
201 }
202 }
203}
204
205#[derive(Debug, Clone, Copy)]
206pub struct Rect {
207 pub x: i32,
208 pub y: i32,
209 pub width: u32,
210 pub height: u32,
211}
212
213#[derive(Debug)]
214pub struct Part {
215 pub surface: WlTyped<WlSurface, SurfaceData>,
216 pub subsurface: WlTyped<WlSubsurface, SubsurfaceData>,
217
218 /// Positioned relative to the main surface.
219 pub surface_rect: Rect,
220 /// Positioned relative to the local surface, aka. `surface_rect`.
221 ///
222 /// `None` if it fully covers `surface_rect`.
223 pub input_rect: Option<Rect>,
224}
225
226impl Part {
227 fn new<State>(
228 parent: &WlTyped<WlSurface, SurfaceData>,
229 subcompositor: &SubcompositorState,
230 queue_handle: &QueueHandle<State>,
231 surface_rect: Rect,
232 input_rect: Option<Rect>,
233 ) -> Part
234 where
235 State: Dispatch<WlSurface, SurfaceData> + Dispatch<WlSubsurface, SubsurfaceData> + 'static,
236 {
237 let (subsurface, surface) =
238 subcompositor.create_subsurface(parent.inner().clone(), queue_handle);
239
240 let subsurface = WlTyped::wrap::<State>(subsurface);
241 let surface = WlTyped::wrap::<State>(surface);
242
243 // Sync with the parent surface.
244 subsurface.set_sync();
245
246 Part {
247 surface,
248 subsurface,
249 surface_rect,
250 input_rect,
251 }
252 }
253}
254
255impl Drop for Part {
256 fn drop(&mut self) {
257 self.subsurface.destroy();
258 self.surface.destroy();
259 }
260}
vendor/sctk-adwaita/src/pointer.rs created+142
...@@ -0,0 +1,142 @@
1use std::time::Duration;
2
3use smithay_client_toolkit::reexports::csd_frame::{
4 CursorIcon, FrameAction, ResizeEdge, WindowManagerCapabilities, WindowState,
5};
6
7use crate::{
8 buttons::ButtonKind,
9 theme::{BORDER_SIZE, HEADER_SIZE},
10};
11
12/// Time to register the next click as a double click.
13///
14/// The value is the same as the default in gtk4.
15const DOUBLE_CLICK_DURATION: Duration = Duration::from_millis(400);
16
17/// The state of the mouse input inside the decorations frame.
18#[derive(Debug, Default)]
19pub(crate) struct MouseState {
20 pub location: Location,
21
22 /// The surface local location inside the surface.
23 position: (f64, f64),
24
25 /// The instant of the last click.
26 last_normal_click: Option<Duration>,
27}
28
29impl MouseState {
30 /// The normal click on decorations frame was made.
31 pub fn click(
32 &mut self,
33 timestamp: Duration,
34 pressed: bool,
35 resizable: bool,
36 state: &WindowState,
37 wm_capabilities: &WindowManagerCapabilities,
38 ) -> Option<FrameAction> {
39 let maximized = state.contains(WindowState::MAXIMIZED);
40 let action = match self.location {
41 Location::Top if resizable => FrameAction::Resize(ResizeEdge::Top),
42 Location::TopLeft if resizable => FrameAction::Resize(ResizeEdge::TopLeft),
43 Location::Left if resizable => FrameAction::Resize(ResizeEdge::Left),
44 Location::BottomLeft if resizable => FrameAction::Resize(ResizeEdge::BottomLeft),
45 Location::Bottom if resizable => FrameAction::Resize(ResizeEdge::Bottom),
46 Location::BottomRight if resizable => FrameAction::Resize(ResizeEdge::BottomRight),
47 Location::Right if resizable => FrameAction::Resize(ResizeEdge::Right),
48 Location::TopRight if resizable => FrameAction::Resize(ResizeEdge::TopRight),
49 Location::Button(ButtonKind::Close) if !pressed => FrameAction::Close,
50 Location::Button(ButtonKind::Maximize) if !pressed && !maximized => {
51 FrameAction::Maximize
52 }
53 Location::Button(ButtonKind::Maximize) if !pressed && maximized => {
54 FrameAction::UnMaximize
55 }
56 Location::Button(ButtonKind::Minimize) if !pressed => FrameAction::Minimize,
57 Location::Head
58 if pressed && wm_capabilities.contains(WindowManagerCapabilities::MAXIMIZE) =>
59 {
60 match self.last_normal_click.replace(timestamp) {
61 Some(last) if timestamp.saturating_sub(last) < DOUBLE_CLICK_DURATION => {
62 if maximized {
63 FrameAction::UnMaximize
64 } else {
65 FrameAction::Maximize
66 }
67 }
68 _ => FrameAction::Move,
69 }
70 }
71 Location::Head if pressed => FrameAction::Move,
72 _ => return None,
73 };
74
75 Some(action)
76 }
77
78 /// Alternative click on decorations frame was made.
79 pub fn alternate_click(
80 &mut self,
81 pressed: bool,
82 wm_capabilities: &WindowManagerCapabilities,
83 ) -> Option<FrameAction> {
84 // Invalidate the normal click.
85 self.last_normal_click = None;
86
87 match self.location {
88 Location::Head | Location::Button(_)
89 if pressed && wm_capabilities.contains(WindowManagerCapabilities::WINDOW_MENU) =>
90 {
91 Some(FrameAction::ShowMenu(
92 // XXX this could be one 1pt off when the frame is not maximized, but it's not
93 // like it really matters in the end.
94 self.position.0 as i32 - BORDER_SIZE as i32,
95 // We must offset it by header size for precise position.
96 self.position.1 as i32 - HEADER_SIZE as i32,
97 ))
98 }
99 _ => None,
100 }
101 }
102
103 /// The mouse moved inside the decorations frame.
104 pub fn moved(&mut self, location: Location, x: f64, y: f64, resizable: bool) -> CursorIcon {
105 self.location = location;
106 self.position = (x, y);
107 match self.location {
108 _ if !resizable => CursorIcon::Default,
109 Location::Top => CursorIcon::NResize,
110 Location::TopRight => CursorIcon::NeResize,
111 Location::Right => CursorIcon::EResize,
112 Location::BottomRight => CursorIcon::SeResize,
113 Location::Bottom => CursorIcon::SResize,
114 Location::BottomLeft => CursorIcon::SwResize,
115 Location::Left => CursorIcon::WResize,
116 Location::TopLeft => CursorIcon::NwResize,
117 _ => CursorIcon::Default,
118 }
119 }
120
121 /// The mouse left the decorations frame.
122 pub fn left(&mut self) {
123 // Reset only the location.
124 self.location = Location::None;
125 }
126}
127
128#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
129pub enum Location {
130 #[default]
131 None,
132 Head,
133 Top,
134 TopRight,
135 Right,
136 BottomRight,
137 Bottom,
138 BottomLeft,
139 Left,
140 TopLeft,
141 Button(ButtonKind),
142}
vendor/sctk-adwaita/src/shadow.rs created+397
...@@ -0,0 +1,397 @@
1use crate::{parts::DecorationParts, theme};
2use std::collections::BTreeMap;
3use tiny_skia::{Pixmap, PixmapMut, PixmapRef, Point, PremultipliedColorU8};
4
5// These values were generated from a screenshot of an libadwaita window using a script.
6// For more details see: https://github.com/PolyMeilex/sctk-adwaita/pull/43
7pub const SHADOW_SIZE: u32 = 43;
8const SHADOW_PARAMS_ACTIVE: (f32, f32, f32) = (0.206_505_5, 0.104_617_53, -0.000_542_446_2);
9const SHADOW_PARAMS_INACTIVE: (f32, f32, f32) = (0.168_297_29, 0.204_299_8, 0.001_769_798_6);
10
11fn shadow(pixel_dist: f32, scale: u32, active: bool) -> f32 {
12 let (a, b, c) = if active {
13 SHADOW_PARAMS_ACTIVE
14 } else {
15 SHADOW_PARAMS_INACTIVE
16 };
17
18 a * (-b * (pixel_dist / scale as f32)).exp() + c
19}
20
21#[derive(Debug)]
22struct RenderedShadow {
23 side: Pixmap,
24 edges: Pixmap,
25}
26
27impl RenderedShadow {
28 fn new(scale: u32, active: bool) -> RenderedShadow {
29 let shadow_size = SHADOW_SIZE * scale;
30 let corner_radius = theme::CORNER_RADIUS * scale;
31
32 #[allow(clippy::unwrap_used)]
33 let mut side = Pixmap::new(shadow_size, 1).unwrap();
34 for x in 0..side.width() as usize {
35 let alpha = (shadow(x as f32 + 0.5, scale, active) * u8::MAX as f32).round() as u8;
36
37 #[allow(clippy::unwrap_used)]
38 let color = PremultipliedColorU8::from_rgba(0, 0, 0, alpha).unwrap();
39 side.pixels_mut()[x] = color;
40 }
41
42 let edges_size = (corner_radius + shadow_size) * 2;
43 #[allow(clippy::unwrap_used)]
44 let mut edges = Pixmap::new(edges_size, edges_size).unwrap();
45 let edges_middle = Point::from_xy(edges_size as f32 / 2.0, edges_size as f32 / 2.0);
46 for y in 0..edges_size as usize {
47 let y_pos = y as f32 + 0.5;
48 for x in 0..edges_size as usize {
49 let dist = edges_middle.distance(Point::from_xy(x as f32 + 0.5, y_pos))
50 - corner_radius as f32;
51 let alpha = (shadow(dist, scale, active) * u8::MAX as f32).round() as u8;
52
53 #[allow(clippy::unwrap_used)]
54 let color = PremultipliedColorU8::from_rgba(0, 0, 0, alpha).unwrap();
55 edges.pixels_mut()[y * edges_size as usize + x] = color;
56 }
57 }
58
59 RenderedShadow { side, edges }
60 }
61
62 fn side_draw(
63 &self,
64 flipped: bool,
65 rotated: bool,
66 stack: usize,
67 dst_pixmap: &mut PixmapMut,
68 dst_left: usize,
69 dst_top: usize,
70 ) {
71 fn iter_copy<'a>(
72 src: impl Iterator<Item = &'a PremultipliedColorU8>,
73 dst: impl Iterator<Item = &'a mut PremultipliedColorU8>,
74 ) {
75 src.zip(dst).for_each(|(src, dst)| *dst = *src)
76 }
77
78 let dst_width = dst_pixmap.width() as usize;
79 let dst_pixels = dst_pixmap.pixels_mut();
80 match (flipped, rotated) {
81 (false, false) => (0..stack).for_each(|i| {
82 let dst = dst_pixels
83 .iter_mut()
84 .skip((dst_top + i) * dst_width + dst_left);
85 iter_copy(self.side.pixels().iter(), dst);
86 }),
87 (false, true) => (0..stack).for_each(|i| {
88 let dst = dst_pixels
89 .iter_mut()
90 .skip(dst_top * dst_width + dst_left + i)
91 .step_by(dst_width);
92 iter_copy(self.side.pixels().iter(), dst);
93 }),
94 (true, false) => (0..stack).for_each(|i| {
95 let dst = dst_pixels
96 .iter_mut()
97 .skip((dst_top + i) * dst_width + dst_left);
98 iter_copy(self.side.pixels().iter().rev(), dst);
99 }),
100 (true, true) => (0..stack).for_each(|i| {
101 let dst = dst_pixels
102 .iter_mut()
103 .skip(dst_top * dst_width + dst_left + i)
104 .step_by(dst_width);
105 iter_copy(self.side.pixels().iter().rev(), dst);
106 }),
107 }
108 }
109
110 #[allow(clippy::too_many_arguments)]
111 fn edges_draw(
112 &self,
113 src_x_offset: isize,
114 src_y_offset: isize,
115 dst_pixmap: &mut PixmapMut,
116 dst_rect_left: usize,
117 dst_rect_top: usize,
118 dst_rect_width: usize,
119 dst_rect_height: usize,
120 ) {
121 let src_width = self.edges.width() as usize;
122 let src_pixels = self.edges.pixels();
123 let dst_width = dst_pixmap.width() as usize;
124 let dst_pixels = dst_pixmap.pixels_mut();
125 for y in 0..dst_rect_height {
126 let dst_y = dst_rect_top + y;
127 let src_y = y as isize + src_y_offset;
128 if src_y < 0 {
129 continue;
130 }
131
132 let src_y = src_y as usize;
133 for x in 0..dst_rect_width {
134 let dst_x = dst_rect_left + x;
135 let src_x = x as isize + src_x_offset;
136 if src_x < 0 {
137 continue;
138 }
139
140 let src = src_pixels.get(src_y * src_width + src_x as usize);
141 let dst = dst_pixels.get_mut(dst_y * dst_width + dst_x);
142 if let (Some(src), Some(dst)) = (src, dst) {
143 *dst = *src;
144 }
145 }
146 }
147 }
148
149 fn draw(&self, dst_pixmap: &mut PixmapMut, scale: u32, part_idx: usize) {
150 let shadow_size = (SHADOW_SIZE * scale) as usize;
151 let visible_border_size = (theme::VISIBLE_BORDER_SIZE * scale) as usize;
152 let corner_radius = (theme::CORNER_RADIUS * scale) as usize;
153 assert!(corner_radius > visible_border_size);
154
155 let dst_width = dst_pixmap.width() as usize;
156 let dst_height = dst_pixmap.height() as usize;
157 let edges_half = self.edges.width() as usize / 2;
158 match part_idx {
159 DecorationParts::TOP => {
160 let left_edge_width = edges_half;
161 let right_edge_width = edges_half;
162 let side_width = dst_width
163 .saturating_sub(left_edge_width)
164 .saturating_sub(right_edge_width);
165
166 self.edges_draw(
167 0,
168 -(visible_border_size as isize),
169 dst_pixmap,
170 0,
171 0,
172 left_edge_width,
173 dst_height,
174 );
175
176 self.side_draw(
177 true,
178 true,
179 side_width,
180 dst_pixmap,
181 left_edge_width,
182 visible_border_size,
183 );
184
185 self.edges_draw(
186 edges_half as isize,
187 -(visible_border_size as isize),
188 dst_pixmap,
189 left_edge_width + side_width,
190 0,
191 right_edge_width,
192 dst_height,
193 );
194 }
195 DecorationParts::LEFT => {
196 let top_edge_height = corner_radius;
197 let bottom_edge_height = corner_radius - visible_border_size;
198 let side_height = dst_height
199 .saturating_sub(top_edge_height)
200 .saturating_sub(bottom_edge_height);
201
202 self.edges_draw(
203 0,
204 shadow_size as isize,
205 dst_pixmap,
206 0,
207 0,
208 dst_width.saturating_sub(visible_border_size),
209 top_edge_height,
210 );
211
212 self.side_draw(true, false, side_height, dst_pixmap, 0, top_edge_height);
213
214 self.edges_draw(
215 0,
216 edges_half as isize,
217 dst_pixmap,
218 0,
219 top_edge_height + side_height,
220 dst_width.saturating_sub(visible_border_size),
221 bottom_edge_height,
222 );
223 }
224 DecorationParts::RIGHT => {
225 let top_edge_height = corner_radius;
226 let bottom_edge_height = corner_radius - visible_border_size;
227 let side_height = dst_height
228 .saturating_sub(top_edge_height)
229 .saturating_sub(bottom_edge_height);
230
231 self.edges_draw(
232 edges_half as isize + corner_radius as isize,
233 shadow_size as isize,
234 dst_pixmap,
235 visible_border_size,
236 0,
237 dst_width.saturating_sub(visible_border_size),
238 top_edge_height,
239 );
240
241 self.side_draw(
242 false,
243 false,
244 side_height,
245 dst_pixmap,
246 visible_border_size,
247 top_edge_height,
248 );
249
250 self.edges_draw(
251 edges_half as isize + corner_radius as isize,
252 edges_half as isize,
253 dst_pixmap,
254 visible_border_size,
255 top_edge_height + side_height,
256 dst_width.saturating_sub(visible_border_size),
257 bottom_edge_height,
258 );
259 }
260 DecorationParts::BOTTOM => {
261 let left_edge_width = edges_half;
262 let right_edge_width = edges_half;
263 let side_width = dst_width
264 .saturating_sub(left_edge_width)
265 .saturating_sub(right_edge_width);
266
267 self.edges_draw(
268 0,
269 edges_half as isize + (corner_radius - visible_border_size) as isize,
270 dst_pixmap,
271 0,
272 0,
273 left_edge_width,
274 dst_height,
275 );
276
277 self.side_draw(
278 false,
279 true,
280 side_width,
281 dst_pixmap,
282 left_edge_width,
283 visible_border_size,
284 );
285
286 self.edges_draw(
287 edges_half as isize,
288 edges_half as isize + (corner_radius - visible_border_size) as isize,
289 dst_pixmap,
290 left_edge_width + side_width,
291 0,
292 right_edge_width,
293 dst_height,
294 );
295 }
296 DecorationParts::HEADER => {
297 self.edges_draw(
298 shadow_size as isize,
299 shadow_size as isize,
300 dst_pixmap,
301 0,
302 0,
303 corner_radius,
304 corner_radius,
305 );
306
307 self.edges_draw(
308 edges_half as isize,
309 shadow_size as isize,
310 dst_pixmap,
311 dst_width.saturating_sub(corner_radius),
312 0,
313 corner_radius,
314 corner_radius,
315 );
316 }
317 _ => unreachable!(),
318 }
319 }
320}
321
322#[derive(Debug)]
323struct CachedPart {
324 pixmap: Pixmap,
325 scale: u32,
326 active: bool,
327}
328
329impl CachedPart {
330 fn new(
331 dst_pixmap: &PixmapRef,
332 rendered: &RenderedShadow,
333 scale: u32,
334 active: bool,
335 part_idx: usize,
336 ) -> CachedPart {
337 #[allow(clippy::unwrap_used)]
338 let mut pixmap = Pixmap::new(dst_pixmap.width(), dst_pixmap.height()).unwrap();
339 rendered.draw(&mut pixmap.as_mut(), scale, part_idx);
340
341 CachedPart {
342 pixmap,
343 scale,
344 active,
345 }
346 }
347
348 fn matches(&self, dst_pixmap: &PixmapRef, dst_scale: u32, dst_active: bool) -> bool {
349 self.pixmap.width() == dst_pixmap.width()
350 && self.pixmap.height() == dst_pixmap.height()
351 && self.scale == dst_scale
352 && self.active == dst_active
353 }
354
355 fn draw(&self, dst_pixmap: &mut PixmapMut) {
356 let src_data = self.pixmap.data();
357 dst_pixmap.data_mut()[..src_data.len()].copy_from_slice(src_data);
358 }
359}
360
361#[derive(Default, Debug)]
362pub struct Shadow {
363 part_cache: [Option<CachedPart>; 5],
364 // (scale, active) -> RenderedShadow
365 rendered: BTreeMap<(u32, bool), RenderedShadow>,
366}
367
368impl Shadow {
369 pub fn draw(&mut self, pixmap: &mut PixmapMut, scale: u32, active: bool, part_idx: usize) {
370 let cache = &mut self.part_cache[part_idx];
371
372 if let Some(cache_value) = cache {
373 if !cache_value.matches(&pixmap.as_ref(), scale, active) {
374 *cache = None;
375 }
376 }
377
378 if cache.is_none() {
379 let rendered = self
380 .rendered
381 .entry((scale, active))
382 .or_insert_with(|| RenderedShadow::new(scale, active));
383
384 *cache = Some(CachedPart::new(
385 &pixmap.as_ref(),
386 rendered,
387 scale,
388 active,
389 part_idx,
390 ));
391 }
392
393 // We filled the cache above.
394 #[allow(clippy::unwrap_used)]
395 cache.as_ref().unwrap().draw(pixmap);
396 }
397}
vendor/sctk-adwaita/src/theme.rs created+139
...@@ -0,0 +1,139 @@
1pub use tiny_skia::Color;
2use tiny_skia::{Paint, Shader};
3
4// https://gitlab.gnome.org/GNOME/gtk/-/blob/1bf88f1d81043fd99740e2f91e56ade7ede7303b/gtk/gtkwindow.c#L165
5pub(crate) const RESIZE_HANDLE_SIZE: u32 = 12;
6// https://gitlab.gnome.org/GNOME/gtk/-/blob/1bf88f1d81043fd99740e2f91e56ade7ede7303b/gtk/gtkwindow.c#L166
7pub(crate) const RESIZE_HANDLE_CORNER_SIZE: u32 = 24;
8pub(crate) const BORDER_SIZE: u32 = crate::shadow::SHADOW_SIZE + VISIBLE_BORDER_SIZE;
9pub(crate) const HEADER_SIZE: u32 = 35;
10pub(crate) const CORNER_RADIUS: u32 = 10;
11pub(crate) const VISIBLE_BORDER_SIZE: u32 = 1;
12
13/// The color theme to use with the decorations frame.
14#[derive(Debug, Clone)]
15pub struct ColorTheme {
16 pub active: ColorMap,
17 pub inactive: ColorMap,
18}
19
20impl ColorTheme {
21 /// Automatically choose between light & dark themes based on:
22 /// * dbus org.freedesktop.portal.Settings
23 /// <https://flatpak.github.io/xdg-desktop-portal/#gdbus-interface-org-freedesktop-portal-Settings>
24 pub fn auto() -> Self {
25 match crate::config::prefer_dark() {
26 true => Self::dark(),
27 false => Self::light(),
28 }
29 }
30
31 /// Predefined light variant, which aims to replecate Adwaita theme.
32 pub fn light() -> Self {
33 Self {
34 active: ColorMap {
35 headerbar: Color::from_rgba8(235, 235, 235, 255),
36 button_idle: Color::from_rgba8(216, 216, 216, 255),
37 button_hover: Color::from_rgba8(207, 207, 207, 255),
38 button_icon: Color::from_rgba8(42, 42, 42, 255),
39 border_color: Color::from_rgba8(220, 220, 220, 255),
40 font_color: Color::from_rgba8(47, 47, 47, 255),
41 },
42 inactive: ColorMap {
43 headerbar: Color::from_rgba8(250, 250, 250, 255),
44 button_idle: Color::from_rgba8(240, 240, 240, 255),
45 button_hover: Color::from_rgba8(216, 216, 216, 255),
46 button_icon: Color::from_rgba8(148, 148, 148, 255),
47 border_color: Color::from_rgba8(220, 220, 220, 255),
48 font_color: Color::from_rgba8(150, 150, 150, 255),
49 },
50 }
51 }
52
53 /// Predefined dark variant, which aims to replecate Adwaita-dark theme.
54 pub fn dark() -> Self {
55 Self {
56 active: ColorMap {
57 headerbar: Color::from_rgba8(48, 48, 48, 255),
58 button_idle: Color::from_rgba8(69, 69, 69, 255),
59 button_hover: Color::from_rgba8(79, 79, 79, 255),
60 button_icon: Color::from_rgba8(255, 255, 255, 255),
61 border_color: Color::from_rgba8(58, 58, 58, 255),
62 font_color: Color::from_rgba8(255, 255, 255, 255),
63 },
64 inactive: ColorMap {
65 headerbar: Color::from_rgba8(36, 36, 36, 255),
66 button_idle: Color::from_rgba8(47, 47, 47, 255),
67 button_hover: Color::from_rgba8(57, 57, 57, 255),
68 button_icon: Color::from_rgba8(144, 144, 144, 255),
69 border_color: Color::from_rgba8(58, 58, 58, 255),
70 font_color: Color::from_rgba8(144, 144, 144, 255),
71 },
72 }
73 }
74
75 pub(crate) fn for_state(&self, active: bool) -> &ColorMap {
76 if active {
77 &self.active
78 } else {
79 &self.inactive
80 }
81 }
82}
83
84impl Default for ColorTheme {
85 fn default() -> Self {
86 Self::auto()
87 }
88}
89
90/// The color map for various decorcation parts.
91#[derive(Debug, Clone)]
92pub struct ColorMap {
93 pub headerbar: Color,
94 pub button_idle: Color,
95 pub button_hover: Color,
96 pub button_icon: Color,
97 pub border_color: Color,
98 pub font_color: Color,
99}
100
101impl ColorMap {
102 pub(crate) fn headerbar_paint(&self) -> Paint {
103 Paint {
104 shader: Shader::SolidColor(self.headerbar),
105 anti_alias: true,
106 ..Default::default()
107 }
108 }
109
110 pub(crate) fn button_idle_paint(&self) -> Paint {
111 Paint {
112 shader: Shader::SolidColor(self.button_idle),
113 anti_alias: true,
114 ..Default::default()
115 }
116 }
117
118 pub(crate) fn button_hover_paint(&self) -> Paint {
119 Paint {
120 shader: Shader::SolidColor(self.button_hover),
121 anti_alias: true,
122 ..Default::default()
123 }
124 }
125
126 pub(crate) fn button_icon_paint(&self) -> Paint {
127 Paint {
128 shader: Shader::SolidColor(self.button_icon),
129 ..Default::default()
130 }
131 }
132
133 pub(crate) fn border_paint(&self) -> Paint {
134 Paint {
135 shader: Shader::SolidColor(self.border_color),
136 ..Default::default()
137 }
138 }
139}
vendor/sctk-adwaita/src/title.rs created+61
...@@ -0,0 +1,61 @@
1use tiny_skia::{Color, Pixmap};
2
3#[cfg(any(feature = "crossfont", feature = "ab_glyph"))]
4mod config;
5#[cfg(any(feature = "crossfont", feature = "ab_glyph"))]
6mod font_preference;
7
8#[cfg(feature = "crossfont")]
9mod crossfont_renderer;
10
11#[cfg(all(not(feature = "crossfont"), feature = "ab_glyph"))]
12mod ab_glyph_renderer;
13
14#[cfg(all(not(feature = "crossfont"), not(feature = "ab_glyph")))]
15mod dumb;
16
17#[derive(Debug)]
18pub struct TitleText {
19 #[cfg(feature = "crossfont")]
20 imp: crossfont_renderer::CrossfontTitleText,
21 #[cfg(all(not(feature = "crossfont"), feature = "ab_glyph"))]
22 imp: ab_glyph_renderer::AbGlyphTitleText,
23 #[cfg(all(not(feature = "crossfont"), not(feature = "ab_glyph")))]
24 imp: dumb::DumbTitleText,
25}
26
27impl TitleText {
28 pub fn new(color: Color) -> Option<Self> {
29 #[cfg(feature = "crossfont")]
30 return crossfont_renderer::CrossfontTitleText::new(color)
31 .ok()
32 .map(|imp| Self { imp });
33
34 #[cfg(all(not(feature = "crossfont"), feature = "ab_glyph"))]
35 return Some(Self {
36 imp: ab_glyph_renderer::AbGlyphTitleText::new(color),
37 });
38
39 #[cfg(all(not(feature = "crossfont"), not(feature = "ab_glyph")))]
40 {
41 let _ = color;
42 return None;
43 }
44 }
45
46 pub fn update_scale(&mut self, scale: u32) {
47 self.imp.update_scale(scale)
48 }
49
50 pub fn update_title(&mut self, title: impl Into<String>) {
51 self.imp.update_title(title)
52 }
53
54 pub fn update_color(&mut self, color: Color) {
55 self.imp.update_color(color)
56 }
57
58 pub fn pixmap(&self) -> Option<&Pixmap> {
59 self.imp.pixmap()
60 }
61}
vendor/sctk-adwaita/src/title/Cantarell-Regular.ttf created
Binary files /dev/null and b/vendor/sctk-adwaita/src/title/Cantarell-Regular.ttf differ
vendor/sctk-adwaita/src/title/ab_glyph_renderer.rs created+199
...@@ -0,0 +1,199 @@
1//! Title renderer using ab_glyph.
2//!
3//! Requires no dynamically linked dependencies.
4//!
5//! Can fallback to a embedded Cantarell-Regular.ttf font (SIL Open Font Licence v1.1)
6//! if the system font doesn't work.
7use crate::title::{config, font_preference::FontPreference};
8use ab_glyph::{point, Font, FontRef, Glyph, PxScale, PxScaleFont, ScaleFont, VariableFont};
9use std::{fs::File, process::Command};
10use tiny_skia::{Color, Pixmap, PremultipliedColorU8};
11
12const CANTARELL: &[u8] = include_bytes!("Cantarell-Regular.ttf");
13
14#[derive(Debug)]
15pub struct AbGlyphTitleText {
16 title: String,
17 font: Option<(memmap2::Mmap, FontPreference)>,
18 original_px_size: f32,
19 size: PxScale,
20 color: Color,
21 pixmap: Option<Pixmap>,
22}
23
24impl AbGlyphTitleText {
25 pub fn new(color: Color) -> Self {
26 let font_pref = config::titlebar_font().unwrap_or_default();
27 let font_pref_pt_size = font_pref.pt_size;
28 let font = font_file_matching(&font_pref)
29 .and_then(|f| mmap(&f))
30 .map(|mmap| (mmap, font_pref));
31
32 let size = parse_font(&font)
33 .pt_to_px_scale(font_pref_pt_size)
34 .unwrap_or_else(|| {
35 log::error!("invalid font units_per_em");
36 PxScale { x: 17.6, y: 17.6 }
37 });
38
39 Self {
40 title: <_>::default(),
41 font,
42 original_px_size: size.x,
43 size,
44 color,
45 pixmap: None,
46 }
47 }
48
49 pub fn update_scale(&mut self, scale: u32) {
50 let new_scale = PxScale::from(self.original_px_size * scale as f32);
51 if (self.size.x - new_scale.x).abs() > f32::EPSILON {
52 self.size = new_scale;
53 self.pixmap = self.render();
54 }
55 }
56
57 pub fn update_title(&mut self, title: impl Into<String>) {
58 let new_title = title.into();
59 if new_title != self.title {
60 self.title = new_title;
61 self.pixmap = self.render();
62 }
63 }
64
65 pub fn update_color(&mut self, color: Color) {
66 if color != self.color {
67 self.color = color;
68 self.pixmap = self.render();
69 }
70 }
71
72 pub fn pixmap(&self) -> Option<&Pixmap> {
73 self.pixmap.as_ref()
74 }
75
76 /// Render returning the new `Pixmap`.
77 fn render(&self) -> Option<Pixmap> {
78 let font = parse_font(&self.font);
79 let font = font.as_scaled(self.size);
80
81 let glyphs = self.layout(&font);
82 let last_glyph = glyphs.last()?;
83 // + 2 because ab_glyph likes to draw outside of its area,
84 // so we add 1px border around the pixmap
85 let width = (last_glyph.position.x + font.h_advance(last_glyph.id)).ceil() as u32 + 2;
86 let height = font.height().ceil() as u32 + 2;
87
88 let mut pixmap = Pixmap::new(width, height)?;
89
90 let pixels = pixmap.pixels_mut();
91
92 for glyph in glyphs {
93 if let Some(outline) = font.outline_glyph(glyph) {
94 let bounds = outline.px_bounds();
95 let left = bounds.min.x as u32;
96 let top = bounds.min.y as u32;
97 outline.draw(|x, y, c| {
98 // `ab_glyph` may return values greater than 1.0, but they are defined to be
99 // same as 1.0. For our purposes, we need to constrain this value.
100 let c = c.min(1.0);
101
102 // offset the index by 1, so it is in the center of the pixmap
103 let p_idx = (top + y + 1) * width + (left + x + 1);
104 let Some(pixel) = pixels.get_mut(p_idx as usize) else {
105 log::warn!("Ignoring out of range pixel (pixel id: {p_idx}");
106 return;
107 };
108
109 let old_alpha_u8 = pixel.alpha();
110
111 let new_alpha = c + (old_alpha_u8 as f32 / 255.0);
112 if let Some(px) = PremultipliedColorU8::from_rgba(
113 (self.color.red() * new_alpha * 255.0) as _,
114 (self.color.green() * new_alpha * 255.0) as _,
115 (self.color.blue() * new_alpha * 255.0) as _,
116 (new_alpha * 255.0) as _,
117 ) {
118 *pixel = px;
119 }
120 })
121 }
122 }
123
124 Some(pixmap)
125 }
126
127 /// Simple single-line glyph layout.
128 fn layout(&self, font: &PxScaleFont<impl Font>) -> Vec<Glyph> {
129 let mut caret = point(0.0, font.ascent());
130 let mut last_glyph: Option<Glyph> = None;
131 let mut target = Vec::new();
132 for c in self.title.chars() {
133 if c.is_control() {
134 continue;
135 }
136 let mut glyph = font.scaled_glyph(c);
137 if let Some(previous) = last_glyph.take() {
138 caret.x += font.kern(previous.id, glyph.id);
139 }
140 glyph.position = caret;
141
142 last_glyph = Some(glyph.clone());
143 caret.x += font.h_advance(glyph.id);
144
145 target.push(glyph);
146 }
147 target
148 }
149}
150
151/// Parse the memmapped system font or fallback to built-in cantarell.
152fn parse_font(sys_font: &Option<(memmap2::Mmap, FontPreference)>) -> FontRef<'_> {
153 match sys_font {
154 Some((mmap, font_pref)) => {
155 FontRef::try_from_slice(mmap)
156 .map(|mut f| {
157 // basic "bold" handling for variable fonts
158 if font_pref
159 .style
160 .as_deref()
161 .map_or(false, |s| s.eq_ignore_ascii_case("bold"))
162 {
163 f.set_variation(b"wght", 700.0);
164 }
165 f
166 })
167 .unwrap_or_else(|_| {
168 // We control the default font, so I guess it's fine to unwrap it
169 #[allow(clippy::unwrap_used)]
170 FontRef::try_from_slice(CANTARELL).unwrap()
171 })
172 }
173 // We control the default font, so I guess it's fine to unwrap it
174 #[allow(clippy::unwrap_used)]
175 _ => FontRef::try_from_slice(CANTARELL).unwrap(),
176 }
177}
178
179/// Font-config without dynamically linked dependencies
180fn font_file_matching(pref: &FontPreference) -> Option<File> {
181 let mut pattern = pref.name.clone();
182 if let Some(style) = &pref.style {
183 pattern.push(':');
184 pattern.push_str(style);
185 }
186 Command::new("fc-match")
187 .arg("-f")
188 .arg("%{file}")
189 .arg(&pattern)
190 .output()
191 .ok()
192 .and_then(|out| String::from_utf8(out.stdout).ok())
193 .and_then(|path| File::open(path.trim()).ok())
194}
195
196fn mmap(file: &File) -> Option<memmap2::Mmap> {
197 // Safety: System font files are not expected to be mutated during use
198 unsafe { memmap2::Mmap::map(file).ok() }
199}
vendor/sctk-adwaita/src/title/config.rs created+20
...@@ -0,0 +1,20 @@
1//! System font configuration.
2use crate::title::font_preference::FontPreference;
3use std::process::Command;
4
5/// Query system for which font to use for window titles.
6pub(crate) fn titlebar_font() -> Option<FontPreference> {
7 // outputs something like: `'Cantarell Bold 12'`
8 let stdout = Command::new("gsettings")
9 .args(["get", "org.gnome.desktop.wm.preferences", "titlebar-font"])
10 .output()
11 .ok()
12 .and_then(|out| String::from_utf8(out.stdout).ok())?;
13
14 FontPreference::from_name_style_size(
15 stdout
16 .trim()
17 .trim_end_matches('\'')
18 .trim_start_matches('\''),
19 )
20}
vendor/sctk-adwaita/src/title/crossfont_renderer.rs created+227
...@@ -0,0 +1,227 @@
1use std::mem;
2
3use crossfont::{GlyphKey, Rasterize, RasterizedGlyph};
4use tiny_skia::{Color, Pixmap, PixmapPaint, PixmapRef, Transform};
5
6use crate::title::config;
7
8pub struct CrossfontTitleText {
9 title: String,
10
11 font_desc: crossfont::FontDesc,
12 font_key: crossfont::FontKey,
13 size: crossfont::Size,
14 scale: u32,
15 metrics: crossfont::Metrics,
16 rasterizer: crossfont::Rasterizer,
17 color: Color,
18
19 pixmap: Option<Pixmap>,
20}
21
22impl std::fmt::Debug for CrossfontTitleText {
23 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24 f.debug_struct("TitleText")
25 .field("title", &self.title)
26 .field("font_desc", &self.font_desc)
27 .field("font_key", &self.font_key)
28 .field("size", &self.size)
29 .field("scale", &self.scale)
30 .field("pixmap", &self.pixmap)
31 .finish()
32 }
33}
34
35impl CrossfontTitleText {
36 pub fn new(color: Color) -> Result<Self, crossfont::Error> {
37 let title = "".into();
38
39 let font_pref = config::titlebar_font().unwrap_or_default();
40 let font_style = font_pref
41 .style
42 .map(crossfont::Style::Specific)
43 .unwrap_or_else(|| crossfont::Style::Description {
44 slant: crossfont::Slant::Normal,
45 weight: crossfont::Weight::Normal,
46 });
47 let font_desc = crossfont::FontDesc::new(&font_pref.name, font_style);
48
49 let mut rasterizer = crossfont::Rasterizer::new()?;
50 let size = crossfont::Size::new(font_pref.pt_size);
51 let font_key = rasterizer.load_font(&font_desc, size)?;
52
53 // Need to load at least one glyph for the face before calling metrics.
54 // The glyph requested here ('m' at the time of writing) has no special
55 // meaning.
56 rasterizer.get_glyph(GlyphKey {
57 font_key,
58 character: 'm',
59 size,
60 })?;
61
62 let metrics = rasterizer.metrics(font_key, size)?;
63
64 let mut this = Self {
65 pixmap: None,
66 rasterizer,
67 font_desc,
68 font_key,
69 scale: 1,
70 metrics,
71 title,
72 color,
73 size,
74 };
75
76 this.rerender();
77
78 Ok(this)
79 }
80
81 fn update_metrics(&mut self) -> Result<(), crossfont::Error> {
82 self.rasterizer.get_glyph(GlyphKey {
83 font_key: self.font_key,
84 character: 'm',
85 size: self.size,
86 })?;
87 self.metrics = self.rasterizer.metrics(self.font_key, self.size)?;
88 Ok(())
89 }
90
91 pub fn update_scale(&mut self, scale: u32) {
92 let old_scale = mem::replace(&mut self.scale, scale);
93 if old_scale != self.scale {
94 self.size = self.size.scale(self.scale as f32 / old_scale as f32);
95 self.update_metrics().ok();
96 self.rerender();
97 }
98 }
99
100 pub fn update_title<S: Into<String>>(&mut self, title: S) {
101 let title = title.into();
102 if self.title != title {
103 self.title = title;
104 self.rerender();
105 }
106 }
107
108 pub fn update_color(&mut self, color: Color) {
109 if self.color != color {
110 self.color = color;
111 self.rerender();
112 }
113 }
114
115 fn rerender(&mut self) {
116 let glyphs: Vec<_> = self
117 .title
118 .chars()
119 .filter_map(|character| {
120 let key = GlyphKey {
121 character,
122 font_key: self.font_key,
123 size: self.size,
124 };
125
126 self.rasterizer
127 .get_glyph(key)
128 .map(|glyph| (key, glyph))
129 .ok()
130 })
131 .collect();
132
133 if glyphs.is_empty() {
134 self.pixmap = None;
135 return;
136 }
137
138 let width = self.calc_width(&glyphs);
139 let height = self.metrics.line_height.round() as i32;
140
141 let mut pixmap = if let Some(p) = Pixmap::new(width as u32, height as u32) {
142 p
143 } else {
144 self.pixmap = None;
145 return;
146 };
147 // pixmap.fill(Color::from_rgba8(255, 0, 0, 55));
148
149 let mut caret = 0;
150 let mut last_glyph = None;
151
152 for (key, glyph) in glyphs {
153 let mut buffer = Vec::with_capacity(glyph.width as usize * 4);
154
155 let glyph_buffer = match &glyph.buffer {
156 crossfont::BitmapBuffer::Rgb(v) => v.chunks(3),
157 crossfont::BitmapBuffer::Rgba(v) => v.chunks(4),
158 };
159
160 for px in glyph_buffer {
161 let alpha = if let Some(alpha) = px.get(3) {
162 *alpha as f32 / 255.0
163 } else {
164 let r = px[0] as f32 / 255.0;
165 let g = px[1] as f32 / 255.0;
166 let b = px[2] as f32 / 255.0;
167 (r + g + b) / 3.0
168 };
169
170 let mut color = self.color;
171 color.set_alpha(alpha);
172 let color = color.premultiply().to_color_u8();
173
174 buffer.push(color.red());
175 buffer.push(color.green());
176 buffer.push(color.blue());
177 buffer.push(color.alpha());
178 }
179
180 if let Some(last) = last_glyph {
181 let (x, _) = self.rasterizer.kerning(last, key);
182 caret += x as i32;
183 }
184
185 if let Some(pixmap_glyph) =
186 PixmapRef::from_bytes(&buffer, glyph.width as _, glyph.height as _)
187 {
188 pixmap.draw_pixmap(
189 glyph.left + caret,
190 height - glyph.top + self.metrics.descent.round() as i32,
191 pixmap_glyph,
192 &PixmapPaint::default(),
193 Transform::identity(),
194 None,
195 );
196 }
197
198 caret += glyph.advance.0;
199
200 last_glyph = Some(key);
201 }
202
203 self.pixmap = Some(pixmap);
204 }
205
206 pub fn pixmap(&self) -> Option<&Pixmap> {
207 self.pixmap.as_ref()
208 }
209
210 fn calc_width(&mut self, glyphs: &[(GlyphKey, RasterizedGlyph)]) -> i32 {
211 let mut caret = 0;
212 let mut last_glyph: Option<&GlyphKey> = None;
213
214 for (key, glyph) in glyphs.iter() {
215 if let Some(last) = last_glyph {
216 let (x, _) = self.rasterizer.kerning(*last, *key);
217 caret += x as i32;
218 }
219
220 caret += glyph.advance.0;
221
222 last_glyph = Some(key);
223 }
224
225 caret
226 }
227}
vendor/sctk-adwaita/src/title/dumb.rs created+16
...@@ -0,0 +1,16 @@
1use tiny_skia::{Color, Pixmap};
2
3#[derive(Debug)]
4pub struct DumbTitleText {}
5
6impl DumbTitleText {
7 pub fn update_scale(&mut self, _scale: u32) {}
8
9 pub fn update_title<S: Into<String>>(&mut self, _title: S) {}
10
11 pub fn update_color(&mut self, _color: Color) {}
12
13 pub fn pixmap(&self) -> Option<&Pixmap> {
14 None
15 }
16}
vendor/sctk-adwaita/src/title/font_preference.rs created+91
...@@ -0,0 +1,91 @@
1#[derive(Debug)]
2pub(crate) struct FontPreference {
3 pub name: String,
4 pub style: Option<String>,
5 pub pt_size: f32,
6}
7
8impl Default for FontPreference {
9 fn default() -> Self {
10 Self {
11 name: "sans-serif".into(),
12 style: None,
13 pt_size: 10.0,
14 }
15 }
16}
17
18impl FontPreference {
19 /// Parse config string like `Cantarell 12`, `Cantarell Bold 11`, `Noto Serif CJK HK Bold 12`.
20 pub fn from_name_style_size(conf: &str) -> Option<Self> {
21 // assume last is size, 2nd last is style and the rest is name.
22 match conf.rsplit_once(' ') {
23 Some((head, tail)) if tail.chars().all(|c| c.is_numeric()) => {
24 let pt_size: f32 = tail.parse().unwrap_or(10.0);
25 match head.rsplit_once(' ') {
26 Some((name, style)) if !name.is_empty() => Some(Self {
27 name: name.into(),
28 style: Some(style.into()),
29 pt_size,
30 }),
31 None if !head.is_empty() => Some(Self {
32 name: head.into(),
33 style: None,
34 pt_size,
35 }),
36 _ => None,
37 }
38 }
39 Some((head, tail)) if !head.is_empty() => Some(Self {
40 name: head.into(),
41 style: Some(tail.into()),
42 pt_size: 10.0,
43 }),
44 None if !conf.is_empty() => Some(Self {
45 name: conf.into(),
46 style: None,
47 pt_size: 10.0,
48 }),
49 _ => None,
50 }
51 }
52}
53
54#[test]
55fn pref_from_multi_name_variant_size() {
56 let pref = FontPreference::from_name_style_size("Noto Serif CJK HK Bold 12").unwrap();
57 assert_eq!(pref.name, "Noto Serif CJK HK");
58 assert_eq!(pref.style, Some("Bold".into()));
59 assert!((pref.pt_size - 12.0).abs() < f32::EPSILON);
60}
61
62#[test]
63fn pref_from_name_variant_size() {
64 let pref = FontPreference::from_name_style_size("Cantarell Bold 12").unwrap();
65 assert_eq!(pref.name, "Cantarell");
66 assert_eq!(pref.style, Some("Bold".into()));
67 assert!((pref.pt_size - 12.0).abs() < f32::EPSILON);
68}
69
70#[test]
71fn pref_from_name_size() {
72 let pref = FontPreference::from_name_style_size("Cantarell 12").unwrap();
73 assert_eq!(pref.name, "Cantarell");
74 assert_eq!(pref.style, None);
75 assert!((pref.pt_size - 12.0).abs() < f32::EPSILON);
76}
77
78#[test]
79fn pref_from_name() {
80 let pref = FontPreference::from_name_style_size("Cantarell").unwrap();
81 assert_eq!(pref.name, "Cantarell");
82 assert_eq!(pref.style, None);
83 assert!((pref.pt_size - 10.0).abs() < f32::EPSILON);
84}
85#[test]
86fn pref_from_multi_name_style() {
87 let pref = FontPreference::from_name_style_size("Foo Bar Baz Bold").unwrap();
88 assert_eq!(pref.name, "Foo Bar Baz");
89 assert_eq!(pref.style, Some("Bold".into()));
90 assert!((pref.pt_size - 10.0).abs() < f32::EPSILON);
91}
vendor/sctk-adwaita/src/wl_typed.rs created+52
...@@ -0,0 +1,52 @@
1use std::{marker::PhantomData, ops::Deref};
2
3use smithay_client_toolkit::reexports::client::{Dispatch, Proxy};
4
5#[derive(Debug)]
6pub struct WlTyped<I, DATA>(I, PhantomData<DATA>);
7
8impl<I, DATA> WlTyped<I, DATA>
9where
10 I: Proxy,
11 DATA: Send + Sync + 'static,
12{
13 #[allow(clippy::extra_unused_type_parameters)]
14 pub fn wrap<STATE>(i: I) -> Self
15 where
16 STATE: Dispatch<I, DATA>,
17 {
18 Self(i, PhantomData)
19 }
20
21 pub fn inner(&self) -> &I {
22 &self.0
23 }
24
25 #[allow(dead_code)]
26 pub fn data(&self) -> &DATA {
27 // Generic on Self::wrap makes sure that this will never panic
28 #[allow(clippy::unwrap_used)]
29 self.0.data().unwrap()
30 }
31}
32
33impl<I: Clone, D> Clone for WlTyped<I, D> {
34 fn clone(&self) -> Self {
35 Self(self.0.clone(), PhantomData)
36 }
37}
38
39impl<I, D> Deref for WlTyped<I, D> {
40 type Target = I;
41
42 fn deref(&self) -> &Self::Target {
43 &self.0
44 }
45}
46
47impl<I: PartialEq, D> PartialEq for WlTyped<I, D> {
48 fn eq(&self, other: &Self) -> bool {
49 self.0 == other.0
50 }
51}
52impl<I: Eq, D> Eq for WlTyped<I, D> {}