| author | |
| committer | |
| log | 9205754444f9fd5a6408f0268058de0dc5d9a3ac |
| tree | 0b8ca1b06f1d9af98fa73ddfc93dfe1747a4f731 |
| parent | 11bf37c8a7d470d535338524dbe2c81bbcff29bb |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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.5140 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 | 4 | with it. Notes are rich text on a free canvas, stored in OneNote's own `.one` |
| 5 | 5 | files, so Snowbound and OneNote 2010 can open the same notebook side by side. |
| 6 | 6 | A notebook is just a folder. Put it on an SMB share and several people can |
| 7 | edit it together from either app, with no cloud service involved. Snowbound | |
| 8 | runs on macOS and Linux, and has an iOS app built around the same core. | |
| 7 | edit it together from either app, with no cloud service involved. With headless | |
| 8 | crates implementing the file format and a generic canvas renderer made with a | |
| 9 | novel UI kit, each new platform port is extremely lightweight. | |
| 9 | 10 | |
| 10 | 11 | ## Crates |
| 11 | 12 | |
| ... | ... | @@ -39,7 +40,6 @@ these pieces meet. |
| 39 | 40 | in the Windows 7 lab (`tools/w7`) before deciding. A storage feature is done |
| 40 | 41 | when a file Snowbound wrote cold-opens in a fresh OneNote and reads back as |
| 41 | 42 | intended ([testing](arc/testing.md)). |
| 42 | - **Jujutsu, not git.** The repository is managed with `jj`. Don't run `git`. | |
| 43 | 43 | - **Disposable copies only.** Never point the app, a replay script or a lab |
| 44 | 44 | harness at an original notebook. |
| 45 | 45 | |
| ... | ... | @@ -48,15 +48,15 @@ these pieces meet. |
| 48 | 48 | A `.one` file is a revision store. Every client, OneNote included, commits by |
| 49 | 49 | appending a revision and then rewriting the 1024-byte header, which makes the |
| 50 | 50 | header plus the file length a cheap *stamp* of the committed state. Snowbound |
| 51 | queues edits as ops in a local SQLite replica. A background thread polls the | |
| 52 | stamp. If the stamp is unchanged, it publishes the queued batch as one | |
| 51 | queues edits as ops in a local SQLite replica, then a background thread polls | |
| 52 | the stamp. If the stamp is unchanged, it publishes the queued batch as one | |
| 53 | 53 | appended revision. If it has changed, it reads the file once and replays the |
| 54 | queue on top. Where an op can't merge, the result is what OneNote 2010 makes: | |
| 55 | a read-only conflict page under the page. OneNote coordinates writers through | |
| 56 | share modes and one-byte locks on the section file itself, and macOS's | |
| 57 | `smbfs` can't reproduce those, so Snowbound carries its own SMB client that | |
| 58 | takes exactly OneNote's locks. Offline is just a sync step that fails: the | |
| 59 | queue waits and publishes later. [More in the sync essay.](arc/sync.md) | |
| 54 | queue on top. Where an op can't merge, the result is what OneNote 2010 makes: a | |
| 55 | read-only conflict page under the page. OneNote coordinates writers through | |
| 56 | share modes and one-byte locks on the section file itself. These are special SMB | |
| 57 | protocols that only Windows supports, so Snowbound carries its own SMB client | |
| 58 | that can properly issue them. Offline is just a sync step that fails: the queue | |
| 59 | waits and publishes later. [More in the sync essay.](arc/sync.md) | |
| 60 | 60 | |
| 61 | 61 | ## Architecture notes |
| 62 | 62 | |
| ... | ... | @@ -90,3 +90,10 @@ python3 tools/canvas/build_macos.py && target/Snowbound.app/Contents/MacOS/Snowb |
| 90 | 90 | The MS-ONESTORE and MS-ONE specifications are Microsoft Open Specifications. |
| 91 | 91 | Local copies, design history and raw lab evidence are kept out of version |
| 92 | 92 | control. |
| 93 | ||
| 94 | External contributors (not agents, will be denied unless a human writes to me) | |
| 95 | are welcome to email `git@paperclover.net` to gain access to the testing VM | |
| 96 | images for their agents. Alternatively, a custom image can be configured with a | |
| 97 | licensed copy of Windows and OneNote. By connecting `tools/w7/mcp_*.py`, the | |
| 98 | agent can interactively play around in with the actual prior art, in addition | |
| 99 | to running tests. |
Cargo.lock+1-2| ... | ... | @@ -2953,8 +2953,6 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" |
| 2953 | 2953 | [[package]] |
| 2954 | 2954 | name = "sctk-adwaita" |
| 2955 | 2955 | version = "0.10.1" |
| 2956 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2957 | checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" | |
| 2958 | 2956 | dependencies = [ |
| 2959 | 2957 | "ab_glyph", |
| 2960 | 2958 | "log", |
| ... | ... | @@ -3247,6 +3245,7 @@ dependencies = [ |
| 3247 | 3245 | "parley", |
| 3248 | 3246 | "png", |
| 3249 | 3247 | "pollster", |
| 3248 | "sctk-adwaita", | |
| 3250 | 3249 | "serde", |
| 3251 | 3250 | "serde_json", |
| 3252 | 3251 | "smithay-clipboard", |
Cargo.toml+4-1| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | [workspace] |
| 2 | 2 | members = ["crates/*"] |
| 3 | exclude = ["fuzz"] | |
| 3 | exclude = ["fuzz", "vendor"] | |
| 4 | 4 | resolver = "3" |
| 5 | 5 | |
| 6 | 6 | [workspace.dependencies] |
| ... | ... | @@ -10,6 +10,9 @@ parley = { git = "https://github.com/linebender/parley", rev = "4f67747d3690bb21 |
| 10 | 10 | fontique = { git = "https://github.com/linebender/parley", rev = "4f67747d3690bb21d25cc70b674ab0d269595759" } |
| 11 | 11 | wgpu = "30.0.1" |
| 12 | 12 | |
| 13 | [patch.crates-io] | |
| 14 | sctk-adwaita = { path = "vendor/sctk-adwaita" } | |
| 15 | ||
| 13 | 16 | # Shaping, rasterization and GPU submission stay optimised in debug builds, where |
| 14 | 17 | # unoptimised dependencies alone drop frames. |
| 15 | 18 | [profile.dev.package."*"] |
arc/canvas.md+9-5| ... | ... | @@ -26,8 +26,10 @@ host translates platform events in; carries out the Reques |
| 26 | 26 | A host feeds `PageView` pointer, key and text-input events. It gets back a |
| 27 | 27 | `Response` saying whether the page, the selection or only the view changed, |
| 28 | 28 | plus the occasional `Request` the page can't do itself: show a date picker, |
| 29 | read the clipboard, open the character palette. Everything platform-specific | |
| 30 | stays on the host's side of that line. | |
| 29 | read the clipboard, open a link. Everything platform-specific stays on the | |
| 30 | host's side of that line, shortcuts included: the page takes editing keys, and | |
| 31 | the host's command table runs the chords for formatting, tags, zoom and the | |
| 32 | clipboard through the page's own methods. | |
| 31 | 33 | |
| 32 | 34 | ## Editing emits ops |
| 33 | 35 | |
| ... | ... | @@ -73,13 +75,15 @@ gives outline sizes, its PDF export gives exact line breaks, and screenshots |
| 73 | 75 | give placement. The comparators in `tools/canvas` (with the probes |
| 74 | 76 | `layout-probe` and `page-probe`) keep checking them. |
| 75 | 77 | |
| 76 | Equations draw in two dimensions from the tree `onestore::page::Math` parses. | |
| 77 | They are edited as OneNote's equation editor edits them: Alt+= starts one, | |
| 78 | Equations draw in two dimensions from the tree `onestore::page::Math` parses, | |
| 79 | each in a space kept in its line of text, so text and links around them keep | |
| 80 | their look and the line grows to hold them. They are edited as OneNote's equation editor edits them: Alt+= starts one, | |
| 78 | 81 | typing is its linear format (UnicodeMath), and a space builds up what it ends. |
| 79 | 82 | Linear and Professional switch an equation between the forms, and OneNote |
| 80 | 83 | stores both. Links follow OneNote too: a typed URL links itself when a space or |
| 81 | 84 | Enter ends it, the Link dialog stores its address in a hidden field code before |
| 82 | the label, and a click opens a link. Ink draws stroke by stroke in page | |
| 85 | the label, and a click or Enter opens a link. URL text shows as a link, as | |
| 86 | OneNote links it when it opens a page, without being stored as one. Ink draws stroke by stroke in page | |
| 83 | 87 | coordinates. Page templates' background art is recreated as vector art and |
| 84 | 88 | recognised by the stored picture's hash. OneNote's bitmaps aren't shipped. |
| 85 | 89 |
arc/platforms.md+7| ... | ... | @@ -23,6 +23,9 @@ page inside it. `snowbound` picks a platform module at compile time |
| 23 | 23 | in the crate is shared: library and settings, the sidebar, menus, page and |
| 24 | 24 | section management, templates, and screenshot and replay support. |
| 25 | 25 | Accessibility 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 | |
| 27 | macOS and elsewhere, when it is enabled or checked, and what it does. The | |
| 28 | keyboard, the toolbar and the macOS menu bar all run commands from it. | |
| 26 | 29 | |
| 27 | 30 | ### macOS |
| 28 | 31 | |
| ... | ... | @@ -40,6 +43,10 @@ Accessibility for the page goes through AccessKit's winit adapter on both. |
| 40 | 43 | - A notebook on a mounted SMB share is opened through the embedded SMB client, |
| 41 | 44 | signed in with the password the keychain keeps for that mount (see |
| 42 | 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 | 50 | - `tools/canvas/build_macos.py` builds and ad-hoc signs `target/Snowbound.app`. |
| 44 | 51 | |
| 45 | 52 | ### 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 | 89 | frame. The page's scrollbars are ordinary `ui` widgets; the canvas only |
| 90 | 90 | reports its scroll bounds. |
| 91 | 91 | |
| 92 | `ui::popup` builds menus, filterable lists, a colour grid and a fuzzy command | |
| 92 | `ui::popup` builds menus, filterable lists, a colour grid, galleries and a fuzzy command | |
| 93 | 93 | palette on a popup layer that takes input above everything else. `ui::shell` |
| 94 | 94 | has the OneNote-specific controls: section tabs and compact toolbar buttons. |
| 95 | 95 | The 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 | 21 | <stop offset="1" stop-color="#e0205a"/> |
| 22 | 22 | </linearGradient> |
| 23 | 23 | </defs> |
| 24 | <path d="M12.31 1.14L14.86 3.69L7.01 11.54L4.46 8.99Z" fill="url(#layer0)"/> | |
| 25 | <path d="M5.35 7.89L8.11 10.65L4.55 13.04L2.96 11.45Z" fill="url(#layer1)"/> | |
| 26 | <path d="M12.31 2.13L13.87 3.69L7.01 10.55L5.45 8.99Z" fill="url(#layer2)"/> | |
| 27 | <path d="M12.31 2.13L13.87 3.69L12.17 5.38L10.62 3.83Z" fill="url(#layer3)"/> | |
| 28 | <path d="M5.45 8.99L7.01 10.55L4.64 12.14L3.86 11.36Z" fill="url(#layer4)"/> | |
| 24 | <path d="M11.31 2.14L13.86 4.69L6.01 12.54L3.46 9.99Z" fill="url(#layer0)"/> | |
| 25 | <path d="M4.35 8.89L7.11 11.65L3.55 14.04L1.96 12.45Z" fill="url(#layer1)"/> | |
| 26 | <path d="M11.31 3.13L12.87 4.69L6.01 11.55L4.45 9.99Z" fill="url(#layer2)"/> | |
| 27 | <path d="M11.31 3.13L12.87 4.69L11.17 6.38L9.62 4.83Z" fill="url(#layer3)"/> | |
| 28 | <path d="M4.45 9.99L6.01 11.55L3.64 13.14L2.86 12.36Z" fill="url(#layer4)"/> | |
| 29 | 29 | </svg> |
crates/canvas/assets/tags/question.svg+1-1| ... | ... | @@ -5,5 +5,5 @@ |
| 5 | 5 | <stop offset="1" stop-color="#8f008f"/> |
| 6 | 6 | </linearGradient> |
| 7 | 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 | 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 | //! Conflict pages as OneNote shows them. | |
| 1 | //! Conflict pages and page versions as OneNote shows them. | |
| 2 | 2 | |
| 3 | 3 | use onestore::{ |
| 4 | 4 | ExGuid, |
| ... | ... | @@ -8,21 +8,72 @@ use onestore::{ |
| 8 | 8 | /// OneNote's highlight for conflicting changes on a conflict page, COLORREF. |
| 9 | 9 | pub const CONFLICTING: u32 = 0xd6d6ff; |
| 10 | 10 | |
| 11 | /// OneNote's highlight for what a page version changed since the version before it, COLORREF. | |
| 12 | pub const CHANGED: u32 = 0xd6ffd6; | |
| 13 | ||
| 11 | 14 | /// `page` with its conflict `objects` marked as OneNote shows a conflict page's: a band |
| 12 | 15 | /// across each conflicting paragraph. |
| 13 | pub fn highlighted(mut page: Page, objects: &[ExGuid]) -> Page { | |
| 14 | fn mark(paragraphs: &mut [PageParagraph], objects: &[ExGuid]) { | |
| 16 | pub fn highlighted(page: Page, objects: &[ExGuid]) -> Page { | |
| 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. | |
| 22 | pub 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 | ||
| 37 | fn 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 | ||
| 64 | fn banded(mut page: Page, objects: &[ExGuid], color: u32) -> Page { | |
| 65 | fn mark(paragraphs: &mut [PageParagraph], objects: &[ExGuid], color: u32) { | |
| 15 | 66 | for paragraph in paragraphs { |
| 16 | 67 | match &mut paragraph.content { |
| 17 | 68 | ParagraphContent::Table(table) => { |
| 18 | 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 | 73 | ParagraphContent::Text(text) |
| 23 | 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 | 81 | } |
| 31 | 82 | for object in &mut page.objects { |
| 32 | 83 | match object { |
| 33 | PageObject::Outline(outline) => mark(&mut outline.paragraphs, objects), | |
| 84 | PageObject::Outline(outline) => mark(&mut outline.paragraphs, objects, color), | |
| 34 | 85 | PageObject::Title(title) => { |
| 35 | 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 | 248 | } |
| 249 | 249 | |
| 250 | 250 | /// 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 run | |
| 252 | /// data belongs to the whole paragraph, and OneNote has not been seen dividing them. | |
| 251 | /// an embedded object at the seam or carries one to another paragraph, or joins inside an | |
| 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`). | |
| 253 | 254 | fn moves_object( |
| 254 | 255 | first: &Paragraph, |
| 255 | 256 | start: u32, |
| 256 | 257 | last: &Paragraph, |
| 257 | 258 | end: u32, |
| 258 | 259 | ) -> Result<bool, EditError> { |
| 259 | let object = |text: &Paragraph, span: usize| { | |
| 260 | let format = &text.spans()[span].format; | |
| 261 | [format.math, format.embedded_object].contains(&Some(true)) | |
| 260 | let math = |text: &Paragraph, offset: u32| { | |
| 261 | text.format_at(offset) | |
| 262 | .is_ok_and(|format| format.math == Some(true)) | |
| 262 | 263 | }; |
| 263 | 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 | 269 | Ok(first.text()[..before].ends_with('\u{fffc}') |
| 265 | 270 | || last.text()[after..].contains('\u{fffc}') |
| 266 | || before > 0 | |
| 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))) | |
| 271 | || start > 0 && math(first, start) && following.is_some_and(|next| math(last, next))) | |
| 273 | 272 | } |
| 274 | 273 | |
| 275 | 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 | 280 | }) |
| 282 | 281 | } |
| 283 | 282 | |
| 283 | fn 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 | 327 | /// The index after `nodes[index]`'s last descendant. Descendants follow their ancestor |
| 285 | 328 | /// contiguously; a paragraph only an outline group indents has no parent to descend from. |
| 286 | 329 | pub(crate) fn subtree_end(nodes: &[PageParagraph], index: usize) -> usize { |
| ... | ... | @@ -551,6 +594,14 @@ impl TextDocument { |
| 551 | 594 | } |
| 552 | 595 | } |
| 553 | 596 | |
| 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 | 605 | /// The root node holding a table cell. |
| 555 | 606 | pub(crate) fn root(&self, cell: ExGuid) -> Result<usize, EditError> { |
| 556 | 607 | self.tables |
crates/canvas/src/editor.rs+325-16| ... | ... | @@ -24,13 +24,18 @@ use std::{ |
| 24 | 24 | |
| 25 | 25 | pub const DEFAULT_OUTLINE_WIDTH: f32 = 468.0; |
| 26 | 26 | |
| 27 | mod equation; | |
| 27 | 28 | #[cfg(test)] |
| 28 | 29 | mod evidence; |
| 29 | 30 | mod format; |
| 31 | mod link; | |
| 30 | 32 | mod ops; |
| 31 | 33 | pub(crate) mod page; |
| 32 | 34 | mod table; |
| 33 | pub use format::{Alignment, FormatState, Formatting, NoteTag, Toggle}; | |
| 35 | pub use format::{ | |
| 36 | Alignment, BULLET_LIBRARY, FormatState, Formatting, ListStyle, NUMBER_LIBRARY, NoteTag, Toggle, | |
| 37 | }; | |
| 38 | pub use link::{Link, shown_urls}; | |
| 34 | 39 | pub use page::ReadOnlyObject; |
| 35 | 40 | |
| 36 | 41 | #[derive(Debug)] |
| ... | ... | @@ -395,6 +400,19 @@ impl TextOutline { |
| 395 | 400 | .unwrap() |
| 396 | 401 | } |
| 397 | 402 | |
| 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 | 416 | pub fn paragraph_layout(&self, source: usize) -> Result<&ParagraphLayout, EditError> { |
| 399 | 417 | Ok(&self.shaped.paragraphs[self.visible_index(source)?]) |
| 400 | 418 | } |
| ... | ... | @@ -2006,6 +2024,50 @@ impl CanvasEditor { |
| 2006 | 2024 | ) |
| 2007 | 2025 | } |
| 2008 | 2026 | |
| 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 | 2071 | pub fn select_at(&mut self, x: f32, y: f32, extend: bool) -> Result<(), EditError> { |
| 2010 | 2072 | let mut selection = self.selection_at(x, y, SelectionUnit::Grapheme)?; |
| 2011 | 2073 | if extend { |
| ... | ... | @@ -2046,20 +2108,10 @@ impl CanvasEditor { |
| 2046 | 2108 | selection.affinities[slot] = cursor.affinity(); |
| 2047 | 2109 | } |
| 2048 | 2110 | 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; | |
| 2052 | let source = selection.positions[0].paragraph; | |
| 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 | } | |
| 2113 | selection.positions[1] = next; | |
| 2114 | selection.affinities[1] = Affinity::Downstream; | |
| 2063 | 2115 | } |
| 2064 | 2116 | Ok(selection) |
| 2065 | 2117 | } |
| ... | ... | @@ -2093,6 +2145,9 @@ impl CanvasEditor { |
| 2093 | 2145 | extend: bool, |
| 2094 | 2146 | ) -> Result<(), EditorError> { |
| 2095 | 2147 | self.finish_composition(); |
| 2148 | if !extend && movement == Movement::Right && self.leave_equation()? { | |
| 2149 | return Ok(()); | |
| 2150 | } | |
| 2096 | 2151 | let [anchor, focus] = self.active_outline().selection.positions; |
| 2097 | 2152 | if !extend |
| 2098 | 2153 | && self.caret_outline().is_some() |
| ... | ... | @@ -2557,7 +2612,9 @@ impl CanvasEditor { |
| 2557 | 2612 | ) |
| 2558 | 2613 | } |
| 2559 | 2614 | |
| 2615 | /// Types `text` at the selection; a space ends a typed URL, which becomes a link. | |
| 2560 | 2616 | pub fn insert(&mut self, engine: &mut TextEngine, text: &str) -> Result<(), EditorError> { |
| 2617 | self.leave_link_code()?; | |
| 2561 | 2618 | let start = self.active_outline().selection.positions[0] |
| 2562 | 2619 | .min(self.active_outline().selection.positions[1]); |
| 2563 | 2620 | let format = self.typing_format(start)?; |
| ... | ... | @@ -2565,7 +2622,12 @@ impl CanvasEditor { |
| 2565 | 2622 | .split('\n') |
| 2566 | 2623 | .map(|line| Paragraph::new(line.to_owned(), format.clone())) |
| 2567 | 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 | } |
| 2570 | 2632 | |
| 2571 | 2633 | /// Pastes plain text as OneNote does: lines become plain Calibri 11 paragraphs without style |
| ... | ... | @@ -2634,7 +2696,13 @@ impl CanvasEditor { |
| 2634 | 2696 | fn split(&mut self, engine: &mut TextEngine) -> Result<(), EditorError> { |
| 2635 | 2697 | let [anchor, focus] = self.active_outline().selection.positions; |
| 2636 | 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 | 2706 | let document = &self.active_outline().document; |
| 2639 | 2707 | let mut edit = document.replace( |
| 2640 | 2708 | start..end, |
| ... | ... | @@ -2746,6 +2814,75 @@ impl CanvasEditor { |
| 2746 | 2814 | Ok(true) |
| 2747 | 2815 | } |
| 2748 | 2816 | |
| 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 | 2886 | /// Deletes the selection or the character beside the caret. Backspace at a paragraph's start |
| 2750 | 2887 | /// first removes its list, then outdents it, then joins it to the paragraph above, whose |
| 2751 | 2888 | /// properties win; Delete at its end joins the paragraph below (`evidence/structural-edits/ |
| ... | ... | @@ -8872,4 +9009,176 @@ mod tests { |
| 8872 | 9009 | assert!(editor.undo.is_empty()); |
| 8873 | 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 | ||
| 6 | use super::link::format_after; | |
| 7 | use super::*; | |
| 8 | use onestore::page::Math; | |
| 9 | ||
| 10 | const OBJECT_START: char = '\u{fdd0}'; | |
| 11 | const ARGUMENT_SEPARATOR: char = '\u{fdee}'; | |
| 12 | const OBJECT_END: char = '\u{fdef}'; | |
| 13 | ||
| 14 | /// The run of math in `text` that UTF-16 `offset` lies in or touches. | |
| 15 | pub(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. | |
| 41 | pub(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 | ||
| 64 | impl 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)] | |
| 406 | mod 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 | 1 | //! OneNote 2010's Enter, Backspace, Delete, Tab and paste replayed against the page XML it |
| 2 | //! reported after each keystroke (`evidence/structural-edits`): each outline is built from, and | |
| 3 | //! compared with, the one-line-per-paragraph summaries in `summaries/`, whose `L` counts | |
| 2 | //! reported after each keystroke: each outline is built from, and compared with, the | |
| 3 | //! one-line-per-paragraph summaries in `corpus/structural-probe/summaries/`, whose `L` counts | |
| 4 | 4 | //! paragraph nesting. |
| 5 | 5 | |
| 6 | use super::format::{NoteTag, list_definition}; | |
| 6 | use super::format::{ListStyle, NoteTag, list_definition}; | |
| 7 | 7 | use super::*; |
| 8 | 8 | use onestore::document::Tag; |
| 9 | 9 | use onestore::page::ParagraphContent; |
| ... | ... | @@ -11,7 +11,7 @@ use onestore::page::text::new_id; |
| 11 | 11 | |
| 12 | 12 | fn summary(name: &str) -> String { |
| 13 | 13 | std::fs::read_to_string(format!( |
| 14 | "{}/../../evidence/structural-edits/summaries/{name}.txt", | |
| 14 | "{}/../../corpus/structural-probe/summaries/{name}.txt", | |
| 15 | 15 | env!("CARGO_MANIFEST_DIR") |
| 16 | 16 | )) |
| 17 | 17 | .unwrap() |
| ... | ... | @@ -76,7 +76,14 @@ fn open(engine: &mut TextEngine, summary: &str) -> CanvasEditor { |
| 76 | 76 | .find(|parent| parent.level + 1 == level) |
| 77 | 77 | .map(|parent| parent.id); |
| 78 | 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 | 87 | node.lists = vec![define(format!("list {}", nodes.len()), definition)]; |
| 81 | 88 | } |
| 82 | 89 | for tag in tags.split(',').filter(|tag| !tag.is_empty()) { |
| ... | ... | @@ -162,9 +169,10 @@ fn render(editor: &CanvasEditor) -> String { |
| 162 | 169 | .paragraphs |
| 163 | 170 | .iter() |
| 164 | 171 | .find(|paragraph| paragraph.id == node.id) |
| 165 | .and_then(|paragraph| paragraph.number) | |
| 172 | .and_then(|paragraph| paragraph.number.as_ref()) | |
| 166 | 173 | .unwrap() |
| 167 | .0; | |
| 174 | .0 | |
| 175 | .number; | |
| 168 | 176 | let numeral = crate::outline::numeral(rest.next(), number).unwrap(); |
| 169 | 177 | format!("num:{numeral}{}", rest.as_str()) |
| 170 | 178 | } |
| ... | ... | @@ -274,7 +282,7 @@ fn replay( |
| 274 | 282 | layout |
| 275 | 283 | .paragraphs |
| 276 | 284 | .iter() |
| 277 | .map(|paragraph| paragraph.number) | |
| 285 | .map(|paragraph| paragraph.number.clone()) | |
| 278 | 286 | .collect::<Vec<_>>() |
| 279 | 287 | }; |
| 280 | 288 | assert_eq!(numbers(&outline.shaped), numbers(&fresh), "{after}"); |
| ... | ... | @@ -715,7 +723,7 @@ fn structural_edits_on_native_pages_survive_the_page_writer() { |
| 715 | 723 | use onestore::{RevisionIndex, Store, document::Document}; |
| 716 | 724 | let section = std::fs::read(concat!( |
| 717 | 725 | env!("CARGO_MANIFEST_DIR"), |
| 718 | "/../../evidence/structural-edits/probe-section/probe.one" | |
| 726 | "/../../corpus/structural-probe/probe.one" | |
| 719 | 727 | )) |
| 720 | 728 | .unwrap(); |
| 721 | 729 | let page = |bytes: &[u8], title: &str| { |
crates/canvas/src/editor/format.rs+497-118| ... | ... | @@ -23,12 +23,17 @@ pub enum Formatting { |
| 23 | 23 | Highlight(Option<u32>), |
| 24 | 24 | /// Returns text to its paragraph style. |
| 25 | 25 | Clear, |
| 26 | /// Toggles OneNote's default bullet or number, as Ctrl+. and Ctrl+/ do. | |
| 26 | 27 | Bullets, |
| 27 | 28 | Numbering, |
| 29 | /// Applies a style from OneNote's bullet or numbering library; `None` removes lists. | |
| 30 | List(Option<ListStyle>), | |
| 28 | 31 | Indent, |
| 29 | 32 | Outdent, |
| 30 | 33 | Align(Alignment), |
| 31 | 34 | Tag(NoteTag), |
| 35 | /// Removes every note tag from the selected paragraphs. | |
| 36 | RemoveTags, | |
| 32 | 37 | /// Checks the selected paragraphs' check boxes, or clears them when all are checked. |
| 33 | 38 | Check, |
| 34 | 39 | } |
| ... | ... | @@ -52,8 +57,108 @@ pub enum Alignment { |
| 52 | 57 | Right, |
| 53 | 58 | } |
| 54 | 59 | |
| 60 | /// A style of OneNote 2010's bullet or numbering library, by its place in the gallery. | |
| 61 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 62 | pub 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. | |
| 69 | pub 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. | |
| 108 | pub 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 | ||
| 130 | impl 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 | 160 | /// 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 | 162 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 58 | 163 | pub enum NoteTag { |
| 59 | 164 | ToDo, |
| ... | ... | @@ -68,7 +173,7 @@ pub enum NoteTag { |
| 68 | 173 | } |
| 69 | 174 | |
| 70 | 175 | /// What the selection shows on the toolbar. |
| 71 | #[derive(Clone, Debug, PartialEq)] | |
| 176 | #[derive(Clone, Debug, Default, PartialEq)] | |
| 72 | 177 | pub struct FormatState { |
| 73 | 178 | /// Attributes every selected character has. |
| 74 | 179 | pub toggles: Vec<Toggle>, |
| ... | ... | @@ -80,6 +185,8 @@ pub struct FormatState { |
| 80 | 185 | /// Whether every selected paragraph is bulleted or numbered. |
| 81 | 186 | pub bullets: bool, |
| 82 | 187 | pub numbering: bool, |
| 188 | /// The library style every selected paragraph's list shares. | |
| 189 | pub list: Option<ListStyle>, | |
| 83 | 190 | /// Tags every selected paragraph has. |
| 84 | 191 | pub tags: Vec<NoteTag>, |
| 85 | 192 | } |
| ... | ... | @@ -118,7 +225,7 @@ impl Toggle { |
| 118 | 225 | } |
| 119 | 226 | |
| 120 | 227 | impl NoteTag { |
| 121 | pub(super) const ALL: [Self; 9] = [ | |
| 228 | pub(crate) const ALL: [Self; 9] = [ | |
| 122 | 229 | Self::ToDo, |
| 123 | 230 | Self::Important, |
| 124 | 231 | Self::Question, |
| ... | ... | @@ -130,21 +237,36 @@ impl NoteTag { |
| 130 | 237 | Self::PhoneNumber, |
| 131 | 238 | ]; |
| 132 | 239 | |
| 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 | 255 | pub(super) fn definition(self) -> Definition { |
| 134 | let (label, shape, highlight) = match self { | |
| 135 | Self::ToDo => ("To Do", CHECKBOX, None), | |
| 136 | Self::Important => ("Important", 13, None), | |
| 137 | Self::Question => ("Question", 15, None), | |
| 138 | Self::RememberForLater => ("Remember for later", 0, Some(0x0000_ffff)), | |
| 139 | Self::Definition => ("Definition", 0, Some(0x0000_ff00)), | |
| 140 | Self::Highlight => ("Highlight", 136, None), | |
| 141 | Self::Contact => ("Contact", 118, None), | |
| 142 | Self::Address => ("Address", 23, None), | |
| 143 | Self::PhoneNumber => ("Phone number", 18, None), | |
| 256 | let (shape, highlight) = match self { | |
| 257 | Self::ToDo => (CHECKBOX, None), | |
| 258 | Self::Important => (13, None), | |
| 259 | Self::Question => (15, None), | |
| 260 | Self::RememberForLater => (0, Some(0x0000_ffff)), | |
| 261 | Self::Definition => (0, Some(0x0000_ff00)), | |
| 262 | Self::Highlight => (136, None), | |
| 263 | Self::Contact => (118, None), | |
| 264 | Self::Address => (23, None), | |
| 265 | Self::PhoneNumber => (18, None), | |
| 144 | 266 | }; |
| 145 | 267 | Definition { |
| 146 | 268 | kind: Kind::TagDefinition { |
| 147 | label: Some(label.into()), | |
| 269 | label: Some(self.label().into()), | |
| 148 | 270 | action_type: Some(self as u16), |
| 149 | 271 | shape: Some(shape), |
| 150 | 272 | // The highlighting tags also set black text. |
| ... | ... | @@ -161,7 +283,7 @@ type Ends = [(ExGuid, usize); 2]; |
| 161 | 283 | |
| 162 | 284 | /// The selection's ends and the container range holding both; a selection ending at a |
| 163 | 285 | /// paragraph's start ends with the paragraph before. |
| 164 | fn selected( | |
| 286 | pub(super) fn selected( | |
| 165 | 287 | document: &TextDocument, |
| 166 | 288 | selection: Selection, |
| 167 | 289 | ) -> Result<(Option<ExGuid>, Range<usize>, Ends), EditError> { |
| ... | ... | @@ -224,7 +346,7 @@ fn covered( |
| 224 | 346 | }) |
| 225 | 347 | } |
| 226 | 348 | |
| 227 | fn leaves_mut(nodes: &mut [PageParagraph], change: &mut impl FnMut(&mut PageParagraph)) { | |
| 349 | pub(super) fn leaves_mut(nodes: &mut [PageParagraph], change: &mut impl FnMut(&mut PageParagraph)) { | |
| 228 | 350 | for node in nodes { |
| 229 | 351 | match &mut node.content { |
| 230 | 352 | ParagraphContent::Text(_) => change(node), |
| ... | ... | @@ -239,7 +361,11 @@ fn leaves_mut(nodes: &mut [PageParagraph], change: &mut impl FnMut(&mut PagePara |
| 239 | 361 | } |
| 240 | 362 | |
| 241 | 363 | /// `text` with `change` applied to the formats of bytes `range`, or to its only format when empty. |
| 242 | fn restyle(text: &Paragraph, range: Range<usize>, change: impl Fn(&mut Format)) -> Paragraph { | |
| 364 | pub(super) fn restyle( | |
| 365 | text: &Paragraph, | |
| 366 | range: Range<usize>, | |
| 367 | change: impl Fn(&mut Format), | |
| 368 | ) -> Paragraph { | |
| 243 | 369 | if text.text().is_empty() { |
| 244 | 370 | let mut format = text.spans()[0].format.clone(); |
| 245 | 371 | change(&mut format); |
| ... | ... | @@ -316,7 +442,8 @@ fn tags(node: &PageParagraph) -> impl Iterator<Item = &Tag> { |
| 316 | 442 | |
| 317 | 443 | impl CanvasEditor { |
| 318 | 444 | /// 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 | 447 | pub(super) fn typing_format(&self, position: TextPosition) -> Result<Format, EditError> { |
| 321 | 448 | if let Some((id, at, format)) = &self.pending |
| 322 | 449 | && *id == self.active_outline().id |
| ... | ... | @@ -324,13 +451,23 @@ impl CanvasEditor { |
| 324 | 451 | { |
| 325 | 452 | return Ok(format.clone()); |
| 326 | 453 | } |
| 327 | Ok(self | |
| 454 | let text = self | |
| 328 | 455 | .active_outline() |
| 329 | 456 | .document |
| 330 | 457 | .paragraph(position.paragraph) |
| 331 | .ok_or(EditError::InvalidRange)? | |
| 332 | .format_at(position.offset)? | |
| 333 | .clone()) | |
| 458 | .ok_or(EditError::InvalidRange)?; | |
| 459 | let mut format = text.format_at(position.offset)?.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 | } |
| 335 | 472 | |
| 336 | 473 | fn list(&self, node: &PageParagraph) -> Option<Formatting> { |
| ... | ... | @@ -349,6 +486,10 @@ impl CanvasEditor { |
| 349 | 486 | } |
| 350 | 487 | } |
| 351 | 488 | |
| 489 | fn list_style(&self, node: &PageParagraph) -> Option<ListStyle> { | |
| 490 | ListStyle::of(&self.definitions.get(node.lists.last()?)?.kind) | |
| 491 | } | |
| 492 | ||
| 352 | 493 | fn tag_kind(&self, tag: &Tag) -> Option<&Kind<'static>> { |
| 353 | 494 | Some(&self.definitions.get(tag.definition.as_ref()?)?.kind) |
| 354 | 495 | } |
| ... | ... | @@ -395,6 +536,7 @@ impl CanvasEditor { |
| 395 | 536 | })), |
| 396 | 537 | bullets: list(Formatting::Bullets), |
| 397 | 538 | numbering: list(Formatting::Numbering), |
| 539 | list: common(paragraphs.iter().map(|node| self.list_style(node))), | |
| 398 | 540 | tags: NoteTag::ALL |
| 399 | 541 | .into_iter() |
| 400 | 542 | .filter(|tag| { |
| ... | ... | @@ -429,7 +571,9 @@ impl CanvasEditor { |
| 429 | 571 | Formatting::Align(_) |
| 430 | 572 | | Formatting::Bullets |
| 431 | 573 | | Formatting::Numbering |
| 574 | | Formatting::List(_) | |
| 432 | 575 | | Formatting::Tag(_) |
| 576 | | Formatting::RemoveTags | |
| 433 | 577 | | Formatting::Check |
| 434 | 578 | if title => |
| 435 | 579 | { |
| ... | ... | @@ -455,13 +599,27 @@ impl CanvasEditor { |
| 455 | 599 | } |
| 456 | 600 | }); |
| 457 | 601 | } |
| 458 | Formatting::Bullets | Formatting::Numbering => { | |
| 459 | let remove = covered(&replacement, ends) | |
| 460 | .all(|(node, _)| self.list(node).as_ref() == Some(&command)); | |
| 602 | Formatting::Bullets | Formatting::Numbering | Formatting::List(_) => { | |
| 603 | let style = match 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 | 617 | // 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 | 620 | let nodes = outline.document.container(container)?; |
| 464 | 621 | if !remove |
| 622 | && nodes[range.start].lists.is_empty() | |
| 465 | 623 | && crate::document::previous_sibling(nodes, range.start, &BTreeSet::new()) |
| 466 | 624 | .is_some_and(|sibling| nodes[sibling].lists.is_empty()) |
| 467 | 625 | && let Some(edit) = |
| ... | ... | @@ -472,13 +630,10 @@ impl CanvasEditor { |
| 472 | 630 | } |
| 473 | 631 | let wanted = covered(&replacement, ends) |
| 474 | 632 | .map(|(node, _)| node) |
| 475 | .filter(|node| !remove && self.list(node).as_ref() != Some(&command)) | |
| 476 | .map(|node| { | |
| 633 | .filter(|node| !remove && !has(node)) | |
| 634 | .filter_map(|node| { | |
| 477 | 635 | let format = &node.text().unwrap().text.spans()[0].format; |
| 478 | ( | |
| 479 | node.id, | |
| 480 | list_definition(command == Formatting::Numbering, format), | |
| 481 | ) | |
| 636 | Some((node.id, list_definition(style?, format))) | |
| 482 | 637 | }) |
| 483 | 638 | .collect::<Vec<_>>(); |
| 484 | 639 | // A list definition belongs to one paragraph, as OneNote stores it. |
| ... | ... | @@ -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 | 724 | Formatting::Check => self.check(&mut replacement, ranges, ends), |
| 564 | 725 | Formatting::Toggle(_) |
| 565 | 726 | | Formatting::Font(_) |
| ... | ... | @@ -567,10 +728,20 @@ impl CanvasEditor { |
| 567 | 728 | | Formatting::Color(_) |
| 568 | 729 | | Formatting::Highlight(_) |
| 569 | 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 | 738 | let on = match command { |
| 571 | Formatting::Toggle(toggle) => !character_formats(covered(&replacement, ends)) | |
| 572 | .iter() | |
| 573 | .all(|format| toggle.get(format) == Some(true)), | |
| 739 | Formatting::Toggle(toggle) => !match &caret { | |
| 740 | Some((_, format)) => vec![format], | |
| 741 | None => character_formats(covered(&replacement, ends)), | |
| 742 | } | |
| 743 | .iter() | |
| 744 | .all(|format| toggle.get(format) == Some(true)), | |
| 574 | 745 | _ => true, |
| 575 | 746 | }; |
| 576 | 747 | let change = |base: &Format, format: &mut Format| match &command { |
| ... | ... | @@ -606,10 +777,7 @@ impl CanvasEditor { |
| 606 | 777 | } |
| 607 | 778 | }; |
| 608 | 779 | let bases = bases(&replacement)?; |
| 609 | if ends[0] == ends[1] { | |
| 610 | let [anchor, focus] = selection.positions; | |
| 611 | let caret = anchor.min(focus); | |
| 612 | let mut format = self.typing_format(caret)?; | |
| 780 | if let Some((caret, mut format)) = caret { | |
| 613 | 781 | change(&bases[&ends[0].0], &mut format); |
| 614 | 782 | self.pending = Some((id, caret, format)); |
| 615 | 783 | return Ok(()); |
| ... | ... | @@ -717,79 +885,47 @@ impl CanvasEditor { |
| 717 | 885 | } |
| 718 | 886 | } |
| 719 | 887 | |
| 720 | /// The list a Tab (`deeper`) or Shift+Tab gives a paragraph with a default list, as OneNote | |
| 721 | /// 2010 steps • to ○ and 1. to a. (`evidence/structural-edits/xml/c6-bullet-tab.xml`, | |
| 722 | /// `c8-tab-1.xml`), then to ■ as `corpus/private` nests bullets, and to i.; outdenting stops at | |
| 723 | /// the first style. | |
| 888 | /// The list a Tab (`deeper`) or Shift+Tab gives a paragraph whose list is one OneNote 2010 | |
| 889 | /// steps through as Tab nests it: from Ctrl+. through nine bullets, and from Ctrl+/ through | |
| 890 | /// eight number formats, each as its gallery stores it, starting over after the last | |
| 891 | /// (`evidence/toolbar-17/tab-lists.txt`). Outdenting stops at the first. | |
| 724 | 892 | pub(super) fn nested_list(definition: &Definition, deeper: bool) -> Option<Definition> { |
| 725 | const BULLETS: [(&str, &str, u16); 3] = [ | |
| 726 | ("Calibri", "\u{2022}", 1), | |
| 727 | ("Courier New", "\u{25cb}", 4), | |
| 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 { | |
| 893 | const BULLETS: [usize; 9] = [2, 3, 13, 14, 9, 7, 15, 26, 8]; | |
| 894 | const NUMBERS: [usize; 8] = [0, 2, 4, 6, 7, 9, 16, 17]; | |
| 895 | let Kind::List { restart, .. } = &definition.kind else { | |
| 738 | 896 | return None; |
| 739 | 897 | }; |
| 740 | let step = |index: usize| { | |
| 741 | if deeper { | |
| 742 | Some((index + 1) % 3) | |
| 743 | } else { | |
| 744 | index.checked_sub(1) | |
| 745 | } | |
| 898 | let current = ListStyle::of(&definition.kind)?; | |
| 899 | let (chain, style): (&[usize], fn(usize) -> ListStyle) = match current { | |
| 900 | ListStyle::Bullet(_) => (&BULLETS, ListStyle::Bullet), | |
| 901 | ListStyle::Number(_) => (&NUMBERS, ListStyle::Number), | |
| 746 | 902 | }; |
| 747 | let kind = match bullet { | |
| 748 | Some(_) => { | |
| 749 | let index = BULLETS.iter().position(|(name, glyph, index)| { | |
| 750 | (font.as_deref(), format.as_str(), *bullet) == (Some(*name), *glyph, Some(*index)) | |
| 751 | })?; | |
| 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 | } | |
| 903 | let at = chain.iter().position(|place| style(*place) == current)?; | |
| 904 | let at = if deeper { | |
| 905 | (at + 1) % chain.len() | |
| 906 | } else { | |
| 907 | at.checked_sub(1)? | |
| 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 | 913 | Some(Definition { |
| 778 | 914 | kind, |
| 779 | 915 | format: definition.format.clone(), |
| 780 | 916 | }) |
| 781 | 917 | } |
| 782 | 918 | |
| 783 | /// The list OneNote 2010 gives a paragraph with `format`: its Ctrl+. bullet | |
| 784 | /// (`corpus/paragraph-edit/reconciliation/keyboard`), or the `##.` arabic numbering it stores | |
| 785 | /// from its COM interface (`corpus/outline-edit/tree`). | |
| 786 | pub(super) fn list_definition(numbering: bool, format: &Format) -> Definition { | |
| 919 | /// The list OneNote 2010 gives a paragraph with `format` in library `style`, as its gallery | |
| 920 | /// and its Ctrl+. bullet store it (`corpus/paragraph-edit/reconciliation/keyboard`); a number | |
| 921 | /// also takes the text's font and language (`corpus/outline-edit/tree`). | |
| 922 | pub(super) fn list_definition(style: ListStyle, format: &Format) -> Definition { | |
| 787 | 923 | let font_size = Some(format.font_size.unwrap_or(11.0)); |
| 788 | if numbering { | |
| 789 | Definition { | |
| 924 | match style { | |
| 925 | ListStyle::Number(index) => Definition { | |
| 790 | 926 | kind: Kind::List { |
| 791 | 927 | font: None, |
| 792 | format: Some("\u{fffd}\u{0}.".into()), | |
| 928 | format: Some(NUMBER_LIBRARY[index].into()), | |
| 793 | 929 | restart: None, |
| 794 | 930 | bullet: None, |
| 795 | 931 | }, |
| ... | ... | @@ -802,20 +938,22 @@ pub(super) fn list_definition(numbering: bool, format: &Format) -> Definition { |
| 802 | 938 | language: format.language, |
| 803 | 939 | ..Format::default() |
| 804 | 940 | }, |
| 805 | } | |
| 806 | } else { | |
| 807 | Definition { | |
| 808 | kind: Kind::List { | |
| 809 | font: Some("Calibri".into()), | |
| 810 | format: Some("\u{2022}".into()), | |
| 811 | restart: None, | |
| 812 | bullet: Some(1), | |
| 813 | }, | |
| 814 | format: Format { | |
| 815 | font_size, | |
| 816 | color: Some(AUTOMATIC), | |
| 817 | ..Format::default() | |
| 818 | }, | |
| 941 | }, | |
| 942 | ListStyle::Bullet(index) => { | |
| 943 | let (font, glyph, bullet) = BULLET_LIBRARY[index]; | |
| 944 | Definition { | |
| 945 | kind: Kind::List { | |
| 946 | font: Some(font.into()), | |
| 947 | format: Some(glyph.into()), | |
| 948 | restart: None, | |
| 949 | bullet: Some(bullet), | |
| 950 | }, | |
| 951 | format: Format { | |
| 952 | font_size, | |
| 953 | color: Some(AUTOMATIC), | |
| 954 | ..Format::default() | |
| 955 | }, | |
| 956 | } | |
| 819 | 957 | } |
| 820 | 958 | } |
| 821 | 959 | } |
| ... | ... | @@ -930,6 +1068,44 @@ mod tests { |
| 930 | 1068 | assert_eq!(bold(&editor)[1], [run("ne", Some(true)), run("xt", None)]); |
| 931 | 1069 | } |
| 932 | 1070 | |
| 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 | 1109 | #[test] |
| 934 | 1110 | fn a_caret_formats_the_text_typed_next_and_scripts_exclude_each_other() { |
| 935 | 1111 | let mut engine = TextEngine::default(); |
| ... | ... | @@ -1112,7 +1288,7 @@ mod tests { |
| 1112 | 1288 | let markers = paragraph.markers.iter().map(|(marker, origin)| { |
| 1113 | 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 | 1293 | .collect::<Vec<_>>() |
| 1118 | 1294 | }; |
| ... | ... | @@ -1121,7 +1297,7 @@ mod tests { |
| 1121 | 1297 | .shaped |
| 1122 | 1298 | .paragraphs |
| 1123 | 1299 | .iter() |
| 1124 | .map(|paragraph| paragraph.number.map(|(number, _)| number)) | |
| 1300 | .map(|paragraph| paragraph.number.as_ref().map(|(count, _)| count.number)) | |
| 1125 | 1301 | .collect() |
| 1126 | 1302 | } |
| 1127 | 1303 | |
| ... | ... | @@ -1139,7 +1315,7 @@ mod tests { |
| 1139 | 1315 | let bullet = editor.active_outline().document.nodes()[0].lists[0]; |
| 1140 | 1316 | assert_eq!( |
| 1141 | 1317 | editor.definitions[&bullet], |
| 1142 | list_definition(false, &calibri()) | |
| 1318 | list_definition(ListStyle::BULLET, &calibri()) | |
| 1143 | 1319 | ); |
| 1144 | 1320 | editor.format(&mut engine, Formatting::Numbering).unwrap(); |
| 1145 | 1321 | let state = editor.format_state().unwrap(); |
| ... | ... | @@ -1197,6 +1373,136 @@ mod tests { |
| 1197 | 1373 | assert_eq!(numbers(&mut engine, &editor), [None; 4]); |
| 1198 | 1374 | } |
| 1199 | 1375 | |
| 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 | 1506 | #[test] |
| 1201 | 1507 | fn numbering_follows_stored_sequences_restarts_and_nesting() { |
| 1202 | 1508 | let mut engine = TextEngine::default(); |
| ... | ... | @@ -1207,10 +1513,10 @@ mod tests { |
| 1207 | 1513 | ('\u{1}', None, 1), |
| 1208 | 1514 | ('\u{4}', None, 2), |
| 1209 | 1515 | ('\u{1}', Some(9), 1), |
| 1210 | ('\u{0}', None, 1), | |
| 1516 | ('\u{1}', None, 1), | |
| 1211 | 1517 | ]) { |
| 1212 | 1518 | let id = new_id().unwrap(); |
| 1213 | let mut definition = list_definition(true, &calibri()); | |
| 1519 | let mut definition = list_definition(ListStyle::NUMBER, &calibri()); | |
| 1214 | 1520 | let Kind::List { |
| 1215 | 1521 | format, |
| 1216 | 1522 | restart: value, |
| ... | ... | @@ -1237,6 +1543,78 @@ mod tests { |
| 1237 | 1543 | ); |
| 1238 | 1544 | } |
| 1239 | 1545 | |
| 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 | 1618 | fn text_tags(editor: &CanvasEditor, paragraph: usize) -> Vec<(String, u16, bool)> { |
| 1241 | 1619 | let (_, _, node) = editor.active_outline().document.leaf(paragraph).unwrap(); |
| 1242 | 1620 | tags(node) |
| ... | ... | @@ -1389,7 +1767,7 @@ mod tests { |
| 1389 | 1767 | #[test] |
| 1390 | 1768 | fn the_nine_default_tags_are_onenotes_stored_definitions_and_all_draw() { |
| 1391 | 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 | 1771 | let store = Store::parse(bytes).unwrap(); |
| 1394 | 1772 | let index = RevisionIndex::parse(&store).unwrap(); |
| 1395 | 1773 | let document = Document::parse(&index).unwrap(); |
| ... | ... | @@ -1561,6 +1939,7 @@ mod tests { |
| 1561 | 1939 | alignment: Some(Alignment::Center), |
| 1562 | 1940 | bullets: false, |
| 1563 | 1941 | numbering: true, |
| 1942 | list: Some(ListStyle::NUMBER), | |
| 1564 | 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 | ||
| 6 | use super::format::{leaves_mut, restyle, selected}; | |
| 7 | use super::*; | |
| 8 | ||
| 9 | /// A link in a paragraph, in UTF-16 offsets of its stored text. | |
| 10 | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 11 | pub 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 | ||
| 20 | impl 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`. | |
| 29 | pub(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. | |
| 82 | pub(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. | |
| 114 | pub 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`. | |
| 148 | pub(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. | |
| 155 | pub(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. | |
| 162 | fn 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 | ||
| 178 | impl 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)] | |
| 500 | mod 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 | 596 | assert_eq!((*target, range.clone()), (text(0), 1..4)); |
| 597 | 597 | } |
| 598 | 598 | |
| 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 | 655 | /// A change from elsewhere reaches only what it changed: a refresh whose stored page is |
| 600 | 656 | /// what the editor's ops left (though storage normalized it) changes nothing, history |
| 601 | 657 | /// 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 | 273 | if self.active_outline().title { |
| 274 | 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 | 286 | let outline = self.active_outline(); |
| 277 | let [anchor, focus] = outline.selection.positions; | |
| 278 | 287 | let (cell, local, source) = outline |
| 279 | 288 | .document |
| 280 | 289 | .leaf(focus.paragraph) |
crates/canvas/src/gpu/mod.rs+15-1| ... | ... | @@ -33,6 +33,7 @@ impl Viewport { |
| 33 | 33 | origin: self.origin, |
| 34 | 34 | clip: None, |
| 35 | 35 | backdrop: None, |
| 36 | motion: None, | |
| 36 | 37 | primitives, |
| 37 | 38 | } |
| 38 | 39 | } |
| ... | ... | @@ -151,6 +152,8 @@ const EXCLAMATION: &str = include_str!("../../assets/tags/exclamation.svg"); |
| 151 | 152 | const RED_SQUARE: &str = include_str!("../../assets/tags/red-square.svg"); |
| 152 | 153 | const YELLOW_SQUARE: &str = include_str!("../../assets/tags/yellow-square.svg"); |
| 153 | 154 | const BLUE_SQUARE: &str = include_str!("../../assets/tags/blue-square.svg"); |
| 155 | const FLAG: &str = include_str!("../../assets/tags/flag.svg"); | |
| 156 | const TAG: &str = include_str!("../../assets/tags/tag.svg"); | |
| 154 | 157 | |
| 155 | 158 | /// The tag's artwork, drawn in order. |
| 156 | 159 | pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] { |
| ... | ... | @@ -168,6 +171,8 @@ pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] { |
| 168 | 171 | TagIcon::RedSquare => &[RED_SQUARE], |
| 169 | 172 | TagIcon::YellowSquare => &[YELLOW_SQUARE], |
| 170 | 173 | TagIcon::BlueSquare => &[BLUE_SQUARE], |
| 174 | TagIcon::Flag => &[FLAG], | |
| 175 | TagIcon::Other => &[TAG], | |
| 171 | 176 | } |
| 172 | 177 | } |
| 173 | 178 | |
| ... | ... | @@ -190,7 +195,13 @@ mod tests { |
| 190 | 195 | #[test] |
| 191 | 196 | fn page_colours_colour_the_paper() { |
| 192 | 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 | 205 | let dark = Paper { |
| 195 | 206 | color: draw::srgb(0x1f, 0x20, 0x22), |
| 196 | 207 | ink: draw::srgb(0xe6, 0xe6, 0xe6), |
| ... | ... | @@ -290,6 +301,8 @@ mod tests { |
| 290 | 301 | TagIcon::RedSquare, |
| 291 | 302 | TagIcon::YellowSquare, |
| 292 | 303 | TagIcon::BlueSquare, |
| 304 | TagIcon::Flag, | |
| 305 | TagIcon::Other, | |
| 293 | 306 | ]; |
| 294 | 307 | let text = TextEngine::default() |
| 295 | 308 | .layout( |
| ... | ... | @@ -322,6 +335,7 @@ mod tests { |
| 322 | 335 | origin: [2.0 + 16.0 * index as f32, 2.0], |
| 323 | 336 | size: crate::outline::ParagraphTag::SIZE, |
| 324 | 337 | tint: [1.0; 4], |
| 338 | palette: draw::Palette::default(), | |
| 325 | 339 | }) |
| 326 | 340 | .collect(); |
| 327 | 341 | primitives.push(Primitive::Text { |
crates/canvas/src/gpu/page.rs+91-72| ... | ... | @@ -838,7 +838,7 @@ impl crate::outline::OutlineLayout { |
| 838 | 838 | paragraph.origin[1] - OVERHANG, |
| 839 | 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 | } |
| 844 | 844 | |
| ... | ... | @@ -925,50 +925,50 @@ impl crate::outline::OutlineLayout { |
| 925 | 925 | bottom + origin[1], |
| 926 | 926 | ] |
| 927 | 927 | }); |
| 928 | match &paragraph.math { | |
| 929 | Some(math) => { | |
| 930 | for item in &math.items { | |
| 931 | primitives.push(match item { | |
| 932 | crate::math::MathItem::Text { layout, origin } => Primitive::Text { | |
| 933 | clip, | |
| 934 | text: layout, | |
| 935 | origin: [x + origin[0], y + origin[1]], | |
| 936 | ink, | |
| 937 | }, | |
| 938 | // As pen strokes, so hairline rules keep a device pixel. | |
| 939 | crate::math::MathItem::Rule([x0, y0, x1, y1]) => { | |
| 940 | let width = (x1 - x0).min(y1 - y0); | |
| 941 | let [from, to] = if x1 - x0 >= y1 - y0 { | |
| 942 | let middle = (y0 + y1) / 2.0; | |
| 943 | [[x0 + width / 2.0, middle], [x1 - width / 2.0, middle]] | |
| 944 | } else { | |
| 945 | let middle = (x0 + x1) / 2.0; | |
| 946 | [[middle, y0 + width / 2.0], [middle, y1 - width / 2.0]] | |
| 947 | }; | |
| 948 | Primitive::Segment { | |
| 949 | from: [x + from[0], y + from[1]], | |
| 950 | to: [x + to[0], y + to[1]], | |
| 951 | width, | |
| 952 | round: false, | |
| 953 | color: math.color.map_or(ink, colorref), | |
| 954 | } | |
| 955 | } | |
| 956 | crate::math::MathItem::Stroke { from, to, width } => Primitive::Segment { | |
| 928 | primitives.push(Primitive::Text { | |
| 929 | clip, | |
| 930 | text: &paragraph.text, | |
| 931 | origin: [x, y], | |
| 932 | ink, | |
| 933 | }); | |
| 934 | for (index, [left, baseline]) in paragraph.text.spaces() { | |
| 935 | let math = &paragraph.math[index]; | |
| 936 | let [x, y] = [x + left, y + baseline - math.baseline]; | |
| 937 | for item in &math.items { | |
| 938 | primitives.push(match item { | |
| 939 | crate::math::MathItem::Text { layout, origin } => Primitive::Text { | |
| 940 | clip, | |
| 941 | text: layout, | |
| 942 | origin: [x + origin[0], y + origin[1]], | |
| 943 | ink, | |
| 944 | }, | |
| 945 | // As pen strokes, so hairline rules keep a device pixel. | |
| 946 | crate::math::MathItem::Rule([x0, y0, x1, y1]) => { | |
| 947 | let width = (x1 - x0).min(y1 - y0); | |
| 948 | let [from, to] = if x1 - x0 >= y1 - y0 { | |
| 949 | let middle = (y0 + y1) / 2.0; | |
| 950 | [[x0 + width / 2.0, middle], [x1 - width / 2.0, middle]] | |
| 951 | } else { | |
| 952 | let middle = (x0 + x1) / 2.0; | |
| 953 | [[middle, y0 + width / 2.0], [middle, y1 - width / 2.0]] | |
| 954 | }; | |
| 955 | Primitive::Segment { | |
| 957 | 956 | from: [x + from[0], y + from[1]], |
| 958 | 957 | to: [x + to[0], y + to[1]], |
| 959 | width: *width, | |
| 960 | round: true, | |
| 958 | width, | |
| 959 | round: false, | |
| 961 | 960 | color: math.color.map_or(ink, colorref), |
| 962 | }, | |
| 963 | }); | |
| 964 | } | |
| 961 | } | |
| 962 | } | |
| 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 | 973 | for (layout, marker) in &paragraph.markers { |
| 974 | 974 | primitives.push(Primitive::Text { |
| ... | ... | @@ -987,6 +987,7 @@ impl crate::outline::OutlineLayout { |
| 987 | 987 | ], |
| 988 | 988 | size: tag.size, |
| 989 | 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 | 1622 | } |
| 1622 | 1623 | |
| 1623 | 1624 | #[test] |
| 1624 | fn unsupported_tags_and_titles_leave_supported_outlines_editable() { | |
| 1625 | fn unsupported_outlines_and_titles_leave_supported_outlines_editable() { | |
| 1625 | 1626 | use crate::document::TextDocument; |
| 1626 | 1627 | use onestore::document::{Kind, Tag}; |
| 1627 | 1628 | use onestore::page::{Definition, Outline, Title}; |
| 1628 | 1629 | for title in [false, true] { |
| 1629 | let definition = ExGuid { | |
| 1630 | guid: [9; 16], | |
| 1630 | let [tag, list] = [9, 10].map(|byte| ExGuid { | |
| 1631 | guid: [byte; 16], | |
| 1631 | 1632 | n: 1, |
| 1632 | }; | |
| 1633 | }); | |
| 1633 | 1634 | let make_outline = |text: &str| { |
| 1634 | 1635 | let document = |
| 1635 | 1636 | TextDocument::new(vec![Paragraph::new(text.into(), Default::default())]) |
| ... | ... | @@ -1650,18 +1651,8 @@ mod tests { |
| 1650 | 1651 | unsupported: Vec::new(), |
| 1651 | 1652 | } |
| 1652 | 1653 | }; |
| 1653 | let mut tagged = make_outline("Tagged"); | |
| 1654 | tagged.paragraphs[0].tags.push(Tag { | |
| 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 | }); | |
| 1654 | let mut numbered = make_outline("Numbered"); | |
| 1655 | numbered.paragraphs[0].lists.push(list); | |
| 1665 | 1656 | let unsupported = if title { |
| 1666 | 1657 | PageObject::Title(Title { |
| 1667 | 1658 | date: None, |
| ... | ... | @@ -1671,31 +1662,59 @@ mod tests { |
| 1671 | 1662 | y: Some(20.0), |
| 1672 | 1663 | ..Default::default() |
| 1673 | 1664 | }, |
| 1674 | outlines: vec![tagged], | |
| 1665 | outlines: vec![numbered], | |
| 1675 | 1666 | }) |
| 1676 | 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 | 1683 | let page = Page { |
| 1680 | 1684 | identity: None, |
| 1681 | 1685 | created: None, |
| 1682 | 1686 | title: String::new(), |
| 1683 | 1687 | margin_origin: [36.0, 14.0], |
| 1684 | 1688 | color: None, |
| 1685 | objects: vec![unsupported, PageObject::Outline(make_outline("Editable"))], | |
| 1686 | definitions: BTreeMap::from([( | |
| 1687 | definition, | |
| 1688 | Definition { | |
| 1689 | kind: Kind::TagDefinition { | |
| 1690 | shape: Some(999), | |
| 1691 | label: Some("Unknown icon".into()), | |
| 1692 | color: None, | |
| 1693 | highlight: None, | |
| 1694 | action_type: None, | |
| 1689 | objects: vec![unsupported, PageObject::Outline(editable)], | |
| 1690 | definitions: BTreeMap::from([ | |
| 1691 | ( | |
| 1692 | tag, | |
| 1693 | Definition { | |
| 1694 | kind: Kind::TagDefinition { | |
| 1695 | shape: Some(999), | |
| 1696 | label: Some("Unknown icon".into()), | |
| 1697 | color: None, | |
| 1698 | highlight: None, | |
| 1699 | action_type: None, | |
| 1700 | }, | |
| 1701 | format: Default::default(), | |
| 1695 | 1702 | }, |
| 1696 | format: Default::default(), | |
| 1697 | }, | |
| 1698 | )]), | |
| 1703 | ), | |
| 1704 | ( | |
| 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 | 1719 | let mut engine = TextEngine::default(); |
| 1701 | 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 | 551 | [view.width() * 0.75, view.height() * 0.75] |
| 552 | 552 | }); |
| 553 | 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 | } |
| 556 | 559 | |
| 557 | 560 | /// 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 | 608 | TagIcon::RedSquare => "Project A", |
| 609 | 609 | TagIcon::YellowSquare => "Project B", |
| 610 | 610 | TagIcon::BlueSquare => "Project C", |
| 611 | TagIcon::Flag => "Outlook task", | |
| 612 | TagIcon::Other => "Tag", | |
| 611 | 613 | }; |
| 612 | 614 | let label = if tag.label.is_empty() { |
| 613 | 615 | fallback |
crates/canvas/src/interaction/mod.rs+221-126| ... | ... | @@ -53,8 +53,20 @@ pub enum Request { |
| 53 | 53 | Copy(String), |
| 54 | 54 | /// Read the clipboard and hand its text to `PageView::commit_text`. |
| 55 | 55 | Paste, |
| 56 | /// Show the platform's character picker. | |
| 57 | CharacterPalette, | |
| 56 | /// Open a link's address, as a click on a link does. | |
| 57 | OpenLink(String), | |
| 58 | } | |
| 59 | ||
| 60 | /// What a secondary press lands on, which its context menu offers commands for. | |
| 61 | #[derive(Debug, Default, PartialEq)] | |
| 62 | pub 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 | } |
| 59 | 71 | |
| 60 | 72 | /// What an event did: `changed` means the page or selection changed (the host saves, |
| ... | ... | @@ -167,10 +179,7 @@ impl ObjectFocus { |
| 167 | 179 | } |
| 168 | 180 | |
| 169 | 181 | fn read_only_shortcut(key: &Key, modifiers: Modifiers) -> bool { |
| 170 | key == &Key::Named(NamedKey::Escape) | |
| 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")))) | |
| 182 | key == &Key::Named(NamedKey::Escape) || (modifiers.control && key == &Key::Named(NamedKey::Tab)) | |
| 174 | 183 | } |
| 175 | 184 | |
| 176 | 185 | /// Where a page was left: its scroll and the focused outline's selection. |
| ... | ... | @@ -750,6 +759,11 @@ impl PageView { |
| 750 | 759 | (Some(Drag::Resize { .. }), _) | (None, Some(Hit::Resize { .. })) => Cursor::EwResize, |
| 751 | 760 | (Some(Drag::Outline { .. }), _) | (None, Some(Hit::Handle { .. })) => Cursor::Move, |
| 752 | 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 | 767 | (None, Some(Hit::ReadOnly(_) | Hit::Check { .. })) => Cursor::Default, |
| 754 | 768 | _ => Cursor::Text, |
| 755 | 769 | } |
| ... | ... | @@ -902,6 +916,17 @@ impl PageView { |
| 902 | 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 | 930 | Some(Hit::Text { id, point }) => { |
| 906 | 931 | let extend = self.modifiers.shift |
| 907 | 932 | && self.object_focus.is_none() |
| ... | ... | @@ -1050,6 +1075,118 @@ impl PageView { |
| 1050 | 1075 | self.edited() |
| 1051 | 1076 | } |
| 1052 | 1077 | |
| 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 | 1190 | /// Undoes the last edit, or redoes the last undone one. |
| 1054 | 1191 | pub fn undo(&mut self, redo: bool) -> Result<Response> { |
| 1055 | 1192 | if redo { |
| ... | ... | @@ -1065,6 +1202,12 @@ impl PageView { |
| 1065 | 1202 | self.edited() |
| 1066 | 1203 | } |
| 1067 | 1204 | |
| 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 | 1211 | /// The date or time chosen after `Request::EditDate`. |
| 1069 | 1212 | pub fn change_date(&mut self, timestamp: u64, text: [String; 2]) -> Result<Response> { |
| 1070 | 1213 | self.editor.change_date(&mut self.engine, timestamp, text)?; |
| ... | ... | @@ -1113,11 +1256,8 @@ impl PageView { |
| 1113 | 1256 | self.set_object_focus(None); |
| 1114 | 1257 | return self.edited(); |
| 1115 | 1258 | } |
| 1116 | if let Some(focus) = self.object_focus { | |
| 1117 | let undo = matches!(focus, ObjectFocus::Image(_)) | |
| 1118 | && command | |
| 1119 | && matches!(key, Key::Character(value) if value.eq_ignore_ascii_case("z")); | |
| 1120 | if !undo && !read_only_shortcut(key, self.modifiers) { | |
| 1259 | if self.object_focus.is_some() { | |
| 1260 | if !read_only_shortcut(key, self.modifiers) { | |
| 1121 | 1261 | return Ok(Response::default()); |
| 1122 | 1262 | } |
| 1123 | 1263 | if key == &Key::Named(NamedKey::Escape) { |
| ... | ... | @@ -1125,12 +1265,6 @@ impl PageView { |
| 1125 | 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 | 1268 | if matches!( |
| 1135 | 1269 | self.drag, |
| 1136 | 1270 | Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. }) |
| ... | ... | @@ -1140,6 +1274,12 @@ impl PageView { |
| 1140 | 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 | 1283 | if command && option { |
| 1144 | 1284 | let delta = match key { |
| 1145 | 1285 | Key::Named(NamedKey::ArrowLeft) => Some([-1.0, 0.0]), |
| ... | ... | @@ -1195,124 +1335,75 @@ impl PageView { |
| 1195 | 1335 | } |
| 1196 | 1336 | return self.edited(); |
| 1197 | 1337 | } |
| 1198 | let mut request = None; | |
| 1199 | if command && let Key::Character(key) = key { | |
| 1200 | match key.to_lowercase().as_str() { | |
| 1201 | "n" if shift => { | |
| 1202 | let position = if let Some(focus) = self.object_focus { | |
| 1203 | let rect = self.object_rect(focus); | |
| 1204 | [rect[2] + 24.0, rect[1]] | |
| 1205 | } else { | |
| 1206 | let bounds = self.editor.active_outline().bounds(); | |
| 1207 | [bounds.x1 as f32 + 24.0, bounds.y0 as f32] | |
| 1208 | }; | |
| 1209 | self.editor.place_caret( | |
| 1210 | &mut self.engine, | |
| 1211 | snap_to_grid(position, self.editor.margin_origin()), | |
| 1212 | DEFAULT_OUTLINE_WIDTH, | |
| 1213 | )?; | |
| 1214 | self.set_object_focus(None); | |
| 1338 | // The host runs the shortcut modifier's chords from its command table. | |
| 1339 | if command && let Key::Character(_) = key { | |
| 1340 | return Ok(Response::default()); | |
| 1341 | } | |
| 1342 | let chord = Command::from_key(key, self.modifiers); | |
| 1343 | if let Some(Command::Move(movement)) = chord { | |
| 1344 | self.editor | |
| 1345 | .move_selection(&mut self.engine, movement, shift)?; | |
| 1346 | } else if let Some(Command::ScrollPage { up }) = chord { | |
| 1347 | // AppKit keeps ten points of the last page in view. | |
| 1348 | let page = (self.viewport.size[1] as f32 - 10.0 * self.display_scale).max(0.0); | |
| 1349 | self.viewport.origin[1] += if up { page } else { -page }; | |
| 1350 | return self.moved(); | |
| 1351 | } else if let Some(Command::MovePage { up }) = chord { | |
| 1352 | self.move_page(up, shift)?; | |
| 1353 | } else if self.editor.marked_range().is_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()?, | |
| 1217 | "z" => return self.undo(shift), | |
| 1218 | "y" if edit::Platform::CURRENT == edit::Platform::Windows => { | |
| 1219 | return self.undo(true); | |
| 1358 | (Some(Command::Delete { backward }), _) => { | |
| 1359 | self.editor.delete(&mut self.engine, backward)?; | |
| 1220 | 1360 | } |
| 1221 | "c" | "x" => { | |
| 1222 | let [anchor, focus] = self.editor.selection().positions; | |
| 1223 | let selected = self | |
| 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)); | |
| 1361 | (Some(Command::Kill), _) => { | |
| 1362 | if !self.editor.delete_to(&mut self.engine, Movement::LineEnd)? { | |
| 1363 | self.editor.delete(&mut self.engine, false)?; | |
| 1242 | 1364 | } |
| 1243 | 1365 | } |
| 1244 | "v" => request = Some(Request::Paste), | |
| 1245 | "+" | "=" => { | |
| 1246 | self.zoom_about(1.1, self.pointer); | |
| 1366 | (_, Key::Named(end @ (NamedKey::Home | NamedKey::End))) => { | |
| 1367 | let limits = self.scroll(); | |
| 1368 | self.viewport.origin[1] = -if *end == NamedKey::Home { | |
| 1369 | limits.min[1] | |
| 1370 | } else { | |
| 1371 | limits.max[1] | |
| 1372 | }; | |
| 1247 | 1373 | return self.moved(); |
| 1248 | 1374 | } |
| 1249 | "-" => { | |
| 1250 | self.zoom_about(1.0 / 1.1, self.pointer); | |
| 1251 | return self.moved(); | |
| 1375 | (_, Key::Named(NamedKey::Enter)) => { | |
| 1376 | // Enter inside a link follows it, as OneNote's does. | |
| 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" => { | |
| 1254 | self.zoom_about(1.0 / self.zoom(), self.pointer); | |
| 1255 | return self.moved(); | |
| 1391 | (_, Key::Named(NamedKey::Tab)) => { | |
| 1392 | self.editor.tab(&mut self.engine, shift)?; | |
| 1256 | 1393 | } |
| 1257 | _ => return Ok(Response::default()), | |
| 1258 | } | |
| 1259 | } else { | |
| 1260 | let chord = Command::from_key(key, self.modifiers); | |
| 1261 | if let Some(Command::Move(movement)) = chord { | |
| 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)?; | |
| 1394 | _ if !command && !control => { | |
| 1395 | if let Some(text) = | |
| 1396 | text.filter(|text| !text.is_empty() && !text.chars().any(char::is_control)) | |
| 1397 | { | |
| 1398 | self.editor.insert(&mut self.engine, text)?; | |
| 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) { | |
| 1309 | self.editor.cancel_composition(&mut self.engine)?; | |
| 1401 | _ => {} | |
| 1310 | 1402 | } |
| 1403 | } else if key == &Key::Named(NamedKey::Escape) { | |
| 1404 | self.editor.cancel_composition(&mut self.engine)?; | |
| 1311 | 1405 | } |
| 1312 | Ok(Response { | |
| 1313 | request, | |
| 1314 | ..self.edited()? | |
| 1315 | }) | |
| 1406 | self.edited() | |
| 1316 | 1407 | } |
| 1317 | 1408 | } |
| 1318 | 1409 | |
| ... | ... | @@ -1526,7 +1617,9 @@ fn page_primitives<'a>( |
| 1526 | 1617 | Some(PointerFeedback::Move(id, origin)) if id == outline.id => origin, |
| 1527 | 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 | 1623 | || matches!(preview, Some(PointerFeedback::Hover(id) | PointerFeedback::Move(id, _)) if id == outline.id) |
| 1531 | 1624 | || matches!(preview, Some(PointerFeedback::Resize(resized)) if resized.id == outline.id) |
| 1532 | 1625 | { |
| ... | ... | @@ -1860,8 +1953,10 @@ fn append_outline_chrome( |
| 1860 | 1953 | if outline.title { |
| 1861 | 1954 | primitives.push(Primitive::RoundedRect { |
| 1862 | 1955 | rect: [left, top, right, bottom], |
| 1863 | radius: [6.0 * pixel, (bottom - top) * 0.5], | |
| 1864 | stroke: Some(Stroke::Dashed(pixel)), | |
| 1956 | radius: [4.5, (bottom - top) * 0.5], | |
| 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 | 1960 | color: paper.shade(crate::gpu::colorref(0x007f7f7f)), |
| 1866 | 1961 | }); |
| 1867 | 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 | 171 | }) |
| 172 | 172 | .collect::<Vec<_>>(); |
| 173 | 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 | 175 | let mut access = accessibility::Accessibility::default(); |
| 176 | 176 | let update = access |
| 177 | 177 | .update( |
| ... | ... | @@ -192,6 +192,49 @@ fn title_chrome_selects_text_and_exposes_a_named_editable_field() { |
| 192 | 192 | && node.label() == Some("Page title"))); |
| 193 | 193 | } |
| 194 | 194 | |
| 195 | #[test] | |
| 196 | fn 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 | 238 | #[test] |
| 196 | 239 | fn provisional_lines_share_one_drawn_hit_tested_and_accessible_outline() { |
| 197 | 240 | let mut engine = TextEngine::default(); |
| ... | ... | @@ -1339,45 +1382,9 @@ fn read_only_focus_accepts_navigation_without_edit_shortcuts() { |
| 1339 | 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 | 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 | 1388 | assert!(!read_only_shortcut( |
| 1382 | 1389 | &Key::Named(NamedKey::Tab), |
| 1383 | 1390 | Modifiers::default() |
| ... | ... | @@ -1611,7 +1618,8 @@ fn events_route_through_the_view_as_the_host_delivers_them() { |
| 1611 | 1618 | .starts_with('X') |
| 1612 | 1619 | ); |
| 1613 | 1620 | |
| 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 | 1623 | let _ = view |
| 1616 | 1624 | .modifiers_changed(Modifiers { |
| 1617 | 1625 | shift: true, |
| ... | ... | @@ -1619,6 +1627,10 @@ fn events_route_through_the_view_as_the_host_delivers_them() { |
| 1619 | 1627 | }) |
| 1620 | 1628 | .unwrap(); |
| 1621 | 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 | 1634 | let _ = view |
| 1623 | 1635 | .modifiers_changed(Modifiers { |
| 1624 | 1636 | command: true, |
| ... | ... | @@ -1626,18 +1638,38 @@ fn events_route_through_the_view_as_the_host_delivers_them() { |
| 1626 | 1638 | }) |
| 1627 | 1639 | .unwrap(); |
| 1628 | 1640 | assert_eq!( |
| 1629 | view.key(&Key::Character("c".into()), None).unwrap().request, | |
| 1630 | Some(Request::Copy("B".into())) | |
| 1631 | ); | |
| 1632 | assert_eq!( | |
| 1633 | view.key(&Key::Character("v".into()), None).unwrap().request, | |
| 1634 | Some(Request::Paste) | |
| 1641 | view.key(&Key::Character("v".into()), Some("v")).unwrap(), | |
| 1642 | Response::default() | |
| 1635 | 1643 | ); |
| 1636 | 1644 | assert!(view.commit_text("line\nbreak".into()).unwrap().changed); |
| 1637 | 1645 | assert_eq!( |
| 1638 | 1646 | view.editor.active_outline().document().paragraphs().count(), |
| 1639 | 1647 | 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 | 1673 | let _ = view.modifiers_changed(Modifiers::default()).unwrap(); |
| 1642 | 1674 | |
| 1643 | 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 | 62 | id: u64, |
| 63 | 63 | pub(crate) shaped: Arc<Layout<TextBrush>>, |
| 64 | 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)] | |
| 72 | pub struct InlineSpace { | |
| 73 | pub index: usize, | |
| 74 | pub width: f32, | |
| 75 | pub ascent: f32, | |
| 76 | pub descent: f32, | |
| 65 | 77 | } |
| 66 | 78 | |
| 67 | 79 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| ... | ... | @@ -255,6 +267,7 @@ impl TextEngine { |
| 255 | 267 | &mut self, |
| 256 | 268 | paragraph: &Paragraph, |
| 257 | 269 | width: f32, |
| 270 | spaces: &[InlineSpace], | |
| 258 | 271 | ) -> Result<Layout<TextBrush>, LayoutError> { |
| 259 | 272 | if !width.is_finite() || width <= 0.0 { |
| 260 | 273 | return Err(LayoutError::InvalidWidth); |
| ... | ... | @@ -335,6 +348,16 @@ impl TextEngine { |
| 335 | 348 | } |
| 336 | 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 | 361 | let mut shaped = builder.build(text); |
| 339 | 362 | shaped.break_all_lines(Some(width)); |
| 340 | 363 | let alignment = match paragraph.spans()[0].format.alignment { |
| ... | ... | @@ -362,6 +385,19 @@ impl TextEngine { |
| 362 | 385 | f32::from(hhea.ascender().to_i16()) * scale, |
| 363 | 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 | 401 | } else if let (Ok(head), Ok(os2)) = (font.head(), font.os2()) |
| 366 | 402 | && head.units_per_em() != 0 |
| 367 | 403 | && (os2.us_win_ascent() != 0 || os2.us_win_descent() != 0) |
| ... | ... | @@ -378,7 +414,18 @@ impl TextEngine { |
| 378 | 414 | } |
| 379 | 415 | |
| 380 | 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 | 429 | let text = paragraph.text(); |
| 383 | 430 | let mut lines = Vec::with_capacity(shaped.len()); |
| 384 | 431 | let mut top = 0.0_f64; |
| ... | ... | @@ -417,7 +464,7 @@ impl TextEngine { |
| 417 | 464 | .unwrap_or_else(|| paragraph.spans().last().unwrap()) |
| 418 | 465 | .format; |
| 419 | 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 | 468 | sample.lines().flat_map(|line| line.runs()).try_fold( |
| 422 | 469 | (0.0_f32, 0.0_f32), |
| 423 | 470 | |(a, d), run| { |
| ... | ... | @@ -434,6 +481,14 @@ impl TextEngine { |
| 434 | 481 | ascent = ascent.max(a); |
| 435 | 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 | 492 | let height = ascent + descent; |
| 438 | 493 | if height <= 0.0 || !height.is_finite() { |
| 439 | 494 | return Err(LayoutError::InvalidFontMetrics); |
| ... | ... | @@ -460,6 +515,7 @@ impl TextEngine { |
| 460 | 515 | id, |
| 461 | 516 | shaped: Arc::new(shaped), |
| 462 | 517 | lines, |
| 518 | spaces: placed, | |
| 463 | 519 | }) |
| 464 | 520 | } |
| 465 | 521 | } |
| ... | ... | @@ -491,6 +547,13 @@ impl TextLayout { |
| 491 | 547 | Ok(()) |
| 492 | 548 | } |
| 493 | 549 | |
| 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 | 557 | pub fn lines(&self) -> impl Iterator<Item = (parley::Line<'_, TextBrush>, &LineBox)> { |
| 495 | 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 | 590 | extent.map(|v| if v.is_finite() { v } else { 0.0 }) |
| 591 | 591 | } |
| 592 | 592 | |
| 593 | /// The byte ranges of `paragraph`'s runs of math holding objects, which draw in two | |
| 594 | /// dimensions; math without objects is text. | |
| 595 | pub(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 | 611 | /// Lays out an equation paragraph at the font size and colour of its first run. |
| 594 | 612 | pub fn layout(engine: &mut TextEngine, paragraph: &Paragraph) -> Result<MathLayout, LayoutError> { |
| 595 | 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 | 630 | .next() |
| 613 | 631 | .ok_or(LayoutError::InvalidFontMetrics)?; |
| 614 | 632 | let mut body = Context { engine, color }.row(&nodes, size)?; |
| 615 | body.ascent = body.ascent.max(line.baseline); | |
| 616 | body.descent = body.descent.max(line.height - line.baseline); | |
| 633 | // Boxes follow ink; lines with taller math keep a gap from the lines beside them. | |
| 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 | 637 | Ok(MathLayout { |
| 618 | 638 | size: [body.width, body.height()], |
| 619 | 639 | color, |
crates/canvas/src/outline.rs+431-113| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | use crate::{ |
| 2 | 2 | document::{DocumentEdit, edited_nodes, leaves}, |
| 3 | layout::{LayoutError, TextEngine, TextLayout}, | |
| 3 | layout::{InlineSpace, LayoutError, TextEngine, TextLayout}, | |
| 4 | 4 | }; |
| 5 | 5 | use onestore::page::text::{Paragraph, TextProjection}; |
| 6 | 6 | use onestore::page::{Definition, Outline, PageParagraph, ParagraphContent, Table, Title}; |
| ... | ... | @@ -11,6 +11,7 @@ use onestore::{ |
| 11 | 11 | use std::{ |
| 12 | 12 | collections::{BTreeMap, BTreeSet}, |
| 13 | 13 | ops::Range, |
| 14 | sync::Arc, | |
| 14 | 15 | }; |
| 15 | 16 | |
| 16 | 17 | pub(crate) const TITLE_WIDTH: f32 = 468.0; |
| ... | ... | @@ -43,7 +44,7 @@ struct Block { |
| 43 | 44 | /// A table's vertical coordinates before its offset. |
| 44 | 45 | rel: Vec<f32>, |
| 45 | 46 | /// The number the next numbered sibling at the node's level continues from. |
| 46 | count: Option<u32>, | |
| 47 | count: Option<Count>, | |
| 47 | 48 | } |
| 48 | 49 | |
| 49 | 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 | 197 | pub markers: Vec<(TextLayout, [f32; 2])>, |
| 197 | 198 | /// A numbered paragraph's number, and whether its list restarts the count instead of |
| 198 | 199 | /// continuing from the previous sibling's. |
| 199 | pub(crate) number: Option<(u32, bool)>, | |
| 200 | pub(crate) number: Option<(Count, bool)>, | |
| 200 | 201 | pub tags: Vec<ParagraphTag>, |
| 201 | 202 | /// Its siblings' group, whose list markers its tags clear: the parent paragraph, or the |
| 202 | 203 | /// table cell holding a cell's top-level paragraph. |
| 203 | 204 | pub(crate) parent: Option<ExGuid>, |
| 204 | /// An equation draws in two dimensions in place of its linear text. | |
| 205 | pub math: Option<crate::math::MathLayout>, | |
| 205 | /// The equations drawn in two dimensions in spaces of the text, in order. | |
| 206 | pub math: Vec<crate::math::MathLayout>, | |
| 206 | 207 | /// A highlight of the whole paragraph (its own `highlight`), COLORREF: a band across its |
| 207 | 208 | /// outline behind its lines, as OneNote marks a conflict page's conflicting changes. |
| 208 | 209 | pub band: Option<u32>, |
| ... | ... | @@ -210,7 +211,9 @@ pub struct ParagraphLayout { |
| 210 | 211 | |
| 211 | 212 | #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] |
| 212 | 213 | pub enum TagIcon { |
| 213 | CheckBox { checked: bool }, | |
| 214 | CheckBox { | |
| 215 | checked: bool, | |
| 216 | }, | |
| 214 | 217 | Star, |
| 215 | 218 | Question, |
| 216 | 219 | Highlight, |
| ... | ... | @@ -222,6 +225,10 @@ pub enum TagIcon { |
| 222 | 225 | RedSquare, |
| 223 | 226 | YellowSquare, |
| 224 | 227 | BlueSquare, |
| 228 | /// An Outlook task flag. | |
| 229 | Flag, | |
| 230 | /// A tag symbol without artwork of its own. | |
| 231 | Other, | |
| 225 | 232 | } |
| 226 | 233 | |
| 227 | 234 | #[derive(Clone, Debug, PartialEq)] |
| ... | ... | @@ -270,6 +277,8 @@ impl ParagraphTag { |
| 270 | 277 | |
| 271 | 278 | /// How much further than its list spacing OneNote 2010 sets a marker's advance from its text. |
| 272 | 279 | const MARKER_OFFSET: f32 = 3.9; |
| 280 | /// Points an equation's linear text lays out at, holding the caret without showing. | |
| 281 | const HELD: f32 = 0.01; | |
| 273 | 282 | |
| 274 | 283 | /// A one-run paragraph standing in for an object's caption, so it lays out through the same |
| 275 | 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 | 336 | Ok(indent) |
| 328 | 337 | } |
| 329 | 338 | |
| 330 | /// The number after a previous sibling's, or the first when the sibling is not numbered. | |
| 331 | fn next(previous: Option<u32>) -> u32 { | |
| 332 | previous.map_or(1, |number| number.saturating_add(1)) | |
| 339 | /// A numbered paragraph's number and its NumberListFormat. | |
| 340 | #[derive(Clone, Debug, PartialEq)] | |
| 341 | pub(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`). | |
| 349 | fn 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 | } |
| 334 | 354 | |
| 335 | 355 | /// The number a paragraph laid out as `number` takes once it follows `previous`. |
| 336 | fn follows(number: Option<(u32, bool)>, previous: Option<u32>) -> Option<u32> { | |
| 337 | number.map(|(number, restart)| if restart { number } else { next(previous) }) | |
| 356 | fn follows(number: Option<&(Count, bool)>, previous: Option<&Count>) -> Option<Count> { | |
| 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 | } |
| 339 | 366 | |
| 340 | 367 | /// What the numbered sibling after `node`, numbered `number`, continues from: siblings without |
| 341 | 368 | /// a number leave the count alone, as do empty numbered ones after another number |
| 342 | 369 | /// (`evidence/structural-edits/xml/c8-bs-1.xml`, `c8-enter-empty-1.xml`, `c2s-num-1.xml`). |
| 343 | fn tally(node: &PageParagraph, number: Option<u32>, previous: Option<u32>) -> Option<u32> { | |
| 370 | fn tally(node: &PageParagraph, number: Option<Count>, previous: Option<Count>) -> Option<Count> { | |
| 344 | 371 | number |
| 345 | 372 | .filter(|_| { |
| 346 | 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 | 377 | |
| 351 | 378 | /// Removes deeper paragraphs' entries from a flow's `(level, count)` stack of the latest |
| 352 | 379 | /// paragraph at each level, and the previous sibling's at `level`, returning that one's count. |
| 353 | fn sibling(siblings: &mut Vec<(u32, Option<u32>)>, level: u32) -> Option<u32> { | |
| 380 | fn sibling(siblings: &mut Vec<(u32, Option<Count>)>, level: u32) -> Option<Count> { | |
| 354 | 381 | while siblings.last().is_some_and(|(deeper, _)| *deeper > level) { |
| 355 | 382 | siblings.pop(); |
| 356 | 383 | } |
| ... | ... | @@ -360,8 +387,56 @@ fn sibling(siblings: &mut Vec<(u32, Option<u32>)>, level: u32) -> Option<u32> { |
| 360 | 387 | } |
| 361 | 388 | } |
| 362 | 389 | |
| 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. | |
| 394 | pub 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`. | |
| 424 | pub 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 | 436 | /// `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`). | |
| 365 | 440 | pub(crate) fn numeral(sequence: Option<char>, number: u32) -> Result<String, LayoutError> { |
| 366 | 441 | let roman = |number: u32| { |
| 367 | 442 | const DIGITS: [(u32, &str); 13] = [ |
| ... | ... | @@ -379,7 +454,9 @@ pub(crate) fn numeral(sequence: Option<char>, number: u32) -> Result<String, Lay |
| 379 | 454 | (4, "IV"), |
| 380 | 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 | 460 | return Err(LayoutError::UnsupportedContent); |
| 384 | 461 | } |
| 385 | 462 | let mut rest = number; |
| ... | ... | @@ -392,18 +469,114 @@ pub(crate) fn numeral(sequence: Option<char>, number: u32) -> Result<String, Lay |
| 392 | 469 | } |
| 393 | 470 | Ok(text) |
| 394 | 471 | }; |
| 472 | // Past Z a letter repeats, AA to ZZ, up to thirty times, then starts over at A. | |
| 395 | 473 | let letter = |number: u32| { |
| 396 | (1..=26) | |
| 397 | .contains(&number) | |
| 398 | .then(|| char::from(b'A' + (number - 1) as u8).to_string()) | |
| 399 | .ok_or(LayoutError::UnsupportedContent) | |
| 474 | let index = number | |
| 475 | .checked_sub(1) | |
| 476 | .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 | 480 | match sequence.map(u32::from) { |
| 402 | 481 | Some(0) => Ok(number.to_string()), |
| 482 | Some(22) => Ok(format!("{number:02}")), | |
| 403 | 483 | Some(1) => roman(number), |
| 404 | 484 | Some(2) => roman(number).map(|text| text.to_lowercase()), |
| 405 | 485 | Some(3) => letter(number), |
| 406 | 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. | |
| 525 | fn 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 | 580 | _ => Err(LayoutError::UnsupportedContent), |
| 408 | 581 | } |
| 409 | 582 | } |
| ... | ... | @@ -438,9 +611,6 @@ impl ParagraphLayout { |
| 438 | 611 | } |
| 439 | 612 | |
| 440 | 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 | 615 | self.origin[0] + self.text.shaped.width(), |
| 446 | 616 | self.markers |
| ... | ... | @@ -454,7 +624,7 @@ impl ParagraphLayout { |
| 454 | 624 | pub(crate) fn shape( |
| 455 | 625 | engine: &mut TextEngine, |
| 456 | 626 | paragraph: &PageParagraph, |
| 457 | previous: Option<u32>, | |
| 627 | previous: Option<&Count>, | |
| 458 | 628 | width: f32, |
| 459 | 629 | indents: &[f32], |
| 460 | 630 | definitions: &BTreeMap<ExGuid, Definition>, |
| ... | ... | @@ -474,53 +644,111 @@ impl ParagraphLayout { |
| 474 | 644 | return Err(LayoutError::InvalidSpacing); |
| 475 | 645 | } |
| 476 | 646 | // Stored newest first; OneNote lists and paints tags oldest first. |
| 477 | let mut tag_definitions = paragraph | |
| 647 | // 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 | 650 | .tags |
| 479 | 651 | .iter() |
| 480 | 652 | .chain(&source.tags) |
| 481 | .map( | |
| 482 | |tag| match tag.definition.as_ref().and_then(|id| definitions.get(id)) { | |
| 483 | Some(Definition { | |
| 484 | kind: | |
| 485 | Kind::TagDefinition { | |
| 486 | shape, | |
| 487 | label, | |
| 488 | color, | |
| 489 | highlight, | |
| 490 | .. | |
| 491 | }, | |
| 653 | .filter_map(|tag| { | |
| 654 | if tag.status & 4 != 0 { | |
| 655 | return Some((tag, None, None, None, None)); | |
| 656 | } | |
| 657 | match &definitions.get(tag.definition.as_ref()?)?.kind { | |
| 658 | Kind::TagDefinition { | |
| 659 | shape, | |
| 660 | label, | |
| 661 | color, | |
| 662 | highlight, | |
| 492 | 663 | .. |
| 493 | }) if tag.status & 4 == 0 => Ok((tag, *shape, label, *color, *highlight)), | |
| 494 | _ => Err(LayoutError::UnsupportedContent), | |
| 495 | }, | |
| 496 | ) | |
| 497 | .collect::<Result<Vec<_>, _>>()?; | |
| 664 | } => Some((tag, Some(*shape), label.as_deref(), *color, *highlight)), | |
| 665 | _ => None, | |
| 666 | } | |
| 667 | }) | |
| 668 | .collect(); | |
| 498 | 669 | tag_definitions.reverse(); |
| 499 | 670 | // The newest tag that sets a colour wins. |
| 500 | 671 | let color = tag_definitions.iter().rev().find_map(|tag| tag.3); |
| 501 | 672 | let highlight = tag_definitions.iter().rev().find_map(|tag| tag.4); |
| 502 | let equation = onestore::page::Math::is_equation(&source.text); | |
| 503 | let mut text = if color.is_none() && highlight.is_none() && !equation { | |
| 504 | engine.layout(projection.text(), width - indent)? | |
| 673 | // Equations with objects draw in two dimensions in a space kept in their line; their | |
| 674 | // linear text stays in the text, too small to see, to hold the caret. | |
| 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 | 709 | } else { |
| 506 | let visible = projection.text(); | |
| 507 | 710 | let mut start = 0; |
| 508 | let runs = visible.spans().iter().map(|span| { | |
| 711 | let runs = visible.spans().iter().flat_map(|span| { | |
| 509 | 712 | let mut format = span.format.clone(); |
| 510 | 713 | format.color = color.or(format.color); |
| 511 | 714 | format.highlight = highlight.or(format.highlight); |
| 512 | // An equation draws in two dimensions; its linear text only holds the caret, | |
| 513 | // which the body font sizes like a line of text. | |
| 514 | if equation { | |
| 515 | format.font = None; | |
| 715 | let mut runs = Vec::new(); | |
| 716 | let mut from = start; | |
| 717 | for (range, equation) in &marks { | |
| 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 | 742 | start = span.end; |
| 519 | run | |
| 743 | runs | |
| 520 | 744 | }); |
| 521 | engine.layout( | |
| 522 | &Paragraph::from_runs(runs.collect::<Vec<_>>()), | |
| 745 | engine.layout_with( | |
| 746 | &Paragraph::from_runs( | |
| 747 | runs.filter(|(text, _)| !text.is_empty()) | |
| 748 | .collect::<Vec<_>>(), | |
| 749 | ), | |
| 523 | 750 | width - indent, |
| 751 | &spaces, | |
| 524 | 752 | )? |
| 525 | 753 | }; |
| 526 | 754 | let mut markers = Vec::new(); |
| ... | ... | @@ -538,34 +766,39 @@ impl ParagraphLayout { |
| 538 | 766 | return Err(LayoutError::InvalidList); |
| 539 | 767 | }; |
| 540 | 768 | let mut color = definition.format.color; |
| 541 | let value = match value.split_once('\u{fffd}') { | |
| 542 | Some((prefix, rest)) => { | |
| 543 | // An empty numbered paragraph shows its number as a grey placeholder. | |
| 544 | if source.text.text().is_empty() { | |
| 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 | ) | |
| 769 | let value = if value.contains('\u{fffd}') { | |
| 770 | // An empty numbered paragraph shows its number as a gray placeholder. | |
| 771 | if source.text.text().is_empty() { | |
| 772 | color = Some(PLACEHOLDER); | |
| 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 | 787 | if value.contains('\u{fffd}') { |
| 559 | 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 | 798 | let marker = Paragraph::new( |
| 562 | 799 | value, |
| 563 | 800 | Format { |
| 564 | // Without a font of its own a marker takes its text's, as a number does. | |
| 565 | font: font | |
| 566 | .clone() | |
| 567 | .or_else(|| definition.format.font.clone()) | |
| 568 | .or_else(|| format.font.clone()), | |
| 801 | font, | |
| 569 | 802 | font_size: definition.format.font_size.or(format.font_size), |
| 570 | 803 | color, |
| 571 | 804 | ..Format::default() |
| ... | ... | @@ -589,22 +822,24 @@ impl ParagraphLayout { |
| 589 | 822 | for tag in &tag_definitions { |
| 590 | 823 | let (tag, shape, label) = (tag.0, tag.1, tag.2); |
| 591 | 824 | let icon = match shape { |
| 592 | Some(0) => continue, | |
| 593 | Some(3) => TagIcon::CheckBox { | |
| 825 | None => TagIcon::Flag, | |
| 826 | Some(Some(0) | None) => continue, | |
| 827 | Some(Some(3)) => TagIcon::CheckBox { | |
| 594 | 828 | checked: tag.status & 1 != 0, |
| 595 | 829 | }, |
| 596 | Some(13) => TagIcon::Star, | |
| 597 | Some(15) => TagIcon::Question, | |
| 598 | Some(17) => TagIcon::Exclamation, | |
| 599 | Some(18) => TagIcon::Phone, | |
| 600 | Some(23) => TagIcon::Address, | |
| 601 | Some(118) => TagIcon::Contact, | |
| 602 | Some(136) => TagIcon::Highlight, | |
| 603 | Some(100) => TagIcon::RedSquare, | |
| 604 | Some(101) => TagIcon::YellowSquare, | |
| 605 | Some(102) => TagIcon::BlueSquare, | |
| 606 | Some(121) => TagIcon::Music, | |
| 607 | _ => return Err(LayoutError::UnsupportedContent), | |
| 830 | Some(Some(13)) => TagIcon::Star, | |
| 831 | Some(Some(15)) => TagIcon::Question, | |
| 832 | Some(Some(17)) => TagIcon::Exclamation, | |
| 833 | Some(Some(18)) => TagIcon::Phone, | |
| 834 | Some(Some(23)) => TagIcon::Address, | |
| 835 | Some(Some(118)) => TagIcon::Contact, | |
| 836 | Some(Some(136)) => TagIcon::Highlight, | |
| 837 | Some(Some(100)) => TagIcon::RedSquare, | |
| 838 | Some(Some(101)) => TagIcon::YellowSquare, | |
| 839 | Some(Some(102)) => TagIcon::BlueSquare, | |
| 840 | Some(Some(121)) => TagIcon::Music, | |
| 841 | // A symbol without artwork here shows as a plain tag. | |
| 842 | Some(_) => TagIcon::Other, | |
| 608 | 843 | }; |
| 609 | 844 | let size = format.font_size.unwrap_or(11.0); |
| 610 | 845 | let side = ParagraphTag::side(size); |
| ... | ... | @@ -612,13 +847,11 @@ impl ParagraphLayout { |
| 612 | 847 | icon, |
| 613 | 848 | origin: [0.0, ParagraphTag::top(baseline, size, side)], |
| 614 | 849 | size: side, |
| 615 | label: label.clone().unwrap_or_default(), | |
| 850 | label: label.unwrap_or_default().to_owned(), | |
| 616 | 851 | disabled: tag.status & 2 != 0, |
| 617 | 852 | }); |
| 618 | 853 | } |
| 619 | let math = equation | |
| 620 | .then(|| crate::math::layout(engine, &source.text)) | |
| 621 | .transpose()?; | |
| 854 | let math = equations.into_iter().map(|(_, math)| math).collect(); | |
| 622 | 855 | let mut result = Self { |
| 623 | 856 | id: paragraph.id, |
| 624 | 857 | origin: [indent, 0.0], |
| ... | ... | @@ -732,7 +965,7 @@ impl OutlineLayout { |
| 732 | 965 | edit: Option<&DocumentEdit>, |
| 733 | 966 | shape: &mut impl FnMut( |
| 734 | 967 | &PageParagraph, |
| 735 | Option<u32>, | |
| 968 | Option<&Count>, | |
| 736 | 969 | f32, |
| 737 | 970 | &[f32], |
| 738 | 971 | ) -> Result<ParagraphLayout, LayoutError>, |
| ... | ... | @@ -779,10 +1012,10 @@ impl OutlineLayout { |
| 779 | 1012 | edit: Option<&DocumentEdit>, |
| 780 | 1013 | mut hiding: BTreeSet<ExGuid>, |
| 781 | 1014 | mut state: (f64, Option<f32>), |
| 782 | siblings: &mut Vec<(u32, Option<u32>)>, | |
| 1015 | siblings: &mut Vec<(u32, Option<Count>)>, | |
| 783 | 1016 | shape: &mut impl FnMut( |
| 784 | 1017 | &PageParagraph, |
| 785 | Option<u32>, | |
| 1018 | Option<&Count>, | |
| 786 | 1019 | f32, |
| 787 | 1020 | &[f32], |
| 788 | 1021 | ) -> Result<ParagraphLayout, LayoutError>, |
| ... | ... | @@ -820,8 +1053,8 @@ impl OutlineLayout { |
| 820 | 1053 | if !hidden { |
| 821 | 1054 | let (space, height, flow, extent) = match &node.content { |
| 822 | 1055 | ParagraphContent::Text(_) => { |
| 823 | let mut paragraph = shape(node, previous, width, indents)?; | |
| 824 | number = paragraph.number.map(|(number, _)| number); | |
| 1056 | let mut paragraph = shape(node, previous.as_ref(), width, indents)?; | |
| 1057 | number = paragraph.number.clone().map(|(count, _)| count); | |
| 825 | 1058 | let space = spacing(&paragraph.projection.text().spans()[0].format)?; |
| 826 | 1059 | paragraph.origin[1] = top(&mut state, space); |
| 827 | 1060 | let size = paragraph.size(); |
| ... | ... | @@ -951,7 +1184,7 @@ impl OutlineLayout { |
| 951 | 1184 | } |
| 952 | 1185 | } |
| 953 | 1186 | block.count = tally(node, number, previous); |
| 954 | siblings.push((node.level, block.count)); | |
| 1187 | siblings.push((node.level, block.count.clone())); | |
| 955 | 1188 | block.state = state; |
| 956 | 1189 | if depth == 0 { |
| 957 | 1190 | result.blocks.push(block); |
| ... | ... | @@ -963,10 +1196,10 @@ impl OutlineLayout { |
| 963 | 1196 | } |
| 964 | 1197 | |
| 965 | 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 | 1200 | let block = &self.blocks[root]; |
| 968 | 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 | 1203 | .flatten() |
| 971 | 1204 | } |
| 972 | 1205 | |
| ... | ... | @@ -996,7 +1229,7 @@ impl OutlineLayout { |
| 996 | 1229 | fixed_width: bool, |
| 997 | 1230 | shape: &mut impl FnMut( |
| 998 | 1231 | &PageParagraph, |
| 999 | Option<u32>, | |
| 1232 | Option<&Count>, | |
| 1000 | 1233 | f32, |
| 1001 | 1234 | &[f32], |
| 1002 | 1235 | ) -> Result<ParagraphLayout, LayoutError>, |
| ... | ... | @@ -1047,7 +1280,7 @@ impl OutlineLayout { |
| 1047 | 1280 | for root in (0..range.start).rev() { |
| 1048 | 1281 | if nodes[root].level < level { |
| 1049 | 1282 | level = nodes[root].level; |
| 1050 | siblings.push((level, self.blocks[root].count)); | |
| 1283 | siblings.push((level, self.blocks[root].count.clone())); | |
| 1051 | 1284 | if level <= 1 { |
| 1052 | 1285 | break; |
| 1053 | 1286 | } |
| ... | ... | @@ -1067,7 +1300,8 @@ impl OutlineLayout { |
| 1067 | 1300 | let indent = indentation(node.level, indents, width)?; |
| 1068 | 1301 | if let Some((source, cached)) = cached.get(&node.id) |
| 1069 | 1302 | && *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 | 1305 | && cached.text.shaped.layout_max_advance() == width - indent |
| 1072 | 1306 | && cached |
| 1073 | 1307 | .markers |
| ... | ... | @@ -1104,9 +1338,11 @@ impl OutlineLayout { |
| 1104 | 1338 | for root in range.end..nodes.len() { |
| 1105 | 1339 | let previous = sibling(&mut siblings, nodes[root].level); |
| 1106 | 1340 | let number = self.number(nodes, root); |
| 1107 | let now = follows(number, previous); | |
| 1108 | let counted = tally(&nodes[root], now, previous); | |
| 1109 | if now != number.map(|(number, _)| number) || counted != self.blocks[root].count { | |
| 1341 | let now = follows(number, previous.as_ref()); | |
| 1342 | let counted = tally(&nodes[root], now.clone(), previous); | |
| 1343 | if now.as_ref() != number.map(|(count, _)| count) | |
| 1344 | || counted != self.blocks[root].count | |
| 1345 | { | |
| 1110 | 1346 | renumbered = root + 1; |
| 1111 | 1347 | } |
| 1112 | 1348 | siblings.push((nodes[root].level, counted)); |
| ... | ... | @@ -1275,7 +1511,7 @@ impl OutlineLayout { |
| 1275 | 1511 | edit: Option<&DocumentEdit>, |
| 1276 | 1512 | shape: &mut impl FnMut( |
| 1277 | 1513 | &PageParagraph, |
| 1278 | Option<u32>, | |
| 1514 | Option<&Count>, | |
| 1279 | 1515 | f32, |
| 1280 | 1516 | &[f32], |
| 1281 | 1517 | ) -> Result<ParagraphLayout, LayoutError>, |
| ... | ... | @@ -1885,17 +2121,26 @@ mod tests { |
| 1885 | 2121 | unreachable!() |
| 1886 | 2122 | }; |
| 1887 | 2123 | *shape = Some(999); |
| 1888 | assert!(matches!( | |
| 2124 | let mut shaped = |node: &PageParagraph, definitions| { | |
| 1889 | 2125 | ParagraphLayout::shape( |
| 1890 | 2126 | &mut engine, |
| 1891 | &node, | |
| 2127 | node, | |
| 1892 | 2128 | None, |
| 1893 | 2129 | 120.0, |
| 1894 | 2130 | &[18.0, 0.0, 27.0], |
| 1895 | &definitions | |
| 1896 | ), | |
| 1897 | Err(LayoutError::UnsupportedContent) | |
| 1898 | )); | |
| 2131 | definitions, | |
| 2132 | ) | |
| 2133 | .unwrap() | |
| 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 | } |
| 1900 | 2145 | |
| 1901 | 2146 | /// OneNote 2010 at 400% (Calibri bullets and numbers from 8 to 24 pt, default list |
| ... | ... | @@ -2475,6 +2720,17 @@ mod tests { |
| 2475 | 2720 | assert_eq!(layout.size[0], 110.0); |
| 2476 | 2721 | } |
| 2477 | 2722 | |
| 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 | 2734 | #[test] |
| 2479 | 2735 | fn numerals_follow_onenote_number_sequences() { |
| 2480 | 2736 | assert_eq!(numeral(Some('\u{0}'), 12).unwrap(), "12"); |
| ... | ... | @@ -2482,8 +2738,70 @@ mod tests { |
| 2482 | 2738 | assert_eq!(numeral(Some('\u{2}'), 4).unwrap(), "iv"); |
| 2483 | 2739 | assert_eq!(numeral(Some('\u{3}'), 3).unwrap(), "C"); |
| 2484 | 2740 | assert_eq!(numeral(Some('\u{4}'), 26).unwrap(), "z"); |
| 2485 | assert!(numeral(Some('\u{4}'), 27).is_err()); | |
| 2486 | 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 | 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 | ||
| 7 | use canvas::{document::TextPosition, editor::CanvasEditor, layout::TextEngine}; | |
| 8 | use onestore::{ | |
| 9 | Arena, ExGuid, RevisionIndex, Section, Store, | |
| 10 | document::Document, | |
| 11 | page::{ | |
| 12 | Math, Page, PageObject, | |
| 13 | link::{LinkTarget, internal_link}, | |
| 14 | }, | |
| 15 | }; | |
| 16 | use std::path::Path; | |
| 17 | ||
| 18 | const BASE_PATH: &str = r"C:\one-tests\runs\capture\notebook\links.one"; | |
| 19 | ||
| 20 | fn 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. | |
| 33 | fn 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 | ||
| 44 | fn caret(editor: &CanvasEditor) -> TextPosition { | |
| 45 | editor.selection().positions[1] | |
| 46 | } | |
| 47 | ||
| 48 | /// End, then Enter, returning the new paragraph. | |
| 49 | fn 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 | ||
| 65 | fn 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] | |
| 84 | fn 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. | |
| 394 | type Runs = Vec<(String, bool, bool, bool, bool, Option<(u32, Option<u32>)>)>; | |
| 395 | ||
| 396 | fn 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] | |
| 425 | fn 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] | |
| 506 | fn 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 | 22 | #[path = "../../onestore/tests/support/sweep.rs"] |
| 23 | 23 | mod sweep; |
| 24 | 24 | |
| 25 | const SECTIONS: [&str; 15] = [ | |
| 25 | const SECTIONS: [&str; 18] = [ | |
| 26 | 26 | "corpus/outline-edit/before/notebook/synthetic.one", |
| 27 | 27 | "corpus/paragraph-edit/before/notebook/synthetic.one", |
| 28 | 28 | "corpus/outline-edit/tree/before/notebook/synthetic.one", |
| ... | ... | @@ -36,6 +36,9 @@ const SECTIONS: [&str; 15] = [ |
| 36 | 36 | "corpus/ink-edit/drawing/cold/notebook/ink.one", |
| 37 | 37 | "corpus/math-edit/native-editor/notebook/links.one", |
| 38 | 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 | 42 | "corpus/paragraph-format/cold/notebook/synthetic.one", |
| 40 | 43 | "corpus/canvas/baseline-anchors.one", |
| 41 | 44 | ]; |
| ... | ... | @@ -82,11 +85,17 @@ enum Kind { |
| 82 | 85 | PlaceImage, |
| 83 | 86 | RemoveImage, |
| 84 | 87 | Date, |
| 88 | Link, | |
| 89 | Unlink, | |
| 90 | TypedUrl, | |
| 91 | Equation, | |
| 92 | Linear, | |
| 93 | Professional, | |
| 85 | 94 | Undo, |
| 86 | 95 | Redo, |
| 87 | 96 | } |
| 88 | 97 | |
| 89 | const KINDS: [Kind; 42] = [ | |
| 98 | const KINDS: [Kind; 48] = [ | |
| 90 | 99 | Kind::Type, |
| 91 | 100 | Kind::TypeEnd, |
| 92 | 101 | Kind::Compose, |
| ... | ... | @@ -127,6 +136,12 @@ const KINDS: [Kind; 42] = [ |
| 127 | 136 | Kind::PlaceImage, |
| 128 | 137 | Kind::RemoveImage, |
| 129 | 138 | Kind::Date, |
| 139 | Kind::Link, | |
| 140 | Kind::Unlink, | |
| 141 | Kind::TypedUrl, | |
| 142 | Kind::Equation, | |
| 143 | Kind::Linear, | |
| 144 | Kind::Professional, | |
| 130 | 145 | Kind::Undo, |
| 131 | 146 | Kind::Redo, |
| 132 | 147 | ]; |
| ... | ... | @@ -372,6 +387,28 @@ fn perform( |
| 372 | 387 | ) |
| 373 | 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 | 412 | Kind::Undo => editor.undo(engine).unwrap_or(false), |
| 376 | 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 | 384 | #[test] |
| 385 | 385 | fn structural_edits_reread_as_saved() { |
| 386 | 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 | 388 | if let Some(extra) = std::env::var_os("CANVAS_SWEEP_SECTIONS") { |
| 389 | 389 | paths.extend(std::env::split_paths(&extra)); |
| 390 | 390 | } |
| ... | ... | @@ -522,7 +522,7 @@ fn structural_edits_write_what_onenote_reads() { |
| 522 | 522 | let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); |
| 523 | 523 | let read = |path: &str| std::fs::read(root.join(path)).unwrap(); |
| 524 | 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 | 526 | for (title, keys) in [ |
| 527 | 527 | ("c6-plain", vec![(Before("Above"), Press::Tab)]), |
| 528 | 528 | ("c6-first", vec![(Before("Target text"), Press::ShiftTab)]), |
crates/draw/src/edit.rs+37-2| ... | ... | @@ -87,6 +87,9 @@ pub enum Command { |
| 87 | 87 | ScrollPage { up: bool }, |
| 88 | 88 | /// Moves the caret a page and the view with it, extending the selection with Shift. |
| 89 | 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 | } |
| 91 | 94 | |
| 92 | 95 | impl Command { |
| ... | ... | @@ -285,6 +288,13 @@ fn mac_command(key: &Key, modifiers: Modifiers) -> Option<Command> { |
| 285 | 288 | })) |
| 286 | 289 | }; |
| 287 | 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 | 298 | Key::Named(NamedKey::ArrowLeft) => { |
| 289 | 299 | pick(Movement::Left, Movement::WordLeft, Movement::LineStart) |
| 290 | 300 | } |
| ... | ... | @@ -341,10 +351,18 @@ fn mac_command(key: &Key, modifiers: Modifiers) -> Option<Command> { |
| 341 | 351 | /// and End reaches the document's ends; Control with Up and Down moves by paragraphs. |
| 342 | 352 | fn pc_command(key: &Key, modifiers: Modifiers) -> Option<Command> { |
| 343 | 353 | let Modifiers { |
| 344 | control, option, .. | |
| 354 | shift, | |
| 355 | control, | |
| 356 | option, | |
| 357 | .. | |
| 345 | 358 | } = modifiers; |
| 346 | 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 | 367 | let pick = |
| 350 | 368 | |plain, with_control| Some(Command::Move(if control { with_control } else { plain })); |
| ... | ... | @@ -643,6 +661,16 @@ mod tests { |
| 643 | 661 | None, |
| 644 | 662 | ), |
| 645 | 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 | 675 | for (key, modifiers, command) in cases { |
| 648 | 676 | assert_eq!( |
| ... | ... | @@ -666,8 +694,15 @@ mod tests { |
| 666 | 694 | option: true, |
| 667 | 695 | ..Modifiers::default() |
| 668 | 696 | }; |
| 697 | let alt_shift = Modifiers { shift: true, ..alt }; | |
| 669 | 698 | let named = |named| Key::Named(named); |
| 670 | 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 | 707 | named(NamedKey::ArrowLeft), |
| 673 | 708 | control, |
crates/draw/src/render.rs+229-22| ... | ... | @@ -1,7 +1,11 @@ |
| 1 | 1 | mod icon; |
| 2 | ||
| 3 | pub use icon::Palette; | |
| 2 | 4 | mod text; |
| 5 | mod translucent; | |
| 3 | 6 | |
| 4 | 7 | pub use text::{Decoration, Glyph, GlyphRun, Glyphs, paint_parley_run}; |
| 8 | pub use translucent::Translucent; | |
| 5 | 9 | |
| 6 | 10 | use bytemuck::{Pod, Zeroable}; |
| 7 | 11 | use image::ImageDecoder; |
| ... | ... | @@ -150,10 +154,20 @@ struct CachedImage { |
| 150 | 154 | |
| 151 | 155 | struct Batch { |
| 152 | 156 | vertices: Range<u32>, |
| 153 | image: Option<u64>, | |
| 157 | blend: Blend, | |
| 154 | 158 | scissor: [u32; 4], |
| 155 | 159 | } |
| 156 | 160 | |
| 161 | /// How a batch meets what lies beneath it. | |
| 162 | #[derive(Clone, Copy, PartialEq)] | |
| 163 | enum 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 | 171 | #[repr(C)] |
| 158 | 172 | #[derive(Clone, Copy, Pod, Zeroable)] |
| 159 | 173 | struct Vertex { |
| ... | ... | @@ -181,6 +195,9 @@ enum AtlasKey { |
| 181 | 195 | sources: &'static [&'static str], |
| 182 | 196 | size: u32, |
| 183 | 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 | 202 | Path { |
| 186 | 203 | data: String, |
| ... | ... | @@ -213,9 +230,47 @@ pub struct Layer<'a> { |
| 213 | 230 | /// The colour behind the layer: on a dark one, text in dark colours of its own is |
| 214 | 231 | /// lifted to stay legible against what lies behind it, as OneNote's dark page does. |
| 215 | 232 | pub backdrop: Option<[f32; 4]>, |
| 233 | pub motion: Option<Motion>, | |
| 216 | 234 | pub primitives: &'a [Primitive<'a>], |
| 217 | 235 | } |
| 218 | 236 | |
| 237 | /// A layer part of the way through appearing: faded, and leaned back in perspective. | |
| 238 | #[derive(Clone, Copy, Debug, PartialEq)] | |
| 239 | pub 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 | ||
| 248 | impl 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 | 274 | /// A layer's transform onto the target. |
| 220 | 275 | #[derive(Clone, Copy)] |
| 221 | 276 | struct Space { |
| ... | ... | @@ -223,6 +278,7 @@ struct Space { |
| 223 | 278 | scale: f32, |
| 224 | 279 | origin: [f32; 2], |
| 225 | 280 | backdrop: Option<[f32; 4]>, |
| 281 | motion: Option<Motion>, | |
| 226 | 282 | } |
| 227 | 283 | |
| 228 | 284 | impl Space { |
| ... | ... | @@ -271,6 +327,9 @@ pub enum PathStyle { |
| 271 | 327 | Stroke(f32), |
| 272 | 328 | /// Filled and blurred this far, as a soft shadow. |
| 273 | 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 | } |
| 275 | 334 | |
| 276 | 335 | #[derive(Clone, Copy, Debug, PartialEq)] |
| ... | ... | @@ -291,12 +350,14 @@ pub enum Primitive<'a> { |
| 291 | 350 | ink: [f32; 4], |
| 292 | 351 | }, |
| 293 | 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 | 355 | Icon { |
| 296 | 356 | sources: &'static [&'static str], |
| 297 | 357 | origin: [f32; 2], |
| 298 | 358 | size: f32, |
| 299 | 359 | tint: [f32; 4], |
| 360 | palette: Palette, | |
| 300 | 361 | }, |
| 301 | 362 | /// SVG path data in layer units from `origin`, shaded from `colors[0]` at the top of |
| 302 | 363 | /// what it paints to `colors[1]` at the bottom. Each distinct path is rasterized once |
| ... | ... | @@ -378,6 +439,7 @@ pub struct Renderer { |
| 378 | 439 | pub queue: wgpu::Queue, |
| 379 | 440 | pipeline: wgpu::RenderPipeline, |
| 380 | 441 | image_pipeline: wgpu::RenderPipeline, |
| 442 | erase_pipeline: wgpu::RenderPipeline, | |
| 381 | 443 | atlas: wgpu::Texture, |
| 382 | 444 | bind_group: wgpu::BindGroup, |
| 383 | 445 | image_sampler: wgpu::Sampler, |
| ... | ... | @@ -451,8 +513,21 @@ impl Renderer { |
| 451 | 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 | 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 | 531 | let atlas = device.create_texture(&wgpu::TextureDescriptor { |
| 457 | 532 | label: Some("Glyph atlas"), |
| 458 | 533 | size: wgpu::Extent3d { |
| ... | ... | @@ -518,6 +593,7 @@ impl Renderer { |
| 518 | 593 | queue, |
| 519 | 594 | pipeline, |
| 520 | 595 | image_pipeline, |
| 596 | erase_pipeline, | |
| 521 | 597 | atlas, |
| 522 | 598 | bind_group, |
| 523 | 599 | image_sampler, |
| ... | ... | @@ -601,6 +677,7 @@ impl Renderer { |
| 601 | 677 | scale: layer.scale, |
| 602 | 678 | origin: layer.origin, |
| 603 | 679 | backdrop: layer.backdrop, |
| 680 | motion: layer.motion, | |
| 604 | 681 | }; |
| 605 | 682 | for primitive in layer.primitives { |
| 606 | 683 | if let Primitive::Image { image, rect } = primitive |
| ... | ... | @@ -647,15 +724,12 @@ impl Renderer { |
| 647 | 724 | for batch in &self.batches { |
| 648 | 725 | let [x, y, width, height] = batch.scissor; |
| 649 | 726 | pass.set_scissor_rect(x, y, width, height); |
| 650 | pass.set_pipeline(if batch.image.is_some() { | |
| 651 | &self.image_pipeline | |
| 652 | } else { | |
| 653 | &self.pipeline | |
| 654 | }); | |
| 655 | let binding = batch | |
| 656 | .image | |
| 657 | .map(|id| &self.images[&id].binding) | |
| 658 | .unwrap_or(&self.bind_group); | |
| 727 | let (pipeline, binding) = match batch.blend { | |
| 728 | Blend::Over => (&self.pipeline, &self.bind_group), | |
| 729 | Blend::Image(id) => (&self.image_pipeline, &self.images[&id].binding), | |
| 730 | Blend::Erase => (&self.erase_pipeline, &self.bind_group), | |
| 731 | }; | |
| 732 | pass.set_pipeline(pipeline); | |
| 659 | 733 | pass.set_bind_group(0, binding, &[]); |
| 660 | 734 | pass.draw(batch.vertices.clone(), 0..1); |
| 661 | 735 | } |
| ... | ... | @@ -676,6 +750,7 @@ impl Renderer { |
| 676 | 750 | scale: layer.scale, |
| 677 | 751 | origin: layer.origin, |
| 678 | 752 | backdrop: layer.backdrop, |
| 753 | motion: layer.motion, | |
| 679 | 754 | }; |
| 680 | 755 | let bounds = match layer.clip { |
| 681 | 756 | Some(clip) => space.scissor(clip), |
| ... | ... | @@ -699,7 +774,7 @@ impl Renderer { |
| 699 | 774 | active_images: &HashSet<u64>, |
| 700 | 775 | ) -> Result<(), RenderError> { |
| 701 | 776 | let start = self.vertices.len() as u32; |
| 702 | let mut image_id = None; | |
| 777 | let mut blend = Blend::Over; | |
| 703 | 778 | let mut scissor = bounds; |
| 704 | 779 | match primitive { |
| 705 | 780 | Primitive::Icon { |
| ... | ... | @@ -707,19 +782,25 @@ impl Renderer { |
| 707 | 782 | origin, |
| 708 | 783 | size, |
| 709 | 784 | tint, |
| 785 | palette, | |
| 710 | 786 | } => { |
| 711 | 787 | let origin = [ |
| 712 | 788 | space.origin[0] + origin[0] * space.scale, |
| 713 | 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 | 793 | Primitive::Path { |
| 718 | 794 | data, |
| 719 | 795 | origin, |
| 720 | 796 | style, |
| 721 | 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 | 804 | Primitive::Text { |
| 724 | 805 | text, |
| 725 | 806 | origin, |
| ... | ... | @@ -779,22 +860,27 @@ impl Renderer { |
| 779 | 860 | Primitive::Image { image, rect } => { |
| 780 | 861 | if let Some(rect) = space.visible_rect(*rect)? { |
| 781 | 862 | self.image(image, active_images)?; |
| 782 | image_id = Some(image.id()); | |
| 863 | blend = Blend::Image(image.id()); | |
| 783 | 864 | self.quad(space, rect, [0.0, 0.0, 1.0, 1.0], [1.0; 4])?; |
| 784 | 865 | } |
| 785 | 866 | } |
| 786 | 867 | } |
| 787 | 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 | 874 | if start != end { |
| 789 | 875 | if let Some(last) = self.batches.last_mut() |
| 790 | && last.image == image_id | |
| 876 | && last.blend == blend | |
| 791 | 877 | && last.scissor == scissor |
| 792 | 878 | { |
| 793 | 879 | last.vertices.end = end; |
| 794 | 880 | } else { |
| 795 | 881 | self.batches.push(Batch { |
| 796 | 882 | vertices: start..end, |
| 797 | image: image_id, | |
| 883 | blend, | |
| 798 | 884 | scissor, |
| 799 | 885 | }); |
| 800 | 886 | } |
| ... | ... | @@ -1073,6 +1159,7 @@ impl Renderer { |
| 1073 | 1159 | sources: &'static [&'static str], |
| 1074 | 1160 | size: f32, |
| 1075 | 1161 | tint: [f32; 4], |
| 1162 | palette: &Palette, | |
| 1076 | 1163 | ) -> Result<(), RenderError> { |
| 1077 | 1164 | let size = size * space.scale; |
| 1078 | 1165 | let [x, y] = space.origin.map(|v| (v * 4.0).round() * 0.25); |
| ... | ... | @@ -1090,10 +1177,13 @@ impl Renderer { |
| 1090 | 1177 | return Err(RenderError::AtlasFull); |
| 1091 | 1178 | } |
| 1092 | 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 | 1181 | let key = AtlasKey::Icon { |
| 1094 | 1182 | sources, |
| 1095 | 1183 | size: size.to_bits(), |
| 1096 | 1184 | phase, |
| 1185 | ink: ink.map(f32::to_bits), | |
| 1186 | palette: palette.bits(), | |
| 1097 | 1187 | }; |
| 1098 | 1188 | let glyph = if let Some(Some(glyph)) = self.glyphs.get(&key) { |
| 1099 | 1189 | *glyph |
| ... | ... | @@ -1101,7 +1191,7 @@ impl Renderer { |
| 1101 | 1191 | if self.glyphs.len() >= MAX_GLYPHS { |
| 1102 | 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 | 1195 | self.glyphs.insert(key, Some(glyph)); |
| 1106 | 1196 | glyph |
| 1107 | 1197 | }; |
| ... | ... | @@ -1122,7 +1212,7 @@ impl Renderer { |
| 1122 | 1212 | (glyph.x + glyph.width) as f32 / atlas, |
| 1123 | 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 | } |
| 1128 | 1218 | |
| ... | ... | @@ -1197,7 +1287,7 @@ impl Renderer { |
| 1197 | 1287 | let [x, y] = [0, 1] |
| 1198 | 1288 | .map(|axis| ((space.origin[axis] + origin[axis] * space.scale) * 4.0).round() * 0.25); |
| 1199 | 1289 | let (kind, width) = match style { |
| 1200 | PathStyle::Fill => (0, 0.0), | |
| 1290 | PathStyle::Fill | PathStyle::Erase => (0, 0.0), | |
| 1201 | 1291 | PathStyle::Stroke(width) => (1, width), |
| 1202 | 1292 | PathStyle::Shadow(width) => (2, width), |
| 1203 | 1293 | }; |
| ... | ... | @@ -1729,6 +1819,10 @@ mod tests { |
| 1729 | 1819 | |
| 1730 | 1820 | impl Target { |
| 1731 | 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 | 1826 | Self { |
| 1733 | 1827 | texture: device.create_texture(&wgpu::TextureDescriptor { |
| 1734 | 1828 | label: Some("Draw readback test"), |
| ... | ... | @@ -1740,7 +1834,7 @@ mod tests { |
| 1740 | 1834 | mip_level_count: 1, |
| 1741 | 1835 | sample_count: 1, |
| 1742 | 1836 | dimension: wgpu::TextureDimension::D2, |
| 1743 | format: wgpu::TextureFormat::Rgba8UnormSrgb, | |
| 1837 | format, | |
| 1744 | 1838 | usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, |
| 1745 | 1839 | view_formats: &[], |
| 1746 | 1840 | }), |
| ... | ... | @@ -1809,6 +1903,7 @@ mod tests { |
| 1809 | 1903 | origin: [24.0; 2], |
| 1810 | 1904 | clip: None, |
| 1811 | 1905 | backdrop: None, |
| 1906 | motion: None, | |
| 1812 | 1907 | primitives, |
| 1813 | 1908 | }] |
| 1814 | 1909 | } |
| ... | ... | @@ -1895,6 +1990,7 @@ mod tests { |
| 1895 | 1990 | origin: [128.125 + 20.0 * index as f32, 80.25], |
| 1896 | 1991 | size: 12.0, |
| 1897 | 1992 | tint: [1.0; 4], |
| 1993 | palette: Palette::default(), | |
| 1898 | 1994 | }); |
| 1899 | 1995 | } |
| 1900 | 1996 | primitives.extend((0..9).map(|step| Primitive::Text { |
| ... | ... | @@ -1998,6 +2094,7 @@ mod tests { |
| 1998 | 2094 | origin: [10000.0; 2], |
| 1999 | 2095 | size: 12.0, |
| 2000 | 2096 | tint: [1.0; 4], |
| 2097 | palette: Palette::default(), | |
| 2001 | 2098 | }]), |
| 2002 | 2099 | ) |
| 2003 | 2100 | .unwrap(); |
| ... | ... | @@ -2210,6 +2307,7 @@ mod tests { |
| 2210 | 2307 | origin: [0.0; 2], |
| 2211 | 2308 | clip: None, |
| 2212 | 2309 | backdrop: None, |
| 2310 | motion: None, | |
| 2213 | 2311 | primitives: &primitives, |
| 2214 | 2312 | }], |
| 2215 | 2313 | ) |
| ... | ... | @@ -2246,6 +2344,112 @@ mod tests { |
| 2246 | 2344 | assert_eq!(beyond, [255; 4]); |
| 2247 | 2345 | } |
| 2248 | 2346 | |
| 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 | 2453 | #[test] |
| 2250 | 2454 | #[ignore = "requires a native GPU adapter"] |
| 2251 | 2455 | fn layers_transform_and_clip_independently() { |
| ... | ... | @@ -2272,6 +2476,7 @@ mod tests { |
| 2272 | 2476 | origin: [0.0; 2], |
| 2273 | 2477 | clip: Some([100.0, 50.0, 200.5, 80.0]), |
| 2274 | 2478 | backdrop: None, |
| 2479 | motion: None, | |
| 2275 | 2480 | primitives: &fill, |
| 2276 | 2481 | }, |
| 2277 | 2482 | Layer { |
| ... | ... | @@ -2279,6 +2484,7 @@ mod tests { |
| 2279 | 2484 | origin: [300.0, 10.0], |
| 2280 | 2485 | clip: None, |
| 2281 | 2486 | backdrop: None, |
| 2487 | motion: None, | |
| 2282 | 2488 | primitives: &chrome, |
| 2283 | 2489 | }, |
| 2284 | 2490 | ], |
| ... | ... | @@ -2306,6 +2512,7 @@ mod tests { |
| 2306 | 2512 | origin: [0.0; 2], |
| 2307 | 2513 | clip: None, |
| 2308 | 2514 | backdrop: None, |
| 2515 | motion: None, | |
| 2309 | 2516 | primitives: &fill, |
| 2310 | 2517 | }] |
| 2311 | 2518 | ), |
crates/draw/src/render/icon.rs+469-34| ... | ... | @@ -1,27 +1,62 @@ |
| 1 | 1 | use swash::{ |
| 2 | 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 | }; |
| 5 | 8 | |
| 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)] | |
| 12 | pub struct Palette { | |
| 13 | pub accent: Option<[f32; 3]>, | |
| 14 | pub highlight: Option<[f32; 3]>, | |
| 15 | pub badge: Option<[f32; 3]>, | |
| 16 | } | |
| 17 | ||
| 18 | impl 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 | 35 | /// 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 vertical | |
| 8 | /// gradients or `currentColor`, which paints white; strokes have round caps and joins. | |
| 9 | pub(crate) fn rasterize(sources: &[&str], size: f32, phase: [u8; 2]) -> Image { | |
| 36 | /// `phase` quarter pixels. Paths are filled and stroked with `#rrggbb`, `currentColor`, | |
| 37 | /// which paints the linear `ink`, or two-stop `linearGradient`s with SVG's defaults | |
| 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. | |
| 43 | pub(crate) fn rasterize( | |
| 44 | sources: &[&str], | |
| 45 | size: f32, | |
| 46 | phase: [u8; 2], | |
| 47 | ink: [f32; 3], | |
| 48 | palette: &Palette, | |
| 49 | ) -> Image { | |
| 10 | 50 | let side = (size + 1.0).ceil() as u32; |
| 11 | 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 | 53 | for source in sources { |
| 13 | 54 | let svg = roxmltree::Document::parse(source).expect("Bundled icon SVG must be valid"); |
| 14 | 55 | assert_eq!(svg.root_element().attribute("viewBox"), Some("0 0 16 16")); |
| 15 | let paint = |value: &str| -> Option<[[f32; 4]; 2]> { | |
| 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 | }; | |
| 56 | let paint = |value: &str| -> Option<Paint> { | |
| 22 | 57 | match value { |
| 23 | 58 | "none" => None, |
| 24 | "currentColor" => Some([[1.0; 4]; 2]), | |
| 59 | "currentColor" => Some(Paint::Flat([ink[0], ink[1], ink[2], 1.0])), | |
| 25 | 60 | _ => Some(match value.strip_prefix("url(#") { |
| 26 | 61 | Some(id) => { |
| 27 | 62 | let id = id.strip_suffix(')').unwrap(); |
| ... | ... | @@ -29,18 +64,38 @@ pub(crate) fn rasterize(sources: &[&str], size: f32, phase: [u8; 2]) -> Image { |
| 29 | 64 | .descendants() |
| 30 | 65 | .find(|node| node.attribute("id") == Some(id)) |
| 31 | 66 | .unwrap(); |
| 32 | let stops: Vec<_> = 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") | |
| 67 | Paint::linear(gradient) | |
| 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 | 99 | let fill = match path.attribute("fill-rule") { |
| 45 | 100 | Some("evenodd") => Fill::EvenOdd, |
| 46 | 101 | _ => Fill::NonZero, |
| ... | ... | @@ -48,13 +103,19 @@ pub(crate) fn rasterize(sources: &[&str], size: f32, phase: [u8; 2]) -> Image { |
| 48 | 103 | let pen = path |
| 49 | 104 | .attribute("stroke-width") |
| 50 | 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 | 110 | let layers = [ |
| 52 | 111 | ( |
| 53 | 112 | path.attribute("fill").unwrap_or("#000000"), |
| 113 | opacity("fill-opacity"), | |
| 54 | 114 | Style::from(fill), |
| 55 | 115 | ), |
| 56 | 116 | ( |
| 57 | 117 | path.attribute("stroke").unwrap_or("none"), |
| 118 | opacity("stroke-opacity"), | |
| 58 | 119 | Style::from(Stroke { |
| 59 | 120 | start_cap: Cap::Round, |
| 60 | 121 | end_cap: Cap::Round, |
| ... | ... | @@ -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 { | |
| 67 | let Some([top, bottom]) = paint(value) else { | |
| 127 | for (value, opacity, style) in layers { | |
| 128 | let Some(mut paint) = paint(value) else { | |
| 68 | 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 | 136 | .style(style) |
| 72 | 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 | 139 | .size(side, side) |
| 75 | 140 | .render(); |
| 76 | 141 | for (index, (pixel, coverage)) in pixels.iter_mut().zip(mask).enumerate() { |
| 77 | let alpha = f32::from(coverage) / 255.0; | |
| 78 | let y = ((index / side as usize) as f32 / size).min(1.0); | |
| 142 | if coverage == 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 | 151 | for channel in 0..3 { |
| 80 | let color = top[channel] + (bottom[channel] - top[channel]) * y; | |
| 81 | pixel[channel] = color * alpha + pixel[channel] * (1.0 - alpha); | |
| 152 | pixel[channel] = color[channel] * alpha + pixel[channel] * (1.0 - alpha); | |
| 82 | 153 | } |
| 83 | 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 | 185 | } |
| 115 | 186 | } |
| 116 | 187 | |
| 188 | fn 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`. | |
| 196 | fn 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 | ||
| 203 | enum 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 | ||
| 214 | impl 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. | |
| 310 | struct Shift([f32; 3]); | |
| 311 | ||
| 312 | impl 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 | ||
| 346 | fn 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 | ||
| 354 | fn 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. | |
| 363 | fn 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 | ||
| 381 | fn 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. | |
| 390 | fn 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 | 452 | #[cfg(test)] |
| 118 | 453 | mod tests { |
| 119 | 454 | use super::*; |
| ... | ... | @@ -121,23 +456,123 @@ mod tests { |
| 121 | 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 | 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>"##; |
| 123 | 458 | |
| 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 | 506 | #[test] |
| 125 | fn current_color_strokes_paint_white_coverage() { | |
| 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>"##; | |
| 127 | let image = rasterize(&[LINE], 16.0, [0, 0]); | |
| 507 | fn bounds_follow_curves_not_their_control_points() { | |
| 508 | let [min, max] = bounds("M2 8A6 6 0 0 1 14 8Q8 14 2 8Z"); | |
| 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 | 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 | 555 | assert_eq!(pixel(8, 2)[3], 0); |
| 131 | 556 | // Round caps reach past the path's ends. |
| 132 | 557 | assert!(pixel(1, 7)[3] > 0); |
| 133 | 558 | } |
| 134 | 559 | |
| 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 | 570 | #[test] |
| 136 | 571 | fn icons_keep_transparency_and_overlays_across_scales() { |
| 137 | 572 | for size in [8.0, 16.0, 32.0, 57.5] { |
| 138 | 573 | for phase in [[0, 0], [1, 2], [3, 3]] { |
| 139 | let plain = rasterize(&[BOX], size, phase); | |
| 140 | let marked = rasterize(&[BOX, MARK], size, phase); | |
| 574 | let plain = rasterize(&[BOX], size, phase, [1.0; 3], &Palette::default()); | |
| 575 | let marked = rasterize(&[BOX, MARK], size, phase, [1.0; 3], &Palette::default()); | |
| 141 | 576 | for image in [&plain, &marked] { |
| 142 | 577 | assert_eq!( |
| 143 | 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. | |
| 5 | pub struct Translucent { | |
| 6 | frame: Option<wgpu::Texture>, | |
| 7 | format: wgpu::TextureFormat, | |
| 8 | pipeline: wgpu::RenderPipeline, | |
| 9 | } | |
| 10 | ||
| 11 | impl 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 | |
| 4 | fn 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 | ||
| 9 | fn 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 | |
| 15 | fn 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 | 24 | [target.'cfg(target_os = "macos")'.dependencies] |
| 25 | 25 | libc = "0.2" |
| 26 | 26 | objc2 = "0.5.2" |
| 27 | objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAlert", "NSButton", "NSControl", "NSView", "NSResponder", "NSWindow", "NSApplication", "NSEvent", "NSMenu", "NSMenuItem", "NSColor", "NSColorSpace", "NSDatePicker", "NSDatePickerCell", "NSOpenPanel", "NSPanel", "NSSavePanel"] } | |
| 27 | objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAlert", "NSButton", "NSCell", "NSControl", "NSView", "NSResponder", "NSWindow", "NSApplication", "NSEvent", "NSMenu", "NSMenuItem", "NSColor", "NSColorSpace", "NSDatePicker", "NSDatePickerCell", "NSOpenPanel", "NSPanel", "NSSavePanel"] } | |
| 28 | 28 | objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSGeometry", "NSLocale", "NSPathUtilities", "NSTimeZone", "NSURL", "NSArray"] } |
| 29 | 29 | |
| 30 | 30 | [target.'cfg(target_os = "linux")'.dependencies] |
| ... | ... | @@ -34,3 +34,5 @@ smithay-clipboard = "0.7.3" |
| 34 | 34 | fontique = { workspace = true, features = ["fontconfig-dlopen"] } |
| 35 | 35 | libc = "0.2" |
| 36 | 36 | zbus = { version = "5.19", default-features = false, features = ["async-io", "blocking-api"] } |
| 37 | # The colours of winit's own window frame, which the toolbar continues. | |
| 38 | sctk-adwaita = { version = "0.10.1", default-features = false } |
crates/snowbound/assets/icons/align-center.svg+1-1| ... | ... | @@ -1,3 +1,3 @@ |
| 1 | 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 | 3 | </svg> |
crates/snowbound/assets/icons/align-left.svg+1-1| ... | ... | @@ -1,3 +1,3 @@ |
| 1 | 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 | 3 | </svg> |
crates/snowbound/assets/icons/align-right.svg+1-1| ... | ... | @@ -1,3 +1,3 @@ |
| 1 | 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 | 3 | </svg> |
crates/snowbound/assets/icons/attachment.svg+6-1| ... | ... | @@ -1,3 +1,8 @@ |
| 1 | 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 | 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 | 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 | 3 | </svg> |
crates/snowbound/assets/icons/bullets.svg+12-4| ... | ... | @@ -1,6 +1,14 @@ |
| 1 | 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"/> | |
| 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"/> | |
| 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"/> | |
| 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"/> | |
| 2 | <defs> | |
| 3 | <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#79c2ff"/><stop offset="1" stop-color="#1f7cf0"/></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="#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 | 14 | </svg> |
crates/snowbound/assets/icons/calendar.svg+14-2| ... | ... | @@ -1,4 +1,16 @@ |
| 1 | 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"/> | |
| 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"/> | |
| 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="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 | 16 | </svg> |
crates/snowbound/assets/icons/clear-formatting.svg+9-1| ... | ... | @@ -1,3 +1,11 @@ |
| 1 | 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 | 11 | </svg> |
crates/snowbound/assets/icons/clock.svg+10-1| ... | ... | @@ -1,3 +1,12 @@ |
| 1 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 14 | </svg> |
crates/snowbound/assets/icons/indent.svg+6-2| ... | ... | @@ -1,4 +1,8 @@ |
| 1 | 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"/> | |
| 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"/> | |
| 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 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 | 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 | 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 | 3 | </svg> |
crates/snowbound/assets/icons/link.svg+14-1| ... | ... | @@ -1,3 +1,16 @@ |
| 1 | 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 | 16 | </svg> |
crates/snowbound/assets/icons/notebook.svg+18-1| ... | ... | @@ -1,3 +1,20 @@ |
| 1 | 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 | 20 | </svg> |
crates/snowbound/assets/icons/numbering.svg+2-3| ... | ... | @@ -1,5 +1,4 @@ |
| 1 | 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"/> | |
| 3 | <path d="M2.25 4L3.75 3V7.25" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/> | |
| 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"/> | |
| 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="M7 2H15V3H7ZM7 7H15V8H7ZM7 12H15V13H7Z" fill="currentColor"/> | |
| 5 | 4 | </svg> |
crates/snowbound/assets/icons/options.svg+6-3| ... | ... | @@ -1,5 +1,8 @@ |
| 1 | 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"/> | |
| 3 | <circle cx="8" cy="8" r="3.5" fill="none" stroke="currentColor" stroke-width="1.25"/> | |
| 4 | <circle cx="8" cy="8" r="1.25" fill="none" stroke="currentColor" stroke-width="1.25"/> | |
| 2 | <defs> | |
| 3 | <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#d3d9e0"/><stop offset="1" stop-color="#7b8796"/></linearGradient> | |
| 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 | 8 | </svg> |
crates/snowbound/assets/icons/outdent.svg+6-2| ... | ... | @@ -1,4 +1,8 @@ |
| 1 | 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"/> | |
| 3 | <path d="M5 5.5L2.5 8L5 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="#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 | 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 | 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 | 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 | 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"/> | |
| 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"/> | |
| 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="#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 | 15 | </svg> |
crates/snowbound/assets/icons/plus.svg+7-1| ... | ... | @@ -1,3 +1,9 @@ |
| 1 | 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 | 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 | 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 | 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 | 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 | 15 | </svg> |
crates/snowbound/assets/icons/section-group.svg+12-1| ... | ... | @@ -1,3 +1,14 @@ |
| 1 | 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 | 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 | 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 | 13 | </svg> |
crates/snowbound/assets/icons/sidebar-expand.svg+11-1| ... | ... | @@ -1,3 +1,13 @@ |
| 1 | 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 | 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 | 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 | 4 | </svg> |
crates/snowbound/assets/icons/subscript.svg+2-2| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | 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"/> | |
| 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"/> | |
| 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 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 | 4 | </svg> |
crates/snowbound/assets/icons/superscript.svg+2-2| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | 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"/> | |
| 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"/> | |
| 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 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 | 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 | 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 | 11 | </svg> |
crates/snowbound/assets/icons/underline.svg+2-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | 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 | 4 | </svg> |
crates/snowbound/assets/icons/undo.svg+6-1| ... | ... | @@ -1,3 +1,8 @@ |
| 1 | 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 | 8 | </svg> |
crates/snowbound/assets/icons/zoom-in.svg+14-1| ... | ... | @@ -1,3 +1,16 @@ |
| 1 | 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 | 16 | </svg> |
crates/snowbound/assets/icons/zoom-out.svg+14-1| ... | ... | @@ -1,3 +1,16 @@ |
| 1 | 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 | 16 | </svg> |
crates/snowbound/src/art.rs+38| ... | ... | @@ -33,6 +33,7 @@ pub const PICTURE: &[&str] = art!("icons/picture"); |
| 33 | 33 | pub const PLUS: &[&str] = art!("icons/plus"); |
| 34 | 34 | pub const REDO: &[&str] = art!("icons/redo"); |
| 35 | 35 | pub const SEARCH: &[&str] = art!("icons/search"); |
| 36 | pub const SECTION: &[&str] = art!("icons/section"); | |
| 36 | 37 | pub const SECTION_GROUP: &[&str] = art!("icons/section-group"); |
| 37 | 38 | pub const SIDEBAR_COLLAPSE: &[&str] = art!("icons/sidebar-collapse"); |
| 38 | 39 | pub const SIDEBAR_EXPAND: &[&str] = art!("icons/sidebar-expand"); |
| ... | ... | @@ -45,5 +46,42 @@ pub const UNDO: &[&str] = art!("icons/undo"); |
| 45 | 46 | pub const ZOOM_IN: &[&str] = art!("icons/zoom-in"); |
| 46 | 47 | pub const ZOOM_OUT: &[&str] = art!("icons/zoom-out"); |
| 47 | 48 | |
| 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")] | |
| 52 | pub const BACK: &[&str] = art!("icons/back"); | |
| 53 | #[expect(dead_code, reason = "Its toolbar button has not landed")] | |
| 54 | pub const COPY: &[&str] = art!("icons/copy"); | |
| 55 | #[expect(dead_code, reason = "Its toolbar button has not landed")] | |
| 56 | pub const CUT: &[&str] = art!("icons/cut"); | |
| 57 | #[expect(dead_code, reason = "Its toolbar button has not landed")] | |
| 58 | pub const DATE_TIME: &[&str] = art!("icons/date-time"); | |
| 59 | #[expect(dead_code, reason = "Its toolbar button has not landed")] | |
| 60 | pub const FIND_TAGS: &[&str] = art!("icons/find-tags"); | |
| 61 | #[expect(dead_code, reason = "Its toolbar button has not landed")] | |
| 62 | pub const FORMAT_PAINTER: &[&str] = art!("icons/format-painter"); | |
| 63 | #[expect(dead_code, reason = "Its toolbar button has not landed")] | |
| 64 | pub const FORWARD: &[&str] = art!("icons/forward"); | |
| 65 | #[expect(dead_code, reason = "Its toolbar button has not landed")] | |
| 66 | pub const FULL_PAGE_VIEW: &[&str] = art!("icons/full-page-view"); | |
| 67 | #[expect(dead_code, reason = "Its toolbar button has not landed")] | |
| 68 | pub const INSERT_SPACE: &[&str] = art!("icons/insert-space"); | |
| 69 | #[expect(dead_code, reason = "Its toolbar button has not landed")] | |
| 70 | pub const PAGE_COLOR: &[&str] = art!("icons/page-color"); | |
| 71 | #[expect(dead_code, reason = "Its toolbar button has not landed")] | |
| 72 | pub const PASTE: &[&str] = art!("icons/paste"); | |
| 73 | #[expect(dead_code, reason = "Its toolbar button has not landed")] | |
| 74 | pub const RECORD_AUDIO: &[&str] = art!("icons/record-audio"); | |
| 75 | #[expect(dead_code, reason = "Its toolbar button has not landed")] | |
| 76 | pub const RECORD_VIDEO: &[&str] = art!("icons/record-video"); | |
| 77 | #[expect(dead_code, reason = "Its toolbar button has not landed")] | |
| 78 | pub const RULE_LINES: &[&str] = art!("icons/rule-lines"); | |
| 79 | #[expect(dead_code, reason = "Its toolbar button has not landed")] | |
| 80 | pub const SCREEN_CLIPPING: &[&str] = art!("icons/screen-clipping"); | |
| 81 | #[expect(dead_code, reason = "Its toolbar button has not landed")] | |
| 82 | pub const SPELLING: &[&str] = art!("icons/spelling"); | |
| 83 | #[expect(dead_code, reason = "Its toolbar button has not landed")] | |
| 84 | pub const SYMBOL: &[&str] = art!("icons/symbol"); | |
| 85 | ||
| 48 | 86 | pub const TAG_REMEMBER: &[&str] = art!("tags/remember"); |
| 49 | 87 | pub 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 | ||
| 4 | use crate::{Command as Work, FONTS, HIGHLIGHTS, SIZES, State, page, platform, search}; | |
| 5 | use canvas::editor::{Alignment, FormatState, Formatting, ListStyle, NoteTag, Toggle}; | |
| 6 | use canvas::interaction::Request; | |
| 7 | use draw::edit::{Key, Modifiers, NamedKey, Platform}; | |
| 8 | use std::{error::Error, sync::Arc}; | |
| 9 | ||
| 10 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 11 | pub 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)] | |
| 63 | pub 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)] | |
| 75 | pub 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)] | |
| 82 | pub 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)] | |
| 91 | pub enum Press { | |
| 92 | /// Unshifted and lowercase, as on a US keyboard. | |
| 93 | Char(char), | |
| 94 | Named(NamedKey), | |
| 95 | } | |
| 96 | ||
| 97 | pub struct Command { | |
| 98 | pub id: Id, | |
| 99 | pub title: &'static str, | |
| 100 | pub mac: &'static [Chord], | |
| 101 | pub pc: &'static [Chord], | |
| 102 | } | |
| 103 | ||
| 104 | const fn key(key: char) -> Chord { | |
| 105 | press(Press::Char(key)) | |
| 106 | } | |
| 107 | ||
| 108 | pub(crate) const fn named(key: NamedKey) -> Chord { | |
| 109 | press(Press::Named(key)) | |
| 110 | } | |
| 111 | ||
| 112 | const 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. | |
| 123 | pub(crate) const fn cmd(key: char) -> Chord { | |
| 124 | self::key(key).command() | |
| 125 | } | |
| 126 | ||
| 127 | impl 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 | ||
| 149 | macro_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 | ||
| 160 | macro_rules! tag { | |
| 161 | ($tag:expr, $digit:literal) => { | |
| 162 | row!(Id::Tag($tag), $tag.label(), &[cmd($digit)], &[cmd($digit)]) | |
| 163 | }; | |
| 164 | } | |
| 165 | ||
| 166 | const NONE: &[Chord] = &[]; | |
| 167 | ||
| 168 | /// OneNote 2010's chords off macOS; there, AppKit's and OneNote for Mac's. | |
| 169 | pub 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. | |
| 351 | const HELP: &str = "https://shale.paperclover.net/snowbound"; | |
| 352 | ||
| 353 | pub 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 | ||
| 360 | impl 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. | |
| 370 | pub 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))] | |
| 386 | pub 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))] | |
| 401 | pub 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. | |
| 408 | pub 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 | ||
| 416 | fn 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. | |
| 439 | const 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 | ||
| 463 | impl 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. | |
| 524 | pub 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 | ||
| 531 | impl 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)] | |
| 835 | mod 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 | //! The conflict page views drawn offscreen, from the conflict-page corpus row's candidate: | |
| 2 | //! the page with its information bar, its versions listed beneath it, and a version open. | |
| 3 | //! `SNOWBOUND_CONFLICT_RENDER` names a directory for the PNGs. | |
| 1 | //! The conflict page and page version views drawn offscreen, from the conflict-page and | |
| 2 | //! page-versions corpus rows: the page with its information bar, its conflict pages or | |
| 3 | //! versions listed beneath it, and one open. `SNOWBOUND_CONFLICT_RENDER` names a directory | |
| 4 | //! for the PNGs. | |
| 4 | 5 | |
| 5 | 6 | use super::*; |
| 6 | 7 | |
| ... | ... | @@ -58,7 +59,16 @@ fn layout(ui: &mut Ui, session: &Session, menu: bool) -> Vec<Command> { |
| 58 | 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 | 72 | commands.extend(rows.clicked.map(Command::OpenPage)); |
| 63 | 73 | ui.close(); |
| 64 | 74 | ui.close(); |
| ... | ... | @@ -67,7 +77,13 @@ fn layout(ui: &mut Ui, session: &Session, menu: bool) -> Vec<Command> { |
| 67 | 77 | } |
| 68 | 78 | |
| 69 | 79 | /// The view's pixels, RGBA: the interface with `page` drawn in its box. |
| 70 | fn paint(ui: &Ui, page: Page, renderer: &mut draw::Renderer, device: &wgpu::Device, queue: &wgpu::Queue) -> Vec<u8> { | |
| 80 | fn paint( | |
| 81 | ui: &Ui, | |
| 82 | page: Page, | |
| 83 | renderer: &mut draw::Renderer, | |
| 84 | device: &wgpu::Device, | |
| 85 | queue: &wgpu::Queue, | |
| 86 | ) -> Vec<u8> { | |
| 71 | 87 | let size = SIZE.map(|side| (side * SCALE) as u32); |
| 72 | 88 | let corner = ui.rect(super::page()).unwrap(); |
| 73 | 89 | let box_size = [ |
| ... | ... | @@ -108,13 +124,7 @@ fn paint(ui: &Ui, page: Page, renderer: &mut draw::Renderer, device: &wgpu::Devi |
| 108 | 124 | let layers: Vec<_> = interface |
| 109 | 125 | .iter() |
| 110 | 126 | .map(|layer| match layer { |
| 111 | ui::Layer::Primitives { clip, primitives } => draw::Layer { | |
| 112 | scale: SCALE, | |
| 113 | origin: [0.0; 2], | |
| 114 | clip: clip.map(|clip| clip.map(|value| value * SCALE)), | |
| 115 | backdrop: None, | |
| 116 | primitives, | |
| 117 | }, | |
| 127 | ui::Layer::Primitives(primitives) => primitives.layer(SCALE), | |
| 118 | 128 | ui::Layer::Custom { rect, .. } => draw::Layer { |
| 119 | 129 | scale: viewport.scale, |
| 120 | 130 | origin: [ |
| ... | ... | @@ -123,6 +133,7 @@ fn paint(ui: &Ui, page: Page, renderer: &mut draw::Renderer, device: &wgpu::Devi |
| 123 | 133 | ], |
| 124 | 134 | clip: Some(rect.map(|value| value * SCALE)), |
| 125 | 135 | backdrop: Some(ui.theme.paper), |
| 136 | motion: None, | |
| 126 | 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 | 153 | view_formats: &[], |
| 143 | 154 | }); |
| 144 | 155 | 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 | 162 | .unwrap(); |
| 147 | 163 | let row = (size[0] * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT); |
| 148 | 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 | 196 | .collect() |
| 181 | 197 | } |
| 182 | 198 | |
| 199 | /// A renderer drawing into `output`'s PNGs. | |
| 200 | fn 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. | |
| 214 | fn 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 | 226 | /// The page shown, a page's versions listed while shown, and the bar's commands: showing |
| 184 | 227 | /// opens the newest version, hiding returns to the page. |
| 185 | 228 | #[test] |
| ... | ... | @@ -189,7 +232,11 @@ fn conflict_views_offer_the_versions_and_render_offscreen() { |
| 189 | 232 | let _ = std::fs::remove_dir_all(&directory); |
| 190 | 233 | std::fs::create_dir_all(directory.join("cache")).unwrap(); |
| 191 | 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 | 240 | let library = Arc::new(crate::Library::section(&file, &directory.join("cache"))); |
| 194 | 241 | let section = library.open(&library.location, || {}).unwrap(); |
| 195 | 242 | let path = library.location.clone(); |
| ... | ... | @@ -197,17 +244,7 @@ fn conflict_views_offer_the_versions_and_render_offscreen() { |
| 197 | 244 | let page = session.space; |
| 198 | 245 | let version = session.versions(page)[0].space; |
| 199 | 246 | let output = std::env::var_os("SNOWBOUND_CONFLICT_RENDER").map(PathBuf::from); |
| 200 | let gpu = output.as_ref().map(|output| { | |
| 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; | |
| 247 | let mut gpu = output.as_deref().map(gpu); | |
| 211 | 248 | for (name, space, shown, menu, appearance) in [ |
| 212 | 249 | ("page", page, None, false, Theme::light()), |
| 213 | 250 | ("versions", page, Some(page), false, Theme::light()), |
| ... | ... | @@ -215,7 +252,13 @@ fn conflict_views_offer_the_versions_and_render_offscreen() { |
| 215 | 252 | ("version-menu", version, Some(page), true, Theme::light()), |
| 216 | 253 | ("page-dark", page, None, false, Theme::dark()), |
| 217 | 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 | 263 | session.space = space; |
| 221 | 264 | session.shown = shown; |
| ... | ... | @@ -229,7 +272,10 @@ fn conflict_views_offer_the_versions_and_render_offscreen() { |
| 229 | 272 | match (name, bar) { |
| 230 | 273 | ("page" | "page-dark", Some(Bar::Page { shown: false, .. })) |
| 231 | 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 | 279 | other => panic!("{other:?}", other = other.0), |
| 234 | 280 | } |
| 235 | 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 | 284 | if let (Some(output), Some((renderer, device, queue))) = (&output, &mut gpu) { |
| 239 | 285 | let shown = session.reader(space)().unwrap(); |
| 240 | 286 | let pixels = paint(&ui, shown, renderer, device, queue); |
| 241 | let size = SIZE.map(|side| (side * SCALE) as u32); | |
| 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(); | |
| 287 | save(&output.join(format!("conflict-{name}.png")), &pixels); | |
| 248 | 288 | } |
| 249 | 289 | } |
| 250 | 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 | 292 | let marked = session.versions(page)[0].objects.clone(); |
| 253 | 293 | assert!(!marked.is_empty()); |
| 254 | 294 | let highlighted = kept.objects.iter().any(|object| match object { |
| 255 | onestore::page::PageObject::Outline(outline) => outline.paragraphs.iter().any(|paragraph| { | |
| 256 | paragraph.text().is_some_and(|text| marked.contains(&text.id)) | |
| 257 | && paragraph.format.highlight == Some(canvas::conflict::CONFLICTING) | |
| 258 | }), | |
| 295 | onestore::page::PageObject::Outline(outline) => { | |
| 296 | outline.paragraphs.iter().any(|paragraph| { | |
| 297 | paragraph | |
| 298 | .text() | |
| 299 | .is_some_and(|text| marked.contains(&text.id)) | |
| 300 | && paragraph.format.highlight == Some(canvas::conflict::CONFLICTING) | |
| 301 | }) | |
| 302 | } | |
| 259 | 303 | _ => false, |
| 260 | 304 | }); |
| 261 | 305 | assert!(highlighted); |
| 262 | 306 | session.section.close().unwrap(); |
| 263 | 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] | |
| 313 | fn 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 | ||
| 5 | use super::*; | |
| 6 | use onestore::PageVersion; | |
| 7 | ||
| 8 | /// Which versions a Delete All Versions deletes. | |
| 9 | #[derive(Clone, Copy, PartialEq)] | |
| 10 | pub enum Scope { | |
| 11 | Section, | |
| 12 | /// The section group holding the open section, with the groups inside it. | |
| 13 | Group, | |
| 14 | Notebook, | |
| 15 | } | |
| 16 | ||
| 17 | impl 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. | |
| 73 | pub 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. | |
| 83 | pub 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 | ||
| 135 | impl 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. | |
| 277 | fn 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 | 83 | |
| 84 | 84 | impl Login { |
| 85 | 85 | /// The guest account a mount without one signed in as. |
| 86 | #[cfg(any(target_os = "macos", test))] | |
| 86 | 87 | pub fn guest(mount: &Mount) -> Self { |
| 87 | 88 | Self { |
| 88 | 89 | user: String::new(), |
| ... | ... | @@ -327,7 +328,12 @@ impl Library { |
| 327 | 328 | if let Some(tab) = tabs(folder).into_iter().next() { |
| 328 | 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 | 338 | None |
| 333 | 339 | } |
| ... | ... | @@ -356,9 +362,9 @@ impl Library { |
| 356 | 362 | } |
| 357 | 363 | } |
| 358 | 364 | |
| 359 | /// Whether `folder` is the notebook's recycle bin, which OneNote keeps out of its lists. | |
| 360 | pub fn recycle_bin(folder: &Folder) -> bool { | |
| 361 | folder.path.rsplit('/').next() == Some("OneNote_RecycleBin") | |
| 365 | /// Whether the folder at `path` is the notebook's recycle bin, which OneNote keeps out of its lists. | |
| 366 | pub fn recycle_bin(path: &str) -> bool { | |
| 367 | path.rsplit('/').next() == Some("OneNote_RecycleBin") | |
| 362 | 368 | } |
| 363 | 369 | |
| 364 | 370 | pub 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 | ||
| 5 | use crate::{Command, State, page, platform}; | |
| 6 | use onestore::ExGuid; | |
| 7 | use onestore::page::link::{LinkTarget, internal_link}; | |
| 8 | use std::error::Error; | |
| 9 | use ui::{Anchor, Axis, Flags, Id, Spec, children, fill, popup::Item, px}; | |
| 10 | use winit::keyboard::NamedKey; | |
| 11 | ||
| 12 | const WIDTH: f32 = 420.0; | |
| 13 | /// Pages the dialog lists at once; the search narrows the rest. | |
| 14 | const PAGES: usize = 8; | |
| 15 | ||
| 16 | /// The Link dialog's fields while it is open. | |
| 17 | pub struct LinkDialog { | |
| 18 | text: String, | |
| 19 | address: String, | |
| 20 | search: String, | |
| 21 | } | |
| 22 | ||
| 23 | fn id() -> Id { | |
| 24 | Id::ROOT.child("link") | |
| 25 | } | |
| 26 | ||
| 27 | fn text_field() -> Id { | |
| 28 | id().child("text") | |
| 29 | } | |
| 30 | ||
| 31 | fn address_field() -> Id { | |
| 32 | id().child("address") | |
| 33 | } | |
| 34 | ||
| 35 | fn search_field() -> Id { | |
| 36 | id().child("search") | |
| 37 | } | |
| 38 | ||
| 39 | fn 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. | |
| 45 | fn 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. | |
| 64 | fn 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 | ||
| 90 | impl 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)] | |
| 441 | mod 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 | 3 | //! the C library's locale. |
| 4 | 4 | |
| 5 | 5 | use canvas::date::DateField; |
| 6 | use std::{ffi::CStr, path::PathBuf, process::Command, sync::OnceLock, time::Duration}; | |
| 6 | use std::{ | |
| 7 | ffi::CStr, | |
| 8 | path::PathBuf, | |
| 9 | process::Command, | |
| 10 | sync::{ | |
| 11 | OnceLock, | |
| 12 | atomic::{AtomicU32, Ordering}, | |
| 13 | }, | |
| 14 | time::Duration, | |
| 15 | }; | |
| 7 | 16 | use ui::{Flags, Spec, Ui, px}; |
| 8 | 17 | use winit::{ |
| 9 | 18 | error::EventLoopError, |
| ... | ... | @@ -16,8 +25,6 @@ use winit::{ |
| 16 | 25 | const APP_ID: &str = "snowbound"; |
| 17 | 26 | /// The title bar's leading margin; the window controls sit at its trailing end. |
| 18 | 27 | pub const LEADING: f32 = 8.0; |
| 19 | /// Square, as compositors that round windows don't report by how much. | |
| 20 | pub const CORNER_RADIUS: f32 = 0.0; | |
| 21 | 28 | /// How far inside the window's edges a press resizes it, and how far along them a corner |
| 22 | 29 | /// reaches, in logical pixels. |
| 23 | 30 | const EDGE: f32 = 5.0; |
| ... | ... | @@ -27,6 +34,9 @@ const MAXIMIZE: &[&str] = &[include_str!("../assets/icons/window-maximize.svg")] |
| 27 | 34 | const RESTORE: &[&str] = &[include_str!("../assets/icons/window-restore.svg")]; |
| 28 | 35 | |
| 29 | 36 | static 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. | |
| 39 | static BREEZE_RADIUS: AtomicU32 = AtomicU32::new(0); | |
| 30 | 40 | |
| 31 | 41 | pub fn event_loop(_headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLoopError> { |
| 32 | 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 | 44 | let event_loop = EventLoop::with_user_event().build()?; |
| 35 | 45 | QUIT.set(event_loop.create_proxy()) |
| 36 | 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 | 51 | Ok(event_loop) |
| 38 | 52 | } |
| 39 | 53 | |
| 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. | |
| 56 | pub 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. | |
| 72 | fn 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. | |
| 108 | fn 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 | 136 | /// Decorated by the window manager, or on Wayland by the compositor where it offers |
| 41 | 137 | /// xdg-decoration and otherwise by winit's Adwaita frame; named so the desktop entry supplies |
| 42 | 138 | /// the icon on Wayland, while X11 takes it from the window. |
| ... | ... | @@ -68,7 +164,10 @@ impl Clipboard { |
| 68 | 164 | |
| 69 | 165 | pub fn set_text(&mut self, text: String) -> Result<(), arboard::Error> { |
| 70 | 166 | match self { |
| 71 | Self::Wayland(clipboard) => Ok(clipboard.store(text)), | |
| 167 | Self::Wayland(clipboard) => { | |
| 168 | clipboard.store(text); | |
| 169 | Ok(()) | |
| 170 | } | |
| 72 | 171 | Self::X11(clipboard) => clipboard.set_text(text), |
| 73 | 172 | } |
| 74 | 173 | } |
| ... | ... | @@ -87,6 +186,109 @@ pub fn system_titlebar(window: &Window) -> bool { |
| 87 | 186 | window.is_decorated() |
| 88 | 187 | } |
| 89 | 188 | |
| 189 | /// Window managers show no document of a window's. | |
| 190 | pub fn represent(_: &Window, _: Option<&std::path::Path>) {} | |
| 191 | ||
| 192 | /// None: the title bars desktops draw are opaque. | |
| 193 | pub 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. | |
| 200 | pub 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. | |
| 219 | fn 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. | |
| 233 | fn 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. | |
| 254 | fn 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`. | |
| 268 | fn 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. | |
| 281 | fn 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 | 292 | /// Minimize, maximize and close at the title bar's trailing end. |
| 91 | 293 | pub fn window_controls(ui: &mut Ui, window: &Window) { |
| 92 | 294 | let theme = ui.theme.clone(); |
| ... | ... | @@ -166,15 +368,15 @@ pub fn install_text_input(_: &Window) {} |
| 166 | 368 | /// Options opens from the sidebar's footer and Ctrl+Comma, as Linux apps have no shared menu. |
| 167 | 369 | pub fn install_menu() {} |
| 168 | 370 | |
| 371 | /// The toolbar and keyboard stand in for a menu bar. | |
| 372 | pub fn update_menu(_: impl FnOnce() -> Vec<crate::commands::Status>) {} | |
| 373 | ||
| 169 | 374 | pub fn clear_marked_text(_: &Window) {} |
| 170 | 375 | |
| 171 | 376 | pub fn configure_presentation(_: &wgpu::Surface<'_>) {} |
| 172 | 377 | |
| 173 | 378 | pub fn commit_presentation(_: &Window) {} |
| 174 | 379 | |
| 175 | /// GNOME has no character palette to open from an app. | |
| 176 | pub fn show_character_palette() {} | |
| 177 | ||
| 178 | 380 | /// GTK's default double-click time. |
| 179 | 381 | pub fn double_click_interval() -> Duration { |
| 180 | 382 | Duration::from_millis(400) |
| ... | ... | @@ -188,6 +390,41 @@ pub fn appearance(_: &Window) -> Theme { |
| 188 | 390 | if dark { Theme::Dark } else { Theme::Light } |
| 189 | 391 | } |
| 190 | 392 | |
| 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. | |
| 395 | fn 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 | ||
| 191 | 428 | fn portal_color_scheme() -> Option<bool> { |
| 192 | 429 | use zbus::zvariant::{OwnedValue, Value}; |
| 193 | 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 | 469 | .lines() |
| 233 | 470 | .filter_map(|line| { |
| 234 | 471 | let mut fields = line.split_whitespace(); |
| 235 | let (source, point, kind, options) = | |
| 236 | (fields.next()?, fields.next()?, fields.next()?, fields.next()?); | |
| 472 | let (source, point, kind, options) = ( | |
| 473 | fields.next()?, | |
| 474 | fields.next()?, | |
| 475 | fields.next()?, | |
| 476 | fields.next()?, | |
| 477 | ); | |
| 237 | 478 | let point = point.replace("\\040", " "); |
| 238 | 479 | (matches!(kind, "cifs" | "smb3") && path.starts_with(&point)) |
| 239 | 480 | .then(|| (source.replace("\\040", " "), point, options.to_owned())) |
| 240 | 481 | }) |
| 241 | 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 | 488 | crate::library::Mount::parse(&source, &within, &options) |
| 244 | 489 | } |
| 245 | 490 | |
| ... | ... | @@ -258,7 +503,9 @@ pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login, |
| 258 | 503 | { |
| 259 | 504 | return Ok(crate::library::Login { |
| 260 | 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 | 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 | 515 | ["--password", &title], |
| 269 | 516 | ) |
| 270 | 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 | 519 | // zenity answers "user|password"; kdialog only the password. |
| 273 | 520 | let (typed, password) = asked |
| 274 | 521 | .split_once('|') |
| ... | ... | @@ -288,11 +535,14 @@ pub fn settings_dir() -> Option<PathBuf> { |
| 288 | 535 | |
| 289 | 536 | /// The app's folder in the XDG base directory `variable` names, or in `fallback` under home. |
| 290 | 537 | fn xdg_dir(variable: &str, fallback: &str) -> Option<PathBuf> { |
| 538 | Some(xdg_base(variable, fallback)?.join(APP_ID)) | |
| 539 | } | |
| 540 | ||
| 541 | fn xdg_base(variable: &str, fallback: &str) -> Option<PathBuf> { | |
| 291 | 542 | std::env::var_os(variable) |
| 292 | 543 | .map(PathBuf::from) |
| 293 | 544 | .filter(|path| path.is_absolute()) |
| 294 | 545 | .or_else(|| Some(PathBuf::from(std::env::var_os("HOME")?).join(fallback))) |
| 295 | .map(|base| base.join(APP_ID)) | |
| 296 | 546 | } |
| 297 | 547 | |
| 298 | 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 | 793 | .then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())) |
| 544 | 794 | } |
| 545 | 795 | |
| 546 | /// Opens `folder` in the file manager. | |
| 547 | pub fn reveal(folder: &std::path::Path) { | |
| 548 | if let Err(error) = Command::new("xdg-open").arg(folder).spawn() { | |
| 549 | eprintln!("Cannot open {}: {error}", folder.display()); | |
| 796 | /// Opens `target`, a folder or a link's URL, with the desktop's handler. | |
| 797 | pub fn reveal(target: impl AsRef<std::ffi::OsStr>) { | |
| 798 | let target = target.as_ref(); | |
| 799 | if let Err(error) = Command::new("xdg-open").arg(target).spawn() { | |
| 800 | eprintln!("Cannot open {}: {error}", target.display()); | |
| 550 | 801 | } |
| 551 | 802 | } |
| 552 | 803 | |
| ... | ... | @@ -585,7 +836,13 @@ pub fn pick_notebook(title: &str) -> Option<PathBuf> { |
| 585 | 836 | &format!("--title={title}"), |
| 586 | 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 | 847 | asked |
| 591 | 848 | .unwrap_or_else(|error| { |
| ... | ... | @@ -675,4 +932,44 @@ mod tests { |
| 675 | 932 | assert_eq!(locale_tag("de_DE"), "de-DE"); |
| 676 | 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 | use crate::commands; | |
| 1 | 2 | use canvas::date::DateField; |
| 2 | 3 | use objc2::{ |
| 3 | 4 | ClassType, DeclaredClass, |
| ... | ... | @@ -14,9 +15,12 @@ use objc2_app_kit::{ |
| 14 | 15 | }; |
| 15 | 16 | use objc2_foundation::{ |
| 16 | 17 | MainThreadMarker, NSAttributedString, NSCalendar, NSCalendarUnit, NSDate, NSDateFormatter, |
| 17 | NSDateFormatterStyle, NSPoint, NSRange, NSRect, NSSize, NSString, | |
| 18 | NSDateFormatterStyle, NSPoint, NSRange, NSRect, NSString, | |
| 19 | }; | |
| 20 | use std::{ | |
| 21 | cell::{Cell, RefCell}, | |
| 22 | sync::OnceLock, | |
| 18 | 23 | }; |
| 19 | use std::{cell::Cell, sync::OnceLock}; | |
| 20 | 24 | use winit::{ |
| 21 | 25 | error::EventLoopError, |
| 22 | 26 | event_loop::{EventLoop, EventLoopProxy}, |
| ... | ... | @@ -27,7 +31,12 @@ use winit::{ |
| 27 | 31 | |
| 28 | 32 | static QUIT: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new(); |
| 29 | 33 | static INPUT_CLASS: OnceLock<&'static AnyClass> = OnceLock::new(); |
| 30 | thread_local! { static IN_KEY_DOWN: Cell<bool> = const { Cell::new(false) }; } | |
| 34 | static BACKDROP_CLASS: OnceLock<&'static AnyClass> = OnceLock::new(); | |
| 35 | static DELEGATE_CLASS: OnceLock<&'static AnyClass> = OnceLock::new(); | |
| 36 | thread_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 | } | |
| 31 | 40 | |
| 32 | 41 | unsafe extern "C" fn key_down(view: &AnyObject, _: Sel, event: &NSEvent) { |
| 33 | 42 | let previous = IN_KEY_DOWN.replace(true); |
| ... | ... | @@ -77,11 +86,14 @@ pub const LEADING: f32 = 78.0; |
| 77 | 86 | /// How far AppKit rounds a window's corners. |
| 78 | 87 | pub const CORNER_RADIUS: f32 = 10.0; |
| 79 | 88 | |
| 89 | pub fn corner_radius(_: &Window) -> f32 { | |
| 90 | CORNER_RADIUS | |
| 91 | } | |
| 92 | ||
| 80 | 93 | /// A transparent title bar over the content, where the window draws its own. |
| 81 | 94 | pub fn window_attributes() -> WindowAttributes { |
| 82 | 95 | Window::default_attributes() |
| 83 | 96 | .with_titlebar_transparent(true) |
| 84 | .with_title_hidden(true) | |
| 85 | 97 | .with_fullsize_content_view(true) |
| 86 | 98 | } |
| 87 | 99 | |
| ... | ... | @@ -109,6 +121,96 @@ pub fn system_titlebar(_: &Window) -> bool { |
| 109 | 121 | false |
| 110 | 122 | } |
| 111 | 123 | |
| 124 | unsafe 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. | |
| 138 | pub 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. | |
| 167 | pub fn titlebar(_: winit::window::Theme) -> Option<[[f32; 4]; 2]> { | |
| 168 | None | |
| 169 | } | |
| 170 | ||
| 171 | unsafe 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. | |
| 179 | pub 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 | 214 | /// AppKit's window frame takes resizing presses. |
| 113 | 215 | pub fn resize_direction(_: &Window, _: [f32; 2]) -> Option<winit::window::ResizeDirection> { |
| 114 | 216 | None |
| ... | ... | @@ -157,7 +259,10 @@ unsafe extern "C" { |
| 157 | 259 | password: *mut *mut std::ffi::c_void, |
| 158 | 260 | item: *mut *const std::ffi::c_void, |
| 159 | 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 | } |
| 162 | 267 | |
| 163 | 268 | /// 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 | 412 | std::time::Duration::from_secs_f64(unsafe { NSEvent::doubleClickInterval() }) |
| 308 | 413 | } |
| 309 | 414 | |
| 310 | pub 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 | ||
| 315 | 415 | pub fn edit_date( |
| 316 | 416 | timestamp: u64, |
| 317 | 417 | field: DateField, |
| ... | ... | @@ -502,10 +602,10 @@ pub fn configure_presentation(surface: &wgpu::Surface<'_>) { |
| 502 | 602 | MainThreadMarker::new().expect("Layer presentation belongs to the main thread"); |
| 503 | 603 | // Retain wgpu's surface ownership while synchronizing presentation with AppKit resize transactions. |
| 504 | 604 | if let Some(surface) = unsafe { surface.as_hal::<wgpu::hal::api::Metal>() } { |
| 505 | surface | |
| 506 | .render_layer() | |
| 507 | .lock() | |
| 508 | .setPresentsWithTransaction(true); | |
| 605 | let layer = surface.render_layer().lock(); | |
| 606 | layer.setPresentsWithTransaction(true); | |
| 607 | // Over the backdrop's layer, which AppKit orders after it. | |
| 608 | layer.setZPosition(1.0); | |
| 509 | 609 | } |
| 510 | 610 | } |
| 511 | 611 | |
| ... | ... | @@ -520,54 +620,9 @@ pub fn commit_presentation(window: &Window) { |
| 520 | 620 | } |
| 521 | 621 | } |
| 522 | 622 | |
| 523 | /// The application's icon as the Dock shows it, `pixels` square. | |
| 524 | pub fn app_icon(pixels: u32) -> Option<draw::RasterImage> { | |
| 525 | let side = pixels as usize; | |
| 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() | |
| 623 | /// None: AppKit's title shows the document's icon in its place. | |
| 624 | pub fn app_icon(_: u32) -> Option<draw::RasterImage> { | |
| 625 | None | |
| 571 | 626 | } |
| 572 | 627 | |
| 573 | 628 | /// The insertion point's colour and selected text's fill with and without keyboard focus, |
| ... | ... | @@ -635,9 +690,28 @@ declare_class!( |
| 635 | 690 | if let Some(proxy) = QUIT.get() { let _ = proxy.send_event(crate::UserEvent::Quit); } |
| 636 | 691 | } |
| 637 | 692 | |
| 638 | #[method(showOptions:)] | |
| 639 | fn show_options(&self, _sender: Option<&AnyObject>) { | |
| 640 | if let Some(proxy) = QUIT.get() { let _ = proxy.send_event(crate::UserEvent::Options); } | |
| 693 | #[method(choose:)] | |
| 694 | fn choose(&self, sender: &NSMenuItem) { | |
| 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 | } |
| 642 | 716 | |
| 643 | 717 | // NSAlert consumes Escape before it reaches cancelOperation:. |
| ... | ... | @@ -665,6 +739,7 @@ pub fn event_loop(headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLo |
| 665 | 739 | let _: Retained<CanvasApplication> = |
| 666 | 740 | unsafe { msg_send_id![CanvasApplication::class(), sharedApplication] }; |
| 667 | 741 | let mut builder = EventLoop::with_user_event(); |
| 742 | builder.with_default_menu(false); | |
| 668 | 743 | if headless { |
| 669 | 744 | builder |
| 670 | 745 | .with_activation_policy(ActivationPolicy::Prohibited) |
| ... | ... | @@ -676,34 +751,30 @@ pub fn event_loop(headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLo |
| 676 | 751 | Ok(event_loop) |
| 677 | 752 | } |
| 678 | 753 | |
| 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. | |
| 680 | 755 | pub fn install_menu() { |
| 681 | 756 | let mtm = MainThreadMarker::new().expect("Menus belong to the main thread"); |
| 682 | 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 | 761 | unsafe { |
| 685 | let Some(menu) = app | |
| 686 | .mainMenu() | |
| 687 | .and_then(|bar| bar.itemAtIndex(0)) | |
| 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); | |
| 762 | app.setServicesMenu(Some(&menus.services)); | |
| 763 | app.setWindowsMenu(Some(&menus.window)); | |
| 764 | app.setHelpMenu(Some(&menus.help)); | |
| 700 | 765 | } |
| 701 | 766 | } |
| 702 | 767 | |
| 703 | /// Opens `folder` in Finder. | |
| 704 | pub fn reveal(folder: &std::path::Path) { | |
| 705 | if let Err(error) = std::process::Command::new("open").arg(folder).spawn() { | |
| 706 | eprintln!("Cannot open {}: {error}", folder.display()); | |
| 768 | /// The table's statuses, in `commands::choices` order, which the menu bar's items show. | |
| 769 | pub fn update_menu(statuses: impl FnOnce() -> Vec<commands::Status>) { | |
| 770 | STATUSES.set(statuses()); | |
| 771 | } | |
| 772 | ||
| 773 | /// Opens `target`, a folder or a link's URL, as Finder would. | |
| 774 | pub 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 | } |
| 709 | 780 |
crates/snowbound/src/main.rs+1123-435| ... | ... | @@ -1,14 +1,21 @@ |
| 1 | 1 | mod art; |
| 2 | mod commands; | |
| 2 | 3 | #[cfg(test)] |
| 3 | 4 | mod conflict_render; |
| 5 | mod history; | |
| 4 | 6 | mod library; |
| 7 | mod link; | |
| 5 | 8 | mod manage; |
| 6 | 9 | mod meeting; |
| 10 | #[cfg(target_os = "macos")] | |
| 11 | mod menubar; | |
| 7 | 12 | mod menus; |
| 13 | mod navigation; | |
| 8 | 14 | mod options; |
| 9 | 15 | #[cfg_attr(target_os = "linux", path = "linux.rs")] |
| 10 | 16 | #[cfg_attr(target_os = "macos", path = "macos.rs")] |
| 11 | 17 | mod platform; |
| 18 | mod rename; | |
| 12 | 19 | mod screenshot; |
| 13 | 20 | mod search; |
| 14 | 21 | mod settings; |
| ... | ... | @@ -28,11 +35,11 @@ use canvas::{ |
| 28 | 35 | outline::TagIcon, |
| 29 | 36 | }; |
| 30 | 37 | use draw::Renderer; |
| 38 | use library::Library; | |
| 31 | 39 | use onestore::ExGuid; |
| 32 | 40 | use onestore::document::Format; |
| 33 | 41 | use onestore::page::Page; |
| 34 | 42 | use onestore::page::text::Paragraph; |
| 35 | use library::Library; | |
| 36 | 43 | use std::{ |
| 37 | 44 | collections::{HashMap, HashSet}, |
| 38 | 45 | error::Error, |
| ... | ... | @@ -43,7 +50,7 @@ use std::{ |
| 43 | 50 | use ui::{Axis, Flags, Id, Spec, Theme, Ui, children, fill, fit, px}; |
| 44 | 51 | use winit::{ |
| 45 | 52 | application::ApplicationHandler, |
| 46 | dpi::{LogicalSize, PhysicalPosition, PhysicalSize}, | |
| 53 | dpi::{LogicalPosition, LogicalSize}, | |
| 47 | 54 | event::{ElementState, Ime, MouseButton, MouseScrollDelta, WindowEvent}, |
| 48 | 55 | event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy}, |
| 49 | 56 | keyboard::{Key, ModifiersState, NamedKey}, |
| ... | ... | @@ -55,23 +62,37 @@ const TITLE: f32 = 28.0; |
| 55 | 62 | const TAB_ROW: f32 = 28.0; |
| 56 | 63 | /// Width of the section colour around the page. |
| 57 | 64 | const FRAME: f32 = 6.0; |
| 58 | /// The page's corners share their centres with the window's. | |
| 59 | const ROUNDING: f32 = (platform::CORNER_RADIUS - FRAME).max(0.0); | |
| 60 | 65 | const PAGE_LIST: f32 = 240.0; |
| 61 | 66 | /// Font sizes the size box offers, OneNote's list in points. |
| 62 | 67 | /// Fonts the font box offers first, OneNote's default leading. |
| 63 | 68 | const FONTS: [&str; 4] = ["Calibri", "Arial", "Times New Roman", "Courier New"]; |
| 64 | /// Fonts picked this session that the font box offers after `FONTS`, at most. | |
| 65 | const RECENT_FONTS: usize = 5; | |
| 69 | /// Picks the font box and the list galleries offer as recent, at most, as OneNote's | |
| 70 | /// galleries do. | |
| 71 | const RECENT: usize = 5; | |
| 72 | /// Height of a line in a numbering gallery's previews. | |
| 73 | const NUMBER_LINE: f32 = 16.0; | |
| 66 | 74 | /// Font sizes the size box offers, OneNote's list in points. |
| 67 | 75 | const SIZES: [f32; 17] = [ |
| 68 | 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 | 77 | 72.0, |
| 70 | 78 | ]; |
| 71 | 79 | /// OneNote's highlight colours, COLORREF. |
| 72 | const HIGHLIGHTS: &[u32] = &[ | |
| 73 | 0x00ffff, 0x00ff00, 0xffff00, 0xff00ff, 0xff0000, 0x0000ff, 0x800000, 0x808000, 0x008000, | |
| 74 | 0x800080, 0x000080, 0x008080, 0x808080, 0xc0c0c0, 0x000000, | |
| 80 | const HIGHLIGHTS: [(u32, &str); 15] = [ | |
| 81 | (0x00ffff, "Yellow"), | |
| 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 | 97 | /// Office's theme and standard font colours, COLORREF. |
| 77 | 98 | const FONT_COLORS: [u32; 20] = [ |
| ... | ... | @@ -85,8 +106,6 @@ const ROW: f32 = 29.0; |
| 85 | 106 | const ROW_GAP: f32 = 3.0; |
| 86 | 107 | /// Space between the page and a page tab that isn't open. |
| 87 | 108 | const PILL_MARGIN: f32 = 3.0; |
| 88 | /// Rounding where the open page tab meets the page, concentric with its neighbours'. | |
| 89 | const JOIN: f32 = PILL_MARGIN + ROUNDING; | |
| 90 | 109 | /// Why the platform's date dialog could not change the page's date. |
| 91 | 110 | const DATE_UNCHOSEN: &str = "Choose another date or time."; |
| 92 | 111 | const DATE_OUT_OF_RANGE: &str = "This date is outside the notebook's supported range."; |
| ... | ... | @@ -104,9 +123,12 @@ enum UserEvent { |
| 104 | 123 | Replay(Replay), |
| 105 | 124 | /// A page background finished rasterizing on its worker thread. |
| 106 | 125 | Redraw, |
| 107 | /// The app menu's Settings item was chosen. | |
| 126 | /// A menu bar item was chosen. | |
| 108 | 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 | } |
| 111 | 133 | |
| 112 | 134 | /// Asks the event loop for a frame from any thread. |
| ... | ... | @@ -166,8 +188,6 @@ struct App { |
| 166 | 188 | proxy: EventLoopProxy<UserEvent>, |
| 167 | 189 | input: Option<Input>, |
| 168 | 190 | substitutes: Vec<PathBuf>, |
| 169 | /// Font files for the interface in place of the system's. | |
| 170 | interface_font: Vec<Vec<u8>>, | |
| 171 | 191 | /// Where `--screenshot` writes its PNGs, drawn from a window never shown. |
| 172 | 192 | screenshot: Option<PathBuf>, |
| 173 | 193 | /// What the window starts from besides its input, until it opens. |
| ... | ... | @@ -220,13 +240,31 @@ struct Session { |
| 220 | 240 | shown: Option<ExGuid>, |
| 221 | 241 | /// Which of the open conflict page's conflicting changes is selected, in page order. |
| 222 | 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 | } |
| 224 | 250 | |
| 225 | 251 | /// The information bar above a page with conflict pages, or above one of them. |
| 226 | 252 | #[derive(Clone, Copy)] |
| 227 | 253 | enum Bar { |
| 228 | Page { page: ExGuid, shown: bool }, | |
| 229 | Version { page: ExGuid, version: ExGuid }, | |
| 254 | Page { | |
| 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 | } |
| 231 | 269 | |
| 232 | 270 | impl Session { |
| ... | ... | @@ -261,11 +299,21 @@ impl Session { |
| 261 | 299 | /// The page conflict page `space` belongs to, with it. |
| 262 | 300 | fn conflict(&self, space: ExGuid) -> Option<(ExGuid, &onestore::ConflictPage)> { |
| 263 | 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 | } |
| 267 | 308 | |
| 268 | 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 | 317 | match self.conflict(self.space) { |
| 270 | 318 | Some((page, _)) => Some(Bar::Version { |
| 271 | 319 | page, |
| ... | ... | @@ -291,7 +339,10 @@ impl Session { |
| 291 | 339 | |
| 292 | 340 | /// The page in `space` as the editor shows it: a conflict page read-only, its |
| 293 | 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 | 346 | let replica = Arc::clone(self.section.replica()); |
| 296 | 347 | let objects = self |
| 297 | 348 | .conflict(space) |
| ... | ... | @@ -311,14 +362,21 @@ impl Session { |
| 311 | 362 | /// section has in it. |
| 312 | 363 | enum Loaded { |
| 313 | 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 | 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 | } |
| 317 | 373 | |
| 318 | 374 | /// What an opening page replaces: the section, or the page of the open one. |
| 319 | 375 | enum Shown { |
| 320 | 376 | Section(Box<Session>), |
| 377 | Created(Box<Session>), | |
| 321 | 378 | Page(ExGuid), |
| 379 | Version(ExGuid, ExGuid), | |
| 322 | 380 | } |
| 323 | 381 | |
| 324 | 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 | 384 | |
| 327 | 385 | enum Laid { |
| 328 | 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 | } |
| 331 | 390 | |
| 332 | 391 | /// The newest page read, laid out and waiting for the pictures it shows first, so it |
| ... | ... | @@ -355,24 +414,60 @@ enum Command { |
| 355 | 414 | Structure(Arc<Library>, manage::Structure), |
| 356 | 415 | /// Adds a page at the end of the open section, or a subpage of a page, and edits its |
| 357 | 416 | /// title. |
| 358 | NewPage { under: Option<ExGuid> }, | |
| 417 | NewPage { | |
| 418 | under: Option<ExGuid>, | |
| 419 | }, | |
| 359 | 420 | /// Deletes pages of the open section to the notebook's recycle bin. |
| 360 | 421 | DeletePages(Vec<ExGuid>), |
| 361 | 422 | /// Moves or indents pages of the open section. |
| 362 | 423 | Pages(Vec<onestore::PageEdit>), |
| 363 | 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 | 429 | /// Gives the open page a template's background or colour. |
| 366 | 430 | Template(templates::Choice), |
| 367 | 431 | OpenPage(ExGuid), |
| 368 | 432 | /// Shows a page's conflict pages in the list and opens the newest, or hides them. |
| 369 | Versions { page: ExGuid, show: bool }, | |
| 370 | DeleteVersion { page: ExGuid, version: ExGuid }, | |
| 433 | Versions { | |
| 434 | page: ExGuid, | |
| 435 | show: bool, | |
| 436 | }, | |
| 437 | DeleteVersion { | |
| 438 | page: ExGuid, | |
| 439 | version: ExGuid, | |
| 440 | }, | |
| 371 | 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 | 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 | 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 | } |
| 377 | 472 | |
| 378 | 473 | struct State { |
| ... | ... | @@ -392,6 +487,8 @@ struct State { |
| 392 | 487 | places: HashMap<(String, ExGuid), Place>, |
| 393 | 488 | /// The page each section showed last this run, by `Library::key`. |
| 394 | 489 | last_pages: HashMap<String, ExGuid>, |
| 490 | /// Pages visited, for Back and Forward. | |
| 491 | trail: navigation::Trail, | |
| 395 | 492 | /// Asks for a frame when a worker thread finishes something the page shows. |
| 396 | 493 | redraw: std::task::Waker, |
| 397 | 494 | instance: wgpu::Instance, |
| ... | ... | @@ -417,10 +514,21 @@ struct State { |
| 417 | 514 | menu: Option<(menus::Target, [f32; 2])>, |
| 418 | 515 | /// The Options dialog's choices while it is open. |
| 419 | 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 | 521 | color_scheme: settings::ColorScheme, |
| 421 | 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 | 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 | 532 | /// What the pointer is dragging: a page's tab, a section tab or a sidebar row. |
| 425 | 533 | drag: Option<menus::Drag>, |
| 426 | 534 | /// Pages whose template strip was dismissed this run. |
| ... | ... | @@ -437,6 +545,8 @@ struct State { |
| 437 | 545 | fonts: Vec<String>, |
| 438 | 546 | /// Fonts picked from the font box this session, latest first. |
| 439 | 547 | recent_fonts: Vec<String>, |
| 548 | /// What the toolbar's buttons apply from their menus' last picks. | |
| 549 | toolbar: settings::Toolbar, | |
| 440 | 550 | /// The application's icon at the display's scale, for the title bar. |
| 441 | 551 | app_icon: Option<draw::RasterImage>, |
| 442 | 552 | commands: Vec<Command>, |
| ... | ... | @@ -486,7 +596,6 @@ impl State { |
| 486 | 596 | proxy: EventLoopProxy<UserEvent>, |
| 487 | 597 | input: Input, |
| 488 | 598 | substitutes: &[PathBuf], |
| 489 | interface_font: Vec<Vec<u8>>, | |
| 490 | 599 | visible: bool, |
| 491 | 600 | settings::Launch { |
| 492 | 601 | file: settings, |
| ... | ... | @@ -510,10 +619,14 @@ impl State { |
| 510 | 619 | Input::Section { title, .. } => title.clone(), |
| 511 | 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 | 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 | 630 | platform::install_menu(); |
| 518 | 631 | let access_adapter = |
| 519 | 632 | accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy.clone()); |
| ... | ... | @@ -531,9 +644,17 @@ impl State { |
| 531 | 644 | .await?; |
| 532 | 645 | let (device, queue) = adapter.request_device(&Default::default()).await?; |
| 533 | 646 | let size = window.inner_size(); |
| 534 | let config = surface | |
| 647 | let mut config = surface | |
| 535 | 648 | .get_default_config(&adapter, size.width, size.height) |
| 536 | 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 | 658 | surface.configure(&device, &config); |
| 538 | 659 | let renderer = Renderer::new(device, queue, config.format); |
| 539 | 660 | let mut engine = TextEngine::default(); |
| ... | ... | @@ -546,6 +667,7 @@ impl State { |
| 546 | 667 | let temporary = matches!(input, Input::Notes { .. } | Input::Page(_)); |
| 547 | 668 | let mut notebooks = Vec::new(); |
| 548 | 669 | let mut session = None; |
| 670 | let mut sectionless = None; | |
| 549 | 671 | let (editor, scene) = match input { |
| 550 | 672 | Input::Notes { |
| 551 | 673 | document, |
| ... | ... | @@ -605,6 +727,11 @@ impl State { |
| 605 | 727 | (editor, Some((scene, [0.0; 2]))) |
| 606 | 728 | } |
| 607 | 729 | None => { |
| 730 | sectionless = notebooks | |
| 731 | .iter() | |
| 732 | .find(|library| Some(&library.location) == current.as_ref()) | |
| 733 | .or(notebooks.first()) | |
| 734 | .cloned(); | |
| 608 | 735 | let blank = TextDocument::new(vec![Paragraph::new( |
| 609 | 736 | String::new(), |
| 610 | 737 | Format::default(), |
| ... | ... | @@ -629,22 +756,17 @@ impl State { |
| 629 | 756 | window.request_redraw(); |
| 630 | 757 | eprintln!("Canvas GPU: {:?}; scale factor {dpr}", adapter.get_info()); |
| 631 | 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 | 763 | let mut ui = Ui::new( |
| 633 | theme( | |
| 634 | stored | |
| 635 | .color_scheme | |
| 636 | .theme() | |
| 637 | .unwrap_or_else(|| platform::appearance(&window)), | |
| 638 | stored.light_pages, | |
| 639 | ), | |
| 764 | theme(appearance, stored.light_pages, backdrop), | |
| 640 | 765 | platform::double_click_interval(), |
| 641 | 766 | ); |
| 642 | if !interface_font.is_empty() { | |
| 643 | let family = ui | |
| 644 | .use_fonts(interface_font) | |
| 645 | .ok_or("The interface font files hold no font")?; | |
| 646 | eprintln!("Using {family} for the interface"); | |
| 647 | } | |
| 767 | let titlebar = platform::titlebar(appearance).unwrap_or([ui.theme.strip; 2]); | |
| 768 | // A window shown but never focused hears no focus event; a hidden one draws as focused. | |
| 769 | ui.window_focused = !visible || window.has_focus(); | |
| 648 | 770 | ui.set_focus(Some(page())); |
| 649 | 771 | for family in FONTS { |
| 650 | 772 | for (face, _) in engine.substitute(family).map_or(&[][..], |s| &s.faces) { |
| ... | ... | @@ -692,10 +814,15 @@ impl State { |
| 692 | 814 | dismissed: HashSet::new(), |
| 693 | 815 | title_focus: None, |
| 694 | 816 | menu: None, |
| 817 | link: None, | |
| 818 | text_menu: None, | |
| 695 | 819 | options: None, |
| 696 | 820 | color_scheme: stored.color_scheme, |
| 697 | 821 | light_pages: stored.light_pages, |
| 822 | translucent, | |
| 823 | titlebar, | |
| 698 | 824 | renaming: None, |
| 825 | sectionless, | |
| 699 | 826 | drag: None, |
| 700 | 827 | templates: templates::View::Strip, |
| 701 | 828 | thumbnails: templates::Thumbnails::default(), |
| ... | ... | @@ -703,6 +830,7 @@ impl State { |
| 703 | 830 | pages_open: true, |
| 704 | 831 | fonts, |
| 705 | 832 | recent_fonts: stored.recent_fonts, |
| 833 | toolbar: stored.toolbar, | |
| 706 | 834 | app_icon: platform::app_icon((16.0 * dpr).round() as u32), |
| 707 | 835 | commands: Vec::new(), |
| 708 | 836 | changed: false, |
| ... | ... | @@ -725,6 +853,7 @@ impl State { |
| 725 | 853 | opening: None, |
| 726 | 854 | places: HashMap::new(), |
| 727 | 855 | last_pages: HashMap::new(), |
| 856 | trail: navigation::Trail::default(), | |
| 728 | 857 | access_adapter, |
| 729 | 858 | accessibility: accessibility::Accessibility::default(), |
| 730 | 859 | }; |
| ... | ... | @@ -741,9 +870,14 @@ impl State { |
| 741 | 870 | } |
| 742 | 871 | lap("open", start); |
| 743 | 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 | 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 | 881 | scale, |
| 748 | 882 | )?; |
| 749 | 883 | lap("build", start); |
| ... | ... | @@ -793,6 +927,8 @@ impl State { |
| 793 | 927 | self.ui.begin(size, scale, Instant::now()); |
| 794 | 928 | let (section, open_tab, open_page) = self.build()?; |
| 795 | 929 | self.options_dialog(); |
| 930 | self.link_dialog()?; | |
| 931 | self.text_menu()?; | |
| 796 | 932 | self.ui.end(); |
| 797 | 933 | self.edges(section, open_tab, open_page); |
| 798 | 934 | if let Some(rect) = self.ui.rect(page()) { |
| ... | ... | @@ -808,6 +944,22 @@ impl State { |
| 808 | 944 | Ok(()) |
| 809 | 945 | } |
| 810 | 946 | |
| 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 | 963 | /// Builds the frame's boxes and returns the section's colours as they ease, the open |
| 812 | 964 | /// section tab and the open page's tab, for the edges drawn once they are laid out. |
| 813 | 965 | fn build(&mut self) -> Result<(ui::Section, Id, Option<Id>), Box<dyn Error>> { |
| ... | ... | @@ -816,6 +968,11 @@ impl State { |
| 816 | 968 | .as_ref() |
| 817 | 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 | 976 | let mut ease = |part: &str, color: [f32; 4]| -> [f32; 4] { |
| 820 | 977 | std::array::from_fn(|channel| { |
| 821 | 978 | self.ui |
| ... | ... | @@ -831,6 +988,7 @@ impl State { |
| 831 | 988 | edge: ease("edge", target.edge), |
| 832 | 989 | accent: ease("accent", target.accent), |
| 833 | 990 | }; |
| 991 | self.ui.theme.strip = ease("strip", strip); | |
| 834 | 992 | self.ui.theme.accent = section.accent; |
| 835 | 993 | // Elsewhere the theme holds the platform's colours. |
| 836 | 994 | #[cfg(target_os = "macos")] |
| ... | ... | @@ -847,7 +1005,8 @@ impl State { |
| 847 | 1005 | if !platform::system_titlebar(&self.window) { |
| 848 | 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 | 1010 | self.welcome(&theme); |
| 852 | 1011 | return Ok((section, Id::ROOT, None)); |
| 853 | 1012 | } |
| ... | ... | @@ -859,7 +1018,7 @@ impl State { |
| 859 | 1018 | ..Spec::default() |
| 860 | 1019 | }, |
| 861 | 1020 | ); |
| 862 | self.sidebar(&theme); | |
| 1021 | let sidebar = self.sidebar(&theme); | |
| 863 | 1022 | self.ui.open( |
| 864 | 1023 | "main", |
| 865 | 1024 | Spec { |
| ... | ... | @@ -877,26 +1036,40 @@ impl State { |
| 877 | 1036 | ..Spec::default() |
| 878 | 1037 | }, |
| 879 | 1038 | ); |
| 1039 | self.sidebar_button(&theme, sidebar); | |
| 880 | 1040 | self.ui.leaf( |
| 881 | 1041 | "corner", |
| 882 | 1042 | Spec { |
| 883 | size: [px(ROUNDING), px(1.0)], | |
| 1043 | size: [px(self.rounding()), px(1.0)], | |
| 884 | 1044 | ..Spec::default() |
| 885 | 1045 | }, |
| 886 | 1046 | ); |
| 887 | 1047 | let row = self.ui.id("sections"); |
| 888 | 1048 | let (clicked, open_tab) = match &self.session { |
| 889 | 1049 | Some(session) => { |
| 1050 | // A tab being renamed takes the name typed, which its field covers. | |
| 890 | 1051 | let tabs: Vec<_> = session |
| 891 | 1052 | .tabs |
| 892 | 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 | 1063 | .collect(); |
| 895 | 1064 | let lit = self.page_drop(row); |
| 1065 | let dragged = self.dragged_tab(row); | |
| 896 | 1066 | let ui::shell::Tabs { |
| 897 | 1067 | clicked, |
| 898 | 1068 | context, |
| 899 | 1069 | held, |
| 1070 | renamed, | |
| 1071 | slot, | |
| 1072 | settled, | |
| 900 | 1073 | open: open_tab, |
| 901 | 1074 | } = ui::shell::section_tabs( |
| 902 | 1075 | &mut self.ui, |
| ... | ... | @@ -904,16 +1077,19 @@ impl State { |
| 904 | 1077 | &tabs, |
| 905 | 1078 | session.tab, |
| 906 | 1079 | lit, |
| 1080 | dragged, | |
| 907 | 1081 | &section, |
| 908 | 1082 | TAB_ROW, |
| 909 | 1083 | ); |
| 910 | let bar = self.ui.rect(tab_row).unwrap_or_default(); | |
| 911 | self.drag_tabs(held, row, bar, section.accent); | |
| 1084 | self.drag_tabs(held, slot, settled, row); | |
| 912 | 1085 | let Some(session) = &self.session else { |
| 913 | 1086 | unreachable!("The tabs are the session's") |
| 914 | 1087 | }; |
| 915 | 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 | 1094 | if let Some((tab, point)) = context { |
| 919 | 1095 | self.menu = Some(( |
| ... | ... | @@ -925,7 +1101,18 @@ impl State { |
| 925 | 1101 | )); |
| 926 | 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 | 1117 | None => { |
| 931 | 1118 | let tabs = [("Temporary page", section_color(None))]; |
| ... | ... | @@ -936,6 +1123,7 @@ impl State { |
| 936 | 1123 | shown, |
| 937 | 1124 | 0, |
| 938 | 1125 | None, |
| 1126 | None, | |
| 939 | 1127 | &section, |
| 940 | 1128 | TAB_ROW, |
| 941 | 1129 | ) |
| ... | ... | @@ -971,11 +1159,7 @@ impl State { |
| 971 | 1159 | && let Some(bar) = session.bar() |
| 972 | 1160 | { |
| 973 | 1161 | let changes = self.changes().len(); |
| 974 | let sections: Vec<&str> = session | |
| 975 | .tabs | |
| 976 | .iter() | |
| 977 | .map(|tab| tab.name.as_str()) | |
| 978 | .collect(); | |
| 1162 | let sections: Vec<&str> = session.tabs.iter().map(|tab| tab.name.as_str()).collect(); | |
| 979 | 1163 | let steps = [ |
| 980 | 1164 | session.change.is_some_and(|at| at > 0), |
| 981 | 1165 | session.change.map_or(changes > 0, |at| at + 1 < changes), |
| ... | ... | @@ -983,6 +1167,15 @@ impl State { |
| 983 | 1167 | self.commands |
| 984 | 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 | 1179 | self.ui.open_as( |
| 987 | 1180 | page(), |
| 988 | 1181 | Spec { |
| ... | ... | @@ -1023,12 +1216,14 @@ impl State { |
| 1023 | 1216 | self.ui.close(); |
| 1024 | 1217 | self.ui.close(); |
| 1025 | 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 | 1219 | Ok((section, open_tab, open_page)) |
| 1030 | 1220 | } |
| 1031 | 1221 | |
| 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 | 1227 | /// Borders the open section tab and the frame's top, and rounds and borders the page |
| 1033 | 1228 | /// together with the open page's tab, joined where they meet. |
| 1034 | 1229 | fn edges(&mut self, section: ui::Section, open_tab: Id, open_page: Option<Id>) { |
| ... | ... | @@ -1036,6 +1231,9 @@ impl State { |
| 1036 | 1231 | let (Some(frame), Some(page)) = (self.ui.rect(frame()), self.ui.rect(page())) else { |
| 1037 | 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 | 1237 | let [left, top, right, bottom] = page; |
| 1040 | 1238 | let tab = open_page |
| 1041 | 1239 | .and_then(|id| self.ui.rect(id)) |
| ... | ... | @@ -1047,14 +1245,14 @@ impl State { |
| 1047 | 1245 | row[3].min(panel[3]).min(bottom), |
| 1048 | 1246 | ]; |
| 1049 | 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 | 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 | 1251 | top |
| 1054 | 1252 | } else { |
| 1055 | 1253 | start |
| 1056 | 1254 | }; |
| 1057 | let end = if end > bottom - ROUNDING - JOIN { | |
| 1255 | let end = if end > bottom - rounding - join { | |
| 1058 | 1256 | bottom |
| 1059 | 1257 | } else { |
| 1060 | 1258 | end |
| ... | ... | @@ -1062,59 +1260,75 @@ impl State { |
| 1062 | 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 | 1264 | match tab { |
| 1067 | 1265 | Some([start, end, reach]) => { |
| 1068 | 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 | 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 | 1277 | let height = (frame[3] - frame[1]).max(1.0); |
| 1080 | 1278 | let paper = self.paper().color; |
| 1081 | 1279 | self.ui.round_corners(&outline, paper, |y| { |
| 1082 | 1280 | ui::mix(section.frame[0], section.frame[1], (y - frame[1]) / height) |
| 1083 | 1281 | }); |
| 1084 | 1282 | self.ui.border(&outline, true, section.edge); |
| 1085 | // The frame's top corners share the page's centres; its top edge breaks where the | |
| 1086 | // open tab stands on it. | |
| 1283 | // The frame's top corners share the page's centres, and beside the sidebar so does | |
| 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 | 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 | 1289 | let strip = self.ui.theme.strip; |
| 1090 | self.ui.round_corners( | |
| 1091 | &[ | |
| 1092 | ([start, frame[1]], outer), | |
| 1093 | ([end, frame[1]], outer), | |
| 1094 | ([end, frame[3]], 0.0), | |
| 1095 | ([start, frame[3]], 0.0), | |
| 1096 | ], | |
| 1097 | paper, | |
| 1098 | |_| strip, | |
| 1099 | ); | |
| 1290 | // The border runs half its width inside the frame, on whole device pixels at any | |
| 1291 | // scale, its corners concentric with the fill's. The fill is cut at the border's | |
| 1292 | // outer edge, then again at its middle, so none shows through its antialiased rim. | |
| 1293 | let [west, north, east] = [start + 0.5, frame[1] + 0.5, end - 0.5]; | |
| 1294 | let inner = |radius: f32| (radius - 0.5).max(0.0); | |
| 1295 | for [left, top, right, cut] in [[start, frame[1], end, 0.0], [west, north, east, 0.5]] { | |
| 1296 | self.ui.round_corners( | |
| 1297 | &[ | |
| 1298 | ([left, top], (outer - cut).max(0.0)), | |
| 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 | 1307 | let [foot, toe] = self |
| 1101 | 1308 | .ui |
| 1102 | 1309 | .rect(open_tab) |
| 1103 | 1310 | .map_or([end; 2], |tab| ui::shell::tab_base(tab, TAB_ROW)); |
| 1104 | for edge in [ | |
| 1105 | [ | |
| 1106 | ([start, frame[1] + outer], 0.0), | |
| 1107 | ([start, frame[1]], outer), | |
| 1108 | ([foot, frame[1]], 0.0), | |
| 1311 | // The border runs down both sides to the window's bottom, round the bottom corner | |
| 1312 | // beside the sidebar. | |
| 1313 | self.ui.border( | |
| 1314 | &[ | |
| 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 | [ | |
| 1111 | ([toe, frame[1]], 0.0), | |
| 1112 | ([end, frame[1]], outer), | |
| 1113 | ([end, frame[1] + outer], 0.0), | |
| 1320 | false, | |
| 1321 | section.edge, | |
| 1322 | ); | |
| 1323 | self.ui.border( | |
| 1324 | &[ | |
| 1325 | ([toe, north], 0.0), | |
| 1326 | ([east, north], inner(outer)), | |
| 1327 | ([east, frame[3]], 0.0), | |
| 1114 | 1328 | ], |
| 1115 | ] { | |
| 1116 | self.ui.border(&edge, false, section.edge); | |
| 1117 | } | |
| 1329 | false, | |
| 1330 | section.edge, | |
| 1331 | ); | |
| 1118 | 1332 | } |
| 1119 | 1333 | |
| 1120 | 1334 | /// The window's title bar beside the traffic lights: the application's icon, the |
| ... | ... | @@ -1136,19 +1350,22 @@ impl State { |
| 1136 | 1350 | ..Spec::default() |
| 1137 | 1351 | }, |
| 1138 | 1352 | ); |
| 1139 | self.ui.leaf( | |
| 1140 | "notebook", | |
| 1141 | Spec { | |
| 1142 | size: [fit(), px(TITLE)], | |
| 1143 | image: self.app_icon.as_ref(), | |
| 1144 | text: Some(match &self.session { | |
| 1145 | Some(session) => &session.library.name, | |
| 1146 | None if self.temporary => "Temporary page", | |
| 1147 | None => "Snowbound", | |
| 1148 | }), | |
| 1149 | ..Spec::default() | |
| 1150 | }, | |
| 1151 | ); | |
| 1353 | // AppKit centres the window's title over the strip. | |
| 1354 | if !cfg!(target_os = "macos") { | |
| 1355 | self.ui.leaf( | |
| 1356 | "notebook", | |
| 1357 | Spec { | |
| 1358 | size: [fit(), px(TITLE)], | |
| 1359 | image: self.app_icon.as_ref(), | |
| 1360 | text: Some(match &self.session { | |
| 1361 | Some(session) => &session.library.name, | |
| 1362 | None if self.temporary => "Temporary page", | |
| 1363 | None => "Snowbound", | |
| 1364 | }), | |
| 1365 | ..Spec::default() | |
| 1366 | }, | |
| 1367 | ); | |
| 1368 | } | |
| 1152 | 1369 | self.ui.leaf( |
| 1153 | 1370 | "space", |
| 1154 | 1371 | Spec { |
| ... | ... | @@ -1175,23 +1392,15 @@ impl State { |
| 1175 | 1392 | /// Two rows of formatting, tag, insert and zoom buttons, grouped as OneNote's Home |
| 1176 | 1393 | /// ribbon groups them. |
| 1177 | 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 | 1399 | // A focused picture has no text to show a format for. |
| 1180 | let state = self | |
| 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 | }); | |
| 1400 | let state = self.format_state(); | |
| 1193 | 1401 | let fonts = &self.fonts; |
| 1194 | 1402 | let recent_fonts = &self.recent_fonts; |
| 1403 | let pens = &self.toolbar; | |
| 1195 | 1404 | let engine = &self.view.engine; |
| 1196 | 1405 | // Under the system's title bar the save status joins the toolbar. |
| 1197 | 1406 | let status = platform::system_titlebar(&self.window) |
| ... | ... | @@ -1200,8 +1409,7 @@ impl State { |
| 1200 | 1409 | let ui = &mut self.ui; |
| 1201 | 1410 | let text = theme.text; |
| 1202 | 1411 | let on = |toggle| state.toggles.contains(&toggle); |
| 1203 | let mut command = None; | |
| 1204 | let mut history = None; | |
| 1412 | let mut choice = None; | |
| 1205 | 1413 | ui.open( |
| 1206 | 1414 | "toolbar", |
| 1207 | 1415 | Spec { |
| ... | ... | @@ -1214,10 +1422,10 @@ impl State { |
| 1214 | 1422 | ); |
| 1215 | 1423 | group(ui, "history", |ui| { |
| 1216 | 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 | 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 | 1431 | divider(ui, "history", theme); |
| ... | ... | @@ -1272,7 +1480,7 @@ impl State { |
| 1272 | 1480 | ui::popup::menu(ui, popup("font"), anchor, &items, Some("Font")) |
| 1273 | 1481 | && 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 | 1485 | let combo = ui.id("size"); |
| 1278 | 1486 | if ui::shell::combo(ui, "size", &size, 44.0).pressed { |
| ... | ... | @@ -1300,20 +1508,113 @@ impl State { |
| 1300 | 1508 | let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default()); |
| 1301 | 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 = | |
| 1306 | ui::shell::split_button(ui, "bullets", art::BULLETS, None, state.bullets); | |
| 1307 | let numbering = | |
| 1308 | ui::shell::split_button(ui, "numbering", art::NUMBERING, None, state.numbering); | |
| 1309 | if bullets.iter().any(|signal| signal.clicked) { | |
| 1310 | command = Some(Formatting::Bullets); | |
| 1513 | // The buttons apply OneNote's default bullet and number, as its own do. | |
| 1514 | let [bullets, numbering] = [ui.id("bullets"), ui.id("numbering")]; | |
| 1515 | if ui::shell::split_button( | |
| 1516 | ui, | |
| 1517 | "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) { | |
| 1313 | command = Some(Formatting::Numbering); | |
| 1570 | let anchor = ui::Anchor::Below(ui.rect(numbering).unwrap_or_default()); | |
| 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 | 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 | 1620 | row(ui, 1, |ui| { |
| ... | ... | @@ -1324,12 +1625,12 @@ impl State { |
| 1324 | 1625 | ("strikethrough", art::STRIKETHROUGH, Toggle::Strikethrough), |
| 1325 | 1626 | ] { |
| 1326 | 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 | 1631 | let script = ui.id("script"); |
| 1331 | 1632 | let superscript = on(Toggle::Superscript); |
| 1332 | let [button, menu] = ui::shell::split_button( | |
| 1633 | if ui::shell::split_button( | |
| 1333 | 1634 | ui, |
| 1334 | 1635 | "script", |
| 1335 | 1636 | if superscript { |
| ... | ... | @@ -1339,47 +1640,46 @@ impl State { |
| 1339 | 1640 | }, |
| 1340 | 1641 | None, |
| 1341 | 1642 | on(Toggle::Subscript) || superscript, |
| 1342 | ); | |
| 1343 | if button.clicked { | |
| 1344 | command = Some(Formatting::Toggle(if superscript { | |
| 1643 | popup("script"), | |
| 1644 | ) | |
| 1645 | .clicked | |
| 1646 | { | |
| 1647 | choice = Some(Choice::Command(Cmd::Toggle(if superscript { | |
| 1345 | 1648 | Toggle::Superscript |
| 1346 | 1649 | } else { |
| 1347 | 1650 | Toggle::Subscript |
| 1348 | })); | |
| 1349 | } | |
| 1350 | if menu.pressed { | |
| 1351 | ui.open_popup(popup("script")); | |
| 1651 | }))); | |
| 1352 | 1652 | } |
| 1353 | let items = [ | |
| 1354 | ui::popup::Item { | |
| 1355 | text: "Subscript", | |
| 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 | }, | |
| 1653 | let scripts = [ | |
| 1654 | (Toggle::Subscript, art::SUBSCRIPT), | |
| 1655 | (Toggle::Superscript, art::SUPERSCRIPT), | |
| 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 | 1669 | let anchor = ui::Anchor::Below(ui.rect(script).unwrap_or_default()); |
| 1370 | 1670 | if let Some(index) = ui::popup::menu(ui, popup("script"), anchor, &items, None) { |
| 1371 | command = Some(Formatting::Toggle( | |
| 1372 | [Toggle::Subscript, Toggle::Superscript][index], | |
| 1373 | )); | |
| 1671 | choice = Some(Choice::Command(Cmd::Toggle(scripts[index].0))); | |
| 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 | 1677 | "highlight", |
| 1378 | 1678 | art::HIGHLIGHTER, |
| 1379 | HIGHLIGHTS, | |
| 1679 | &HIGHLIGHTS.map(|(color, _)| color)[..], | |
| 1380 | 1680 | 5, |
| 1381 | "No colour", | |
| 1382 | 0x00ffff, | |
| 1681 | "No Color", | |
| 1682 | None, | |
| 1383 | 1683 | ), |
| 1384 | 1684 | ( |
| 1385 | 1685 | "color", |
| ... | ... | @@ -1387,68 +1687,69 @@ impl State { |
| 1387 | 1687 | &FONT_COLORS[..], |
| 1388 | 1688 | 10, |
| 1389 | 1689 | "Automatic", |
| 1390 | 0x3a3ae8, | |
| 1690 | Some(pens.font_color.map_or(text, colorref)), | |
| 1391 | 1691 | ), |
| 1392 | 1692 | ] { |
| 1393 | 1693 | let split = ui.id(part); |
| 1394 | let [button, menu] = | |
| 1395 | ui::shell::split_button(ui, part, icon, Some(colorref(default)), false); | |
| 1396 | let paint = |color| { | |
| 1397 | if part == "highlight" { | |
| 1398 | Formatting::Highlight(color) | |
| 1399 | } else { | |
| 1400 | Formatting::Color(color) | |
| 1401 | } | |
| 1402 | }; | |
| 1694 | let button = ui::shell::split_button(ui, part, icon, bar, false, popup(part)); | |
| 1695 | let highlight = part == "highlight"; | |
| 1403 | 1696 | if button.clicked { |
| 1404 | command = Some(paint(Some(default))); | |
| 1405 | } | |
| 1406 | if menu.pressed { | |
| 1407 | ui.open_popup(popup(part)); | |
| 1697 | choice = Some(Choice::Command(if highlight { | |
| 1698 | Cmd::Highlight | |
| 1699 | } else { | |
| 1700 | Cmd::FontColor | |
| 1701 | })); | |
| 1408 | 1702 | } |
| 1409 | 1703 | let colors: Vec<_> = swatches.iter().map(|color| colorref(*color)).collect(); |
| 1410 | 1704 | let anchor = ui::Anchor::Below(ui.rect(split).unwrap_or_default()); |
| 1411 | 1705 | if let Some(chosen) = |
| 1412 | 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 | 1709 | swatches |
| 1416 | 1710 | .iter() |
| 1417 | 1711 | .zip(&colors) |
| 1418 | 1712 | .find(|(_, color)| **color == chosen) |
| 1419 | 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 | 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 | 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 | 1728 | let alignments = [ |
| 1431 | (Alignment::Left, "Align left", art::ALIGN_LEFT), | |
| 1432 | (Alignment::Center, "Centre", art::ALIGN_CENTER), | |
| 1433 | (Alignment::Right, "Align right", art::ALIGN_RIGHT), | |
| 1729 | (Alignment::Left, art::ALIGN_LEFT), | |
| 1730 | (Alignment::Center, art::ALIGN_CENTER), | |
| 1731 | (Alignment::Right, art::ALIGN_RIGHT), | |
| 1434 | 1732 | ]; |
| 1733 | let keys = alignments.map(|(alignment, _)| shortcut(Cmd::Align(alignment))); | |
| 1435 | 1734 | let current = alignments |
| 1436 | 1735 | .iter() |
| 1437 | .find(|(alignment, ..)| Some(*alignment) == state.alignment) | |
| 1736 | .find(|(alignment, _)| Some(*alignment) == state.alignment) | |
| 1438 | 1737 | .unwrap_or(&alignments[0]); |
| 1439 | let [button, menu] = ui::shell::split_button(ui, "align", current.2, None, false); | |
| 1440 | if button.pressed || menu.pressed { | |
| 1441 | ui.open_popup(popup("align")); | |
| 1442 | } | |
| 1443 | let items = alignments.map(|(alignment, name, icon)| ui::popup::Item { | |
| 1444 | text: name, | |
| 1445 | icon: Some(icon), | |
| 1446 | checked: Some(alignment) == state.alignment, | |
| 1447 | ..Default::default() | |
| 1448 | }); | |
| 1449 | let anchor = ui::Anchor::Below(ui.rect(align).unwrap_or_default()); | |
| 1738 | let anchor = ui::shell::menu_button(ui, "align", current.1, popup("align")); | |
| 1739 | // The current alignment starts highlighted, keeping its icon under the button's. | |
| 1740 | let items: Vec<_> = alignments | |
| 1741 | .iter() | |
| 1742 | .zip(&keys) | |
| 1743 | .map(|((alignment, icon), key)| ui::popup::Item { | |
| 1744 | text: commands::command(Cmd::Align(*alignment)).title, | |
| 1745 | icon: Some(icon), | |
| 1746 | shortcut: key, | |
| 1747 | current: Some(*alignment) == state.alignment, | |
| 1748 | ..Default::default() | |
| 1749 | }) | |
| 1750 | .collect(); | |
| 1450 | 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 | 1768 | (tag_sources(TagIcon::Address), NoteTag::Address), |
| 1468 | 1769 | (tag_sources(TagIcon::Phone), NoteTag::PhoneNumber), |
| 1469 | 1770 | ]; |
| 1771 | let all_tags = &tags; | |
| 1470 | 1772 | group(ui, "tags", |ui| { |
| 1471 | 1773 | for (index, tags) in tags.chunks(5).enumerate() { |
| 1472 | 1774 | row(ui, index, |ui| { |
| 1473 | 1775 | for (column, (icon, tag)) in tags.iter().enumerate() { |
| 1474 | 1776 | let lit = state.tags.contains(tag); |
| 1475 | 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 | 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 | 1825 | divider(ui, "tags", theme); |
| 1486 | 1826 | group(ui, "insert", |ui| { |
| 1487 | row(ui, 0, |ui| { | |
| 1488 | for (part, icon) in [ | |
| 1489 | ("table", art::TABLE), | |
| 1490 | ("picture", art::PICTURE), | |
| 1491 | ("file", art::ATTACHMENT), | |
| 1492 | ("link", art::LINK), | |
| 1493 | ] { | |
| 1494 | ui::shell::tool_button(ui, part, icon, text, false); | |
| 1495 | } | |
| 1496 | }); | |
| 1497 | row(ui, 1, |ui| { | |
| 1498 | for (part, icon) in [ | |
| 1499 | ("date", art::CALENDAR), | |
| 1500 | ("time", art::CLOCK), | |
| 1501 | ("equation", art::EQUATION), | |
| 1502 | ] { | |
| 1503 | ui::shell::tool_button(ui, part, icon, text, false); | |
| 1504 | } | |
| 1505 | }); | |
| 1827 | for (index, buttons) in [ | |
| 1828 | &[ | |
| 1829 | ("table", art::TABLE, Cmd::Table), | |
| 1830 | ("picture", art::PICTURE, Cmd::Picture), | |
| 1831 | ("file", art::ATTACHMENT, Cmd::Attachment), | |
| 1832 | ("link", art::LINK, Cmd::Link), | |
| 1833 | ][..], | |
| 1834 | &[ | |
| 1835 | ("date", art::CALENDAR, Cmd::Date), | |
| 1836 | ("time", art::CLOCK, Cmd::Time), | |
| 1837 | ("equation", art::EQUATION, Cmd::Equation), | |
| 1838 | ], | |
| 1839 | ] | |
| 1840 | .into_iter() | |
| 1841 | .enumerate() | |
| 1842 | { | |
| 1843 | row(ui, index, |ui| { | |
| 1844 | for (part, icon, id) in buttons { | |
| 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 | 1852 | ui.leaf( |
| 1508 | 1853 | "space", |
| ... | ... | @@ -1525,11 +1870,10 @@ impl State { |
| 1525 | 1870 | } |
| 1526 | 1871 | let zoom = self.view.zoom(); |
| 1527 | 1872 | let label = format!("{:.0}%", zoom * 100.0); |
| 1528 | let mut chosen = None; | |
| 1529 | 1873 | group(ui, "zoom", |ui| { |
| 1530 | 1874 | row(ui, 0, |ui| { |
| 1531 | 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 | 1878 | let level = ui.leaf( |
| 1535 | 1879 | "level", |
| ... | ... | @@ -1544,36 +1888,16 @@ impl State { |
| 1544 | 1888 | }, |
| 1545 | 1889 | ); |
| 1546 | 1890 | if level.clicked { |
| 1547 | chosen = Some(1.0); | |
| 1891 | choice = Some(Choice::Command(Cmd::ActualSize)); | |
| 1548 | 1892 | } |
| 1549 | 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 | 1898 | ui.close(); |
| 1555 | if let Some(zoom) = chosen { | |
| 1556 | let response = self.view.set_zoom(zoom)?; | |
| 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; | |
| 1899 | if let Some(choice) = choice { | |
| 1900 | self.choose(choice); | |
| 1577 | 1901 | } |
| 1578 | 1902 | Ok(()) |
| 1579 | 1903 | } |
| ... | ... | @@ -1626,24 +1950,43 @@ impl State { |
| 1626 | 1950 | let found = self |
| 1627 | 1951 | .search |
| 1628 | 1952 | .found_in(&session.library.key(&session.tabs[session.tab].path)); |
| 1629 | let rows = page_rows(&mut self.ui, theme, section, session, &found); | |
| 1630 | self.commands.extend(rows.clicked.map(Command::OpenPage)); | |
| 1953 | let rounding = self.rounding(); | |
| 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 | 1969 | if let Some((space, point)) = rows.context { |
| 1632 | 1970 | self.menu = Some((menus::Target::Page(space), point)); |
| 1633 | 1971 | self.ui.open_popup(menus::id()); |
| 1634 | 1972 | } |
| 1635 | let rect = self.ui.rect(panel).unwrap_or_default(); | |
| 1636 | self.drag_pages(rows.held, section, rect, tabs); | |
| 1973 | if let Some(space) = rows.renamed { | |
| 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 | 1983 | self.ui.close(); |
| 1638 | 1984 | rows.open |
| 1639 | 1985 | } |
| 1640 | 1986 | |
| 1641 | 1987 | /// Hands the page the events routed to its box, in its device pixels. |
| 1642 | 1988 | fn page_events(&mut self, events: Vec<ui::Event>) -> Result<(), Box<dyn Error>> { |
| 1643 | let read_only = self | |
| 1644 | .session | |
| 1645 | .as_ref() | |
| 1646 | .is_some_and(|session| session.conflict(session.space).is_some()); | |
| 1989 | let read_only = self.session.as_ref().is_some_and(Session::read_only); | |
| 1647 | 1990 | let scale = self.ui.scale(); |
| 1648 | 1991 | let corner = self.ui.rect(page()).unwrap_or_default(); |
| 1649 | 1992 | let device = |point: [f32; 2]| { |
| ... | ... | @@ -1667,6 +2010,14 @@ impl State { |
| 1667 | 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 | 2021 | ui::Event::Button { .. } | ui::Event::Ime(Ime::Enabled) => continue, |
| 1671 | 2022 | // A conflict page takes no typing; commands and caret moves still apply. |
| 1672 | 2023 | ui::Event::Ime(Ime::Preedit(..) | Ime::Commit(_)) if read_only => continue, |
| ... | ... | @@ -1691,28 +2042,6 @@ impl State { |
| 1691 | 2042 | } |
| 1692 | 2043 | ui::Event::Wheel(delta) => self.view.wheel([delta[0] * scale, delta[1] * scale])?, |
| 1693 | 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 | 2045 | self.view.key(&ui::edit_key(&key), text.as_deref())? |
| 1717 | 2046 | } |
| 1718 | 2047 | ui::Event::Ime(Ime::Preedit(text, cursor)) => self.view.compose(text, cursor)?, |
| ... | ... | @@ -1774,7 +2103,12 @@ impl State { |
| 1774 | 2103 | } |
| 1775 | 2104 | Command::CopyVersion { version, tab } => { |
| 1776 | 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 | 2112 | if tab == session.tab { |
| 1779 | 2113 | session.section.import_page(&page, &self.author)?; |
| 1780 | 2114 | } else { |
| ... | ... | @@ -1793,6 +2127,11 @@ impl State { |
| 1793 | 2127 | } |
| 1794 | 2128 | } |
| 1795 | 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 | 2135 | Command::OpenNotebook => { |
| 1797 | 2136 | if let Some(path) = platform::pick_notebook("Open Notebook") { |
| 1798 | 2137 | self.open_path(&path); |
| ... | ... | @@ -1824,17 +2163,15 @@ impl State { |
| 1824 | 2163 | let response = self.view.paste(&text, language)?; |
| 1825 | 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 | 2169 | Ok(()) |
| 1830 | 2170 | } |
| 1831 | 2171 | |
| 1832 | 2172 | /// Runs `read` on a thread of its own, which lays out the page it read; `open_loaded` |
| 1833 | 2173 | /// shows it unless a newer read was asked for meanwhile. |
| 1834 | fn load( | |
| 1835 | &mut self, | |
| 1836 | read: impl FnOnce() -> Result<Loaded, Box<dyn Error>> + Send + 'static, | |
| 1837 | ) { | |
| 2174 | fn load(&mut self, read: impl FnOnce() -> Result<Loaded, Box<dyn Error>> + Send + 'static) { | |
| 1838 | 2175 | self.loading += 1; |
| 1839 | 2176 | let (id, sender, redraw) = (self.loading, self.loads.0.clone(), self.redraw.clone()); |
| 1840 | 2177 | let layouts = Arc::clone(&self.layouts); |
| ... | ... | @@ -1842,7 +2179,9 @@ impl State { |
| 1842 | 2179 | let laid = read().and_then(|loaded| { |
| 1843 | 2180 | let (shown, page) = match loaded { |
| 1844 | 2181 | Loaded::Section(session, page) => (Shown::Section(session), page), |
| 2182 | Loaded::Created(session, page) => (Shown::Created(session), page), | |
| 1845 | 2183 | Loaded::Page(space, page) => (Shown::Page(space), page), |
| 2184 | Loaded::Version(space, version, page) => (Shown::Version(space, version), page), | |
| 1846 | 2185 | Loaded::Library(library, path) => return Ok(Laid::Library(library, path)), |
| 1847 | 2186 | }; |
| 1848 | 2187 | let mut engine = layouts.lock().map_err(|_| "Page layout failed")?; |
| ... | ... | @@ -1864,12 +2203,11 @@ impl State { |
| 1864 | 2203 | fn open_loaded(&mut self) -> Result<(), Box<dyn Error>> { |
| 1865 | 2204 | let reads: Vec<Read> = self.loads.1.try_iter().collect(); |
| 1866 | 2205 | for (id, loaded) in reads { |
| 1867 | if id != self.loading { | |
| 1868 | continue; | |
| 1869 | } | |
| 1870 | 2206 | match loaded { |
| 2207 | // A later load supersedes what this one shows, but not its failure. | |
| 2208 | Ok(_) if id != self.loading => {} | |
| 1871 | 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 | 2211 | Err(error) => { |
| 1874 | 2212 | eprintln!("{error}"); |
| 1875 | 2213 | platform::alert("Couldn't open", &error); |
| ... | ... | @@ -1896,11 +2234,13 @@ impl State { |
| 1896 | 2234 | self.persist()?; |
| 1897 | 2235 | if let Some(session) = &self.session { |
| 1898 | 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 | 2239 | self.last_pages.insert(key, session.space); |
| 1901 | 2240 | } |
| 2241 | let created = matches!(opening.loaded, Shown::Created(_)); | |
| 1902 | 2242 | match opening.loaded { |
| 1903 | Shown::Section(session) => { | |
| 2243 | Shown::Section(session) | Shown::Created(session) => { | |
| 1904 | 2244 | // The settings keep the notebook shown, not the section. |
| 1905 | 2245 | let other_notebook = self |
| 1906 | 2246 | .session |
| ... | ... | @@ -1916,13 +2256,18 @@ impl State { |
| 1916 | 2256 | None => self.notebooks.push(Arc::clone(&session.library)), |
| 1917 | 2257 | } |
| 1918 | 2258 | self.session = Some(*session); |
| 2259 | self.sectionless = None; | |
| 1919 | 2260 | if other_notebook { |
| 1920 | 2261 | self.save_settings(); |
| 1921 | 2262 | } |
| 1922 | 2263 | } |
| 1923 | Shown::Page(space) => { | |
| 2264 | Shown::Page(space) | Shown::Version(space, _) => { | |
| 1924 | 2265 | let session = self.session.as_mut().ok_or("No section is open")?; |
| 1925 | 2266 | session.space = space; |
| 2267 | session.version = match opening.loaded { | |
| 2268 | Shown::Version(_, version) => Some(version), | |
| 2269 | _ => None, | |
| 2270 | }; | |
| 1926 | 2271 | session.change = None; |
| 1927 | 2272 | // Pages created, moved or deleted since the list was read. |
| 1928 | 2273 | session.pages = session.section.pages()?; |
| ... | ... | @@ -1933,24 +2278,39 @@ impl State { |
| 1933 | 2278 | self.places.get(&(key, session.space)).copied() |
| 1934 | 2279 | }); |
| 1935 | 2280 | self.view.open(opening.editor, Some(opening.scene), place); |
| 2281 | self.visited(); | |
| 1936 | 2282 | self.opened()?; |
| 1937 | 2283 | self.refind(true)?; |
| 1938 | 2284 | if self.title_focus.take().is_some_and(|space| { |
| 1939 | 2285 | self.session |
| 1940 | 2286 | .as_ref() |
| 1941 | 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 | 2295 | self.view.editor.focus_outline(title.id)?; |
| 1945 | 2296 | let response = self.view.focus_text()?; |
| 1946 | 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 | 2307 | Ok(()) |
| 1949 | 2308 | } |
| 1950 | 2309 | |
| 1951 | 2310 | /// Takes `library`, a notebook read again after a change, in place of the one it was; |
| 1952 | 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 | 2314 | match self |
| 1955 | 2315 | .notebooks |
| 1956 | 2316 | .iter_mut() |
| ... | ... | @@ -1959,6 +2319,23 @@ impl State { |
| 1959 | 2319 | Some(open) => *open = Arc::clone(&library), |
| 1960 | 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 | 2339 | let Some(session) = &mut self.session else { |
| 1963 | 2340 | return Ok(()); |
| 1964 | 2341 | }; |
| ... | ... | @@ -2018,8 +2395,8 @@ impl State { |
| 2018 | 2395 | let corner = self.ui.rect(page()).unwrap_or_default(); |
| 2019 | 2396 | let [x0, y0, x1, y1] = self.view.caret_area()?; |
| 2020 | 2397 | self.window.set_ime_cursor_area( |
| 2021 | PhysicalPosition::new(x0 + corner[0] * scale, y0 + corner[1] * scale), | |
| 2022 | PhysicalSize::new(x1 - x0, y1 - y0), | |
| 2398 | LogicalPosition::new(x0 / scale + corner[0], y0 / scale + corner[1]), | |
| 2399 | LogicalSize::new((x1 - x0) / scale, (y1 - y0) / scale), | |
| 2023 | 2400 | ); |
| 2024 | 2401 | let start = Instant::now(); |
| 2025 | 2402 | self.update_accessibility()?; |
| ... | ... | @@ -2066,6 +2443,10 @@ impl State { |
| 2066 | 2443 | }; |
| 2067 | 2444 | if self.window.title() != title { |
| 2068 | 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 | } |
| 2071 | 2452 | |
| ... | ... | @@ -2153,8 +2534,8 @@ impl State { |
| 2153 | 2534 | if ops.is_empty() { |
| 2154 | 2535 | return Ok(()); |
| 2155 | 2536 | } |
| 2156 | if session.conflict(session.space).is_some() { | |
| 2157 | // A conflict page is read-only: it returns to what is stored. | |
| 2537 | if session.read_only() { | |
| 2538 | // A conflict page or a version is read-only: it returns to what is stored. | |
| 2158 | 2539 | self.stale = true; |
| 2159 | 2540 | return Ok(()); |
| 2160 | 2541 | } |
| ... | ... | @@ -2267,7 +2648,19 @@ impl State { |
| 2267 | 2648 | let back = session.shown; |
| 2268 | 2649 | session.pages = session.section.pages()?; |
| 2269 | 2650 | session.refresh_conflicts()?; |
| 2651 | session.refresh_history()?; | |
| 2270 | 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 | 2664 | if !listed(session.space) && session.conflict(session.space).is_none() { |
| 2272 | 2665 | // A page removed elsewhere, or a conflict page deleted elsewhere, which |
| 2273 | 2666 | // returns to its page. |
| ... | ... | @@ -2291,9 +2684,14 @@ impl State { |
| 2291 | 2684 | let mut error = None; |
| 2292 | 2685 | let view = &self.view; |
| 2293 | 2686 | let corner = self.ui.rect(page()).unwrap_or_default(); |
| 2294 | let mut viewport = view.viewport; | |
| 2295 | viewport.origin[0] += corner[0] * self.ui.scale(); | |
| 2296 | viewport.origin[1] += corner[1] * self.ui.scale(); | |
| 2687 | let scale = self.ui.scale(); | |
| 2688 | // Bounds are in the window's pixels, which `scale` renders fewer of when capped. | |
| 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 | 2695 | self.access_adapter.update_if_active(|| { |
| 2298 | 2696 | match self.accessibility.update( |
| 2299 | 2697 | &view.editor, |
| ... | ... | @@ -2419,7 +2817,23 @@ impl State { |
| 2419 | 2817 | } |
| 2420 | 2818 | }; |
| 2421 | 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 | 2837 | self.window.pre_present_notify(); |
| 2424 | 2838 | let start = Instant::now(); |
| 2425 | 2839 | self.renderer.queue.present(frame); |
| ... | ... | @@ -2432,6 +2846,15 @@ impl State { |
| 2432 | 2846 | Ok(()) |
| 2433 | 2847 | } |
| 2434 | 2848 | |
| 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 | 2858 | /// The paper the open page lies on: the theme's, in the page's colour. |
| 2436 | 2859 | fn paper(&self) -> canvas::gpu::Paper { |
| 2437 | 2860 | canvas::gpu::Paper { |
| ... | ... | @@ -2464,13 +2887,7 @@ impl State { |
| 2464 | 2887 | let layers: Vec<_> = interface |
| 2465 | 2888 | .iter() |
| 2466 | 2889 | .map(|layer| match layer { |
| 2467 | ui::Layer::Primitives { clip, primitives } => draw::Layer { | |
| 2468 | scale, | |
| 2469 | origin: [0.0; 2], | |
| 2470 | clip: clip.map(|clip| clip.map(|value| value * scale)), | |
| 2471 | backdrop: None, | |
| 2472 | primitives, | |
| 2473 | }, | |
| 2890 | ui::Layer::Primitives(primitives) => primitives.layer(scale), | |
| 2474 | 2891 | ui::Layer::Custom { rect, .. } => draw::Layer { |
| 2475 | 2892 | scale: viewport.scale, |
| 2476 | 2893 | origin: [ |
| ... | ... | @@ -2479,6 +2896,7 @@ impl State { |
| 2479 | 2896 | ], |
| 2480 | 2897 | clip: Some(rect.map(|value| value * scale)), |
| 2481 | 2898 | backdrop: Some(paper.color), |
| 2899 | motion: None, | |
| 2482 | 2900 | primitives: &page_primitives, |
| 2483 | 2901 | }, |
| 2484 | 2902 | }) |
| ... | ... | @@ -2489,7 +2907,7 @@ impl State { |
| 2489 | 2907 | .draw( |
| 2490 | 2908 | target, |
| 2491 | 2909 | [self.config.width, self.config.height], |
| 2492 | self.ui.theme.base, | |
| 2910 | self.ui.theme.strip, | |
| 2493 | 2911 | &layers, |
| 2494 | 2912 | ) |
| 2495 | 2913 | .map_err(|error| format!("Canvas drawing failed: {error:?}"))?; |
| ... | ... | @@ -2583,6 +3001,23 @@ impl State { |
| 2583 | 3001 | if let ui::Event::Button { pressed: false, .. } = event { |
| 2584 | 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 | 3021 | if let ui::Event::Button { |
| 2587 | 3022 | button: MouseButton::Left, |
| 2588 | 3023 | pressed: true, |
| ... | ... | @@ -2613,15 +3048,12 @@ impl State { |
| 2613 | 3048 | } |
| 2614 | 3049 | return; |
| 2615 | 3050 | } |
| 2616 | // Settings' shortcut, which AppKit's menu takes first on macOS. | |
| 2617 | if let ui::Event::Key { | |
| 2618 | key: Key::Character(character), | |
| 2619 | .. | |
| 2620 | } = &event | |
| 2621 | && character == "," | |
| 2622 | && ui::edit_modifiers(self.ui.modifiers()).command | |
| 3051 | // On macOS the menu bar takes the chords of the items it enables first. | |
| 3052 | if let ui::Event::Key { key, .. } = &event | |
| 3053 | && let Some(id) = | |
| 3054 | commands::find(&ui::edit_key(key), ui::edit_modifiers(self.ui.modifiers())) | |
| 2623 | 3055 | { |
| 2624 | self.open_options(); | |
| 3056 | self.choose(commands::Choice::Command(id)); | |
| 2625 | 3057 | } else { |
| 2626 | 3058 | self.ui.event(event); |
| 2627 | 3059 | } |
| ... | ... | @@ -2629,8 +3061,9 @@ impl State { |
| 2629 | 3061 | } |
| 2630 | 3062 | } |
| 2631 | 3063 | |
| 2632 | /// The interface's colours in `appearance`, on white pages when `light_pages`. | |
| 2633 | fn theme(appearance: winit::window::Theme, light_pages: bool) -> Theme { | |
| 3064 | /// The interface's colours in `appearance`, on white pages when `light_pages`, and over the | |
| 3065 | /// system's material where it shows through as a `backdrop`. | |
| 3066 | fn theme(appearance: winit::window::Theme, light_pages: bool, backdrop: bool) -> Theme { | |
| 2634 | 3067 | let mut theme = match appearance { |
| 2635 | 3068 | winit::window::Theme::Dark => Theme::dark(), |
| 2636 | 3069 | winit::window::Theme::Light => Theme::light(), |
| ... | ... | @@ -2639,7 +3072,11 @@ fn theme(appearance: winit::window::Theme, light_pages: bool) -> Theme { |
| 2639 | 3072 | let light = Theme::light(); |
| 2640 | 3073 | [theme.paper, theme.paper_ink] = [light.paper, light.paper_ink]; |
| 2641 | 3074 | } |
| 2642 | theme | |
| 3075 | if backdrop { | |
| 3076 | theme.over_backdrop() | |
| 3077 | } else { | |
| 3078 | theme | |
| 3079 | } | |
| 2643 | 3080 | } |
| 2644 | 3081 | |
| 2645 | 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 | 3100 | ..Spec::default() |
| 2664 | 3101 | }, |
| 2665 | 3102 | ); |
| 2666 | ui.leaf( | |
| 2667 | "icon", | |
| 2668 | Spec { | |
| 2669 | size: [fit(), px(16.0)], | |
| 2670 | icon: Some(art::CONFLICT), | |
| 2671 | color: Some(draw::srgb(0xc0, 0x48, 0x20)), | |
| 2672 | ..Spec::default() | |
| 2673 | }, | |
| 2674 | ); | |
| 2675 | // OneNote 2010's words (corpus/conflict-page/native), a sentence to a line. | |
| 3103 | // OneNote marks conflicts, not versions. | |
| 3104 | if !matches!(bar, Bar::History { .. }) { | |
| 3105 | ui.leaf( | |
| 3106 | "icon", | |
| 3107 | Spec { | |
| 3108 | size: [fit(), px(16.0)], | |
| 3109 | icon: Some(art::CONFLICT), | |
| 3110 | color: Some(draw::srgb(0xc0, 0x48, 0x20)), | |
| 3111 | ..Spec::default() | |
| 3112 | }, | |
| 3113 | ); | |
| 3114 | } | |
| 3115 | // OneNote 2010's words (corpus/conflict-page/native, corpus/page-versions/native). | |
| 2676 | 3116 | let [said, action] = match bar { |
| 2677 | 3117 | Bar::Page { shown, .. } => [ |
| 2678 | 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 | 3126 | "Conflicting changes are highlighted in red. This page cannot be edited, but you can copy changes to the primary page.", |
| 2687 | 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 | 3134 | ui.open( |
| 2691 | 3135 | "message", |
| ... | ... | @@ -2699,8 +3143,9 @@ fn conflict_bar(ui: &mut Ui, bar: Bar, sections: &[&str], steps: [bool; 2]) -> O |
| 2699 | 3143 | ui.leaf( |
| 2700 | 3144 | part, |
| 2701 | 3145 | Spec { |
| 2702 | size: [fit(), px(18.0)], | |
| 3146 | size: [fill(), fit()], | |
| 2703 | 3147 | text: Some(text), |
| 3148 | overflow: ui::Overflow::Wrap, | |
| 2704 | 3149 | color: Some(draw::srgb(0x20, 0x20, 0x20)), |
| 2705 | 3150 | ..Spec::default() |
| 2706 | 3151 | }, |
| ... | ... | @@ -2717,6 +3162,16 @@ fn conflict_bar(ui: &mut Ui, bar: Bar, sections: &[&str], steps: [bool; 2]) -> O |
| 2717 | 3162 | let anchor = ui::Anchor::Below([left, top, left, bottom]); |
| 2718 | 3163 | match bar { |
| 2719 | 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 | 3175 | Bar::Version { page, version } => { |
| 2721 | 3176 | if clicked { |
| 2722 | 3177 | ui.open_popup(menu); |
| ... | ... | @@ -2782,76 +3237,246 @@ struct Rows { |
| 2782 | 3237 | context: Option<(ExGuid, [f32; 2])>, |
| 2783 | 3238 | /// The page held down, which a drag moves. |
| 2784 | 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)] | |
| 3255 | struct 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. | |
| 3262 | fn page_list_top() -> &'static str { | |
| 3263 | "top" | |
| 2785 | 3264 | } |
| 2786 | 3265 | |
| 2787 | 3266 | /// 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)] | |
| 2789 | 3270 | fn page_rows( |
| 2790 | 3271 | ui: &mut Ui, |
| 2791 | 3272 | theme: &Theme, |
| 2792 | 3273 | section: &ui::Section, |
| 2793 | 3274 | session: &Session, |
| 2794 | 3275 | found: &HashSet<ExGuid>, |
| 3276 | rounding: f32, | |
| 3277 | mut renaming: Option<&mut rename::Renaming>, | |
| 3278 | dragged: Option<PageDrag>, | |
| 2795 | 3279 | ) -> Rows { |
| 2796 | let mut rows = Rows::default(); | |
| 2797 | for (space, title, level) in &session.pages { | |
| 2798 | let versions = session.versions(*space); | |
| 2799 | let tab = PageTab { | |
| 2800 | label: if title.is_empty() { | |
| 2801 | "Untitled page" | |
| 2802 | } else { | |
| 2803 | title | |
| 2804 | }, | |
| 2805 | dim: title.is_empty(), | |
| 2806 | indent: level.saturating_sub(1), | |
| 2807 | conflicted: !versions.is_empty(), | |
| 2808 | found: found.contains(space), | |
| 2809 | }; | |
| 2810 | let selected = *space == session.space; | |
| 2811 | if selected { | |
| 2812 | rows.open = Some(ui.id(space)); | |
| 2813 | } | |
| 2814 | let signal = page_tab(ui, theme, section, space, &tab, selected); | |
| 2815 | if signal.clicked && !selected { | |
| 2816 | rows.clicked = Some(*space); | |
| 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) { | |
| 3280 | let mut rows = Rows { | |
| 3281 | settled: true, | |
| 3282 | ..Rows::default() | |
| 3283 | }; | |
| 3284 | ui.leaf(page_list_top(), Spec::default()); | |
| 3285 | let from = dragged.and_then(|dragged| { | |
| 3286 | session | |
| 3287 | .pages | |
| 3288 | .iter() | |
| 3289 | .position(|(space, ..)| *space == dragged.space) | |
| 3290 | }); | |
| 3291 | let slot = dragged.and_then(|dragged| dragged.slot).or(from); | |
| 3292 | // Down the list as laid out without the dragged tab, and as it stands with it. | |
| 3293 | let (mut laid, mut natural, mut gap) = (0.0, 0.0, None); | |
| 3294 | let mut others = 0; | |
| 3295 | let mut lifted = None; | |
| 3296 | for (index, (space, title, level)) in session.pages.iter().enumerate() { | |
| 3297 | rows.places.push(natural); | |
| 3298 | if from == Some(index) { | |
| 3299 | lifted = Some((space, title, level)); | |
| 3300 | natural += ROW; | |
| 2825 | 3301 | continue; |
| 2826 | 3302 | } |
| 2827 | // OneNote lists a page's conflict pages beneath it, each by its date and whose | |
| 2828 | // version it is. | |
| 2829 | let muted = ui::Section { | |
| 2830 | tab: ui::mix(section.tab, theme.chip, 0.6), | |
| 2831 | ..*section | |
| 3303 | if Some(others) == slot { | |
| 3304 | gap = Some(laid); | |
| 3305 | } | |
| 3306 | // Eased only while a tab is dragged: the list lays out anew once it ends. | |
| 3307 | 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 { | |
| 2834 | let label = match version.created { | |
| 2835 | Some(created) => format!("{} {}", platform::short_date(created), version.user), | |
| 2836 | None => version.user.clone(), | |
| 2837 | }; | |
| 3314 | others += 1; | |
| 3315 | let height = page_row( | |
| 3316 | ui, | |
| 3317 | theme, | |
| 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)] | |
| 3363 | fn 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 | 3424 | let tab = PageTab { |
| 2839 | 3425 | label: &label, |
| 2840 | 3426 | dim: false, |
| 2841 | 3427 | indent: level.saturating_sub(1), |
| 2842 | 3428 | conflicted: false, |
| 2843 | 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 | 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 { | |
| 2850 | rows.clicked = Some(version.space); | |
| 3438 | height += ROW; | |
| 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 | rows | |
| 3448 | 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 | } |
| 2856 | 3481 | |
| 2857 | 3482 | /// A row of the page list. |
| ... | ... | @@ -2864,23 +3489,38 @@ struct PageTab<'a> { |
| 2864 | 3489 | conflicted: bool, |
| 2865 | 3490 | /// A search found the page, which OneNote marks yellow. |
| 2866 | 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 | } |
| 2868 | 3499 | |
| 2869 | 3500 | /// 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. | |
| 2871 | 3502 | fn page_tab( |
| 2872 | 3503 | ui: &mut Ui, |
| 2873 | 3504 | theme: &Theme, |
| 2874 | 3505 | section: &ui::Section, |
| 2875 | 3506 | id: &ExGuid, |
| 2876 | tab: &PageTab, | |
| 3507 | tab: PageTab, | |
| 2877 | 3508 | selected: bool, |
| 2878 | ) -> ui::Signal { | |
| 2879 | let pad = 10.0 + 16.0 * tab.indent as f32; | |
| 3509 | rounding: 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 | 3518 | let spec = Spec { |
| 2881 | 3519 | flags: Flags::CLICKABLE, |
| 2882 | 3520 | size: [px(PAGE_LIST), px(ROW)], |
| 2883 | 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 | 3524 | ..Spec::default() |
| 2885 | 3525 | }; |
| 2886 | 3526 | let (spec, color) = if selected { |
| ... | ... | @@ -2891,15 +3531,18 @@ fn page_tab( |
| 2891 | 3531 | }; |
| 2892 | 3532 | (spec, theme.paper_ink) |
| 2893 | 3533 | } else { |
| 2894 | let fill = if tab.found { | |
| 2895 | ui::mix(section.tab, draw::srgb(0xff, 0xd8, 0x30), 0.6) | |
| 2896 | } else { | |
| 2897 | section.tab | |
| 3534 | // A found page's yellow lies over the tab, hovered or not. | |
| 3535 | let tint = |fill| { | |
| 3536 | if tab.found { | |
| 3537 | ui::mix(fill, draw::srgb(0xff, 0xd8, 0x30), 0.6) | |
| 3538 | } else { | |
| 3539 | fill | |
| 3540 | } | |
| 2898 | 3541 | }; |
| 2899 | 3542 | let spec = Spec { |
| 2900 | fill: Some(fill), | |
| 2901 | hover_fill: Some(ui::mix(fill, section.accent, 0.3)), | |
| 2902 | radius: ROUNDING, | |
| 3543 | fill: Some(tint(section.tab)), | |
| 3544 | hover_fill: Some(tint(section.hover())), | |
| 3545 | radius: rounding, | |
| 2903 | 3546 | inset: [PILL_MARGIN, 0.0, 6.0, ROW_GAP], |
| 2904 | 3547 | // The label keeps its place as the tab opens and closes. |
| 2905 | 3548 | pad: [pad - PILL_MARGIN, 0.0], |
| ... | ... | @@ -2913,15 +3556,22 @@ fn page_tab( |
| 2913 | 3556 | (spec, color) |
| 2914 | 3557 | }; |
| 2915 | 3558 | let row = ui.open(id, spec); |
| 2916 | ui.leaf( | |
| 2917 | "label", | |
| 2918 | Spec { | |
| 2919 | size: [fill(), px(ROW - ROW_GAP)], | |
| 2920 | text: Some(tab.label), | |
| 2921 | color: Some(color), | |
| 2922 | ..Spec::default() | |
| 2923 | }, | |
| 2924 | ); | |
| 3559 | let kept = match tab.renaming { | |
| 3560 | Some(name) => rename::edit(ui, theme, name, ROW - ROW_GAP), | |
| 3561 | None => { | |
| 3562 | ui.leaf( | |
| 3563 | "label", | |
| 3564 | Spec { | |
| 3565 | size: [fill(), px(ROW - ROW_GAP)], | |
| 3566 | text: Some(tab.label), | |
| 3567 | overflow: ui::Overflow::Ellipsis, | |
| 3568 | color: Some(color), | |
| 3569 | ..Spec::default() | |
| 3570 | }, | |
| 3571 | ); | |
| 3572 | None | |
| 3573 | } | |
| 3574 | }; | |
| 2925 | 3575 | if tab.conflicted { |
| 2926 | 3576 | ui.leaf( |
| 2927 | 3577 | "conflict", |
| ... | ... | @@ -2935,7 +3585,60 @@ fn page_tab( |
| 2935 | 3585 | ); |
| 2936 | 3586 | } |
| 2937 | 3587 | ui.close(); |
| 2938 | ui.signal(row) | |
| 3588 | (ui.signal(row), kept) | |
| 3589 | } | |
| 3590 | ||
| 3591 | /// Puts `item` first among `recent` picks. | |
| 3592 | fn 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)] | |
| 3603 | fn 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 | } |
| 2940 | 3643 | |
| 2941 | 3644 | /// A toolbar group's rows, stacked. |
| ... | ... | @@ -2997,6 +3700,7 @@ fn read_session( |
| 2997 | 3700 | .ok_or("The section has no pages")?; |
| 2998 | 3701 | let page = section.page(space)?; |
| 2999 | 3702 | let conflicts = section.conflicts()?; |
| 3703 | let history = section.versions()?; | |
| 3000 | 3704 | Ok(( |
| 3001 | 3705 | Session { |
| 3002 | 3706 | section, |
| ... | ... | @@ -3009,6 +3713,9 @@ fn read_session( |
| 3009 | 3713 | conflicts, |
| 3010 | 3714 | shown: None, |
| 3011 | 3715 | change: None, |
| 3716 | history, | |
| 3717 | shown_history: None, | |
| 3718 | version: None, | |
| 3012 | 3719 | }, |
| 3013 | 3720 | page, |
| 3014 | 3721 | )) |
| ... | ... | @@ -3111,9 +3818,15 @@ impl ApplicationHandler<UserEvent> for App { |
| 3111 | 3818 | } |
| 3112 | 3819 | return; |
| 3113 | 3820 | } |
| 3114 | UserEvent::Options => { | |
| 3821 | UserEvent::Choose(choice) => { | |
| 3115 | 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 | 3830 | state.window.request_redraw(); |
| 3118 | 3831 | } |
| 3119 | 3832 | return; |
| ... | ... | @@ -3132,7 +3845,7 @@ impl ApplicationHandler<UserEvent> for App { |
| 3132 | 3845 | Replay::Tick => {} |
| 3133 | 3846 | Replay::Appearance(appearance) => { |
| 3134 | 3847 | state.window.set_theme(Some(appearance)); |
| 3135 | state.ui.theme = theme(appearance, state.light_pages); | |
| 3848 | state.set_appearance(appearance); | |
| 3136 | 3849 | } |
| 3137 | 3850 | Replay::Quit => { |
| 3138 | 3851 | self.close(event_loop); |
| ... | ... | @@ -3183,7 +3896,6 @@ impl ApplicationHandler<UserEvent> for App { |
| 3183 | 3896 | self.proxy.clone(), |
| 3184 | 3897 | self.input.take().unwrap(), |
| 3185 | 3898 | &self.substitutes, |
| 3186 | std::mem::take(&mut self.interface_font), | |
| 3187 | 3899 | self.screenshot.is_none(), |
| 3188 | 3900 | self.launch.take().expect("The window opens once"), |
| 3189 | 3901 | )) { |
| ... | ... | @@ -3230,8 +3942,9 @@ impl ApplicationHandler<UserEvent> for App { |
| 3230 | 3942 | WindowEvent::RedrawRequested => return state.frame(), |
| 3231 | 3943 | WindowEvent::Resized(size) => { |
| 3232 | 3944 | if size.width > 0 && size.height > 0 { |
| 3233 | state.config.width = size.width; | |
| 3234 | state.config.height = size.height; | |
| 3945 | let ratio = state.scale() / scale; | |
| 3946 | state.config.width = (size.width as f32 * ratio) as u32; | |
| 3947 | state.config.height = (size.height as f32 * ratio) as u32; | |
| 3235 | 3948 | state |
| 3236 | 3949 | .surface |
| 3237 | 3950 | .configure(&state.renderer.device, &state.config); |
| ... | ... | @@ -3241,15 +3954,12 @@ impl ApplicationHandler<UserEvent> for App { |
| 3241 | 3954 | return state.frame(); |
| 3242 | 3955 | } |
| 3243 | 3956 | WindowEvent::ThemeChanged(appearance) => { |
| 3244 | let chosen = state.color_scheme.theme().unwrap_or(appearance); | |
| 3245 | state.ui.theme = theme(chosen, state.light_pages); | |
| 3957 | state.set_appearance(state.color_scheme.theme().unwrap_or(appearance)); | |
| 3246 | 3958 | state.window.request_redraw(); |
| 3247 | 3959 | } |
| 3248 | 3960 | WindowEvent::ScaleFactorChanged { scale_factor, .. } => { |
| 3249 | 3961 | state.renderer.clear_glyph_cache(); |
| 3250 | 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 | 3963 | state.window.request_redraw(); |
| 3254 | 3964 | } |
| 3255 | 3965 | WindowEvent::Focused(focused) => { |
| ... | ... | @@ -3423,7 +4133,6 @@ fn main() -> Result<(), Box<dyn Error>> { |
| 3423 | 4133 | let mut args = std::env::args_os().skip(1); |
| 3424 | 4134 | let mut positional = Vec::new(); |
| 3425 | 4135 | let mut substitutes = Vec::new(); |
| 3426 | let mut interface_font = Vec::new(); | |
| 3427 | 4136 | let mut reference = None; |
| 3428 | 4137 | let mut editable = false; |
| 3429 | 4138 | let mut section = None; |
| ... | ... | @@ -3437,27 +4146,6 @@ fn main() -> Result<(), Box<dyn Error>> { |
| 3437 | 4146 | args.next() |
| 3438 | 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 | 4149 | } else if arg == "--notebook" { |
| 3462 | 4150 | notebook = Some(PathBuf::from( |
| 3463 | 4151 | args.next() |
| ... | ... | @@ -3484,7 +4172,8 @@ fn main() -> Result<(), Box<dyn Error>> { |
| 3484 | 4172 | )); |
| 3485 | 4173 | } else if arg == "--settings" { |
| 3486 | 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 | 4178 | } else if arg == "--screenshot" { |
| 3490 | 4179 | screenshot = Some(PathBuf::from( |
| ... | ... | @@ -3525,7 +4214,7 @@ fn main() -> Result<(), Box<dyn Error>> { |
| 3525 | 4214 | } |
| 3526 | 4215 | if positional.len() > 2 { |
| 3527 | 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 | 4218 | .into(), |
| 3530 | 4219 | ); |
| 3531 | 4220 | } |
| ... | ... | @@ -3592,7 +4281,6 @@ fn main() -> Result<(), Box<dyn Error>> { |
| 3592 | 4281 | cache, |
| 3593 | 4282 | }), |
| 3594 | 4283 | substitutes, |
| 3595 | interface_font, | |
| 3596 | 4284 | screenshot, |
| 3597 | 4285 | state: None, |
| 3598 | 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 | 52 | } |
| 53 | 53 | } |
| 54 | 54 | |
| 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. | |
| 57 | fn shown(library: &Library, open: Option<String>) -> Option<String> { | |
| 58 | open.filter(|path| library.contains(path)) | |
| 59 | .or_else(|| library.first_section()) | |
| 60 | } | |
| 61 | ||
| 55 | 62 | /// The first of `base`, `base 2`, `base 3`… that `taken` does not hold, as OneNote names |
| 56 | 63 | /// new sections ("New Section 1", …) and groups. |
| 57 | 64 | fn unused(taken: &[String], base: &str, numbered: bool) -> String { |
| ... | ... | @@ -162,13 +169,16 @@ impl State { |
| 162 | 169 | /// Closes `library`: its files stay, and the sidebar and the next launch leave it out. |
| 163 | 170 | pub(crate) fn close_notebook(&mut self, library: &Arc<Library>) { |
| 164 | 171 | self.notebooks.retain(|open| !Arc::ptr_eq(open, library)); |
| 165 | if self | |
| 166 | .session | |
| 167 | .as_ref() | |
| 168 | .is_some_and(|session| Arc::ptr_eq(&session.library, library)) | |
| 172 | let shown = |shown: &Arc<Library>| shown.location == library.location; | |
| 173 | if self.sectionless.as_ref().is_some_and(shown) | |
| 174 | || self | |
| 175 | .session | |
| 176 | .as_ref() | |
| 177 | .is_some_and(|session| shown(&session.library)) | |
| 169 | 178 | { |
| 170 | 179 | self.persist().unwrap_or_else(|error| eprintln!("{error}")); |
| 171 | 180 | self.session = None; |
| 181 | self.sectionless = None; | |
| 172 | 182 | self.templates = crate::templates::View::Strip; |
| 173 | 183 | match self |
| 174 | 184 | .notebooks |
| ... | ... | @@ -176,7 +186,10 @@ impl State { |
| 176 | 186 | .find_map(|open| Some((Arc::clone(open), open.first_section()?))) |
| 177 | 187 | { |
| 178 | 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 | 195 | self.save_settings(); |
| ... | ... | @@ -257,20 +270,24 @@ impl State { |
| 257 | 270 | (current, None) |
| 258 | 271 | } |
| 259 | 272 | }; |
| 273 | let fresh = created.is_some(); | |
| 260 | 274 | let open = created.or(followed.clone()); |
| 261 | 275 | let library = Arc::new(library.with(notebook)); |
| 262 | let path = open | |
| 263 | .filter(|path| library.contains(path)) | |
| 264 | .or_else(|| library.first_section()) | |
| 265 | .ok_or("The notebook has no sections left")?; | |
| 276 | let Some(path) = shown(&library, open) else { | |
| 277 | return Ok(Loaded::Library(library, None)); | |
| 278 | }; | |
| 266 | 279 | // The open section, perhaps renamed or moved, stays open: its replica is this |
| 267 | 280 | // thread's to reopen only where no section holds it. |
| 268 | 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 | 284 | let section = library.open(&path, notify)?; |
| 272 | 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 | } |
| 276 | 293 | |
| ... | ... | @@ -765,6 +782,36 @@ mod tests { |
| 765 | 782 | std::fs::remove_dir_all(&temporary).unwrap(); |
| 766 | 783 | } |
| 767 | 784 | |
| 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 | 815 | #[test] |
| 769 | 816 | fn moved_sections_are_followed_and_new_names_take_the_next_number() { |
| 770 | 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 | ||
| 4 | use crate::commands::{self, Choice, Chord, Id, cmd, named}; | |
| 5 | use canvas::editor::{Alignment, NoteTag, Toggle}; | |
| 6 | use draw::edit::NamedKey; | |
| 7 | use objc2::{ | |
| 8 | rc::Retained, | |
| 9 | runtime::{AnyObject, Sel}, | |
| 10 | }; | |
| 11 | use objc2_app_kit::{NSEventModifierFlags, NSMenu, NSMenuItem}; | |
| 12 | use objc2_foundation::{MainThreadMarker, NSString}; | |
| 13 | ||
| 14 | enum 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 | ||
| 28 | use Item::{Command as C, Separator as S}; | |
| 29 | ||
| 30 | const 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. | |
| 178 | pub 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. | |
| 186 | pub 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 | ||
| 215 | fn 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 | ||
| 269 | fn 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)] | |
| 311 | mod 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 | 7 | use ui::{Anchor, Id, popup::Item}; |
| 8 | 8 | |
| 9 | 9 | /// What a drag holds. |
| 10 | pub enum Drag { | |
| 10 | pub enum Dragged { | |
| 11 | 11 | Page(ExGuid), |
| 12 | /// A sidebar row. | |
| 13 | Entry(crate::sidebar::Entry), | |
| 12 | /// A sidebar row, where it would land, and where it came from. | |
| 13 | Entry { | |
| 14 | entry: crate::sidebar::Entry, | |
| 15 | landing: crate::sidebar::Landing, | |
| 16 | home: crate::sidebar::Landing, | |
| 17 | }, | |
| 14 | 18 | /// A section tab, by index. |
| 15 | 19 | Tab(usize), |
| 16 | 20 | } |
| 17 | 21 | |
| 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. | |
| 24 | pub 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 | ||
| 37 | impl 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 | 49 | /// What a context menu was opened on. |
| 19 | 50 | pub enum Target { |
| 20 | 51 | Page(ExGuid), |
| ... | ... | @@ -28,12 +59,72 @@ pub fn id() -> Id { |
| 28 | 59 | } |
| 29 | 60 | |
| 30 | 61 | /// The catalog folder holding `path`. |
| 31 | fn folder(path: &str) -> String { | |
| 62 | pub(crate) fn folder(path: &str) -> String { | |
| 32 | 63 | path.rsplit_once('/') |
| 33 | 64 | .map_or(String::new(), |(folder, _)| folder.to_owned()) |
| 34 | 65 | } |
| 35 | 66 | |
| 36 | 67 | impl 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 | 128 | /// The open context menu, and what its choice does. |
| 38 | 129 | pub(crate) fn context_menu(&mut self) { |
| 39 | 130 | let Some((target, point)) = &self.menu else { |
| ... | ... | @@ -58,8 +149,18 @@ impl State { |
| 58 | 149 | let above = at |
| 59 | 150 | .and_then(|at| at.checked_sub(1)) |
| 60 | 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 | 158 | vec![ |
| 62 | 159 | item("Delete"), |
| 160 | Item { | |
| 161 | separated: true, | |
| 162 | ..item("Copy Link to Page") | |
| 163 | }, | |
| 63 | 164 | Item { |
| 64 | 165 | separated: true, |
| 65 | 166 | ..item("New Page") |
| ... | ... | @@ -75,6 +176,15 @@ impl State { |
| 75 | 176 | disabled: level <= 1, |
| 76 | 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 | 190 | Target::Section { .. } | Target::Group { .. } => vec![ |
| ... | ... | @@ -104,8 +214,19 @@ impl State { |
| 104 | 214 | }; |
| 105 | 215 | let command = match (target, items[chosen].text) { |
| 106 | 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 | 226 | (Target::Page(_), "New Page") => Some(Command::NewPage { under: None }), |
| 108 | 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 | 230 | (Target::Page(space), text) => self.session.as_ref().and_then(|session| { |
| 110 | 231 | let level = session |
| 111 | 232 | .pages |
| ... | ... | @@ -123,13 +244,11 @@ impl State { |
| 123 | 244 | Target::Section { library, path } | Target::Group { library, path }, |
| 124 | 245 | "Rename", |
| 125 | 246 | ) => { |
| 126 | let name = path.rsplit('/').next().unwrap_or_default(); | |
| 127 | self.renaming = Some(crate::sidebar::Renaming { | |
| 247 | self.rename(crate::rename::Target::Entry { | |
| 128 | 248 | library, |
| 129 | name: name.strip_suffix(".one").unwrap_or(name).to_owned(), | |
| 130 | 249 | path, |
| 250 | in_tab: false, | |
| 131 | 251 | }); |
| 132 | self.sidebar = true; | |
| 133 | 252 | None |
| 134 | 253 | } |
| 135 | 254 | (Target::Section { library, path }, "Delete") => platform::confirm( |
| ... | ... | @@ -166,8 +285,18 @@ impl State { |
| 166 | 285 | /// The section tab, of those built as `row`, under the pointer while a page is dragged |
| 167 | 286 | /// there, which a drop moves the page into. |
| 168 | 287 | pub(crate) fn page_drop(&self, row: Id) -> Option<usize> { |
| 169 | let (Some(Drag::Page(_)), Some(session), Some([x, y])) = | |
| 170 | (&self.drag, &self.session, self.ui.pointer()) | |
| 288 | let ( | |
| 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 | 300 | else { |
| 172 | 301 | return None; |
| 173 | 302 | }; |
| ... | ... | @@ -180,156 +309,166 @@ impl State { |
| 180 | 309 | }) |
| 181 | 310 | } |
| 182 | 311 | |
| 183 | /// Moves a page by dragging its tab: while `held`, a line shows where it would go; let | |
| 184 | /// go, it moves there at its level, or under a page at most one level above it. Onto a | |
| 185 | /// section tab of those built as `row`, it moves to the end of that section, as | |
| 186 | /// OneNote moves a page dropped on a section's tab. `panel` is the page list's rectangle. | |
| 312 | /// The page tab dragged, as the page list, built as the current box, shows it. | |
| 313 | pub(crate) fn dragged_page(&self) -> Option<crate::PageDrag> { | |
| 314 | let drag = self.drag.as_ref().filter(|drag| drag.lifted)?; | |
| 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 | 331 | pub(crate) fn drag_pages( |
| 188 | 332 | &mut self, |
| 189 | 333 | held: Option<ExGuid>, |
| 190 | section: &ui::Section, | |
| 191 | panel: [f32; 4], | |
| 334 | places: &[f32], | |
| 335 | settled: bool, | |
| 192 | 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 | 355 | return; |
| 196 | 356 | }; |
| 197 | let dropped = match (&self.drag, held) { | |
| 198 | (_, Some(space)) => Some(space), | |
| 199 | (Some(Drag::Page(space)), None) => Some(*space), | |
| 200 | _ => None, | |
| 357 | let Dragged::Page(space) = drag.what else { | |
| 358 | return; | |
| 201 | 359 | }; |
| 202 | let target = self.page_drop(row); | |
| 203 | if let Some(space) = held { | |
| 204 | self.drag = Some(Drag::Page(space)); | |
| 205 | } else if matches!(self.drag, Some(Drag::Page(_))) { | |
| 360 | let Some(from) = session | |
| 361 | .pages | |
| 362 | .iter() | |
| 363 | .position(|(listed, ..)| *listed == space) | |
| 364 | else { | |
| 365 | // The page left the section, dropped on another's tab. | |
| 206 | 366 | self.drag = None; |
| 207 | } | |
| 208 | let (Some(space), Some([_, y])) = (dropped, self.ui.pointer()) else { | |
| 209 | 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 | 386 | if let Some(tab) = target { |
| 212 | if held.is_none() { | |
| 213 | let path = session.tabs[tab].path.clone(); | |
| 214 | self.commands.push(Command::MovePage { space, path }); | |
| 215 | } | |
| 387 | let path = session.tabs[tab].path.clone(); | |
| 388 | self.commands.push(Command::MovePage { space, path }); | |
| 216 | 389 | return; |
| 217 | 390 | } |
| 218 | // Rows other than the dragged one, with where each lies. | |
| 219 | let rows: Vec<(ExGuid, u32, [f32; 4])> = session | |
| 391 | let Some(slot) = slot.filter(|slot| *slot != from) else { | |
| 392 | return; | |
| 393 | }; | |
| 394 | let others: Vec<_> = session | |
| 220 | 395 | .pages |
| 221 | 396 | .iter() |
| 222 | 397 | .filter(|(listed, ..)| *listed != space) |
| 223 | .filter_map(|(listed, _, level)| { | |
| 224 | Some((*listed, *level, self.ui.rect(self.ui.id(listed))?)) | |
| 225 | }) | |
| 226 | 398 | .collect(); |
| 227 | let Some(level) = session | |
| 228 | .pages | |
| 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 | |
| 399 | let before = others.get(slot).map(|(listed, ..)| *listed); | |
| 400 | let level = slot | |
| 241 | 401 | .checked_sub(1) |
| 242 | .map_or(1, |above| level.min(rows[above].1 + 1)); | |
| 243 | let stays = session | |
| 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) { | |
| 402 | .map_or(1, |above| session.pages[from].2.min(others[above].2 + 1)); | |
| 403 | if let Ok(edit) = PageEdit::move_to(space, before, level) { | |
| 267 | 404 | self.commands.push(Command::Pages(vec![edit])); |
| 268 | 405 | } |
| 269 | 406 | } |
| 270 | 407 | |
| 271 | /// Reorders section tabs by dragging one: while `held`, a line shows where it would go | |
| 272 | /// among the tabs built as `row` in the tab row `bar`; let go, the folder takes that | |
| 273 | /// order. | |
| 408 | /// The section tab dragged, as the tabs built as `row` show it: its index, and its | |
| 409 | /// leading edge following the pointer along the row. | |
| 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 | 425 | pub(crate) fn drag_tabs( |
| 275 | 426 | &mut self, |
| 276 | 427 | held: Option<usize>, |
| 428 | slot: Option<usize>, | |
| 429 | settled: bool, | |
| 277 | 430 | row: Id, |
| 278 | bar: [f32; 4], | |
| 279 | accent: [f32; 4], | |
| 280 | 431 | ) { |
| 281 | let Some(session) = &self.session else { | |
| 282 | return; | |
| 283 | }; | |
| 284 | let dragged = match (&self.drag, held) { | |
| 285 | (_, Some(tab)) => Some(tab), | |
| 286 | (Some(Drag::Tab(tab)), None) => Some(*tab), | |
| 287 | _ => None, | |
| 288 | }; | |
| 289 | if let Some(tab) = held { | |
| 290 | self.drag = Some(Drag::Tab(tab)); | |
| 291 | } else if matches!(self.drag, Some(Drag::Tab(_))) { | |
| 292 | self.drag = None; | |
| 293 | } | |
| 294 | let (Some(tab), Some([x, _])) = (dragged, self.ui.pointer()) else { | |
| 432 | let mine = |what: &Dragged| matches!(what, Dragged::Tab(_)); | |
| 433 | self.settle(mine, settled); | |
| 434 | let held = held.and_then(|tab| { | |
| 435 | Some(( | |
| 436 | Dragged::Tab(tab), | |
| 437 | self.ui.rect(ui::shell::tab_id(row, tab))?, | |
| 438 | )) | |
| 439 | }); | |
| 440 | let dropped = self.follow_drag(held, mine); | |
| 441 | let ( | |
| 442 | true, | |
| 443 | Some(Drag { | |
| 444 | what: Dragged::Tab(tab), | |
| 445 | .. | |
| 446 | }), | |
| 447 | Some(slot), | |
| 448 | Some(session), | |
| 449 | ) = (dropped, &mut self.drag, slot, &mut self.session) | |
| 450 | else { | |
| 295 | 451 | return; |
| 296 | 452 | }; |
| 297 | let others: Vec<(usize, [f32; 4])> = (0..session.tabs.len()) | |
| 298 | .filter(|other| *other != tab) | |
| 299 | .filter_map(|other| Some((other, self.ui.rect(ui::shell::tab_id(row, other))?))) | |
| 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()) { | |
| 453 | // It eases into its new place from where it was let go. | |
| 454 | let tab = std::mem::replace(tab, slot); | |
| 455 | if slot == tab { | |
| 308 | 456 | return; |
| 309 | 457 | } |
| 310 | if held.is_some() { | |
| 311 | let edge = match ( | |
| 312 | at.checked_sub(1).map(|before| others[before].1), | |
| 313 | others.get(at), | |
| 314 | ) { | |
| 315 | (_, Some((_, after))) => after[0], | |
| 316 | (Some(before), None) => before[2], | |
| 317 | (None, None) => return, | |
| 318 | } - bar[0]; | |
| 319 | self.ui | |
| 320 | .mark([edge - 1.5, 4.0, edge + 1.5, bar[3] - bar[1]], accent, 1.5); | |
| 321 | } else { | |
| 322 | let path = &session.tabs[tab].path; | |
| 323 | let folder = folder(path); | |
| 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 | } | |
| 458 | let open = session.tabs[session.tab].path.clone(); | |
| 459 | let moved = session.tabs.remove(tab); | |
| 460 | session.tabs.insert(slot, moved); | |
| 461 | session.tab = session | |
| 462 | .tabs | |
| 463 | .iter() | |
| 464 | .position(|tab| tab.path == open) | |
| 465 | .unwrap_or_default(); | |
| 466 | let folder = folder(&open); | |
| 467 | let paths = session.tabs.iter().map(|tab| tab.path.clone()).collect(); | |
| 468 | self.commands.push(Command::Structure( | |
| 469 | Arc::clone(&session.library), | |
| 470 | Structure::Reorder { folder, paths }, | |
| 471 | )); | |
| 333 | 472 | } |
| 334 | 473 | } |
| 335 | 474 |
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 | ||
| 4 | use super::*; | |
| 5 | ||
| 6 | /// A page shown: its notebook's location, its section's catalog path and the page. | |
| 7 | #[derive(Clone, Debug, PartialEq)] | |
| 8 | pub 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)] | |
| 16 | pub struct Trail { | |
| 17 | back: Vec<Place>, | |
| 18 | here: Option<Place>, | |
| 19 | forward: Vec<Place>, | |
| 20 | } | |
| 21 | ||
| 22 | /// Places Back keeps, at most. | |
| 23 | const KEPT: usize = 100; | |
| 24 | ||
| 25 | impl 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 | ||
| 62 | impl 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)] | |
| 111 | mod 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 | 77 | } |
| 78 | 78 | let theme = ui.theme.clone(); |
| 79 | 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 | 80 | let entered = ui::popup::navigation(ui, &[user_name()], &[NamedKey::Enter]) |
| 87 | 81 | .contains(&NamedKey::Enter); |
| 88 | 82 | ui.open_as( |
| ... | ... | @@ -95,7 +89,7 @@ impl State { |
| 95 | 89 | shadow: Some(theme.shadow), |
| 96 | 90 | radius: 8.0, |
| 97 | 91 | pad: [6.0, 6.0], |
| 98 | anchor: Some(Anchor::Below([left, top, left, top])), | |
| 92 | anchor: Some(Anchor::Dialog), | |
| 99 | 93 | ..Spec::default() |
| 100 | 94 | }, |
| 101 | 95 | ); |
| ... | ... | @@ -189,8 +183,13 @@ impl State { |
| 189 | 183 | Page::Display => { |
| 190 | 184 | heading(ui, &theme, "Display"); |
| 191 | 185 | let dark = !options.light_pages; |
| 192 | if ui::check_box(ui, "dark-pages", "Pages appear dark in the Dark color scheme", dark) | |
| 193 | .clicked | |
| 186 | if ui::check_box( | |
| 187 | ui, | |
| 188 | "dark-pages", | |
| 189 | "Pages appear dark in the Dark color scheme", | |
| 190 | dark, | |
| 191 | ) | |
| 192 | .clicked | |
| 194 | 193 | { |
| 195 | 194 | options.light_pages = dark; |
| 196 | 195 | } |
| ... | ... | @@ -247,12 +246,7 @@ impl State { |
| 247 | 246 | self.author = name.to_owned(); |
| 248 | 247 | self.color_scheme = options.color_scheme; |
| 249 | 248 | self.light_pages = options.light_pages; |
| 250 | self.window.set_theme(self.color_scheme.theme()); | |
| 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); | |
| 249 | self.follow_color_scheme(); | |
| 256 | 250 | self.save_settings(); |
| 257 | 251 | } else if !cancel { |
| 258 | 252 | return; |
| ... | ... | @@ -270,7 +264,7 @@ fn heading(ui: &mut Ui, theme: &Theme, text: &str) { |
| 270 | 264 | size: [fill(), px(theme.font_size * 1.8)], |
| 271 | 265 | text: Some(text), |
| 272 | 266 | bold: true, |
| 273 | fill: Some(theme.strip), | |
| 267 | fill: Some(ui::mix(theme.panel, theme.chip, 0.4)), | |
| 274 | 268 | radius: 4.0, |
| 275 | 269 | pad: [8.0, 0.0], |
| 276 | 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 | ||
| 5 | use crate::{Command, Library, State, manage::Structure}; | |
| 6 | use onestore::{ | |
| 7 | ExGuid, | |
| 8 | op::{Edit, Op, PageOp}, | |
| 9 | page::PageObject, | |
| 10 | }; | |
| 11 | use std::{error::Error, sync::Arc}; | |
| 12 | use ui::{Event, Id, Spec, Theme, Ui, fill, px}; | |
| 13 | use winit::keyboard::{Key, NamedKey}; | |
| 14 | ||
| 15 | /// Room between the field's edge and its text. | |
| 16 | pub const PAD: f32 = 3.0; | |
| 17 | ||
| 18 | /// What is being renamed, and the name typed so far. | |
| 19 | pub struct Renaming { | |
| 20 | pub target: Target, | |
| 21 | pub name: String, | |
| 22 | } | |
| 23 | ||
| 24 | pub 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 | ||
| 35 | impl 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 | ||
| 47 | pub 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. | |
| 53 | pub 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 | ||
| 77 | impl 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 | 5 | use super::*; |
| 6 | 6 | use winit::window::Theme as Appearance; |
| 7 | 7 | |
| 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. | |
| 9 | 10 | const SIZE: [f32; 2] = [1440.0, 900.0]; |
| 10 | 11 | const SCALE: f32 = 2.0; |
| 11 | 12 | /// 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 | 21 | impl State { |
| 21 | 22 | /// Writes `PREFIX-light.png` and `PREFIX-dark.png`, the page unfocused so no caret shows. |
| 22 | 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 | 34 | self.renderer.clear_glyph_cache(); |
| 25 | self.app_icon = platform::app_icon((16.0 * SCALE) as u32); | |
| 26 | let response = self.view.scale_factor_changed(SCALE)?; | |
| 35 | self.app_icon = platform::app_icon((16.0 * scale) as u32); | |
| 36 | let response = self.view.scale_factor_changed(scale)?; | |
| 27 | 37 | self.respond(response); |
| 28 | 38 | self.ui.set_focus(None); |
| 29 | 39 | if let Some(title) = self |
| ... | ... | @@ -37,9 +47,9 @@ impl State { |
| 37 | 47 | } |
| 38 | 48 | for (name, appearance) in [("light", Appearance::Light), ("dark", Appearance::Dark)] { |
| 39 | 49 | self.window.set_theme(Some(appearance)); |
| 40 | self.ui.theme = theme(appearance, self.light_pages); | |
| 50 | self.set_appearance(appearance); | |
| 41 | 51 | // The page's box sizes the view whose pictures settle. |
| 42 | self.layout(SIZE, SCALE)?; | |
| 52 | self.layout(size, scale)?; | |
| 43 | 53 | let paper = self.paper(); |
| 44 | 54 | if let Some((scene, _)) = &mut self.view.scene { |
| 45 | 55 | scene.settle(Some(&self.view.editor), self.view.viewport.scale, paper); |
| ... | ... | @@ -47,7 +57,7 @@ impl State { |
| 47 | 57 | // Later frames lay out with earlier frames' measurements while colours ease. |
| 48 | 58 | let deadline = Instant::now() + std::time::Duration::from_secs(2); |
| 49 | 59 | loop { |
| 50 | self.layout(SIZE, SCALE)?; | |
| 60 | self.layout(size, scale)?; | |
| 51 | 61 | if !self.ui.wants_frame() || Instant::now() > deadline { |
| 52 | 62 | break; |
| 53 | 63 | } |
| ... | ... | @@ -56,7 +66,7 @@ impl State { |
| 56 | 66 | let path = PathBuf::from(format!("{}-{name}.png", prefix.display())); |
| 57 | 67 | self.snapshot(&path)?; |
| 58 | 68 | #[cfg(target_os = "macos")] |
| 59 | window_frame(&path, appearance)?; | |
| 69 | window_frame(&path, appearance, scale)?; | |
| 60 | 70 | } |
| 61 | 71 | Ok(()) |
| 62 | 72 | } |
| ... | ... | @@ -65,7 +75,7 @@ impl State { |
| 65 | 75 | /// Paints the traffic lights into the PNG at `path`, rounds its corners to transparency |
| 66 | 76 | /// and edges them with the window's hairline. |
| 67 | 77 | #[cfg(target_os = "macos")] |
| 68 | fn window_frame(path: &Path, appearance: Appearance) -> Result<(), Box<dyn Error>> { | |
| 78 | fn window_frame(path: &Path, appearance: Appearance, scale: f32) -> Result<(), Box<dyn Error>> { | |
| 69 | 79 | let mut reader = |
| 70 | 80 | png::Decoder::new(std::io::BufReader::new(std::fs::File::open(path)?)).read_info()?; |
| 71 | 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 | 94 | } |
| 85 | 95 | }; |
| 86 | 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 | 98 | for (index, pixel) in pixels.chunks_exact_mut(4).enumerate() { |
| 89 | 99 | let x = (index as u32 % info.width) as f32 + 0.5; |
| 90 | 100 | let y = (index as u32 / info.width) as f32 + 0.5; |
| 91 | 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 | 103 | blend( |
| 94 | 104 | pixel, |
| 95 | 105 | [rim[0], rim[1], rim[2], 255], |
| 96 | inside(distance - 6.0 * SCALE), | |
| 106 | inside(distance - 6.0 * scale), | |
| 97 | 107 | ); |
| 98 | 108 | blend( |
| 99 | 109 | pixel, |
| 100 | 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 | 114 | // Signed distance to the window's rounded rectangle. |
crates/snowbound/src/search.rs+35-46| ... | ... | @@ -2,7 +2,7 @@ |
| 2 | 2 | //! results dropping down from it, and Find on This Page. The index is kept on a thread of |
| 3 | 3 | //! its own, from the section files and the open section's replica. |
| 4 | 4 | |
| 5 | use crate::{Command, Library, State, Theme, art, lap, page}; | |
| 5 | use crate::{Command, Library, State, Theme, art, commands, lap, page}; | |
| 6 | 6 | use canvas::search::{Entry, Found, Index, Query, page_matches}; |
| 7 | 7 | use notebook::Replica; |
| 8 | 8 | use onestore::ExGuid; |
| ... | ... | @@ -43,16 +43,6 @@ impl Scope { |
| 43 | 43 | } |
| 44 | 44 | } |
| 45 | 45 | |
| 46 | const SEARCH_KEY: &str = if cfg!(target_os = "macos") { | |
| 47 | "⌘E" | |
| 48 | } else { | |
| 49 | "Ctrl+E" | |
| 50 | }; | |
| 51 | const FIND_KEY: &str = if cfg!(target_os = "macos") { | |
| 52 | "⌘F" | |
| 53 | } else { | |
| 54 | "Ctrl+F" | |
| 55 | }; | |
| 56 | 46 | /// The results list's rows, the headings above their groups, and its width. |
| 57 | 47 | const RESULT: f32 = 42.0; |
| 58 | 48 | const HEADING: f32 = 24.0; |
| ... | ... | @@ -71,7 +61,7 @@ fn scope_menu() -> Id { |
| 71 | 61 | } |
| 72 | 62 | |
| 73 | 63 | /// The field of the box, and of the results over it. |
| 74 | fn field() -> Id { | |
| 64 | pub(crate) fn field() -> Id { | |
| 75 | 65 | Id::ROOT.child("search-field") |
| 76 | 66 | } |
| 77 | 67 | |
| ... | ... | @@ -274,7 +264,7 @@ fn sections(library: &Library) -> Vec<String> { |
| 274 | 264 | folder |
| 275 | 265 | .groups |
| 276 | 266 | .iter() |
| 277 | .filter(|group| !crate::library::recycle_bin(group)), | |
| 267 | .filter(|group| !crate::library::recycle_bin(&group.path)), | |
| 278 | 268 | ); |
| 279 | 269 | } |
| 280 | 270 | paths |
| ... | ... | @@ -678,7 +668,11 @@ impl State { |
| 678 | 668 | }, |
| 679 | 669 | ); |
| 680 | 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 | 676 | let signal = ui::text_field( |
| 683 | 677 | &mut self.ui, |
| 684 | 678 | field(), |
| ... | ... | @@ -705,15 +699,6 @@ impl State { |
| 705 | 699 | Ok(()) |
| 706 | 700 | } |
| 707 | 701 | |
| 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 | 702 | fn scope_button(&mut self, theme: &Theme) { |
| 718 | 703 | let button = self.ui.id("scope"); |
| 719 | 704 | if ui::shell::tool_button(&mut self.ui, "scope", art::SEARCH, theme.text_dim, false).pressed |
| ... | ... | @@ -727,6 +712,7 @@ impl State { |
| 727 | 712 | /// OneNote's "Search In:" menu. |
| 728 | 713 | fn scope_popup(&mut self) { |
| 729 | 714 | let current = self.search.scope; |
| 715 | let find = commands::shortcut(commands::Id::Find); | |
| 730 | 716 | let mut items = vec![ |
| 731 | 717 | ui::popup::Item { |
| 732 | 718 | text: "Search In:", |
| ... | ... | @@ -734,8 +720,8 @@ impl State { |
| 734 | 720 | ..Default::default() |
| 735 | 721 | }, |
| 736 | 722 | ui::popup::Item { |
| 737 | text: "Find on This Page", | |
| 738 | shortcut: FIND_KEY, | |
| 723 | text: commands::command(commands::Id::Find).title, | |
| 724 | shortcut: &find, | |
| 739 | 725 | ..Default::default() |
| 740 | 726 | }, |
| 741 | 727 | ]; |
| ... | ... | @@ -800,30 +786,31 @@ impl State { |
| 800 | 786 | self.ui.set_focus(Some(field())); |
| 801 | 787 | } |
| 802 | 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 | 794 | self.ui.open_as( |
| 804 | 795 | results(), |
| 805 | 796 | Spec { |
| 806 | 797 | axis: Axis::Y, |
| 807 | size: [px(RESULTS.max(right - left)), children()], | |
| 798 | size: [px(width), children()], | |
| 808 | 799 | fill: Some(theme.popup), |
| 809 | 800 | border: Some(theme.chip), |
| 810 | 801 | shadow: Some(theme.shadow), |
| 811 | 802 | radius: 6.0, |
| 812 | 803 | pad: [4.0, 4.0], |
| 813 | 804 | gap: 4.0, |
| 814 | anchor: Some(Anchor::Over([ | |
| 815 | left - 4.0, | |
| 816 | top - 4.0, | |
| 817 | right + 4.0, | |
| 818 | bottom + 4.0, | |
| 819 | ])), | |
| 805 | anchor: Some(anchor), | |
| 820 | 806 | ..Spec::default() |
| 821 | 807 | }, |
| 822 | 808 | ); |
| 823 | 809 | self.ui.open( |
| 824 | 810 | "box", |
| 825 | 811 | Spec { |
| 826 | size: [fill(), px(ui::shell::TOOL)], | |
| 812 | flags: Flags::STILL, | |
| 813 | size: [px(field_width), px(ui::shell::TOOL)], | |
| 827 | 814 | fill: Some(theme.base), |
| 828 | 815 | border: Some(theme.accent), |
| 829 | 816 | radius: 4.0, |
| ... | ... | @@ -832,8 +819,12 @@ impl State { |
| 832 | 819 | ..Spec::default() |
| 833 | 820 | }, |
| 834 | 821 | ); |
| 835 | let placeholder = format!("Search {} ({SEARCH_KEY})", self.search.scope.name()); | |
| 836 | let signal = ui::text_field( | |
| 822 | let placeholder = format!( | |
| 823 | "Search {} ({})", | |
| 824 | self.search.scope.name(), | |
| 825 | commands::shortcut(commands::Id::Search) | |
| 826 | ); | |
| 827 | ui::text_field( | |
| 837 | 828 | &mut self.ui, |
| 838 | 829 | field(), |
| 839 | 830 | &mut self.search.query, |
| ... | ... | @@ -846,10 +837,6 @@ impl State { |
| 846 | 837 | ); |
| 847 | 838 | self.scope_button(theme); |
| 848 | 839 | self.ui.close(); |
| 849 | if self.shortcut(&signal, "f") { | |
| 850 | self.ui.close(); | |
| 851 | return self.start_find(true); | |
| 852 | } | |
| 853 | 840 | let query = self.search.query.trim().to_owned(); |
| 854 | 841 | let found = &self.search.found; |
| 855 | 842 | let titled = found.iter().take_while(|found| found.in_title).count(); |
| ... | ... | @@ -916,7 +903,10 @@ impl State { |
| 916 | 903 | "find", |
| 917 | 904 | Spec { |
| 918 | 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 | 910 | color: Some(theme.text_dim), |
| 921 | 911 | ..Spec::default() |
| 922 | 912 | }, |
| ... | ... | @@ -1018,7 +1008,10 @@ impl State { |
| 1018 | 1008 | let label = if self.search.query.trim().is_empty() { |
| 1019 | 1009 | "Find on page".to_owned() |
| 1020 | 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 | 1015 | } else { |
| 1023 | 1016 | format!( |
| 1024 | 1017 | "Match {} of {count}", |
| ... | ... | @@ -1051,7 +1044,7 @@ impl State { |
| 1051 | 1044 | step = Some(true); |
| 1052 | 1045 | } |
| 1053 | 1046 | } |
| 1054 | let signal = ui::text_field( | |
| 1047 | ui::text_field( | |
| 1055 | 1048 | &mut self.ui, |
| 1056 | 1049 | find_field(), |
| 1057 | 1050 | &mut self.search.query, |
| ... | ... | @@ -1065,10 +1058,6 @@ impl State { |
| 1065 | 1058 | if std::mem::take(&mut self.search.claim) { |
| 1066 | 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 | 1061 | if ui::shell::tool_button(&mut self.ui, "close", art::CLOSE, theme.text_dim, false).clicked |
| 1073 | 1062 | { |
| 1074 | 1063 | self.end_search(); |
crates/snowbound/src/settings.rs+38| ... | ... | @@ -15,6 +15,7 @@ pub struct Settings { |
| 15 | 15 | pub sidebar: bool, |
| 16 | 16 | /// Fonts picked from the font box, latest first. |
| 17 | 17 | pub recent_fonts: Vec<String>, |
| 18 | pub toolbar: Toolbar, | |
| 18 | 19 | /// The name edits are stored under, OneNote's User name; the account's full name until |
| 19 | 20 | /// first saved. |
| 20 | 21 | pub user_name: Option<String>, |
| ... | ... | @@ -25,6 +26,31 @@ pub struct Settings { |
| 25 | 26 | pub search_scope: crate::search::Scope, |
| 26 | 27 | } |
| 27 | 28 | |
| 29 | /// What the toolbar's buttons apply from their menus' last picks. | |
| 30 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 31 | #[serde(default)] | |
| 32 | pub 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 | ||
| 42 | impl 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 | 54 | /// The interface's colours: the system's appearance, or one chosen in Options. |
| 29 | 55 | #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] |
| 30 | 56 | #[serde(rename_all = "lowercase")] |
| ... | ... | @@ -97,6 +123,7 @@ impl crate::State { |
| 97 | 123 | .map(|library| library.location.clone()), |
| 98 | 124 | sidebar: self.sidebar, |
| 99 | 125 | recent_fonts: self.recent_fonts.clone(), |
| 126 | toolbar: self.toolbar.clone(), | |
| 100 | 127 | user_name: Some(self.author.clone()), |
| 101 | 128 | color_scheme: self.color_scheme, |
| 102 | 129 | light_pages: self.light_pages, |
| ... | ... | @@ -140,6 +167,12 @@ mod tests { |
| 140 | 167 | current: Some("/notebooks/Personal".into()), |
| 141 | 168 | sidebar: true, |
| 142 | 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 | 176 | user_name: Some("Snowbound Test".into()), |
| 144 | 177 | color_scheme: ColorScheme::Dark, |
| 145 | 178 | light_pages: true, |
| ... | ... | @@ -155,6 +188,11 @@ mod tests { |
| 155 | 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 | 196 | std::fs::remove_dir_all(&directory).unwrap(); |
| 159 | 197 | } |
| 160 | 198 | } |
crates/snowbound/src/sidebar.rs+617-292| ... | ... | @@ -1,26 +1,25 @@ |
| 1 | 1 | //! OneNote 2010's navigation bar: the open notebooks with their sections and section |
| 2 | 2 | //! groups, down the window's left, beside the section tabs. |
| 3 | 3 | |
| 4 | use crate::{art, library::Library, menus::Target, section_color}; | |
| 4 | use crate::{ | |
| 5 | art, | |
| 6 | library::Library, | |
| 7 | menus::{Dragged, Target}, | |
| 8 | rename, section_color, | |
| 9 | }; | |
| 5 | 10 | use notebook::discover::{Folder, SectionState}; |
| 6 | 11 | use std::{collections::HashSet, sync::Arc}; |
| 7 | use ui::{Axis, Event, Flags, Id, Signal, Spec, Theme, Ui, fill, fit, px}; | |
| 8 | use winit::keyboard::{Key, NamedKey}; | |
| 12 | use ui::{Axis, Flags, Id, Signal, Spec, Theme, Ui, fill, fit, px}; | |
| 9 | 13 | |
| 10 | 14 | pub const WIDTH: f32 = 220.0; |
| 11 | /// The collapsed sidebar: a rail as wide as its button, which stays where it is as the | |
| 12 | /// sidebar opens. | |
| 13 | pub const RAIL: f32 = crate::FRAME + ui::shell::TOOL + 2.0; | |
| 15 | /// The square the sidebar's button stands in at the start of the section tabs. | |
| 16 | const RAIL: f32 = crate::TAB_ROW; | |
| 14 | 17 | const ROW: f32 = 24.0; |
| 15 | 18 | /// How far each level of the tree sits inside its parent. |
| 16 | 19 | const INDENT: f32 = 16.0; |
| 17 | 20 | |
| 18 | 21 | /// What the sidebar was asked to do. |
| 19 | 22 | pub enum Action { |
| 20 | /// Expands or collapses the sidebar. | |
| 21 | Toggle, | |
| 22 | /// Shows a notebook where it was left. | |
| 23 | Notebook(usize), | |
| 24 | 23 | /// Opens a notebook's section by catalog path. |
| 25 | 24 | Open { |
| 26 | 25 | notebook: usize, |
| ... | ... | @@ -36,18 +35,8 @@ pub enum Action { |
| 36 | 35 | Menu(Target, [f32; 2]), |
| 37 | 36 | /// Ends renaming, with the name typed or without. |
| 38 | 37 | Renamed(bool), |
| 39 | } | |
| 40 | ||
| 41 | /// A section or group being renamed in place, and the name typed so far. | |
| 42 | pub 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. | |
| 49 | pub fn rename_field() -> Id { | |
| 50 | Id::ROOT.child("rename") | |
| 38 | /// Explains why a section or group can't be read: its name and the reason. | |
| 39 | Unavailable(String, String), | |
| 51 | 40 | } |
| 52 | 41 | |
| 53 | 42 | /// Names a notebook's or group's rows for folding them. |
| ... | ... | @@ -61,12 +50,35 @@ pub struct Tree<'a> { |
| 61 | 50 | /// The open section's notebook index and catalog path. |
| 62 | 51 | open: Option<(usize, &'a str)>, |
| 63 | 52 | folded: &'a HashSet<String>, |
| 64 | renaming: Option<&'a mut Renaming>, | |
| 53 | renaming: Option<&'a mut rename::Renaming>, | |
| 65 | 54 | action: Option<Action>, |
| 66 | 55 | /// Each section and group row: where it lies, and what it is. |
| 67 | 56 | rows: Vec<Entry>, |
| 68 | 57 | /// The section or group held down, which a drag moves. |
| 69 | 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)] | |
| 69 | pub 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. | |
| 76 | struct Ghost { | |
| 77 | label: String, | |
| 78 | icon: Leading, | |
| 79 | depth: u32, | |
| 80 | dim: bool, | |
| 81 | selected: Option<[f32; 4]>, | |
| 70 | 82 | } |
| 71 | 83 | |
| 72 | 84 | impl<'a> Tree<'a> { |
| ... | ... | @@ -74,7 +86,7 @@ impl<'a> Tree<'a> { |
| 74 | 86 | theme: &'a Theme, |
| 75 | 87 | open: Option<(usize, &'a str)>, |
| 76 | 88 | folded: &'a HashSet<String>, |
| 77 | renaming: Option<&'a mut Renaming>, | |
| 89 | renaming: Option<&'a mut rename::Renaming>, | |
| 78 | 90 | ) -> Self { |
| 79 | 91 | Self { |
| 80 | 92 | theme, |
| ... | ... | @@ -84,6 +96,9 @@ impl<'a> Tree<'a> { |
| 84 | 96 | action: None, |
| 85 | 97 | rows: Vec::new(), |
| 86 | 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 | 110 | pub path: String, |
| 96 | 111 | pub group: bool, |
| 97 | 112 | pub rect: [f32; 4], |
| 113 | /// The row's box, which a drag draws over the others. | |
| 114 | pub id: Id, | |
| 98 | 115 | } |
| 99 | 116 | |
| 100 | 117 | impl Entry { |
| 101 | 118 | fn folder(&self) -> &str { |
| 102 | 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 | } |
| 105 | 128 | |
| 106 | 129 | /// The sidebar's header row, `header` tall, then with `rows` the tree of `notebooks` with |
| 107 | 130 | /// the open section marked. |
| 108 | 131 | fn 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 | 133 | ui.open( |
| 111 | 134 | "header", |
| 112 | 135 | Spec { |
| ... | ... | @@ -116,9 +139,14 @@ fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc<Library>], header: f32 |
| 116 | 139 | ..Spec::default() |
| 117 | 140 | }, |
| 118 | 141 | ); |
| 119 | if ui::shell::tool_button(ui, "toggle", art::NOTEBOOK, theme.text, false).clicked { | |
| 120 | tree.action = Some(Action::Toggle); | |
| 121 | } | |
| 142 | // The button's square, which floats over this place from the section tabs. | |
| 143 | ui.leaf( | |
| 144 | "toggle", | |
| 145 | Spec { | |
| 146 | size: [px(RAIL - crate::FRAME), px(ui::shell::TOOL)], | |
| 147 | ..Spec::default() | |
| 148 | }, | |
| 149 | ); | |
| 122 | 150 | ui.leaf( |
| 123 | 151 | "title", |
| 124 | 152 | Spec { |
| ... | ... | @@ -148,7 +176,7 @@ fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc<Library>], header: f32 |
| 148 | 176 | let (row, fold) = tree_row( |
| 149 | 177 | ui, |
| 150 | 178 | tree, |
| 151 | ("notebook", index), | |
| 179 | ui.id(("notebook", index)), | |
| 152 | 180 | Row { |
| 153 | 181 | label: &library.name, |
| 154 | 182 | icon: Leading::Icon(art::NOTEBOOK), |
| ... | ... | @@ -159,16 +187,18 @@ fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc<Library>], header: f32 |
| 159 | 187 | renamed: false, |
| 160 | 188 | }, |
| 161 | 189 | ); |
| 162 | if fold { | |
| 190 | if row.clicked || fold { | |
| 163 | 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 | 193 | if let Some(point) = row.context { |
| 168 | 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 | 199 | folder(ui, tree, library, index, catalog, 1); |
| 200 | ui.close(); | |
| 201 | ui.close(); | |
| 172 | 202 | } |
| 173 | 203 | } |
| 174 | 204 | ui.close(); |
| ... | ... | @@ -192,7 +222,7 @@ fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc<Library>], header: f32 |
| 192 | 222 | let (row, _) = tree_row( |
| 193 | 223 | ui, |
| 194 | 224 | tree, |
| 195 | part, | |
| 225 | ui.id(part), | |
| 196 | 226 | Row { |
| 197 | 227 | label, |
| 198 | 228 | icon: Leading::Icon(icon), |
| ... | ... | @@ -224,9 +254,9 @@ fn folder( |
| 224 | 254 | .filter(|(index, _)| *index == notebook) |
| 225 | 255 | .map(|(_, path)| path); |
| 226 | 256 | let renamed = |tree: &Tree, path: &str| { |
| 227 | tree.renaming.as_ref().is_some_and(|renaming| { | |
| 228 | Arc::ptr_eq(&renaming.library, library) && renaming.path == path | |
| 229 | }) | |
| 257 | tree.renaming | |
| 258 | .as_ref() | |
| 259 | .is_some_and(|renaming| renaming.entry(library, path, false)) | |
| 230 | 260 | }; |
| 231 | 261 | for section in &folder.sections { |
| 232 | 262 | let (name, color, readable) = match &section.state { |
| ... | ... | @@ -241,28 +271,29 @@ fn folder( |
| 241 | 271 | false, |
| 242 | 272 | ), |
| 243 | 273 | }; |
| 274 | gap(ui, tree, &folder.path, Some(&section.path)); | |
| 244 | 275 | let renaming = renamed(tree, &section.path); |
| 245 | let (row, _) = tree_row( | |
| 246 | ui, | |
| 247 | tree, | |
| 248 | ("section", notebook, &section.path), | |
| 249 | Row { | |
| 250 | label: &name, | |
| 251 | icon: Leading::Swatch(section_color(color)), | |
| 252 | depth, | |
| 253 | dim: !readable, | |
| 254 | fold: None, | |
| 255 | selected: (open == Some(section.path.as_str())).then_some(section_color(color)), | |
| 256 | renamed: renaming, | |
| 257 | }, | |
| 258 | ); | |
| 276 | let id = ui.id(("section", notebook, &section.path)); | |
| 277 | let row = Row { | |
| 278 | label: &name, | |
| 279 | icon: Leading::Section(section_color(color)), | |
| 280 | depth, | |
| 281 | dim: !readable, | |
| 282 | fold: None, | |
| 283 | selected: (open == Some(section.path.as_str())).then_some(section_color(color)), | |
| 284 | renamed: renaming, | |
| 285 | }; | |
| 286 | if lifts(tree, notebook, &section.path, &row) { | |
| 287 | continue; | |
| 288 | } | |
| 289 | let (row, _) = tree_row(ui, tree, id, row); | |
| 259 | 290 | if row.clicked && readable && open != Some(section.path.as_str()) { |
| 260 | 291 | tree.action = Some(Action::Open { |
| 261 | 292 | notebook, |
| 262 | 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 | 297 | if let Some(point) = row.context { |
| 267 | 298 | let target = Target::Section { |
| 268 | 299 | library: Arc::clone(library), |
| ... | ... | @@ -273,50 +304,200 @@ fn folder( |
| 273 | 304 | } |
| 274 | 305 | for group in &folder.groups { |
| 275 | 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 | 308 | continue; |
| 278 | 309 | } |
| 279 | 310 | let key = fold_key(library, &group.path); |
| 280 | 311 | let unfolded = !tree.folded.contains(&key); |
| 281 | 312 | let name = group.path.rsplit('/').next().unwrap_or_default(); |
| 313 | gap(ui, tree, &folder.path, Some(&group.path)); | |
| 282 | 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 | 363 | ui, |
| 285 | 364 | tree, |
| 286 | ("group", notebook, &group.path), | |
| 365 | ui.id(("unavailable", notebook, &entry.path)), | |
| 287 | 366 | Row { |
| 288 | label: name, | |
| 289 | icon: Leading::Icon(art::SECTION_GROUP), | |
| 367 | label: &name, | |
| 368 | icon: if entry.group { | |
| 369 | Leading::Icon(art::SECTION_GROUP) | |
| 370 | } else { | |
| 371 | Leading::Section(section_color(None)) | |
| 372 | }, | |
| 290 | 373 | depth, |
| 291 | dim: false, | |
| 292 | fold: Some(unfolded), | |
| 374 | dim: true, | |
| 375 | fold: None, | |
| 293 | 376 | selected: None, |
| 294 | renamed: renaming, | |
| 377 | renamed: false, | |
| 295 | 378 | }, |
| 296 | 379 | ); |
| 297 | if row.clicked || fold { | |
| 298 | tree.action = Some(Action::Fold(key)); | |
| 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); | |
| 380 | if row.clicked { | |
| 381 | tree.action = Some(Action::Unavailable(name, entry.error.clone())); | |
| 310 | 382 | } |
| 311 | 383 | } |
| 312 | 384 | } |
| 313 | 385 | |
| 314 | /// Records a section or group row for dragging. | |
| 315 | fn entry(ui: &Ui, tree: &mut Tree, notebook: usize, path: &str, group: bool, row: &Signal) { | |
| 316 | let Some(rect) = ui | |
| 317 | .rect(ui.id(("section", notebook, path))) | |
| 318 | .or_else(|| ui.rect(ui.id(("group", notebook, path)))) | |
| 319 | else { | |
| 386 | /// Where the row `lifted` lands with the pointer at height `y` among `rows`: onto a | |
| 387 | /// group's middle, into it; above or below a row's middle, before or after it in its | |
| 388 | /// folder; into another folder, at its end. | |
| 389 | fn landing_at(rows: &[Entry], lifted: &Entry, y: f32) -> Option<Landing> { | |
| 390 | let candidates: Vec<&Entry> = rows | |
| 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. | |
| 428 | fn 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. | |
| 446 | fn 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. | |
| 469 | fn 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. | |
| 499 | fn 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 | 501 | return; |
| 321 | 502 | }; |
| 322 | 503 | let entry = Entry { |
| ... | ... | @@ -324,6 +505,7 @@ fn entry(ui: &Ui, tree: &mut Tree, notebook: usize, path: &str, group: bool, row |
| 324 | 505 | path: path.to_owned(), |
| 325 | 506 | group, |
| 326 | 507 | rect, |
| 508 | id, | |
| 327 | 509 | }; |
| 328 | 510 | if row.dragging { |
| 329 | 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 | 513 | tree.rows.push(entry); |
| 332 | 514 | } |
| 333 | 515 | |
| 516 | #[derive(Clone, Copy)] | |
| 334 | 517 | enum Leading { |
| 335 | 518 | Icon(&'static [&'static str]), |
| 336 | /// A section's colour, as OneNote's section icon shows it. | |
| 337 | Swatch([f32; 4]), | |
| 519 | /// A section's tab, drawn in its colour. | |
| 520 | Section([f32; 4]), | |
| 338 | 521 | } |
| 339 | 522 | |
| 340 | 523 | struct Row<'a> { |
| ... | ... | @@ -351,13 +534,13 @@ struct Row<'a> { |
| 351 | 534 | } |
| 352 | 535 | |
| 353 | 536 | /// One row of the tree: its signal, and whether its fold arrow was clicked. |
| 354 | fn tree_row(ui: &mut Ui, tree: &mut Tree, part: impl std::hash::Hash, row: Row) -> (Signal, bool) { | |
| 537 | fn tree_row(ui: &mut Ui, tree: &mut Tree, id: Id, row: Row) -> (Signal, bool) { | |
| 355 | 538 | let theme = tree.theme; |
| 356 | 539 | let lit = row |
| 357 | 540 | .selected |
| 358 | 541 | .map(|color| ui::mix(theme.section(color).tab, theme.base, 0.35)); |
| 359 | let id = ui.open( | |
| 360 | part, | |
| 542 | ui.open_as( | |
| 543 | id, | |
| 361 | 544 | Spec { |
| 362 | 545 | flags: Flags::CLICKABLE, |
| 363 | 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 | 548 | hover_fill: Some(ui::mix(lit.unwrap_or(theme.strip), theme.hover(), 0.6)), |
| 366 | 549 | radius: 4.0, |
| 367 | 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 | 553 | ..Spec::default() |
| 370 | 554 | }, |
| 371 | 555 | ); |
| 372 | 556 | let color = if row.dim { theme.text_dim } else { theme.text }; |
| 373 | match row.icon { | |
| 374 | Leading::Icon(icon) => { | |
| 375 | ui.leaf( | |
| 376 | "icon", | |
| 377 | Spec { | |
| 378 | size: [px(16.0), px(ROW)], | |
| 379 | icon: Some(icon), | |
| 380 | color: Some(color), | |
| 381 | ..Spec::default() | |
| 382 | }, | |
| 383 | ); | |
| 384 | } | |
| 385 | Leading::Swatch(swatch) => { | |
| 386 | ui.open( | |
| 387 | "swatch", | |
| 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 | } | |
| 557 | let (icon, [red, green, blue, _]) = match row.icon { | |
| 558 | Leading::Icon(icon) => (icon, theme.text), | |
| 559 | Leading::Section(section) => (art::SECTION, theme.section(section).accent), | |
| 560 | }; | |
| 561 | // Coloured art keeps its colours, so a dim row fades its icon. | |
| 562 | let alpha = if row.dim { 0.5 } else { 1.0 }; | |
| 563 | ui.leaf( | |
| 564 | "icon", | |
| 565 | Spec { | |
| 566 | size: [px(16.0), px(ROW)], | |
| 567 | icon: Some(icon), | |
| 568 | color: Some([red, green, blue, alpha]), | |
| 569 | ..Spec::default() | |
| 570 | }, | |
| 571 | ); | |
| 407 | 572 | match (row.renamed, tree.renaming.as_deref_mut()) { |
| 408 | 573 | (true, Some(renaming)) => { |
| 409 | let field = ui::text_field( | |
| 410 | ui, | |
| 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 | } | |
| 574 | if let Some(keep) = rename::edit(ui, theme, &mut renaming.name, ROW - 4.0) { | |
| 575 | tree.action = Some(Action::Renamed(keep)); | |
| 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 | 609 | } |
| 465 | 610 | |
| 466 | 611 | impl crate::State { |
| 467 | /// The sidebar left of the section tabs, easing open and shut. | |
| 468 | pub(crate) fn sidebar(&mut self, theme: &Theme) { | |
| 612 | /// The sidebar left of the section tabs, easing open and shut; returns its width. | |
| 613 | pub(crate) fn sidebar(&mut self, theme: &Theme) -> f32 { | |
| 469 | 614 | if self.temporary { |
| 470 | return; | |
| 615 | return 0.0; | |
| 471 | 616 | } |
| 472 | 617 | let width = self.ui.animate( |
| 473 | 618 | self.ui.id("sidebar"), |
| 474 | if self.sidebar { WIDTH } else { RAIL }, | |
| 619 | if self.sidebar { WIDTH } else { 0.0 }, | |
| 475 | 620 | ); |
| 476 | 621 | self.ui.open( |
| 477 | 622 | "sidebar", |
| ... | ... | @@ -500,39 +645,43 @@ impl crate::State { |
| 500 | 645 | Some((index, session.tabs[session.tab].path.as_str())) |
| 501 | 646 | }); |
| 502 | 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 | 656 | sidebar( |
| 505 | 657 | &mut self.ui, |
| 506 | 658 | &mut tree, |
| 507 | 659 | &self.notebooks, |
| 508 | 660 | crate::TAB_ROW, |
| 509 | width > RAIL + 0.5, | |
| 661 | width > 0.5, | |
| 510 | 662 | ); |
| 511 | 663 | let Tree { |
| 512 | action, rows, held, .. | |
| 664 | action, | |
| 665 | rows, | |
| 666 | mut held, | |
| 667 | ghost, | |
| 668 | gap, | |
| 669 | .. | |
| 513 | 670 | } = tree; |
| 514 | 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 | 676 | self.ui.close(); |
| 517 | 677 | self.ui.close(); |
| 518 | 678 | match action { |
| 519 | Some(Action::Toggle) => { | |
| 520 | self.sidebar = !self.sidebar; | |
| 521 | self.save_settings(); | |
| 522 | } | |
| 523 | 679 | Some(Action::Fold(key)) => { |
| 524 | 680 | if !self.folded.remove(&key) { |
| 525 | 681 | self.folded.insert(key); |
| 526 | 682 | } |
| 527 | 683 | } |
| 528 | Some(Action::Notebook(index)) => { | |
| 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 }) => { | |
| 684 | Some(Action::Open { notebook, path }) if !self.dragged() => { | |
| 536 | 685 | let library = Arc::clone(&self.notebooks[notebook]); |
| 537 | 686 | self.commands |
| 538 | 687 | .push(crate::Command::OpenSection(library, path)); |
| ... | ... | @@ -541,120 +690,208 @@ impl crate::State { |
| 541 | 690 | self.menu = Some((target, point)); |
| 542 | 691 | self.ui.open_popup(crate::menus::id()); |
| 543 | 692 | } |
| 544 | Some(Action::Renamed(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 | } | |
| 693 | Some(Action::Renamed(keep)) => self.finish_renaming(keep), | |
| 562 | 694 | Some(Action::NewNotebook) => self.commands.push(crate::Command::NewNotebook), |
| 563 | 695 | Some(Action::OpenNotebook) => self.commands.push(crate::Command::OpenNotebook), |
| 564 | 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 | } |
| 568 | 811 | |
| 569 | /// Moves a section or group by dragging its row: onto a group's middle it moves into the | |
| 570 | /// group, as OneNote moves one dropped on a group's tab; between rows of its folder it | |
| 571 | /// takes that place; between rows of another folder it moves there, last. `corner` is | |
| 572 | /// the rows' box. | |
| 812 | /// Moves a section or group by dragging its row, `held`, among `rows` in the rows' box | |
| 813 | /// `corner`: the others open a `gap` where it would land. Onto a group's middle it moves | |
| 814 | /// into the group, as OneNote moves one dropped on a group's tab; between rows of its | |
| 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 | 817 | fn drag_entries( |
| 574 | 818 | &mut self, |
| 575 | 819 | held: Option<Entry>, |
| 576 | 820 | rows: &[Entry], |
| 821 | gap: Option<[f32; 4]>, | |
| 822 | settled: bool, | |
| 577 | 823 | corner: [f32; 4], |
| 578 | 824 | theme: &Theme, |
| 579 | 825 | ) { |
| 580 | let dragged = match (&self.drag, &held) { | |
| 581 | (_, Some(entry)) => Some(entry.clone()), | |
| 582 | (Some(crate::menus::Drag::Entry(entry)), None) => Some(entry.clone()), | |
| 583 | _ => None, | |
| 584 | }; | |
| 585 | if let Some(entry) = &held { | |
| 586 | self.drag = Some(crate::menus::Drag::Entry(entry.clone())); | |
| 587 | } else if matches!(self.drag, Some(crate::menus::Drag::Entry(_))) { | |
| 588 | self.drag = None; | |
| 589 | } | |
| 590 | let (Some(dragged), Some([_, y])) = (dragged, self.ui.pointer()) else { | |
| 826 | let mine = |what: &Dragged| matches!(what, Dragged::Entry { .. }); | |
| 827 | self.settle(mine, settled); | |
| 828 | let held = held.map(|entry| { | |
| 829 | let rect = entry.rect; | |
| 830 | let home = Landing { | |
| 831 | folder: entry.folder().to_owned(), | |
| 832 | before: rows | |
| 833 | .iter() | |
| 834 | .skip_while(|row| row.path != entry.path) | |
| 835 | .skip(1) | |
| 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 | 857 | return; |
| 592 | 858 | }; |
| 593 | let inside = |entry: &Entry| { | |
| 594 | entry.path == dragged.path || entry.path.starts_with(&format!("{}/", dragged.path)) | |
| 595 | }; | |
| 596 | let candidates: Vec<&Entry> = rows | |
| 597 | .iter() | |
| 598 | .filter(|entry| entry.notebook == dragged.notebook && !inside(entry)) | |
| 599 | .collect(); | |
| 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])) | |
| 859 | let live = drag.live(); | |
| 860 | let middle = drag.corner(pointer)[1] + ROW / 2.0; | |
| 861 | let Dragged::Entry { | |
| 862 | entry, | |
| 863 | landing, | |
| 864 | home, | |
| 865 | } = &mut drag.what | |
| 604 | 866 | else { |
| 605 | 867 | return; |
| 606 | 868 | }; |
| 607 | let [top, bottom] = [under.rect[1], under.rect[3]]; | |
| 608 | let quarter = (bottom - top) / 4.0; | |
| 609 | let into = under.group && y > top + quarter && y < bottom - quarter; | |
| 610 | let (folder, before) = if into { | |
| 611 | (under.path.as_str(), None) | |
| 612 | } else if y < (top + bottom) / 2.0 { | |
| 613 | (under.folder(), Some(under.path.as_str())) | |
| 614 | } else { | |
| 615 | let next = candidates | |
| 869 | if drag.cancelled { | |
| 870 | *landing = home.clone(); | |
| 871 | } else if live { | |
| 872 | // Where the rows stand with no gap open, so the gap follows the dragged row | |
| 873 | // rather than the rows it pushed aside. | |
| 874 | let closed: Vec<Entry> = rows | |
| 616 | 875 | .iter() |
| 617 | .skip_while(|entry| entry.path != under.path) | |
| 618 | .nth(1) | |
| 619 | .filter(|entry| entry.folder() == under.folder()); | |
| 620 | (under.folder(), next.map(|entry| entry.path.as_str())) | |
| 621 | }; | |
| 622 | let change = if folder == dragged.folder() { | |
| 623 | if into { | |
| 624 | return; | |
| 625 | } | |
| 626 | // The folder's order with the dragged entry where it was dropped. | |
| 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()) | |
| 876 | .map(|row| { | |
| 877 | let mut row = row.clone(); | |
| 878 | if let Some([_, top, _, bottom]) = gap | |
| 879 | && row.rect[1] >= bottom - 0.5 | |
| 880 | { | |
| 881 | row.rect[1] -= bottom - top; | |
| 882 | row.rect[3] -= bottom - top; | |
| 883 | } | |
| 884 | row | |
| 885 | }) | |
| 640 | 886 | .collect(); |
| 641 | if paths.iter().map(String::as_str).eq(current) { | |
| 642 | return; | |
| 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(), | |
| 887 | if let Some(found) = landing_at(&closed, entry, middle) { | |
| 888 | *landing = found; | |
| 652 | 889 | } |
| 653 | }; | |
| 654 | if held.is_some() { | |
| 890 | } | |
| 891 | if live && landing.into { | |
| 655 | 892 | let accent = theme.accent; |
| 656 | if into { | |
| 657 | let [left, top, right, bottom] = under.rect; | |
| 893 | if let Some(group) = rows.iter().find(|row| row.path == landing.folder) { | |
| 894 | let [left, top, right, bottom] = group.rect; | |
| 658 | 895 | self.ui.mark( |
| 659 | 896 | [ |
| 660 | 897 | left - corner[0], |
| ... | ... | @@ -665,28 +902,38 @@ impl crate::State { |
| 665 | 902 | [accent[0], accent[1], accent[2], 0.3], |
| 666 | 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 | } |
| 691 | 938 | |
| 692 | 939 | /// Opens what the open panel chose: a notebook folder, a notebook's table of contents, |
| ... | ... | @@ -745,13 +992,64 @@ impl crate::State { |
| 745 | 992 | /// The window below the title bar on the first run, or once every notebook is closed: |
| 746 | 993 | /// what is missing, and the two ways to start. |
| 747 | 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 | 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(); | |
| 750 | self.ui.open( | |
| 751 | "welcome", | |
| 1047 | let [left, top, right, bottom] = self.ui.rect(id).unwrap_or_default(); | |
| 1048 | self.ui.open_as( | |
| 1049 | id, | |
| 752 | 1050 | Spec { |
| 753 | 1051 | size: [fill(), fill()], |
| 754 | fill: Some(theme.base), | |
| 1052 | fill: Some(background), | |
| 755 | 1053 | ..Spec::default() |
| 756 | 1054 | }, |
| 757 | 1055 | ); |
| ... | ... | @@ -773,26 +1071,13 @@ impl crate::State { |
| 773 | 1071 | "title", |
| 774 | 1072 | Spec { |
| 775 | 1073 | size: [fill(), px(28.0)], |
| 776 | text: Some("No notebooks open"), | |
| 1074 | text: Some(title), | |
| 777 | 1075 | center: true, |
| 778 | 1076 | ..Spec::default() |
| 779 | 1077 | }, |
| 780 | 1078 | ); |
| 781 | 1079 | let mut chosen = None; |
| 782 | for (part, icon, label, command) in [ | |
| 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 | ] { | |
| 1080 | for (part, icon, label, command) in buttons { | |
| 796 | 1081 | let button = self.ui.leaf( |
| 797 | 1082 | part, |
| 798 | 1083 | Spec { |
| ... | ... | @@ -817,3 +1102,43 @@ impl crate::State { |
| 817 | 1102 | self.ui.close(); |
| 818 | 1103 | } |
| 819 | 1104 | } |
| 1105 | ||
| 1106 | #[cfg(test)] | |
| 1107 | mod 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 | 418 | .chain(editor.caret_outline()) |
| 419 | 419 | .filter(|outline| !outline.title) |
| 420 | 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 | 422 | self.templates = View::Strip; |
| 423 | 423 | return; |
| 424 | 424 | } |
crates/ui/examples/popups.rs+194-13| ... | ... | @@ -65,10 +65,176 @@ fn main() { |
| 65 | 65 | render(panels, 5, &path); |
| 66 | 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 | 93 | measure(); |
| 70 | 94 | } |
| 71 | 95 | |
| 96 | /// Builds a frame's popup, given the ids of the combo and the split button's arrow. | |
| 97 | type 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. | |
| 101 | fn 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. | |
| 136 | fn 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. | |
| 195 | fn 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 | 238 | /// Frames a change shows at: before it, then each of the 150 ms it takes. |
| 73 | 239 | const FRAMES: [u32; 9] = [1, 2, 3, 4, 5, 6, 7, 8, 10]; |
| 74 | 240 | |
| ... | ... | @@ -344,6 +510,7 @@ impl Scene { |
| 344 | 510 | art!("icons/font-color"), |
| 345 | 511 | Some(draw::srgb(0xe8, 0x3a, 0x30)), |
| 346 | 512 | false, |
| 513 | Id::ROOT.child("font color"), | |
| 347 | 514 | ); |
| 348 | 515 | ui.close(); |
| 349 | 516 | ui.close(); |
| ... | ... | @@ -568,7 +735,7 @@ fn highlight(scene: &mut Scene) { |
| 568 | 735 | let id = Id::ROOT.child("highlight"); |
| 569 | 736 | scene.open(id, vec![key(NamedKey::ArrowDown)], |ui, [_, split]| { |
| 570 | 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 | } |
| 574 | 741 | |
| ... | ... | @@ -601,14 +768,34 @@ fn palette(scene: &mut Scene) { |
| 601 | 768 | fn tags(scene: &mut Scene) { |
| 602 | 769 | let tags = [ |
| 603 | 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 | 776 | ("Question", None, "⌘3"), |
| 606 | 777 | ("Remember for later", Some(art!("tags/remember")), "⌘4"), |
| 607 | 778 | ("Definition", Some(art!("tags/definition")), "⌘5"), |
| 608 | ("Highlight", Some(&[include_str!("../../canvas/assets/tags/highlight.svg")]), "⌘6"), | |
| 609 | ("Contact", Some(&[include_str!("../../canvas/assets/tags/contact.svg")]), "⌘7"), | |
| 610 | ("Address", Some(&[include_str!("../../canvas/assets/tags/address.svg")]), "⌘8"), | |
| 611 | ("Phone number", Some(&[include_str!("../../canvas/assets/tags/phone.svg")]), "⌘9"), | |
| 779 | ( | |
| 780 | "Highlight", | |
| 781 | Some(&[include_str!("../../canvas/assets/tags/highlight.svg")]), | |
| 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 | 799 | ("Web site to visit", None, ""), |
| 613 | 800 | ("Idea", None, ""), |
| 614 | 801 | ("Password", None, ""), |
| ... | ... | @@ -686,13 +873,7 @@ fn paint( |
| 686 | 873 | let layers: Vec<_> = interface |
| 687 | 874 | .iter() |
| 688 | 875 | .filter_map(|layer| match layer { |
| 689 | ui::Layer::Primitives { clip, primitives } => Some(draw::Layer { | |
| 690 | scale: SCALE, | |
| 691 | origin: [0.0; 2], | |
| 692 | clip: clip.map(|clip| clip.map(|value| value * SCALE)), | |
| 693 | backdrop: None, | |
| 694 | primitives, | |
| 695 | }), | |
| 876 | ui::Layer::Primitives(primitives) => Some(primitives.layer(SCALE)), | |
| 696 | 877 | ui::Layer::Custom { .. } => None, |
| 697 | 878 | }) |
| 698 | 879 | .collect(); |
crates/ui/src/layout.rs+40-6| ... | ... | @@ -1,12 +1,22 @@ |
| 1 | use crate::{Axis, Built, Flags, Id, Size, State}; | |
| 1 | use crate::{Axis, Built, Flags, ICON, ICON_GAP, Id, Overflow, Size, State, text::Texts}; | |
| 2 | 2 | use std::collections::HashMap; |
| 3 | 3 | |
| 4 | 4 | /// Sizes each box on both axes, then places it: standalone sizes, sizes taken from |
| 5 | 5 | /// 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, so | |
| 6 | /// 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 | 8 | /// index order is pre-order. |
| 8 | pub(crate) fn solve(nodes: &mut [Built], states: &HashMap<Id, State>, scale: f32) { | |
| 9 | pub(crate) fn solve( | |
| 10 | nodes: &mut [Built], | |
| 11 | states: &HashMap<Id, State>, | |
| 12 | scale: f32, | |
| 13 | texts: &mut Texts, | |
| 14 | frame: u64, | |
| 15 | ) { | |
| 9 | 16 | for axis in 0..2 { |
| 17 | if axis == 1 { | |
| 18 | fit_labels(nodes, texts, frame); | |
| 19 | } | |
| 10 | 20 | for node in nodes.iter_mut() { |
| 11 | 21 | node.computed[axis] = match node.size[axis].size { |
| 12 | 22 | Size::Pixels(pixels) => pixels, |
| ... | ... | @@ -47,7 +57,7 @@ pub(crate) fn solve(nodes: &mut [Built], states: &HashMap<Id, State>, scale: f32 |
| 47 | 57 | if node.children.is_empty() || (axis == 1 && node.flags.contains(Flags::SCROLL)) { |
| 48 | 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 | 61 | let children: Vec<_> = in_flow(nodes, index).collect(); |
| 52 | 62 | if along(node, axis) { |
| 53 | 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 | 120 | } else { |
| 111 | 121 | 0.0 |
| 112 | 122 | }; |
| 113 | let x = parent.rect[0] + nodes[index].relative[0]; | |
| 114 | let y = parent.rect[1] + nodes[index].relative[1] - scroll; | |
| 123 | let [dx, dy] = nodes[index].offset; | |
| 124 | let x = parent.rect[0] + nodes[index].relative[0] + dx; | |
| 125 | let y = parent.rect[1] + nodes[index].relative[1] - scroll + dy; | |
| 115 | 126 | nodes[index].rect = [ |
| 116 | 127 | snap(x), |
| 117 | 128 | snap(y), |
| ... | ... | @@ -145,3 +156,26 @@ fn flow(nodes: &[Built], index: usize, axis: usize) -> f32 { |
| 145 | 156 | sizes.fold(0.0, f32::max) |
| 146 | 157 | } |
| 147 | 158 | } |
| 159 | ||
| 160 | fn 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 | 43 | /// Logical size of a box's icon, and its distance from the label. |
| 44 | 44 | const ICON: f32 = 16.0; |
| 45 | 45 | const ICON_GAP: f32 = 6.0; |
| 46 | /// Seconds a dialog and other popups take to open and to close. | |
| 47 | const DIALOG: [f32; 2] = [0.24, 0.16]; | |
| 48 | const POPUP: [f32; 2] = [0.16, 0.12]; | |
| 46 | 49 | |
| 47 | 50 | /// A box's identity across frames: its parent's id combined with a builder-chosen part. |
| 48 | 51 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] |
| ... | ... | @@ -136,6 +139,9 @@ impl Flags { |
| 136 | 139 | pub const FLOAT: Flags = Flags(16); |
| 137 | 140 | /// The host paints it and receives the events routed to it. |
| 138 | 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); | |
| 139 | 145 | |
| 140 | 146 | pub(crate) fn contains(self, other: Flags) -> bool { |
| 141 | 147 | self.0 & other.0 == other.0 |
| ... | ... | @@ -165,6 +171,18 @@ pub enum Shape { |
| 165 | 171 | Tab { lean: f32 }, |
| 166 | 172 | } |
| 167 | 173 | |
| 174 | /// What a label does where it is wider than its box. | |
| 175 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] | |
| 176 | pub 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 | 186 | /// Where a popup opens, flipping to the far side of its anchor where the window ends first. |
| 169 | 187 | #[derive(Clone, Copy, Debug, PartialEq)] |
| 170 | 188 | pub enum Anchor { |
| ... | ... | @@ -174,8 +192,10 @@ pub enum Anchor { |
| 174 | 192 | Right([f32; 4]), |
| 175 | 193 | /// Over the rectangle from its corner, as a combo box opens into its own list. |
| 176 | 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 | 196 | Point([f32; 2]), |
| 197 | /// Centred in the window over the interface, which dims, as a dialog opens. | |
| 198 | Dialog, | |
| 179 | 199 | } |
| 180 | 200 | |
| 181 | 201 | impl Anchor { |
| ... | ... | @@ -187,6 +207,7 @@ impl Anchor { |
| 187 | 207 | Anchor::Right(rect) => (rect, Some(0)), |
| 188 | 208 | Anchor::Over(rect) => (rect, None), |
| 189 | 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 | 212 | let [low, high] = [rect[axis], rect[axis + 2]]; |
| 192 | 213 | let (first, second) = if along == Some(axis) { |
| ... | ... | @@ -202,6 +223,51 @@ impl Anchor { |
| 202 | 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)] | |
| 266 | struct Motion { | |
| 267 | zoom: f32, | |
| 268 | pivot: [f32; 2], | |
| 269 | tilt: f32, | |
| 270 | opacity: f32, | |
| 205 | 271 | } |
| 206 | 272 | |
| 207 | 273 | /// What a box is this frame. Colours are linear RGBA. |
| ... | ... | @@ -218,6 +284,7 @@ pub struct Spec<'a> { |
| 218 | 284 | /// Shapes the label semibold. |
| 219 | 285 | pub bold: bool, |
| 220 | 286 | pub font: Option<&'a str>, |
| 287 | pub overflow: Overflow, | |
| 221 | 288 | /// The label's colour; the theme's text colour otherwise. |
| 222 | 289 | pub color: Option<[f32; 4]>, |
| 223 | 290 | pub fill: Option<[f32; 4]>, |
| ... | ... | @@ -246,6 +313,9 @@ pub struct Spec<'a> { |
| 246 | 313 | pub center: bool, |
| 247 | 314 | /// For floating boxes, the offset from the parent's corner. |
| 248 | 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 | 319 | pub cursor: Option<CursorIcon>, |
| 250 | 320 | /// Floats the box over all others beside a rectangle in the window, as a popup |
| 251 | 321 | /// `Ui::open_popup` opened; boxes beneath take no input while one is open. |
| ... | ... | @@ -291,13 +361,9 @@ pub struct Signal { |
| 291 | 361 | pub events: Vec<Event>, |
| 292 | 362 | } |
| 293 | 363 | |
| 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. | |
| 295 | 365 | pub enum Layer<'a> { |
| 296 | Primitives { | |
| 297 | /// Logical pixels. | |
| 298 | clip: Option<[f32; 4]>, | |
| 299 | primitives: Vec<Primitive<'a>>, | |
| 300 | }, | |
| 366 | Primitives(Primitives<'a>), | |
| 301 | 367 | Custom { |
| 302 | 368 | id: Id, |
| 303 | 369 | /// The box's visible logical rectangle. |
| ... | ... | @@ -305,6 +371,52 @@ pub enum Layer<'a> { |
| 305 | 371 | }, |
| 306 | 372 | } |
| 307 | 373 | |
| 374 | /// Interface primitives under a clip, in logical pixels, perhaps part of the way through | |
| 375 | /// opening or closing. | |
| 376 | pub struct Primitives<'a> { | |
| 377 | pub clip: Option<[f32; 4]>, | |
| 378 | motion: Option<Motion>, | |
| 379 | pub primitives: Vec<Primitive<'a>>, | |
| 380 | } | |
| 381 | ||
| 382 | impl 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 | ||
| 308 | 420 | struct Built { |
| 309 | 421 | id: Id, |
| 310 | 422 | parent: usize, |
| ... | ... | @@ -313,6 +425,7 @@ struct Built { |
| 313 | 425 | size: [Extent; 2], |
| 314 | 426 | axis: Axis, |
| 315 | 427 | label: Option<Rc<Label>>, |
| 428 | overflow: Overflow, | |
| 316 | 429 | color: [f32; 4], |
| 317 | 430 | fill: Option<[f32; 4]>, |
| 318 | 431 | gradient: Option<[f32; 4]>, |
| ... | ... | @@ -329,8 +442,10 @@ struct Built { |
| 329 | 442 | gap: f32, |
| 330 | 443 | center: bool, |
| 331 | 444 | position: [f32; 2], |
| 445 | offset: [f32; 2], | |
| 332 | 446 | cursor: Option<CursorIcon>, |
| 333 | 447 | anchor: Option<Anchor>, |
| 448 | motion: Option<Motion>, | |
| 334 | 449 | /// Rectangles relative to the box, painted over its fill. |
| 335 | 450 | marks: Vec<([f32; 4], [f32; 4], f32)>, |
| 336 | 451 | computed: [f32; 2], |
| ... | ... | @@ -353,6 +468,8 @@ struct State { |
| 353 | 468 | /// A text field's selection, and the selection and unit of the press a drag extends. |
| 354 | 469 | selection: Selection, |
| 355 | 470 | press: (Selection, SelectionUnit), |
| 471 | /// The field selects all its text when next built. | |
| 472 | select_all: bool, | |
| 356 | 473 | /// Where a dragged scrollbar thumb was taken, from its start. |
| 357 | 474 | grab: f32, |
| 358 | 475 | /// An animated value and its target, for `Ui::animate`. |
| ... | ... | @@ -369,6 +486,16 @@ struct Popup { |
| 369 | 486 | highlight: Option<u64>, |
| 370 | 487 | /// The height its results ease from and to, and the seconds since they set out. |
| 371 | 488 | height: Option<[f32; 3]>, |
| 489 | opened: Instant, | |
| 490 | } | |
| 491 | ||
| 492 | /// A popup's painting as it last showed, fading out since it closed. | |
| 493 | struct Closing { | |
| 494 | id: Id, | |
| 495 | display: Vec<Display>, | |
| 496 | anchor: Anchor, | |
| 497 | rect: [f32; 4], | |
| 498 | closed: Instant, | |
| 372 | 499 | } |
| 373 | 500 | |
| 374 | 501 | struct Hit { |
| ... | ... | @@ -378,8 +505,10 @@ struct Hit { |
| 378 | 505 | cursor: Option<CursorIcon>, |
| 379 | 506 | } |
| 380 | 507 | |
| 508 | #[derive(Clone)] | |
| 381 | 509 | enum Display { |
| 382 | 510 | Clip(Option<[f32; 4]>), |
| 511 | Motion(Option<Motion>), | |
| 383 | 512 | Rect { |
| 384 | 513 | rect: [f32; 4], |
| 385 | 514 | fill: [f32; 4], |
| ... | ... | @@ -425,6 +554,8 @@ enum Display { |
| 425 | 554 | |
| 426 | 555 | pub struct Ui { |
| 427 | 556 | pub theme: Theme, |
| 557 | /// The colours icons' slots take. | |
| 558 | pub icon_palette: draw::Palette, | |
| 428 | 559 | /// Whether the window has keyboard focus; without it a field hides its caret and dims |
| 429 | 560 | /// its selection. |
| 430 | 561 | pub window_focused: bool, |
| ... | ... | @@ -456,6 +587,10 @@ pub struct Ui { |
| 456 | 587 | display: Vec<Display>, |
| 457 | 588 | /// Where the popups' painting starts in `display`, for edges drawn beneath them. |
| 458 | 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 | 594 | animating: bool, |
| 460 | 595 | /// This frame routed input, whose effects the builder may only have seen after boxes |
| 461 | 596 | /// built before it read their state; one more frame shows them. |
| ... | ... | @@ -471,6 +606,7 @@ impl Ui { |
| 471 | 606 | pub fn new(theme: Theme, double_click: Duration) -> Self { |
| 472 | 607 | Self { |
| 473 | 608 | theme, |
| 609 | icon_palette: draw::Palette::default(), | |
| 474 | 610 | window_focused: true, |
| 475 | 611 | frame: 0, |
| 476 | 612 | now: Instant::now(), |
| ... | ... | @@ -495,6 +631,8 @@ impl Ui { |
| 495 | 631 | clicks: Clicks::new(double_click), |
| 496 | 632 | display: Vec::new(), |
| 497 | 633 | popups_painted: 0, |
| 634 | painted: Vec::new(), | |
| 635 | closing: Vec::new(), | |
| 498 | 636 | animating: false, |
| 499 | 637 | routed: false, |
| 500 | 638 | caret: None, |
| ... | ... | @@ -538,6 +676,15 @@ impl Ui { |
| 538 | 676 | self.focus = id; |
| 539 | 677 | } |
| 540 | 678 | |
| 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 | 688 | /// The clickable or custom box at `point` in the latest layout. |
| 542 | 689 | pub fn box_at(&self, point: [f32; 2]) -> Option<Id> { |
| 543 | 690 | self.hit(point, Flags::CLICKABLE | Flags::CUSTOM) |
| ... | ... | @@ -767,6 +914,7 @@ impl Ui { |
| 767 | 914 | query: String::new(), |
| 768 | 915 | highlight: None, |
| 769 | 916 | height: None, |
| 917 | opened: self.now, | |
| 770 | 918 | }); |
| 771 | 919 | self.focus = Some(id); |
| 772 | 920 | self.states.entry(id).or_default().touched = self.frame; |
| ... | ... | @@ -838,11 +986,10 @@ impl Ui { |
| 838 | 986 | /// Opens a box under an id chosen outside the hierarchy, so the host can name it |
| 839 | 987 | /// wherever it is built. |
| 840 | 988 | pub fn open_as(&mut self, id: Id, spec: Spec<'_>) -> Id { |
| 841 | let label = spec | |
| 842 | .text | |
| 843 | .map(|text| { | |
| 989 | let label = spec.text.map(|text| { | |
| 844 | 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 | 994 | // Popups hang from the root, outside the clips and flow of where they are built. |
| 848 | 995 | let parent = if spec.anchor.is_some() { |
| ... | ... | @@ -872,6 +1019,15 @@ impl Ui { |
| 872 | 1019 | } |
| 873 | 1020 | |
| 874 | 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 | 1031 | pub fn animate(&mut self, id: Id, target: f32) -> f32 { |
| 876 | 1032 | let state = self.states.entry(id).or_default(); |
| 877 | 1033 | state.touched = self.frame; |
| ... | ... | @@ -881,12 +1037,6 @@ impl Ui { |
| 881 | 1037 | *value |
| 882 | 1038 | } |
| 883 | 1039 | |
| 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 | 1040 | /// Previews `family` in the font `data` holds, as a font menu shows the substitute a |
| 891 | 1041 | /// missing family lays out in. |
| 892 | 1042 | pub fn preview_font(&mut self, data: parley::fontique::Blob<u8>, family: &str) { |
| ... | ... | @@ -937,7 +1087,13 @@ impl Ui { |
| 937 | 1087 | /// Solves this frame's layout and prepares its painting. |
| 938 | 1088 | pub fn end(&mut self) { |
| 939 | 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 | 1097 | for node in &self.nodes { |
| 942 | 1098 | let state = self.states.entry(node.id).or_default(); |
| 943 | 1099 | state.rect = node.rect; |
| ... | ... | @@ -960,16 +1116,74 @@ impl Ui { |
| 960 | 1116 | { |
| 961 | 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 | 1121 | self.hits.clear(); |
| 965 | self.paint(0, None); | |
| 1122 | self.paint(0, None, None); | |
| 966 | 1123 | self.popups_painted = self.display.len(); |
| 967 | 1124 | let beneath = self.hits.len(); |
| 1125 | let mut painted = Vec::new(); | |
| 968 | 1126 | for index in self.nodes[0].children.clone() { |
| 969 | if self.nodes[index].anchor.is_some() { | |
| 970 | self.paint(index, None); | |
| 1127 | let node = &self.nodes[index]; | |
| 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 | 1187 | self.modal = if self.popups.is_empty() { 0 } else { beneath }; |
| 974 | 1188 | for id in [&mut self.hover, &mut self.active, &mut self.focus] { |
| 975 | 1189 | if id.is_some_and(|id| !self.states.contains_key(&id)) { |
| ... | ... | @@ -978,19 +1192,50 @@ impl Ui { |
| 978 | 1192 | } |
| 979 | 1193 | } |
| 980 | 1194 | |
| 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 | 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 | 1234 | let state = &self.states[&node.id]; |
| 984 | 1235 | let rect = node.rect; |
| 985 | 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 | 1237 | let blend = |base: Option<[f32; 4]>, hover: Option<[f32; 4]>| match (base, hover) { |
| 989 | (base, Some(hover)) => Some(mix( | |
| 990 | base.unwrap_or([hover[0], hover[1], hover[2], 0.0]), | |
| 991 | hover, | |
| 992 | state.hot, | |
| 993 | )), | |
| 1238 | (base, Some(hover)) => Some(mix(base.unwrap_or_default(), hover, state.hot)), | |
| 994 | 1239 | (base, None) => base, |
| 995 | 1240 | }; |
| 996 | 1241 | let fill = blend(node.fill, node.hover_fill); |
| ... | ... | @@ -1085,7 +1330,12 @@ impl Ui { |
| 1085 | 1330 | let rect = if node.label.is_some() { |
| 1086 | 1331 | [x, top, x + ICON, top + ICON] |
| 1087 | 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 | 1340 | self.display.push(Display::Image { |
| 1091 | 1341 | image: image.clone(), |
| ... | ... | @@ -1130,30 +1380,47 @@ impl Ui { |
| 1130 | 1380 | } |
| 1131 | 1381 | for child in node.children.clone() { |
| 1132 | 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 | 1386 | if inner_clip != clip { |
| 1137 | 1387 | self.display.push(Display::Clip(clip)); |
| 1138 | 1388 | } |
| 1389 | if own.is_some() { | |
| 1390 | self.display.push(Display::Motion(motion)); | |
| 1391 | } | |
| 1139 | 1392 | } |
| 1140 | 1393 | |
| 1141 | 1394 | /// This frame's painting in order, in logical pixels. |
| 1142 | 1395 | pub fn layers(&self) -> Vec<Layer<'_>> { |
| 1143 | 1396 | let mut layers = Vec::new(); |
| 1144 | 1397 | let mut clip = None; |
| 1398 | let mut motion = None; | |
| 1145 | 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 | 1414 | for item in &self.display { |
| 1147 | 1415 | match item { |
| 1148 | 1416 | Display::Clip(next) => { |
| 1149 | if !primitives.is_empty() { | |
| 1150 | layers.push(Layer::Primitives { | |
| 1151 | clip, | |
| 1152 | primitives: std::mem::take(&mut primitives), | |
| 1153 | }); | |
| 1154 | } | |
| 1417 | flush(&mut layers, clip, motion, &mut primitives); | |
| 1155 | 1418 | clip = *next; |
| 1156 | 1419 | } |
| 1420 | Display::Motion(next) => { | |
| 1421 | flush(&mut layers, clip, motion, &mut primitives); | |
| 1422 | motion = *next; | |
| 1423 | } | |
| 1157 | 1424 | Display::Rect { |
| 1158 | 1425 | rect, |
| 1159 | 1426 | fill, |
| ... | ... | @@ -1221,6 +1488,7 @@ impl Ui { |
| 1221 | 1488 | origin: *origin, |
| 1222 | 1489 | size: ICON, |
| 1223 | 1490 | tint: *tint, |
| 1491 | palette: self.icon_palette, | |
| 1224 | 1492 | }), |
| 1225 | 1493 | Display::Text { |
| 1226 | 1494 | label, |
| ... | ... | @@ -1234,12 +1502,7 @@ impl Ui { |
| 1234 | 1502 | ink: *color, |
| 1235 | 1503 | }), |
| 1236 | 1504 | Display::Custom { id, rect } => { |
| 1237 | if !primitives.is_empty() { | |
| 1238 | layers.push(Layer::Primitives { | |
| 1239 | clip, | |
| 1240 | primitives: std::mem::take(&mut primitives), | |
| 1241 | }); | |
| 1242 | } | |
| 1505 | flush(&mut layers, clip, motion, &mut primitives); | |
| 1243 | 1506 | layers.push(Layer::Custom { |
| 1244 | 1507 | id: *id, |
| 1245 | 1508 | rect: *rect, |
| ... | ... | @@ -1247,9 +1510,7 @@ impl Ui { |
| 1247 | 1510 | } |
| 1248 | 1511 | } |
| 1249 | 1512 | } |
| 1250 | if !primitives.is_empty() { | |
| 1251 | layers.push(Layer::Primitives { clip, primitives }); | |
| 1252 | } | |
| 1513 | flush(&mut layers, clip, motion, &mut primitives); | |
| 1253 | 1514 | layers |
| 1254 | 1515 | } |
| 1255 | 1516 | |
| ... | ... | @@ -1257,9 +1518,16 @@ impl Ui { |
| 1257 | 1518 | (&mut self.texts, self.frame) |
| 1258 | 1519 | } |
| 1259 | 1520 | |
| 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 | 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 | } |
| 1264 | 1532 | |
| 1265 | 1533 | pub(crate) fn grab(&mut self, id: Id) -> &mut f32 { |
| ... | ... | @@ -1293,6 +1561,7 @@ impl Built { |
| 1293 | 1561 | size: spec.size, |
| 1294 | 1562 | axis: spec.axis, |
| 1295 | 1563 | label, |
| 1564 | overflow: spec.overflow, | |
| 1296 | 1565 | color: spec.color.unwrap_or(text), |
| 1297 | 1566 | fill: spec.fill, |
| 1298 | 1567 | gradient: spec.gradient, |
| ... | ... | @@ -1309,8 +1578,10 @@ impl Built { |
| 1309 | 1578 | gap: spec.gap, |
| 1310 | 1579 | center: spec.center, |
| 1311 | 1580 | position: spec.position, |
| 1581 | offset: spec.offset, | |
| 1312 | 1582 | cursor: spec.cursor, |
| 1313 | 1583 | anchor: spec.anchor, |
| 1584 | motion: None, | |
| 1314 | 1585 | marks: Vec::new(), |
| 1315 | 1586 | computed: [0.0; 2], |
| 1316 | 1587 | relative: [0.0; 2], |
| ... | ... | @@ -1407,9 +1678,38 @@ impl Corner { |
| 1407 | 1678 | } |
| 1408 | 1679 | } |
| 1409 | 1680 | |
| 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. | |
| 1684 | pub 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`. | |
| 1694 | pub 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. | |
| 1411 | 1706 | pub 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 | } |
| 1414 | 1714 | |
| 1415 | 1715 | fn contains(rect: [f32; 4], point: [f32; 2]) -> bool { |
crates/ui/src/list.rs+1-26| ... | ... | @@ -4,8 +4,6 @@ |
| 4 | 4 | use crate::{Axis, Event, Flags, HALF_LIFE, Id, Size, Spec, Ui, fill, mix, px, scrollbar}; |
| 5 | 5 | use winit::keyboard::NamedKey; |
| 6 | 6 | |
| 7 | /// How far rows fade out under a scrolled list's edges. | |
| 8 | const EDGE: f64 = 10.0; | |
| 9 | 7 | /// Room beside a scrolling list's rows for its scrollbar. |
| 10 | 8 | pub(crate) const GUTTER: f32 = 12.0; |
| 11 | 9 | |
| ... | ... | @@ -191,8 +189,6 @@ pub fn list<R: Rows>( |
| 191 | 189 | let scroll = state.scroll; |
| 192 | 190 | ui.animating |= scroll != state.target; |
| 193 | 191 | |
| 194 | let theme = ui.theme.clone(); | |
| 195 | let background = spec.fill.unwrap_or(theme.base); | |
| 196 | 192 | ui.open_as( |
| 197 | 193 | id, |
| 198 | 194 | Spec { |
| ... | ... | @@ -235,28 +231,7 @@ pub fn list<R: Rows>( |
| 235 | 231 | ui.close(); |
| 236 | 232 | shown.push((key, place)); |
| 237 | 233 | } |
| 238 | // Rows fade out under the list's edges rather than end at a cut. | |
| 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); | |
| 234 | let thumb = mix(ui.theme.text_dim, ui.theme.chip, 0.5); | |
| 260 | 235 | if let Some(offset) = scrollbar( |
| 261 | 236 | ui, |
| 262 | 237 | "bar", |
crates/ui/src/popup.rs+149-18| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | //! Controls built on the popup layer: menus and filterable lists, a colour grid and a | |
| 2 | //! command palette. Each builds while its popup is open and returns what was chosen the | |
| 1 | //! Controls built on the popup layer: menus and filterable lists, a colour grid, a gallery | |
| 2 | //! and a command palette. Each builds while its popup is open and returns what was chosen the | |
| 3 | 3 | //! frame it is, closing the popup. |
| 4 | 4 | |
| 5 | 5 | use crate::{ |
| ... | ... | @@ -16,7 +16,13 @@ use winit::keyboard::{Key, NamedKey}; |
| 16 | 16 | pub(crate) const CHECK: &[&str] = &[include_str!("../assets/check.svg")]; |
| 17 | 17 | /// Inset of a popup's contents, and its distance from what it drops down from. |
| 18 | 18 | const PAD: f32 = 4.0; |
| 19 | /// A palette's rows and filter field, and a menu's more compact rows. | |
| 19 | 20 | const ROW: f32 = 26.0; |
| 21 | pub(crate) const MENU_ROW: f32 = 22.0; | |
| 22 | /// Inset of a menu row's icon and text. | |
| 23 | const ROW_PAD: f32 = 8.0; | |
| 24 | /// How far a menu's icons sit inside its leading edge. | |
| 25 | pub(crate) const ICON_INSET: f32 = PAD + ROW_PAD; | |
| 20 | 26 | /// Rows a list shows before it scrolls. |
| 21 | 27 | const ROWS: f32 = 12.0; |
| 22 | 28 | /// Height of the rule between groups. |
| ... | ... | @@ -108,6 +114,7 @@ pub fn menu( |
| 108 | 114 | items, |
| 109 | 115 | filter, |
| 110 | 116 | width.max(least).max(NARROWEST), |
| 117 | MENU_ROW, | |
| 111 | 118 | ) |
| 112 | 119 | } |
| 113 | 120 | |
| ... | ... | @@ -128,6 +135,7 @@ pub fn palette(ui: &mut Ui, id: Id, commands: &[Item]) -> Option<usize> { |
| 128 | 135 | commands, |
| 129 | 136 | Some("Search commands"), |
| 130 | 137 | width, |
| 138 | ROW, | |
| 131 | 139 | ) |
| 132 | 140 | } |
| 133 | 141 | |
| ... | ... | @@ -138,6 +146,7 @@ fn choose( |
| 138 | 146 | items: &[Item], |
| 139 | 147 | filter: Option<&str>, |
| 140 | 148 | width: f32, |
| 149 | row: f32, | |
| 141 | 150 | ) -> Option<usize> { |
| 142 | 151 | let field = id.child("filter"); |
| 143 | 152 | if filter.is_some() && ui.focus == Some(id) { |
| ... | ... | @@ -148,21 +157,31 @@ fn choose( |
| 148 | 157 | let mut query = std::mem::take(&mut popup.query); |
| 149 | 158 | let mut highlight = popup.highlight; |
| 150 | 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 | 174 | surface(ui, id, anchor, width); |
| 152 | 175 | if let Some(placeholder) = filter { |
| 153 | 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 | 177 | text_field( |
| 160 | 178 | ui, |
| 161 | 179 | field, |
| 162 | 180 | &mut query, |
| 163 | 181 | placeholder, |
| 164 | 182 | Spec { |
| 165 | size: [fill(), px(height)], | |
| 183 | flags, | |
| 184 | size, | |
| 166 | 185 | fill: Some(theme.base), |
| 167 | 186 | border: Some(theme.accent), |
| 168 | 187 | radius: 4.0, |
| ... | ... | @@ -178,7 +197,10 @@ fn choose( |
| 178 | 197 | // Unfiltered, the current or checked item starts highlighted; filtered, the best match. |
| 179 | 198 | highlight = highlight.or_else(|| { |
| 180 | 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 | 204 | } else { |
| 183 | 205 | (0..matches.count()).find(|row| matches.selectable(*row)) |
| 184 | 206 | }; |
| ... | ... | @@ -186,10 +208,10 @@ fn choose( |
| 186 | 208 | }); |
| 187 | 209 | let window = ui.rect(Id::ROOT).map_or(0.0, |window| window[3]); |
| 188 | 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 | 212 | let view = content |
| 191 | .max(ROW) | |
| 192 | .min((ROWS * ROW).min(window - 4.0 * PAD - field).max(ROW)); | |
| 213 | .max(row) | |
| 214 | .min((ROWS * row).min(window - 4.0 * PAD - field).max(row)); | |
| 193 | 215 | // The popup eases to its new height as the results change; the rows do not move. |
| 194 | 216 | let dt = ui.dt; |
| 195 | 217 | let [from, to, elapsed] = state(ui, id).height.get_or_insert([view, view, RESIZE]); |
| ... | ... | @@ -212,7 +234,7 @@ fn choose( |
| 212 | 234 | ui.leaf( |
| 213 | 235 | "empty", |
| 214 | 236 | Spec { |
| 215 | size: [fill(), px(ROW)], | |
| 237 | size: [fill(), px(row)], | |
| 216 | 238 | text: Some("No matches"), |
| 217 | 239 | color: Some(theme.text_dim), |
| 218 | 240 | pad: [8.0, 0.0], |
| ... | ... | @@ -223,7 +245,7 @@ fn choose( |
| 223 | 245 | } else { |
| 224 | 246 | let list = List { |
| 225 | 247 | rows: &matches, |
| 226 | row: ROW, | |
| 248 | row, | |
| 227 | 249 | keys: &keys, |
| 228 | 250 | hover_selects: true, |
| 229 | 251 | }; |
| ... | ... | @@ -285,7 +307,7 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) { |
| 285 | 307 | size: [fill(), fill()], |
| 286 | 308 | fill: row.selected.then(|| theme.hover()), |
| 287 | 309 | radius: 4.0, |
| 288 | pad: [8.0, 0.0], | |
| 310 | pad: [ROW_PAD, 0.0], | |
| 289 | 311 | gap: ICON_GAP, |
| 290 | 312 | ..Spec::default() |
| 291 | 313 | }, |
| ... | ... | @@ -299,7 +321,7 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) { |
| 299 | 321 | ui.leaf( |
| 300 | 322 | "icon", |
| 301 | 323 | Spec { |
| 302 | size: [px(ICON), px(ROW)], | |
| 324 | size: [px(ICON), fill()], | |
| 303 | 325 | icon: if item.checked { Some(CHECK) } else { item.icon }, |
| 304 | 326 | color: Some(if item.disabled { |
| 305 | 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 | 335 | ui.leaf( |
| 314 | 336 | "text", |
| 315 | 337 | Spec { |
| 316 | size: [fill(), px(ROW)], | |
| 338 | size: [fill(), fill()], | |
| 317 | 339 | text: Some(item.text), |
| 318 | 340 | font: item.font, |
| 319 | 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 | 348 | ui.leaf( |
| 327 | 349 | "shortcut", |
| 328 | 350 | Spec { |
| 329 | size: [fit(), px(ROW)], | |
| 351 | size: [fit(), fill()], | |
| 330 | 352 | text: Some(item.shortcut), |
| 331 | 353 | color: Some(theme.text_dim), |
| 332 | 354 | ..Spec::default() |
| ... | ... | @@ -451,6 +473,115 @@ pub fn colors( |
| 451 | 473 | None |
| 452 | 474 | } |
| 453 | 475 | |
| 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)] | |
| 481 | pub 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 | 585 | /// Opens popup `id`'s panel `width` wide beside `anchor`; the caller closes it. |
| 455 | 586 | fn surface(ui: &mut Ui, id: Id, anchor: Anchor, width: f32) { |
| 456 | 587 | let anchor = match anchor { |
crates/ui/src/shell.rs+198-41| ... | ... | @@ -1,10 +1,10 @@ |
| 1 | 1 | //! Controls a notebook window's chrome is built from: OneNote's section tabs and the |
| 2 | 2 | //! toolbar's compact buttons. |
| 3 | 3 | |
| 4 | use draw::PathStyle; | |
| 4 | use draw::{PathStyle, edit::SelectionUnit}; | |
| 5 | 5 | |
| 6 | 6 | use 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 | }; |
| 9 | 9 | use std::hash::Hash; |
| 10 | 10 | |
| ... | ... | @@ -14,17 +14,25 @@ pub const CHEVRON: &[&str] = &[include_str!("../assets/chevron-down.svg")]; |
| 14 | 14 | const TAB_ROUNDING: f32 = 4.0; |
| 15 | 15 | /// Side of a toolbar button. |
| 16 | 16 | pub const TOOL: f32 = 22.0; |
| 17 | /// Width of a menu arrow beside a toolbar button. | |
| 18 | const ARROW: f32 = 10.0; | |
| 19 | /// Where a section tab's label starts inside it. | |
| 20 | pub const TAB_PAD: f32 = 10.0; | |
| 17 | 21 | |
| 18 | 22 | /// Section tabs in a row `height` tall, as OneNote draws them: each leans over the next |
| 19 | 23 | /// at 45°, and the open one lies on top in `section`'s colours, rising to meet the frame |
| 20 | 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)] | |
| 22 | 29 | pub fn section_tabs( |
| 23 | 30 | ui: &mut Ui, |
| 24 | 31 | part: impl Hash, |
| 25 | 32 | tabs: &[(&str, [f32; 4])], |
| 26 | 33 | active: usize, |
| 27 | 34 | lit: Option<usize>, |
| 35 | dragged: Option<Dragged>, | |
| 28 | 36 | section: &Section, |
| 29 | 37 | height: f32, |
| 30 | 38 | ) -> Tabs { |
| ... | ... | @@ -38,25 +46,60 @@ pub fn section_tabs( |
| 38 | 46 | }, |
| 39 | 47 | ); |
| 40 | 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 | 51 | let placed: Vec<_> = tabs |
| 43 | 52 | .iter() |
| 44 | 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 | 55 | left += width; |
| 47 | 56 | (left - width, width) |
| 48 | 57 | }) |
| 49 | 58 | .collect(); |
| 50 | let (mut clicked, mut context, mut held) = (None, None, None); | |
| 51 | // Earlier tabs lie over later ones; the open tab over all. | |
| 59 | let (mut clicked, mut context, mut held, mut renamed) = (None, None, None, None); | |
| 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 | 72 | let order = (0..tabs.len()) |
| 53 | 73 | .rev() |
| 54 | .filter(|index| *index != active) | |
| 55 | .chain((active < tabs.len()).then_some(active)); | |
| 74 | .filter(|index| *index != active && Some(*index) != lifted) | |
| 75 | .chain((active < tabs.len() && Some(active) != lifted).then_some(active)) | |
| 76 | .chain(lifted); | |
| 56 | 77 | for index in order { |
| 57 | 78 | let (name, color) = tabs[index]; |
| 58 | 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 | 103 | // Whole device pixels, so a rising tab reuses its rasterized outlines. |
| 61 | 104 | let tall = ((low + (tallest - low) * open) * ui.scale()).round() / ui.scale(); |
| 62 | 105 | let colors = theme.section(color); |
| ... | ... | @@ -77,11 +120,12 @@ pub fn section_tabs( |
| 77 | 120 | }), |
| 78 | 121 | gradient: Some(fill), |
| 79 | 122 | hover_fill: (index != active).then(|| mix(colors.frame[0], [1.0; 4], 0.08)), |
| 80 | border: Some(fade(section.edge, open)), | |
| 81 | shadow: (open > 0.0).then(|| fade([0.0, 0.0, 0.0, 0.35], open)), | |
| 123 | // A tab not open is outlined in its own hue, fainter than the open one's. | |
| 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 | 126 | radius: TAB_ROUNDING, |
| 83 | 127 | shape: Shape::Tab { lean: lean(height) }, |
| 84 | pad: [10.0, 0.0], | |
| 128 | pad: [TAB_PAD, 0.0], | |
| 85 | 129 | ..Spec::default() |
| 86 | 130 | }, |
| 87 | 131 | ); |
| ... | ... | @@ -94,6 +138,9 @@ pub fn section_tabs( |
| 94 | 138 | if signal.dragging { |
| 95 | 139 | held = Some(index); |
| 96 | 140 | } |
| 141 | if signal.pressed && signal.unit != SelectionUnit::Grapheme { | |
| 142 | renamed = Some(index); | |
| 143 | } | |
| 97 | 144 | } |
| 98 | 145 | let open_tab = ui.id(("tab", active)); |
| 99 | 146 | ui.close(); |
| ... | ... | @@ -101,10 +148,21 @@ pub fn section_tabs( |
| 101 | 148 | clicked, |
| 102 | 149 | context, |
| 103 | 150 | held, |
| 151 | renamed, | |
| 152 | slot, | |
| 153 | settled, | |
| 104 | 154 | open: open_tab, |
| 105 | 155 | } |
| 106 | 156 | } |
| 107 | 157 | |
| 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)] | |
| 161 | pub struct Dragged { | |
| 162 | pub index: usize, | |
| 163 | pub start: Option<f32>, | |
| 164 | } | |
| 165 | ||
| 108 | 166 | /// The id of tab `index` of the section tabs built as `row`. |
| 109 | 167 | pub fn tab_id(row: Id, index: usize) -> Id { |
| 110 | 168 | row.child(("tab", index)) |
| ... | ... | @@ -117,6 +175,12 @@ pub struct Tabs { |
| 117 | 175 | pub context: Option<(usize, [f32; 2])>, |
| 118 | 176 | /// The tab held down, which a drag moves. |
| 119 | 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 | 184 | /// The open tab's id, for `tab_base`. |
| 121 | 185 | pub open: Id, |
| 122 | 186 | } |
| ... | ... | @@ -158,34 +222,62 @@ pub fn tool_button( |
| 158 | 222 | ) |
| 159 | 223 | } |
| 160 | 224 | |
| 161 | /// A button showing `icon`, lit while `on`, with a menu arrow beside it, and optionally | |
| 162 | /// the colour it applies as a bar under the icon. Returns the button's and the arrow's | |
| 163 | /// signals. | |
| 225 | /// A button showing `icon`, lit while `on`, joined to an arrow that opens popup `menu`, | |
| 226 | /// with the colour it applies as a bar under the icon when given. Hovering the button fills | |
| 227 | /// it alone; hovering the arrow, or its menu being open, outlines both as one control. | |
| 228 | /// Returns the button's signal. | |
| 164 | 229 | pub fn split_button( |
| 165 | 230 | ui: &mut Ui, |
| 166 | 231 | part: impl Hash, |
| 167 | 232 | icon: &'static [&'static str], |
| 168 | 233 | bar: Option<[f32; 4]>, |
| 169 | 234 | on: bool, |
| 170 | ) -> [Signal; 2] { | |
| 171 | let theme = ui.theme.clone(); | |
| 172 | ui.open( | |
| 173 | part, | |
| 235 | menu: Id, | |
| 236 | ) -> Signal { | |
| 237 | const RADIUS: f32 = 4.0; | |
| 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 | 251 | Spec { |
| 175 | 252 | size: [children(), px(TOOL)], |
| 176 | 253 | ..Spec::default() |
| 177 | 254 | }, |
| 178 | 255 | ); |
| 179 | let button = ui.open( | |
| 180 | "button", | |
| 256 | ui.open_as( | |
| 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 | 277 | Spec { |
| 182 | flags: Flags::CLICKABLE, | |
| 183 | 278 | size: [px(TOOL), px(TOOL)], |
| 184 | 279 | icon: Some(icon), |
| 185 | color: Some(theme.text), | |
| 186 | fill: on.then(|| theme.hover()), | |
| 187 | hover_fill: Some(theme.hover()), | |
| 188 | radius: 4.0, | |
| 280 | color: Some(ui.theme.text), | |
| 189 | 281 | center: true, |
| 190 | 282 | ..Spec::default() |
| 191 | 283 | }, |
| ... | ... | @@ -196,21 +288,79 @@ pub fn split_button( |
| 196 | 288 | ui.mark([top, top + 13.0, top + 16.0, top + 16.0], color, 0.0); |
| 197 | 289 | } |
| 198 | 290 | ui.close(); |
| 199 | let menu = ui.leaf( | |
| 200 | "menu", | |
| 291 | ui.close(); | |
| 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. | |
| 324 | pub 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 | 333 | Spec { |
| 202 | 334 | flags: Flags::CLICKABLE, |
| 203 | size: [px(10.0), px(TOOL)], | |
| 204 | icon: Some(CHEVRON), | |
| 205 | color: Some(theme.text_dim), | |
| 335 | size: [px(TOOL + ARROW), px(TOOL)], | |
| 336 | fill: ui.popup_open(menu).then(|| theme.hover()), | |
| 206 | 337 | hover_fill: Some(theme.hover()), |
| 207 | 338 | radius: 4.0, |
| 208 | center: true, | |
| 209 | 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 | 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 | } |
| 215 | 365 | |
| 216 | 366 | /// A drop-down box `width` wide showing `text`. |
| ... | ... | @@ -295,7 +445,8 @@ impl Ui { |
| 295 | 445 | |
| 296 | 446 | /// Rounds the corners of the clockwise outline `points`, as `border` does: paints |
| 297 | 447 | /// `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 | 450 | pub fn round_corners( |
| 300 | 451 | &mut self, |
| 301 | 452 | points: &[([f32; 2], f32)], |
| ... | ... | @@ -311,6 +462,16 @@ impl Ui { |
| 311 | 462 | corner.start[0] - corner.point[0], |
| 312 | 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 | 475 | self.beneath_popups(Display::Path { |
| 315 | 476 | data: format!( |
| 316 | 477 | "M{} {}{}L0 0Z", |
| ... | ... | @@ -319,12 +480,8 @@ impl Ui { |
| 319 | 480 | corner.curve(corner.point) |
| 320 | 481 | ), |
| 321 | 482 | origin: corner.point, |
| 322 | style: PathStyle::Fill, | |
| 323 | colors: [if corner.outward { | |
| 324 | outside(corner.point[1]) | |
| 325 | } else { | |
| 326 | inside | |
| 327 | }; 2], | |
| 483 | style, | |
| 484 | colors: [color; 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 | 61 | assert_eq!(ui.rect(ids[1]), Some([280.0, 0.0, 400.0, 100.0])); |
| 62 | 62 | } |
| 63 | 63 | |
| 64 | #[test] | |
| 65 | fn 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 | 94 | #[test] |
| 65 | 95 | fn children_sum_with_gaps_and_padding() { |
| 66 | 96 | let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); |
| ... | ... | @@ -327,10 +357,10 @@ fn wheel_scrolls_within_the_content_and_clips_children() { |
| 327 | 357 | let layers = ui.layers(); |
| 328 | 358 | assert!(matches!( |
| 329 | 359 | layers[..], |
| 330 | [Layer::Primitives { | |
| 360 | [Layer::Primitives(Primitives { | |
| 331 | 361 | clip: Some([0.0, 0.0, 100.0, 100.0]), |
| 332 | 362 | .. |
| 333 | }] | [] | |
| 363 | })] | [] | |
| 334 | 364 | )); |
| 335 | 365 | } |
| 336 | 366 | |
| ... | ... | @@ -424,6 +454,20 @@ fn text_fields_edit_with_keys_and_selection() { |
| 424 | 454 | assert_eq!(text, ""); |
| 425 | 455 | } |
| 426 | 456 | |
| 457 | #[test] | |
| 458 | fn 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 | 471 | #[test] |
| 428 | 472 | #[cfg_attr(not(target_vendor = "apple"), ignore = "macOS editing conventions")] |
| 429 | 473 | 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 | 492 | .layers() |
| 449 | 493 | .iter() |
| 450 | 494 | .flat_map(|layer| match layer { |
| 451 | Layer::Primitives { primitives, .. } => primitives.as_slice(), | |
| 495 | Layer::Primitives(layer) => layer.primitives.as_slice(), | |
| 452 | 496 | Layer::Custom { .. } => &[], |
| 453 | 497 | }) |
| 454 | 498 | .find_map(|primitive| match primitive { |
| ... | ... | @@ -467,9 +511,16 @@ fn text_field_carets_fade_on_appkit_s_blink_and_restart_when_moved() { |
| 467 | 511 | assert_eq!(at(&mut ui, 700).0, faded); |
| 468 | 512 | assert_eq!(at(&mut ui, 900).0, 0.0); |
| 469 | 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 | 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 | } |
| 474 | 525 | |
| 475 | 526 | #[test] |
| ... | ... | @@ -1397,8 +1448,9 @@ fn a_filtered_menu_eases_to_its_new_height_while_its_rows_appear_in_place() { |
| 1397 | 1448 | let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); |
| 1398 | 1449 | build(&mut ui); |
| 1399 | 1450 | ui.open_popup(menu_id()); |
| 1400 | build(&mut ui); | |
| 1401 | build(&mut ui); | |
| 1451 | for _ in 0..12 { | |
| 1452 | build(&mut ui); | |
| 1453 | } | |
| 1402 | 1454 | let results = menu_id().child("results"); |
| 1403 | 1455 | let height = |ui: &Ui| { |
| 1404 | 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 | 1458 | }; |
| 1407 | 1459 | let tall = height(&ui); |
| 1408 | 1460 | assert!( |
| 1409 | !ui.wants_frame() && tall > 5.0 * 26.0, | |
| 1461 | !ui.wants_frame() && tall > 5.0 * popup::MENU_ROW, | |
| 1410 | 1462 | "open at its full height" |
| 1411 | 1463 | ); |
| 1412 | 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 | 1471 | "Times New Roman at the top" |
| 1420 | 1472 | ); |
| 1421 | 1473 | let easing = height(&ui); |
| 1422 | assert!(easing > 26.0 && easing < tall, "{easing}"); | |
| 1474 | assert!(easing > popup::MENU_ROW && easing < tall, "{easing}"); | |
| 1423 | 1475 | let mut frames = 0; |
| 1424 | 1476 | while ui.wants_frame() { |
| 1425 | 1477 | build(&mut ui); |
| 1426 | 1478 | frames += 1; |
| 1427 | 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 | 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. | |
| 1486 | fn 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 | ||
| 1521 | fn advances(label: &Label) -> Vec<f32> { | |
| 1522 | label | |
| 1523 | .layout | |
| 1524 | .lines() | |
| 1525 | .map(|line| line.metrics().advance) | |
| 1526 | .collect() | |
| 1527 | } | |
| 1528 | ||
| 1529 | #[test] | |
| 1530 | fn 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] | |
| 1557 | fn 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] | |
| 1571 | fn 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] | |
| 1593 | fn 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] | |
| 1622 | fn 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] | |
| 1674 | fn 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. | |
| 1696 | fn 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)] | |
| 1709 | struct 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. | |
| 1716 | fn 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] | |
| 1762 | fn 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] | |
| 1796 | fn 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 | ||
| 1839 | fn 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. | |
| 1844 | fn 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 | ||
| 1867 | const CHECK_ICON: &[&str] = popup::CHECK; | |
| 1868 | ||
| 1869 | fn split_menu() -> Id { | |
| 1870 | Id::ROOT.child("split menu") | |
| 1871 | } | |
| 1872 | ||
| 1873 | fn split_part(part: &str) -> Id { | |
| 1874 | Id::ROOT.child("bar").child("split").child(part) | |
| 1875 | } | |
| 1876 | ||
| 1877 | fn center(rect: [f32; 4]) -> [f32; 2] { | |
| 1878 | [(rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0] | |
| 1879 | } | |
| 1880 | ||
| 1881 | #[test] | |
| 1882 | fn 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] | |
| 1926 | fn 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] | |
| 1978 | fn 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 | 1 | use draw::{GlyphRun, Glyphs, RenderError}; |
| 2 | 2 | use parley::{ |
| 3 | FontContext, FontFamily, FontFamilyName, GenericFamily, Layout, LayoutContext, | |
| 4 | PositionedLayoutItem, StyleProperty, fontique::Blob, | |
| 3 | Alignment, AlignmentOptions, Cluster, FontContext, FontFamily, FontFamilyName, GenericFamily, | |
| 4 | Layout, LayoutContext, OverflowWrap, PositionedLayoutItem, StyleProperty, fontique::Blob, | |
| 5 | 5 | }; |
| 6 | use std::{borrow::Cow, collections::HashMap, rc::Rc, sync::Arc}; | |
| 6 | use std::{borrow::Cow, collections::HashMap, rc::Rc}; | |
| 7 | 7 | |
| 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. | |
| 9 | 9 | pub(crate) struct Label { |
| 10 | 10 | pub layout: Layout<()>, |
| 11 | 11 | pub size: [f32; 2], |
| 12 | pub key: LabelKey, | |
| 12 | 13 | } |
| 13 | 14 | |
| 14 | 15 | type LabelKey = (String, u32, bool, Option<String>); |
| ... | ... | @@ -18,8 +19,6 @@ type LabelKey = (String, u32, bool, Option<String>); |
| 18 | 19 | pub(crate) struct Texts { |
| 19 | 20 | fonts: FontContext, |
| 20 | 21 | context: LayoutContext<()>, |
| 21 | /// A family registered in place of the system's interface font. | |
| 22 | family: Option<String>, | |
| 23 | 22 | /// By text, size, weight and preferred family, with the frame each was last used. |
| 24 | 23 | cache: HashMap<LabelKey, (Rc<Label>, u64)>, |
| 25 | 24 | } |
| ... | ... | @@ -34,7 +33,12 @@ impl Texts { |
| 34 | 33 | font: Option<&str>, |
| 35 | 34 | frame: u64, |
| 36 | 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 | 42 | if let Some((label, touched)) = self.cache.get_mut(&key) { |
| 39 | 43 | *touched = frame; |
| 40 | 44 | return label.clone(); |
| ... | ... | @@ -47,7 +51,6 @@ impl Texts { |
| 47 | 51 | [GenericFamily::SystemUi, GenericFamily::SansSerif].map(FontFamilyName::Generic); |
| 48 | 52 | let families: Vec<_> = font |
| 49 | 53 | .into_iter() |
| 50 | .chain(self.family.as_deref()) | |
| 51 | 54 | .map(FontFamilyName::named) |
| 52 | 55 | .chain(system) |
| 53 | 56 | .collect(); |
| ... | ... | @@ -55,29 +58,40 @@ impl Texts { |
| 55 | 58 | families, |
| 56 | 59 | )))); |
| 57 | 60 | builder.push_default(StyleProperty::FontSize(size)); |
| 61 | builder.push_default(StyleProperty::OverflowWrap(OverflowWrap::Anywhere)); | |
| 58 | 62 | if bold { |
| 59 | 63 | builder.push_default(StyleProperty::FontWeight(parley::FontWeight::SEMI_BOLD)); |
| 60 | 64 | } |
| 61 | 65 | let mut layout = builder.build(text); |
| 62 | 66 | layout.break_all_lines(None); |
| 63 | 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 | 73 | self.cache.insert(key, (label.clone(), frame)); |
| 66 | 74 | label |
| 67 | 75 | } |
| 68 | 76 | |
| 69 | /// Shapes labels in the family `files` define, falling back to the system's | |
| 70 | /// interface font; returns the family's name, or None when the files hold no font. | |
| 71 | pub fn use_fonts(&mut self, files: impl IntoIterator<Item = Vec<u8>>) -> Option<String> { | |
| 72 | let collection = &mut self.fonts.collection; | |
| 73 | let families: Vec<_> = files | |
| 74 | .into_iter() | |
| 75 | .flat_map(|file| collection.register_fonts(Blob::new(Arc::new(file)), None)) | |
| 76 | .collect(); | |
| 77 | let family = collection.family_name(families.first()?.0)?.to_owned(); | |
| 78 | self.family = Some(family.clone()); | |
| 79 | self.cache.clear(); | |
| 80 | Some(family) | |
| 77 | /// `label` cut short to fit `width` with an ellipsis. | |
| 78 | pub fn ellipsis(&mut self, label: &Label, width: f32, frame: u64) -> Rc<Label> { | |
| 79 | let (text, size, bold, font) = &label.key; | |
| 80 | let (size, font) = (f32::from_bits(*size), font.as_deref()); | |
| 81 | let room = width - self.label("…", size, *bold, font, frame).size[0]; | |
| 82 | let mut end = 0; | |
| 83 | let mut advance = 0.0; | |
| 84 | let mut cluster = Cluster::from_byte_index(&label.layout, 0); | |
| 85 | while let Some(next) = cluster { | |
| 86 | advance += next.advance(); | |
| 87 | if advance > room { | |
| 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 | } |
| 82 | 96 | |
| 83 | 97 | pub fn preview_font(&mut self, data: Blob<u8>, family: &str) { |
| ... | ... | @@ -96,6 +110,25 @@ impl Texts { |
| 96 | 110 | } |
| 97 | 111 | } |
| 98 | 112 | |
| 113 | impl 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 | ||
| 99 | 132 | impl Glyphs for Label { |
| 100 | 133 | fn runs( |
| 101 | 134 | &self, |
crates/ui/src/theme.rs+119-1| ... | ... | @@ -8,7 +8,8 @@ pub struct Theme { |
| 8 | 8 | pub base: [f32; 4], |
| 9 | 9 | /// Side panels. |
| 10 | 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 | 13 | pub strip: [f32; 4], |
| 13 | 14 | pub accent: [f32; 4], |
| 14 | 15 | /// The text caret, and selected text's fill with and without keyboard focus; the |
| ... | ... | @@ -65,6 +66,14 @@ pub struct Section { |
| 65 | 66 | pub accent: [f32; 4], |
| 66 | 67 | } |
| 67 | 68 | |
| 69 | impl 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 | ||
| 68 | 77 | impl Theme { |
| 69 | 78 | /// After File Pilot's default dark scheme. |
| 70 | 79 | pub fn dark() -> Self { |
| ... | ... | @@ -123,11 +132,61 @@ impl Theme { |
| 123 | 132 | } |
| 124 | 133 | } |
| 125 | 134 | |
| 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 | 148 | /// Hovered controls: the accent over the base. |
| 127 | 149 | pub fn hover(&self) -> [f32; 4] { |
| 128 | 150 | crate::mix(self.base, self.accent, 0.35) |
| 129 | 151 | } |
| 130 | 152 | |
| 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 | 190 | /// The colours a section takes from the hue of `color`, linear RGBA. |
| 132 | 191 | pub fn section(&self, color: [f32; 4]) -> Section { |
| 133 | 192 | let hue = hue(color); |
| ... | ... | @@ -147,6 +206,9 @@ impl Theme { |
| 147 | 206 | } |
| 148 | 207 | } |
| 149 | 208 | |
| 209 | /// Least OKLab lightness difference between icons' accent marks and the toolbar. | |
| 210 | const ICON_CONTRAST: f32 = 0.4; | |
| 211 | ||
| 150 | 212 | /// The hue in degrees of a linear colour, as it looks in sRGB. |
| 151 | 213 | fn hue(color: [f32; 4]) -> f32 { |
| 152 | 214 | let [r, g, b] = [color[0], color[1], color[2]].map(|value| { |
| ... | ... | @@ -195,4 +257,60 @@ mod tests { |
| 195 | 257 | assert_eq!(hsl(0.0, 1.0, 0.5), srgb(0xff, 0, 0)); |
| 196 | 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 | 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 | 49 | ui.leaf( |
| 49 | 50 | "box", |
| 50 | 51 | Spec { |
| 51 | 52 | size: [px(side), px(height)], |
| 52 | 53 | inset: [0.0, margin, 0.0, margin], |
| 53 | 54 | fill: Some(if checked { theme.accent } else { theme.base }), |
| 54 | border: Some(if checked || hovered { | |
| 55 | border: Some(if checked || signal.hovered { | |
| 55 | 56 | theme.accent |
| 56 | 57 | } else { |
| 57 | 58 | theme.chip |
| ... | ... | @@ -71,7 +72,7 @@ pub fn check_box(ui: &mut Ui, part: impl Hash, label: &str, checked: bool) -> Si |
| 71 | 72 | }, |
| 72 | 73 | ); |
| 73 | 74 | ui.close(); |
| 74 | ui.signal(id) | |
| 75 | signal | |
| 75 | 76 | } |
| 76 | 77 | |
| 77 | 78 | /// An overlay scrollbar along the far edge of the current box, for content whose scroll |
| ... | ... | @@ -151,9 +152,9 @@ pub fn text_field( |
| 151 | 152 | .zip(ui.rect(id)) |
| 152 | 153 | .map(|(pointer, rect)| pointer[0] - rect[0] - pad) |
| 153 | 154 | .filter(|_| signal.pressed || signal.dragging); |
| 154 | let (mut selection, mut press) = { | |
| 155 | let (selection, press) = ui.field(id); | |
| 156 | (*selection, *press) | |
| 155 | let (mut selection, mut press, select_all) = { | |
| 156 | let (selection, press, select_all) = ui.field(id); | |
| 157 | (*selection, *press, std::mem::take(select_all)) | |
| 157 | 158 | }; |
| 158 | 159 | let before = [selection.anchor(), selection.focus()]; |
| 159 | 160 | let (texts, frame) = ui.texts(); |
| ... | ... | @@ -181,7 +182,14 @@ pub fn text_field( |
| 181 | 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 | 193 | for event in &signal.events { |
| 186 | 194 | let layout = &label.layout; |
| 187 | 195 | let (range, inserted) = match event { |
| ... | ... | @@ -192,7 +200,14 @@ pub fn text_field( |
| 192 | 200 | // page keys to scroll. |
| 193 | 201 | let command = match (Command::from_key(&key, modifiers), &key) { |
| 194 | 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 | 211 | (None, edit::Key::Named(edit::NamedKey::Home)) => { |
| 197 | 212 | Some(Command::Move(Movement::DocumentStart)) |
| 198 | 213 | } |
| ... | ... | @@ -254,7 +269,7 @@ pub fn text_field( |
| 254 | 269 | } |
| 255 | 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 | 273 | *stored = selection; |
| 259 | 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] | |
| 13 | edition = "2021" | |
| 14 | name = "sctk-adwaita" | |
| 15 | version = "0.10.1" | |
| 16 | authors = ["Poly <marynczak.bartlomiej@gmail.com>"] | |
| 17 | description = "Adwaita-like SCTK Frame" | |
| 18 | documentation = "https://docs.rs/sctk-adwaita" | |
| 19 | readme = "README.md" | |
| 20 | keywords = ["sctk"] | |
| 21 | license = "MIT" | |
| 22 | repository = "https://github.com/PolyMeilex/sctk-adwaita" | |
| 23 | ||
| 24 | [dependencies.ab_glyph] | |
| 25 | version = "0.2.17" | |
| 26 | optional = true | |
| 27 | ||
| 28 | [dependencies.crossfont] | |
| 29 | version = "0.8.0" | |
| 30 | optional = true | |
| 31 | ||
| 32 | [dependencies.log] | |
| 33 | version = "0.4" | |
| 34 | ||
| 35 | [dependencies.memmap2] | |
| 36 | version = "0.9.0" | |
| 37 | optional = true | |
| 38 | ||
| 39 | [dependencies.smithay-client-toolkit] | |
| 40 | version = "0.19.0" | |
| 41 | default_features = false | |
| 42 | ||
| 43 | [dependencies.tiny-skia] | |
| 44 | version = "0.11" | |
| 45 | features = [ | |
| 46 | "std", | |
| 47 | "simd", | |
| 48 | ] | |
| 49 | default-features = false | |
| 50 | ||
| 51 | [features] | |
| 52 | ab_glyph = [ | |
| 53 | "dep:ab_glyph", | |
| 54 | "memmap2", | |
| 55 | ] | |
| 56 | crossfont = ["dep:crossfont"] | |
| 57 | default = ["ab_glyph"] | |
| 58 | ||
| 59 | # Upstream predates this lint; a path dependency's warnings are not capped. | |
| 60 | [lints.rust] | |
| 61 | mismatched_lifetime_syntaxes = "allow" |
vendor/sctk-adwaita/LICENSE created+21| ... | ... | @@ -0,0 +1,21 @@ |
| 1 | MIT License | |
| 2 | ||
| 3 | Copyright (c) 2022 Bartłomiej Maryńczak | |
| 4 | ||
| 5 | Permission is hereby granted, free of charge, to any person obtaining a copy | |
| 6 | of this software and associated documentation files (the "Software"), to deal | |
| 7 | in the Software without restriction, including without limitation the rights | |
| 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| 9 | copies of the Software, and to permit persons to whom the Software is | |
| 10 | furnished to do so, subject to the following conditions: | |
| 11 | ||
| 12 | The above copyright notice and this permission notice shall be included in all | |
| 13 | copies or substantial portions of the Software. | |
| 14 | ||
| 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
| 21 | SOFTWARE. |
vendor/sctk-adwaita/README.md created+19| ... | ... | @@ -0,0 +1,19 @@ |
| 1 | # Adwaita-like SCTK Frame | |
| 2 | ||
| 3 | | | | | |
| 4 | |---|---| | |
| 5 | ||| | |
| 6 | | | |
| 7 | ||
| 8 | ### Dark mode: | |
| 9 |  | |
| 10 | ||
| 11 | ## Title text: ab_glyph | |
| 12 | By default title text is drawn with _ab_glyph_ crate. This can be disabled by disabling default features. | |
| 13 | ||
| 14 | ## Title text: crossfont | |
| 15 | Alternatively title text may be drawn with _crossfont_ crate. This adds a requirement on _freetype_. | |
| 16 | ||
| 17 | ```toml | |
| 18 | sctk-adwaita = { default-features = false, features = ["crossfont"] } | |
| 19 | ``` |
vendor/sctk-adwaita/SNOWBOUND.md created+34| ... | ... | @@ -0,0 +1,34 @@ |
| 1 | # sctk-adwaita, vendored | |
| 2 | ||
| 3 | Upstream: [sctk-adwaita](https://github.com/PolyMeilex/sctk-adwaita) 0.10.1 from | |
| 4 | crates.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 | |
| 9 | release, since Cargo does not cap a path dependency's warnings. The one source | |
| 10 | change drops the 1 px border line under the header bar, so the title | |
| 11 | bar 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 | ||
| 31 | To bump: copy the new release's `src/`, `Cargo.toml`, `LICENSE`, `README.md` | |
| 32 | and `CHANGELOG.md` over this folder, reapply the diff, and update the version | |
| 33 | above. The patch applies only while the version here satisfies winit's | |
| 34 | requirement; `cargo tree -i sctk-adwaita` shows the path source when it does. |
vendor/sctk-adwaita/src/buttons.rs created+382| ... | ... | @@ -0,0 +1,382 @@ |
| 1 | use log::{debug, warn}; | |
| 2 | use smithay_client_toolkit::reexports::csd_frame::{WindowManagerCapabilities, WindowState}; | |
| 3 | use tiny_skia::{FillRule, PathBuilder, PixmapMut, Rect, Stroke, Transform}; | |
| 4 | ||
| 5 | use crate::{theme::ColorMap, Location, SkiaResult}; | |
| 6 | ||
| 7 | /// The size of the button on the header bar in logical points. | |
| 8 | const BUTTON_SIZE: f32 = 24.; | |
| 9 | const BUTTON_MARGIN: f32 = 5.; | |
| 10 | const BUTTON_SPACING: f32 = 13.; | |
| 11 | ||
| 12 | #[derive(Debug)] | |
| 13 | pub(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 | ||
| 20 | type ButtonLayout = (Vec<Button>, Vec<Button>); | |
| 21 | ||
| 22 | impl 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 | ||
| 34 | impl 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)] | |
| 201 | pub(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 | ||
| 208 | impl 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)] | |
| 372 | pub enum ButtonKind { | |
| 373 | Close, | |
| 374 | Maximize, | |
| 375 | Minimize, | |
| 376 | } | |
| 377 | ||
| 378 | #[derive(Debug, Copy, Clone, PartialEq, Eq)] | |
| 379 | pub enum Side { | |
| 380 | Left, | |
| 381 | Right, | |
| 382 | } |
vendor/sctk-adwaita/src/config.rs created+56| ... | ... | @@ -0,0 +1,56 @@ |
| 1 | //! System configuration. | |
| 2 | use std::process::Command; | |
| 3 | ||
| 4 | /// Query system to see if dark theming should be preferred. | |
| 5 | pub(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 | |
| 29 | pub(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 @@ |
| 1 | use std::error::Error; | |
| 2 | use std::mem; | |
| 3 | use std::num::NonZeroU32; | |
| 4 | use std::sync::Arc; | |
| 5 | use std::time::Duration; | |
| 6 | ||
| 7 | use tiny_skia::{ | |
| 8 | Color, FillRule, Mask, Path, PathBuilder, Pixmap, PixmapMut, PixmapPaint, Point, Rect, | |
| 9 | Transform, | |
| 10 | }; | |
| 11 | ||
| 12 | use smithay_client_toolkit::reexports::client::backend::ObjectId; | |
| 13 | use smithay_client_toolkit::reexports::client::protocol::wl_shm; | |
| 14 | use smithay_client_toolkit::reexports::client::protocol::wl_subsurface::WlSubsurface; | |
| 15 | use smithay_client_toolkit::reexports::client::protocol::wl_surface::WlSurface; | |
| 16 | use smithay_client_toolkit::reexports::client::{Dispatch, Proxy, QueueHandle}; | |
| 17 | use smithay_client_toolkit::reexports::csd_frame::{ | |
| 18 | CursorIcon, DecorationsFrame, FrameAction, FrameClick, WindowManagerCapabilities, WindowState, | |
| 19 | }; | |
| 20 | ||
| 21 | use smithay_client_toolkit::compositor::{CompositorState, Region, SurfaceData}; | |
| 22 | use smithay_client_toolkit::shell::WaylandSurface; | |
| 23 | use smithay_client_toolkit::shm::{slot::SlotPool, Shm}; | |
| 24 | use smithay_client_toolkit::subcompositor::SubcompositorState; | |
| 25 | use smithay_client_toolkit::subcompositor::SubsurfaceData; | |
| 26 | ||
| 27 | mod buttons; | |
| 28 | mod config; | |
| 29 | mod parts; | |
| 30 | mod pointer; | |
| 31 | mod shadow; | |
| 32 | pub mod theme; | |
| 33 | mod title; | |
| 34 | mod wl_typed; | |
| 35 | ||
| 36 | use crate::theme::{ | |
| 37 | ColorMap, ColorTheme, BORDER_SIZE, CORNER_RADIUS, HEADER_SIZE, RESIZE_HANDLE_CORNER_SIZE, | |
| 38 | VISIBLE_BORDER_SIZE, | |
| 39 | }; | |
| 40 | ||
| 41 | use buttons::Buttons; | |
| 42 | use config::get_button_layout_config; | |
| 43 | use parts::DecorationParts; | |
| 44 | use pointer::{Location, MouseState}; | |
| 45 | use shadow::Shadow; | |
| 46 | use title::TitleText; | |
| 47 | use wl_typed::WlTyped; | |
| 48 | ||
| 49 | /// XXX this is not result, so `must_use` when needed. | |
| 50 | type SkiaResult = Option<()>; | |
| 51 | ||
| 52 | /// A simple set of decorations | |
| 53 | #[derive(Debug)] | |
| 54 | pub 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 | ||
| 94 | impl<State> AdwaitaFrame<State> | |
| 95 | where | |
| 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 | ||
| 379 | impl<State> DecorationsFrame for AdwaitaFrame<State> | |
| 380 | where | |
| 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)] | |
| 546 | pub struct FrameConfig { | |
| 547 | pub theme: ColorTheme, | |
| 548 | } | |
| 549 | ||
| 550 | impl 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)] | |
| 585 | fn 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] | |
| 677 | fn 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 | ||
| 705 | fn 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 | |
| 767 | fn 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 @@ |
| 1 | use smithay_client_toolkit::reexports::client::{ | |
| 2 | backend::ObjectId, | |
| 3 | protocol::{wl_subsurface::WlSubsurface, wl_surface::WlSurface}, | |
| 4 | Dispatch, Proxy, QueueHandle, | |
| 5 | }; | |
| 6 | ||
| 7 | use smithay_client_toolkit::{ | |
| 8 | compositor::SurfaceData, | |
| 9 | subcompositor::{SubcompositorState, SubsurfaceData}, | |
| 10 | }; | |
| 11 | ||
| 12 | use crate::theme::{BORDER_SIZE, HEADER_SIZE, RESIZE_HANDLE_SIZE}; | |
| 13 | use crate::{pointer::Location, wl_typed::WlTyped}; | |
| 14 | ||
| 15 | /// The decoration's 'parts'. | |
| 16 | #[derive(Debug)] | |
| 17 | pub struct DecorationParts { | |
| 18 | parts: [Part; 5], | |
| 19 | } | |
| 20 | ||
| 21 | impl 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)] | |
| 206 | pub struct Rect { | |
| 207 | pub x: i32, | |
| 208 | pub y: i32, | |
| 209 | pub width: u32, | |
| 210 | pub height: u32, | |
| 211 | } | |
| 212 | ||
| 213 | #[derive(Debug)] | |
| 214 | pub 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 | ||
| 226 | impl 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 | ||
| 255 | impl 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 @@ |
| 1 | use std::time::Duration; | |
| 2 | ||
| 3 | use smithay_client_toolkit::reexports::csd_frame::{ | |
| 4 | CursorIcon, FrameAction, ResizeEdge, WindowManagerCapabilities, WindowState, | |
| 5 | }; | |
| 6 | ||
| 7 | use 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. | |
| 15 | const DOUBLE_CLICK_DURATION: Duration = Duration::from_millis(400); | |
| 16 | ||
| 17 | /// The state of the mouse input inside the decorations frame. | |
| 18 | #[derive(Debug, Default)] | |
| 19 | pub(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 | ||
| 29 | impl 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)] | |
| 129 | pub 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 @@ |
| 1 | use crate::{parts::DecorationParts, theme}; | |
| 2 | use std::collections::BTreeMap; | |
| 3 | use 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 | |
| 7 | pub const SHADOW_SIZE: u32 = 43; | |
| 8 | const SHADOW_PARAMS_ACTIVE: (f32, f32, f32) = (0.206_505_5, 0.104_617_53, -0.000_542_446_2); | |
| 9 | const SHADOW_PARAMS_INACTIVE: (f32, f32, f32) = (0.168_297_29, 0.204_299_8, 0.001_769_798_6); | |
| 10 | ||
| 11 | fn 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)] | |
| 22 | struct RenderedShadow { | |
| 23 | side: Pixmap, | |
| 24 | edges: Pixmap, | |
| 25 | } | |
| 26 | ||
| 27 | impl 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)] | |
| 323 | struct CachedPart { | |
| 324 | pixmap: Pixmap, | |
| 325 | scale: u32, | |
| 326 | active: bool, | |
| 327 | } | |
| 328 | ||
| 329 | impl 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)] | |
| 362 | pub struct Shadow { | |
| 363 | part_cache: [Option<CachedPart>; 5], | |
| 364 | // (scale, active) -> RenderedShadow | |
| 365 | rendered: BTreeMap<(u32, bool), RenderedShadow>, | |
| 366 | } | |
| 367 | ||
| 368 | impl 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 @@ |
| 1 | pub use tiny_skia::Color; | |
| 2 | use tiny_skia::{Paint, Shader}; | |
| 3 | ||
| 4 | // https://gitlab.gnome.org/GNOME/gtk/-/blob/1bf88f1d81043fd99740e2f91e56ade7ede7303b/gtk/gtkwindow.c#L165 | |
| 5 | pub(crate) const RESIZE_HANDLE_SIZE: u32 = 12; | |
| 6 | // https://gitlab.gnome.org/GNOME/gtk/-/blob/1bf88f1d81043fd99740e2f91e56ade7ede7303b/gtk/gtkwindow.c#L166 | |
| 7 | pub(crate) const RESIZE_HANDLE_CORNER_SIZE: u32 = 24; | |
| 8 | pub(crate) const BORDER_SIZE: u32 = crate::shadow::SHADOW_SIZE + VISIBLE_BORDER_SIZE; | |
| 9 | pub(crate) const HEADER_SIZE: u32 = 35; | |
| 10 | pub(crate) const CORNER_RADIUS: u32 = 10; | |
| 11 | pub(crate) const VISIBLE_BORDER_SIZE: u32 = 1; | |
| 12 | ||
| 13 | /// The color theme to use with the decorations frame. | |
| 14 | #[derive(Debug, Clone)] | |
| 15 | pub struct ColorTheme { | |
| 16 | pub active: ColorMap, | |
| 17 | pub inactive: ColorMap, | |
| 18 | } | |
| 19 | ||
| 20 | impl 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 | ||
| 84 | impl 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)] | |
| 92 | pub 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 | ||
| 101 | impl 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 @@ |
| 1 | use tiny_skia::{Color, Pixmap}; | |
| 2 | ||
| 3 | #[cfg(any(feature = "crossfont", feature = "ab_glyph"))] | |
| 4 | mod config; | |
| 5 | #[cfg(any(feature = "crossfont", feature = "ab_glyph"))] | |
| 6 | mod font_preference; | |
| 7 | ||
| 8 | #[cfg(feature = "crossfont")] | |
| 9 | mod crossfont_renderer; | |
| 10 | ||
| 11 | #[cfg(all(not(feature = "crossfont"), feature = "ab_glyph"))] | |
| 12 | mod ab_glyph_renderer; | |
| 13 | ||
| 14 | #[cfg(all(not(feature = "crossfont"), not(feature = "ab_glyph")))] | |
| 15 | mod dumb; | |
| 16 | ||
| 17 | #[derive(Debug)] | |
| 18 | pub 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 | ||
| 27 | impl 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. | |
| 7 | use crate::title::{config, font_preference::FontPreference}; | |
| 8 | use ab_glyph::{point, Font, FontRef, Glyph, PxScale, PxScaleFont, ScaleFont, VariableFont}; | |
| 9 | use std::{fs::File, process::Command}; | |
| 10 | use tiny_skia::{Color, Pixmap, PremultipliedColorU8}; | |
| 11 | ||
| 12 | const CANTARELL: &[u8] = include_bytes!("Cantarell-Regular.ttf"); | |
| 13 | ||
| 14 | #[derive(Debug)] | |
| 15 | pub 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 | ||
| 24 | impl 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. | |
| 152 | fn 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 | |
| 180 | fn 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 | ||
| 196 | fn 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. | |
| 2 | use crate::title::font_preference::FontPreference; | |
| 3 | use std::process::Command; | |
| 4 | ||
| 5 | /// Query system for which font to use for window titles. | |
| 6 | pub(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 @@ |
| 1 | use std::mem; | |
| 2 | ||
| 3 | use crossfont::{GlyphKey, Rasterize, RasterizedGlyph}; | |
| 4 | use tiny_skia::{Color, Pixmap, PixmapPaint, PixmapRef, Transform}; | |
| 5 | ||
| 6 | use crate::title::config; | |
| 7 | ||
| 8 | pub 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 | ||
| 22 | impl 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 | ||
| 35 | impl 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 @@ |
| 1 | use tiny_skia::{Color, Pixmap}; | |
| 2 | ||
| 3 | #[derive(Debug)] | |
| 4 | pub struct DumbTitleText {} | |
| 5 | ||
| 6 | impl 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)] | |
| 2 | pub(crate) struct FontPreference { | |
| 3 | pub name: String, | |
| 4 | pub style: Option<String>, | |
| 5 | pub pt_size: f32, | |
| 6 | } | |
| 7 | ||
| 8 | impl 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 | ||
| 18 | impl 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] | |
| 55 | fn 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] | |
| 63 | fn 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] | |
| 71 | fn 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] | |
| 79 | fn 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] | |
| 86 | fn 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 @@ |
| 1 | use std::{marker::PhantomData, ops::Deref}; | |
| 2 | ||
| 3 | use smithay_client_toolkit::reexports::client::{Dispatch, Proxy}; | |
| 4 | ||
| 5 | #[derive(Debug)] | |
| 6 | pub struct WlTyped<I, DATA>(I, PhantomData<DATA>); | |
| 7 | ||
| 8 | impl<I, DATA> WlTyped<I, DATA> | |
| 9 | where | |
| 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 | ||
| 33 | impl<I: Clone, D> Clone for WlTyped<I, D> { | |
| 34 | fn clone(&self) -> Self { | |
| 35 | Self(self.0.clone(), PhantomData) | |
| 36 | } | |
| 37 | } | |
| 38 | ||
| 39 | impl<I, D> Deref for WlTyped<I, D> { | |
| 40 | type Target = I; | |
| 41 | ||
| 42 | fn deref(&self) -> &Self::Target { | |
| 43 | &self.0 | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 47 | impl<I: PartialEq, D> PartialEq for WlTyped<I, D> { | |
| 48 | fn eq(&self, other: &Self) -> bool { | |
| 49 | self.0 == other.0 | |
| 50 | } | |
| 51 | } | |
| 52 | impl<I: Eq, D> Eq for WlTyped<I, D> {} |