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

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

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

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

AGENTS.md+18-11
......@@ -4,8 +4,9 @@ Snowbound is a modern remake of OneNote 2010 that stays fully interoperable
44with it. Notes are rich text on a free canvas, stored in OneNote's own `.one`
55files, so Snowbound and OneNote 2010 can open the same notebook side by side.
66A notebook is just a folder. Put it on an SMB share and several people can
7edit it together from either app, with no cloud service involved. Snowbound
8runs on macOS and Linux, and has an iOS app built around the same core.
7edit it together from either app, with no cloud service involved. With headless
8crates implementing the file format and a generic canvas renderer made with a
9novel UI kit, each new platform port is extremely lightweight.
910
1011## Crates
1112
......@@ -39,7 +40,6 @@ these pieces meet.
3940 in the Windows 7 lab (`tools/w7`) before deciding. A storage feature is done
4041 when a file Snowbound wrote cold-opens in a fresh OneNote and reads back as
4142 intended ([testing](arc/testing.md)).
42- **Jujutsu, not git.** The repository is managed with `jj`. Don't run `git`.
4343- **Disposable copies only.** Never point the app, a replay script or a lab
4444 harness at an original notebook.
4545
......@@ -48,15 +48,15 @@ these pieces meet.
4848A `.one` file is a revision store. Every client, OneNote included, commits by
4949appending a revision and then rewriting the 1024-byte header, which makes the
5050header plus the file length a cheap *stamp* of the committed state. Snowbound
51queues edits as ops in a local SQLite replica. A background thread polls the
52stamp. If the stamp is unchanged, it publishes the queued batch as one
51queues edits as ops in a local SQLite replica, then a background thread polls
52the stamp. If the stamp is unchanged, it publishes the queued batch as one
5353appended revision. If it has changed, it reads the file once and replays the
54queue on top. Where an op can't merge, the result is what OneNote 2010 makes:
55a read-only conflict page under the page. OneNote coordinates writers through
56share 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
58takes exactly OneNote's locks. Offline is just a sync step that fails: the
59queue waits and publishes later. [More in the sync essay.](arc/sync.md)
54queue on top. Where an op can't merge, the result is what OneNote 2010 makes: a
55read-only conflict page under the page. OneNote coordinates writers through
56share modes and one-byte locks on the section file itself. These are special SMB
57protocols that only Windows supports, so Snowbound carries its own SMB client
58that can properly issue them. Offline is just a sync step that fails: the queue
59waits and publishes later. [More in the sync essay.](arc/sync.md)
6060
6161## Architecture notes
6262
......@@ -90,3 +90,10 @@ python3 tools/canvas/build_macos.py && target/Snowbound.app/Contents/MacOS/Snowb
9090The MS-ONESTORE and MS-ONE specifications are Microsoft Open Specifications.
9191Local copies, design history and raw lab evidence are kept out of version
9292control.
93
94External contributors (not agents, will be denied unless a human writes to me)
95are welcome to email `git@paperclover.net` to gain access to the testing VM
96images for their agents. Alternatively, a custom image can be configured with a
97licensed copy of Windows and OneNote. By connecting `tools/w7/mcp_*.py`, the
98agent can interactively play around in with the actual prior art, in addition
99to running tests.
Cargo.lock+1-2
......@@ -2953,8 +2953,6 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
29532953[[package]]
29542954name = "sctk-adwaita"
29552955version = "0.10.1"
2956source = "registry+https://github.com/rust-lang/crates.io-index"
2957checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
29582956dependencies = [
29592957 "ab_glyph",
29602958 "log",
......@@ -3247,6 +3245,7 @@ dependencies = [
32473245 "parley",
32483246 "png",
32493247 "pollster",
3248 "sctk-adwaita",
32503249 "serde",
32513250 "serde_json",
32523251 "smithay-clipboard",
Cargo.toml+4-1
......@@ -1,6 +1,6 @@
11[workspace]
22members = ["crates/*"]
3exclude = ["fuzz"]
3exclude = ["fuzz", "vendor"]
44resolver = "3"
55
66[workspace.dependencies]
......@@ -10,6 +10,9 @@ parley = { git = "https://github.com/linebender/parley", rev = "4f67747d3690bb21
1010fontique = { git = "https://github.com/linebender/parley", rev = "4f67747d3690bb21d25cc70b674ab0d269595759" }
1111wgpu = "30.0.1"
1212
13[patch.crates-io]
14sctk-adwaita = { path = "vendor/sctk-adwaita" }
15
1316# Shaping, rasterization and GPU submission stay optimised in debug builds, where
1417# unoptimised dependencies alone drop frames.
1518[profile.dev.package."*"]
arc/canvas.md+9-5
......@@ -26,8 +26,10 @@ host translates platform events in; carries out the Reques
2626A host feeds `PageView` pointer, key and text-input events. It gets back a
2727`Response` saying whether the page, the selection or only the view changed,
2828plus the occasional `Request` the page can't do itself: show a date picker,
29read the clipboard, open the character palette. Everything platform-specific
30stays on the host's side of that line.
29read the clipboard, open a link. Everything platform-specific stays on the
30host's side of that line, shortcuts included: the page takes editing keys, and
31the host's command table runs the chords for formatting, tags, zoom and the
32clipboard through the page's own methods.
3133
3234## Editing emits ops
3335
......@@ -73,13 +75,15 @@ gives outline sizes, its PDF export gives exact line breaks, and screenshots
7375give placement. The comparators in `tools/canvas` (with the probes
7476`layout-probe` and `page-probe`) keep checking them.
7577
76Equations draw in two dimensions from the tree `onestore::page::Math` parses.
77They are edited as OneNote's equation editor edits them: Alt+= starts one,
78Equations draw in two dimensions from the tree `onestore::page::Math` parses,
79each in a space kept in its line of text, so text and links around them keep
80their look and the line grows to hold them. They are edited as OneNote's equation editor edits them: Alt+= starts one,
7881typing is its linear format (UnicodeMath), and a space builds up what it ends.
7982Linear and Professional switch an equation between the forms, and OneNote
8083stores both. Links follow OneNote too: a typed URL links itself when a space or
8184Enter ends it, the Link dialog stores its address in a hidden field code before
82the label, and a click opens a link. Ink draws stroke by stroke in page
85the label, and a click or Enter opens a link. URL text shows as a link, as
86OneNote links it when it opens a page, without being stored as one. Ink draws stroke by stroke in page
8387coordinates. Page templates' background art is recreated as vector art and
8488recognised by the stored picture's hash. OneNote's bitmaps aren't shipped.
8589
arc/platforms.md+7
......@@ -23,6 +23,9 @@ page inside it. `snowbound` picks a platform module at compile time
2323in the crate is shared: library and settings, the sidebar, menus, page and
2424section management, templates, and screenshot and replay support.
2525Accessibility for the page goes through AccessKit's winit adapter on both.
26`commands.rs` is the one table of commands: each one's title, its chords on
27macOS and elsewhere, when it is enabled or checked, and what it does. The
28keyboard, the toolbar and the macOS menu bar all run commands from it.
2629
2730### macOS
2831
......@@ -40,6 +43,10 @@ Accessibility for the page goes through AccessKit's winit adapter on both.
4043- A notebook on a mounted SMB share is opened through the embedded SMB client,
4144 signed in with the password the keychain keeps for that mount (see
4245 [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.
4350- `tools/canvas/build_macos.py` builds and ad-hoc signs `target/Snowbound.app`.
4451
4552### Linux
arc/ui.md+1-1
......@@ -89,7 +89,7 @@ input-method events that land on it, in order. The host hands them to
8989frame. The page's scrollbars are ordinary `ui` widgets; the canvas only
9090reports its scroll bounds.
9191
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
9393palette on a popup layer that takes input above everything else. `ui::shell`
9494has the OneNote-specific controls: section tabs and compact toolbar buttons.
9595The 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 @@
2121 <stop offset="1" stop-color="#e0205a"/>
2222 </linearGradient>
2323 </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)"/>
2929</svg>
crates/canvas/assets/tags/question.svg+1-1
......@@ -5,5 +5,5 @@
55 <stop offset="1" stop-color="#8f008f"/>
66 </linearGradient>
77 </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)"/>
99</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.
22
33use onestore::{
44 ExGuid,
......@@ -8,21 +8,72 @@ use onestore::{
88/// OneNote's highlight for conflicting changes on a conflict page, COLORREF.
99pub const CONFLICTING: u32 = 0xd6d6ff;
1010
11/// OneNote's highlight for what a page version changed since the version before it, COLORREF.
12pub const CHANGED: u32 = 0xd6ffd6;
13
1114/// `page` with its conflict `objects` marked as OneNote shows a conflict page's: a band
1215/// across each conflicting paragraph.
13pub fn highlighted(mut page: Page, objects: &[ExGuid]) -> Page {
14 fn mark(paragraphs: &mut [PageParagraph], objects: &[ExGuid]) {
16pub 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.
22pub fn changes(version: Page, older: Option<&Page>) -> Page {
23 let texts = |page: &Page| {
24 let mut texts = Vec::new();
25 each_text(page, &mut |id, text| texts.push((id, text.to_owned())));
26 texts
27 };
28 let before = older.map(texts).unwrap_or_default();
29 let changed: Vec<ExGuid> = texts(&version)
30 .into_iter()
31 .filter(|text| !before.contains(text))
32 .map(|(id, _)| id)
33 .collect();
34 banded(version, &changed, CHANGED)
35}
36
37fn each_text(page: &Page, f: &mut impl FnMut(ExGuid, &str)) {
38 fn walk(paragraphs: &[PageParagraph], f: &mut impl FnMut(ExGuid, &str)) {
39 for paragraph in paragraphs {
40 match &paragraph.content {
41 ParagraphContent::Table(table) => {
42 for cell in table.rows.iter().flat_map(|row| &row.cells) {
43 walk(&cell.paragraphs, f);
44 }
45 }
46 ParagraphContent::Text(text) => f(text.id, text.text.text()),
47 _ => {}
48 }
49 }
50 }
51 for object in &page.objects {
52 match object {
53 PageObject::Outline(outline) => walk(&outline.paragraphs, f),
54 PageObject::Title(title) => {
55 for outline in &title.outlines {
56 walk(&outline.paragraphs, f);
57 }
58 }
59 _ => {}
60 }
61 }
62}
63
64fn banded(mut page: Page, objects: &[ExGuid], color: u32) -> Page {
65 fn mark(paragraphs: &mut [PageParagraph], objects: &[ExGuid], color: u32) {
1566 for paragraph in paragraphs {
1667 match &mut paragraph.content {
1768 ParagraphContent::Table(table) => {
1869 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);
2071 }
2172 }
2273 ParagraphContent::Text(text)
2374 if objects.contains(&paragraph.id) || objects.contains(&text.id) =>
2475 {
25 paragraph.format.highlight = Some(CONFLICTING);
76 paragraph.format.highlight = Some(color);
2677 }
2778 _ => {}
2879 }
......@@ -30,10 +81,10 @@ pub fn highlighted(mut page: Page, objects: &[ExGuid]) -> Page {
3081 }
3182 for object in &mut page.objects {
3283 match object {
33 PageObject::Outline(outline) => mark(&mut outline.paragraphs, objects),
84 PageObject::Outline(outline) => mark(&mut outline.paragraphs, objects, color),
3485 PageObject::Title(title) => {
3586 for outline in &mut title.outlines {
36 mark(&mut outline.paragraphs, objects);
87 mark(&mut outline.paragraphs, objects, color);
3788 }
3889 }
3990 _ => {}
crates/canvas/src/document.rs+63-12
......@@ -248,28 +248,27 @@ fn divides_link(text: &Paragraph, offset: u32) -> Result<bool, EditError> {
248248}
249249
250250/// 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`).
253254fn moves_object(
254255 first: &Paragraph,
255256 start: u32,
256257 last: &Paragraph,
257258 end: u32,
258259) -> 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))
262263 };
263264 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);
264269 Ok(first.text()[..before].ends_with('\u{fffc}')
265270 || 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)))
273272}
274273
275274/// 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>
281280 })
282281}
283282
283fn enclosing(
284 nodes: &[PageParagraph],
285 first: usize,
286 paragraph: usize,
287 ranges: &mut Vec<Range<usize>>,
288) {
289 let count = |nodes: &[PageParagraph]| leaves(nodes, None).count();
290 let Some((index, start)) = starts(nodes, first)
291 .enumerate()
292 .take_while(|(_, start)| *start <= paragraph)
293 .last()
294 else {
295 return;
296 };
297 let ParagraphContent::Table(table) = &nodes[index].content else {
298 let end = subtree_end(nodes, index);
299 if end > index + 1 {
300 ranges.push(start..start + count(&nodes[index..end]));
301 }
302 return;
303 };
304 let mut cell_start = start;
305 for row in &table.rows {
306 let row_start = cell_start;
307 let mut holder = None;
308 for cell in &row.cells {
309 let cell_end = cell_start + count(&cell.paragraphs);
310 if (cell_start..cell_end).contains(&paragraph) {
311 holder = Some((cell, cell_start..cell_end));
312 }
313 cell_start = cell_end;
314 }
315 if let Some((cell, range)) = holder {
316 enclosing(&cell.paragraphs, range.start, paragraph, ranges);
317 ranges.extend([
318 range,
319 row_start..cell_start,
320 start..start + count(&nodes[index..=index]),
321 ]);
322 return;
323 }
324 }
325}
326
284327/// The index after `nodes[index]`'s last descendant. Descendants follow their ancestor
285328/// contiguously; a paragraph only an outline group indents has no parent to descend from.
286329pub(crate) fn subtree_end(nodes: &[PageParagraph], index: usize) -> usize {
......@@ -551,6 +594,14 @@ impl TextDocument {
551594 }
552595 }
553596
597 /// Text leaf ranges holding `paragraph`, innermost first: its subtree when it has
598 /// descendants, then the cell, row and table of each table around it.
599 pub(crate) fn enclosing(&self, paragraph: usize) -> Vec<Range<usize>> {
600 let mut ranges = Vec::new();
601 enclosing(&self.nodes, 0, paragraph, &mut ranges);
602 ranges
603 }
604
554605 /// The root node holding a table cell.
555606 pub(crate) fn root(&self, cell: ExGuid) -> Result<usize, EditError> {
556607 self.tables
crates/canvas/src/editor.rs+325-16
......@@ -24,13 +24,18 @@ use std::{
2424
2525pub const DEFAULT_OUTLINE_WIDTH: f32 = 468.0;
2626
27mod equation;
2728#[cfg(test)]
2829mod evidence;
2930mod format;
31mod link;
3032mod ops;
3133pub(crate) mod page;
3234mod table;
33pub use format::{Alignment, FormatState, Formatting, NoteTag, Toggle};
35pub use format::{
36 Alignment, BULLET_LIBRARY, FormatState, Formatting, ListStyle, NUMBER_LIBRARY, NoteTag, Toggle,
37};
38pub use link::{Link, shown_urls};
3439pub use page::ReadOnlyObject;
3540
3641#[derive(Debug)]
......@@ -395,6 +400,19 @@ impl TextOutline {
395400 .unwrap()
396401 }
397402
403 /// The start of the paragraph after visible paragraph `index` when it follows in the same
404 /// container, where a whole-paragraph selection ends so that deleting it takes the break.
405 fn next_sibling_start(&self, index: usize) -> Option<TextPosition> {
406 let next =
407 (index + 1 < self.shaped.paragraphs.len()).then(|| self.source_index(index + 1))?;
408 let (container, local, _) = self.document.leaf(self.source_index(index))?;
409 let (next_container, next_local, _) = self.document.leaf(next)?;
410 (container == next_container && next_local == local + 1).then_some(TextPosition {
411 paragraph: next,
412 offset: 0,
413 })
414 }
415
398416 pub fn paragraph_layout(&self, source: usize) -> Result<&ParagraphLayout, EditError> {
399417 Ok(&self.shaped.paragraphs[self.visible_index(source)?])
400418 }
......@@ -2006,6 +2024,50 @@ impl CanvasEditor {
20062024 )
20072025 }
20082026
2027 /// Selects the smallest unit strictly holding the selection, as OneNote 2010's Ctrl+A widens
2028 /// it: the paragraph, its subtree, each enclosing table cell, row and table, then the outline.
2029 pub fn widen_selection(&mut self) -> Result<(), EditError> {
2030 let outline = self.active_outline();
2031 let [anchor, focus] = outline.selection.positions;
2032 let (start, end) = (anchor.min(focus), anchor.max(focus));
2033 let last_shown = |range: Range<usize>| {
2034 let paragraph = range
2035 .rev()
2036 .find(|&paragraph| outline.visible_index(paragraph).is_ok())?;
2037 let text = outline.document.paragraph(paragraph)?;
2038 Some(TextPosition {
2039 paragraph,
2040 offset: text.utf16_offset(text.text().len()).ok()?,
2041 })
2042 };
2043 let first = |paragraph| TextPosition {
2044 paragraph,
2045 offset: 0,
2046 };
2047 let own = outline
2048 .next_sibling_start(outline.visible_index(start.paragraph)?)
2049 .or_else(|| last_shown(start.paragraph..start.paragraph + 1));
2050 let units = std::iter::once((first(start.paragraph), own))
2051 .chain(
2052 outline
2053 .document
2054 .enclosing(start.paragraph)
2055 .into_iter()
2056 .map(|range| (first(range.start), last_shown(range))),
2057 )
2058 .chain([(
2059 first(0),
2060 last_shown(0..outline.document.paragraphs().count()),
2061 )]);
2062 match units
2063 .filter_map(|(unit_start, unit_end)| Some([unit_start, unit_end?]))
2064 .find(|&unit| unit[0] <= start && end <= unit[1] && unit != [start, end])
2065 {
2066 Some(unit) => self.select(unit.into()),
2067 None => Ok(()),
2068 }
2069 }
2070
20092071 pub fn select_at(&mut self, x: f32, y: f32, extend: bool) -> Result<(), EditError> {
20102072 let mut selection = self.selection_at(x, y, SelectionUnit::Grapheme)?;
20112073 if extend {
......@@ -2046,20 +2108,10 @@ impl CanvasEditor {
20462108 selection.affinities[slot] = cursor.affinity();
20472109 }
20482110 if unit == SelectionUnit::Paragraph
2049 && index + 1 < self.active_outline().shaped.paragraphs.len()
2111 && let Some(next) = self.active_outline().next_sibling_start(index)
20502112 {
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;
20632115 }
20642116 Ok(selection)
20652117 }
......@@ -2093,6 +2145,9 @@ impl CanvasEditor {
20932145 extend: bool,
20942146 ) -> Result<(), EditorError> {
20952147 self.finish_composition();
2148 if !extend && movement == Movement::Right && self.leave_equation()? {
2149 return Ok(());
2150 }
20962151 let [anchor, focus] = self.active_outline().selection.positions;
20972152 if !extend
20982153 && self.caret_outline().is_some()
......@@ -2557,7 +2612,9 @@ impl CanvasEditor {
25572612 )
25582613 }
25592614
2615 /// Types `text` at the selection; a space ends a typed URL, which becomes a link.
25602616 pub fn insert(&mut self, engine: &mut TextEngine, text: &str) -> Result<(), EditorError> {
2617 self.leave_link_code()?;
25612618 let start = self.active_outline().selection.positions[0]
25622619 .min(self.active_outline().selection.positions[1]);
25632620 let format = self.typing_format(start)?;
......@@ -2565,7 +2622,12 @@ impl CanvasEditor {
25652622 .split('\n')
25662623 .map(|line| Paragraph::new(line.to_owned(), format.clone()))
25672624 .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(())
25692631 }
25702632
25712633 /// Pastes plain text as OneNote does: lines become plain Calibri 11 paragraphs without style
......@@ -2634,7 +2696,13 @@ impl CanvasEditor {
26342696 fn split(&mut self, engine: &mut TextEngine) -> Result<(), EditorError> {
26352697 let [anchor, focus] = self.active_outline().selection.positions;
26362698 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 }
26382706 let document = &self.active_outline().document;
26392707 let mut edit = document.replace(
26402708 start..end,
......@@ -2746,6 +2814,75 @@ impl CanvasEditor {
27462814 Ok(true)
27472815 }
27482816
2817 /// Alt+Shift+Up or Down: swaps the selected paragraphs, with their children, and the
2818 /// sibling above or below with its children. Does nothing past the first or last sibling.
2819 pub fn move_paragraphs(
2820 &mut self,
2821 engine: &mut TextEngine,
2822 up: bool,
2823 ) -> Result<bool, EditorError> {
2824 use crate::document::{previous_sibling, subtree_end};
2825 let outline = self.active_outline();
2826 if outline.title {
2827 return Ok(false);
2828 }
2829 let selection = outline.selection;
2830 let [anchor, focus] = selection.positions;
2831 let document = &outline.document;
2832 let (container, first, _) = document
2833 .leaf(anchor.min(focus).paragraph)
2834 .ok_or(EditError::InvalidRange)?;
2835 let (end_container, last, _) = document
2836 .leaf(anchor.max(focus).paragraph)
2837 .ok_or(EditError::InvalidRange)?;
2838 if container != end_container {
2839 return Ok(false);
2840 }
2841 let nodes = document.container(container)?;
2842 let parent = nodes[first].parent;
2843 let mut end = first;
2844 while end <= last {
2845 if nodes[end].parent != parent {
2846 return Ok(false);
2847 }
2848 end = subtree_end(nodes, end);
2849 }
2850 let (range, [moved, other]) = if up {
2851 let Some(sibling) = previous_sibling(nodes, first, &BTreeSet::new()) else {
2852 return Ok(false);
2853 };
2854 (sibling..end, [first..end, sibling..first])
2855 } else {
2856 if nodes.get(end).is_none_or(|node| node.parent != parent) {
2857 return Ok(false);
2858 }
2859 let next = subtree_end(nodes, end);
2860 (first..next, [first..end, end..next])
2861 };
2862 let passed = leaves(&nodes[other.clone()], None).count();
2863 let replacement = if up {
2864 [&nodes[moved], &nodes[other]].concat()
2865 } else {
2866 [&nodes[other], &nodes[moved]].concat()
2867 };
2868 let mut moved = selection;
2869 for position in &mut moved.positions {
2870 position.paragraph = if up {
2871 position.paragraph - passed
2872 } else {
2873 position.paragraph + passed
2874 };
2875 }
2876 let edit = DocumentEdit {
2877 columns: BTreeMap::new(),
2878 container,
2879 range,
2880 replacement,
2881 };
2882 self.commit(engine, edit, moved)?;
2883 Ok(true)
2884 }
2885
27492886 /// Deletes the selection or the character beside the caret. Backspace at a paragraph's start
27502887 /// first removes its list, then outdents it, then joins it to the paragraph above, whose
27512888 /// properties win; Delete at its end joins the paragraph below (`evidence/structural-edits/
......@@ -8872,4 +9009,176 @@ mod tests {
88729009 assert!(editor.undo.is_empty());
88739010 assert_eq!(editor.take_ops().unwrap(), []);
88749011 }
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 }
88759184}
crates/canvas/src/editor/equation.rs created+557
......@@ -0,0 +1,557 @@
1//! Equations as OneNote 2010's equation editor edits them (`corpus/math-edit/native-linear`):
2//! Alt+= starts an equation at the caret or makes one of the selected text, text typed into
3//! an equation is its linear format until a space builds it up, and Linear and Professional
4//! switch an equation between the forms, both of which OneNote stores.
5
6use super::link::format_after;
7use super::*;
8use onestore::page::Math;
9
10const OBJECT_START: char = '\u{fdd0}';
11const ARGUMENT_SEPARATOR: char = '\u{fdee}';
12const OBJECT_END: char = '\u{fdef}';
13
14/// The run of math in `text` that UTF-16 `offset` lies in or touches.
15pub(crate) fn zone(text: &Paragraph, offset: u32) -> Option<Range<u32>> {
16 let (mut byte, mut unit) = (0, 0);
17 let mut current: Option<Range<u32>> = None;
18 for span in text.spans() {
19 let end = unit + text.text()[byte..span.end].encode_utf16().count() as u32;
20 if span.format.math == Some(true) {
21 current = Some(
22 current
23 .filter(|zone| zone.end == unit)
24 .map_or(unit, |zone| zone.start)..end,
25 );
26 } else if current
27 .as_ref()
28 .is_some_and(|zone| zone.start <= offset && offset <= zone.end)
29 {
30 return current;
31 } else {
32 current = None;
33 }
34 (byte, unit) = (span.end, end);
35 }
36 current.filter(|zone| zone.start <= offset && offset <= zone.end)
37}
38
39/// The format text typed after an equation in `text` takes: the paragraph's last text's, or
40/// its first run's without the equation editor's.
41pub(super) fn text_after(text: &Paragraph) -> Format {
42 let mut format = text
43 .spans()
44 .iter()
45 .rev()
46 .find(|span| span.format.math != Some(true))
47 .map_or_else(
48 || text.spans()[0].format.clone(),
49 |span| span.format.clone(),
50 );
51 format.math = format.math.map(|_| false);
52 format.embedded_object = None;
53 format.math_object = None;
54 if format.font.as_deref() == Some("Cambria Math") {
55 // OneNote's body font, where the paragraph has no text to take one from.
56 format.font = Some("Calibri".into());
57 format.italic = format.italic.map(|_| false);
58 // The writers' default, where the paragraph has no text to take a language from.
59 format.language = Some(0x409);
60 }
61 format
62}
63
64impl CanvasEditor {
65 /// The paragraph and math run at the caret.
66 fn caret_zone(&self) -> Option<(usize, Range<u32>)> {
67 let [anchor, focus] = self.active_outline().selection.positions;
68 let caret = anchor.min(focus);
69 let text = self.active_outline().document.paragraph(caret.paragraph)?;
70 Some((caret.paragraph, zone(text, caret.offset)?))
71 }
72
73 /// Whether the caret is in an equation, which Linear and Professional act on.
74 pub fn in_equation(&self) -> bool {
75 self.caret_zone().is_some()
76 }
77
78 /// Professional: builds the equation at the caret up from its linear text.
79 pub fn build_equation(&mut self, engine: &mut TextEngine) -> Result<bool, EditorError> {
80 let Some((paragraph, zone)) = self.caret_zone() else {
81 return Ok(false);
82 };
83 self.rebuild(engine, paragraph, zone, None)
84 }
85
86 /// Linear: shows the equation at the caret in its linear format, as text to edit.
87 pub fn linear_equation(&mut self, engine: &mut TextEngine) -> Result<bool, EditorError> {
88 let Some((paragraph, zone)) = self.caret_zone() else {
89 return Ok(false);
90 };
91 let text = self
92 .active_outline()
93 .document
94 .paragraph(paragraph)
95 .ok_or(EditError::InvalidRange)?;
96 let math = text.slice(zone.clone())?;
97 let Ok(nodes) = Math::parse(&math) else {
98 return Ok(false);
99 };
100 let Some(linear) = Math::linear_paragraph(&nodes, &math.spans()[0].format) else {
101 return Ok(false);
102 };
103 let end = zone.start + linear.utf16_offset(linear.text().len())?;
104 self.replace_zone(engine, paragraph, zone, linear, end)
105 }
106
107 /// Alt+=: the selected text becomes an equation, the equation at the caret builds up, or
108 /// what is typed next at the caret starts one.
109 pub fn insert_equation(&mut self, engine: &mut TextEngine) -> Result<(), EditorError> {
110 let outline = self.active_outline();
111 let [anchor, focus] = outline.selection.positions;
112 let (start, end) = (anchor.min(focus), anchor.max(focus));
113 if start == end {
114 if self.caret_zone().is_some() {
115 self.build_equation(engine)?;
116 return Ok(());
117 }
118 let format = Math::format(&self.typing_format(start)?);
119 self.pending = Some((outline.id, start, format));
120 return Ok(());
121 }
122 if start.paragraph != end.paragraph {
123 return Err(EditError::UnsupportedContent.into());
124 }
125 let text = outline
126 .document
127 .paragraph(start.paragraph)
128 .ok_or(EditError::InvalidRange)?;
129 let selected = text.slice(start.offset..end.offset)?;
130 let linear = selected.project()?.text().text().replace('\n', " ");
131 let base = format_after(text, start.offset)
132 .cloned()
133 .unwrap_or_default();
134 let math = Math::paragraph(&Math::from_linear(&linear), &base);
135 let caret = start.offset + math.utf16_offset(math.text().len())?;
136 self.replace_zone(
137 engine,
138 start.paragraph,
139 start.offset..end.offset,
140 math,
141 caret,
142 )?;
143 Ok(())
144 }
145
146 /// Builds the equation up after the space typed at `typed` ended part of it, as the
147 /// equation editor builds as it is typed. Only a caret at the end of the equation, or
148 /// only closing its objects before that end, builds; the caret stays as deep in the built
149 /// objects.
150 pub(super) fn build_typed_equation(
151 &mut self,
152 engine: &mut TextEngine,
153 typed: TextPosition,
154 ) -> Result<(), EditorError> {
155 let text = self
156 .active_outline()
157 .document
158 .paragraph(typed.paragraph)
159 .ok_or(EditError::InvalidRange)?;
160 if format_after(text, typed.offset).is_none_or(|format| format.math != Some(true)) {
161 return Ok(());
162 }
163 let Some(zone) = zone(text, typed.offset) else {
164 return Ok(());
165 };
166 let caret = typed.offset + 1;
167 let after = &text.text()[text.byte_offset(caret)?..text.byte_offset(zone.end)?];
168 if !after.chars().all(|c| c == OBJECT_END) {
169 return Ok(());
170 }
171 let depth = after.chars().count() as u32;
172 self.rebuild(engine, typed.paragraph, zone, Some(depth))?;
173 Ok(())
174 }
175
176 /// Right at the end of an equation ending its paragraph leaves it: what is typed next is
177 /// text again.
178 pub(super) fn leave_equation(&mut self) -> Result<bool, EditError> {
179 let outline = self.active_outline();
180 let [anchor, focus] = outline.selection.positions;
181 let Some(text) = outline.document.paragraph(focus.paragraph) else {
182 return Ok(false);
183 };
184 let end = text.utf16_offset(text.text().len())?;
185 let math = self.typing_format(focus)?.math == Some(true);
186 if anchor != focus || focus.offset != end || !math {
187 return Ok(false);
188 }
189 self.pending = Some((outline.id, focus, text_after(text)));
190 Ok(true)
191 }
192
193 /// Rebuilds math run `zone` of paragraph `paragraph` from its linear text. With `depth`,
194 /// the caret goes that many objects before the end of the built run, and into an
195 /// argument left empty there; without, to the run's end.
196 fn rebuild(
197 &mut self,
198 engine: &mut TextEngine,
199 paragraph: usize,
200 zone: Range<u32>,
201 depth: Option<u32>,
202 ) -> Result<bool, EditorError> {
203 let text = self
204 .active_outline()
205 .document
206 .paragraph(paragraph)
207 .ok_or(EditError::InvalidRange)?;
208 let math = text.slice(zone.clone())?;
209 let Some(linear) = Math::parse(&math)
210 .ok()
211 .and_then(|nodes| Math::linear(&nodes))
212 else {
213 return Ok(false);
214 };
215 let nodes = if depth.is_some() {
216 Math::typed(&linear)
217 } else {
218 Math::from_linear(&linear)
219 };
220 let built = Math::paragraph(&nodes, &math.spans()[0].format);
221 if built == math {
222 return Ok(false);
223 }
224 let units: Vec<char> = built.text().chars().collect();
225 let mut caret = units.len() - depth.unwrap_or(0).min(units.len() as u32) as usize;
226 if depth.is_some()
227 && caret >= 2
228 && units[caret - 1] == OBJECT_END
229 && matches!(units[caret - 2], ARGUMENT_SEPARATOR | OBJECT_START)
230 {
231 caret -= 1;
232 }
233 let caret = zone.start
234 + units[..caret]
235 .iter()
236 .map(|c| c.len_utf16() as u32)
237 .sum::<u32>();
238 self.replace_zone(engine, paragraph, zone, built, caret)
239 }
240
241 /// Enter inside an equation, as OneNote 2010's equation editor takes it
242 /// (`corpus/math-edit/native-enter`): in the equation's own row a line break the
243 /// paragraph keeps, in an object's argument a new row of it, the argument becoming an
244 /// equation array where it is not one. False where the caret is not inside an equation.
245 pub(super) fn break_equation(&mut self, engine: &mut TextEngine) -> Result<bool, EditorError> {
246 let caret = self.active_outline().selection.positions[1];
247 let Some((paragraph, zone)) = self.caret_zone() else {
248 return Ok(false);
249 };
250 let text = self
251 .active_outline()
252 .document
253 .paragraph(paragraph)
254 .ok_or(EditError::InvalidRange)?;
255 let units: Vec<char> = text.slice(zone.clone())?.text().chars().collect();
256 let at = text.slice(zone.start..caret.offset)?.text().chars().count();
257 if at == 0 || at == units.len() || !units.contains(&OBJECT_START) {
258 return Ok(false);
259 }
260 // The argument around the caret: from the control before it at its depth to the one
261 // after.
262 let mut depth = 0;
263 let start = units[..at].iter().rposition(|&c| {
264 match c {
265 OBJECT_END => depth += 1,
266 OBJECT_START if depth > 0 => depth -= 1,
267 OBJECT_START | ARGUMENT_SEPARATOR if depth == 0 => return true,
268 _ => {}
269 }
270 false
271 });
272 let Some(start) = start else {
273 // The equation's own row breaks its line, outside the math.
274 let mut updated = text.clone();
275 let break_format = text_after(text);
276 updated.apply(onestore::page::text::Edit {
277 range: caret.offset..caret.offset,
278 replacement: Paragraph::new("\r".into(), break_format),
279 })?;
280 let after = TextPosition {
281 paragraph,
282 offset: caret.offset + 1,
283 };
284 self.rewrite(engine, paragraph, updated, [after; 2].into())?;
285 return Ok(true);
286 };
287 let mut depth = 0;
288 let end = at
289 + units[at..]
290 .iter()
291 .position(|&c| {
292 match c {
293 OBJECT_START => depth += 1,
294 OBJECT_END if depth > 0 => depth -= 1,
295 OBJECT_END | ARGUMENT_SEPARATOR if depth == 0 => return true,
296 _ => {}
297 }
298 false
299 })
300 .ok_or(EditError::InvalidStructure)?;
301 // The object the argument belongs to opens at the start of its first argument.
302 let mut depth = 0;
303 let opening = units[..=start]
304 .iter()
305 .rposition(|&c| {
306 match c {
307 OBJECT_END => depth += 1,
308 OBJECT_START if depth > 0 => depth -= 1,
309 OBJECT_START => return true,
310 _ => {}
311 }
312 false
313 })
314 .ok_or(EditError::InvalidStructure)?;
315 let math = text.slice(zone.clone())?;
316 let offset =
317 |index: usize| -> u32 { units[..index].iter().map(|c| c.len_utf16() as u32).sum() };
318 let kind = math
319 .format_at(offset(opening) + 1)?
320 .math_object
321 .as_ref()
322 .map(|object| object.kind);
323 let format = math.format_at(offset(at))?.clone();
324 let control = |c: char, kind: u32| {
325 let mut format = format.clone();
326 format.math_object = Some(onestore::document::MathObject {
327 kind,
328 arguments: None,
329 columns: Some(1),
330 symbols: vec!['█'],
331 });
332 (c.to_string(), format)
333 };
334 let piece = |range: Range<usize>| -> Result<Paragraph, EditError> {
335 math.slice(offset(range.start)..offset(range.end))
336 };
337 let mut parts = vec![piece(0..start + 1)?];
338 if kind != Some(15) {
339 parts.push(Paragraph::from_runs([control(OBJECT_START, 15)]));
340 }
341 parts.push(piece(start + 1..at)?);
342 parts.push(Paragraph::from_runs([control(ARGUMENT_SEPARATOR, 15)]));
343 parts.push(piece(at..end)?);
344 if kind != Some(15) {
345 parts.push(Paragraph::from_runs([control(OBJECT_END, 15)]));
346 }
347 parts.push(piece(end..units.len())?);
348 let mut broken = parts.remove(0);
349 for part in parts {
350 broken.append(part)?;
351 }
352 let Some(linear) = Math::parse(&broken)
353 .ok()
354 .and_then(|nodes| Math::linear(&nodes))
355 else {
356 return Ok(false);
357 };
358 let built = Math::paragraph(&Math::from_linear(&linear), &math.spans()[0].format);
359 // The caret starts the new row: after as many characters other than spaces as came
360 // before it.
361 let before = broken
362 .text()
363 .chars()
364 .take(at + if kind == Some(15) { 1 } else { 2 });
365 let count = before.filter(|c| *c != ' ').count();
366 let mut seen = 0;
367 let mut caret = zone.start;
368 for c in built.text().chars() {
369 if seen == count {
370 break;
371 }
372 seen += usize::from(c != ' ');
373 caret += c.len_utf16() as u32;
374 }
375 self.replace_zone(engine, paragraph, zone, built, caret)
376 }
377
378 fn replace_zone(
379 &mut self,
380 engine: &mut TextEngine,
381 paragraph: usize,
382 zone: Range<u32>,
383 math: Paragraph,
384 caret: u32,
385 ) -> Result<bool, EditorError> {
386 let mut text = self
387 .active_outline()
388 .document
389 .paragraph(paragraph)
390 .ok_or(EditError::InvalidRange)?
391 .clone();
392 text.apply(onestore::page::text::Edit {
393 range: zone,
394 replacement: math,
395 })?;
396 let caret = TextPosition {
397 paragraph,
398 offset: caret,
399 };
400 self.rewrite(engine, paragraph, text, [caret; 2].into())?;
401 Ok(true)
402 }
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408 use onestore::document::MathObject;
409 use onestore::page::PageObject;
410
411 type Runs = Vec<(String, Option<MathObject>, Option<bool>)>;
412
413 fn runs(text: &Paragraph) -> Runs {
414 let mut start = 0;
415 text.spans()
416 .iter()
417 .map(|span| {
418 let run = text.text()[start..span.end].to_owned();
419 start = span.end;
420 (run, span.format.math_object.clone(), span.format.italic)
421 })
422 .collect()
423 }
424
425 fn equations(bytes: &[u8]) -> Vec<Runs> {
426 let store = onestore::Store::parse(bytes).unwrap();
427 let index = onestore::RevisionIndex::parse(&store).unwrap();
428 let document = onestore::document::Document::parse(&index).unwrap();
429 let (space, _) = document.pages().unwrap()[0];
430 Page::from_space(&document, space)
431 .unwrap()
432 .objects
433 .iter()
434 .filter_map(|object| match object {
435 PageObject::Outline(outline) => Some(outline),
436 _ => None,
437 })
438 .flat_map(|outline| &outline.paragraphs)
439 .filter_map(|paragraph| paragraph.text())
440 .filter(|text| Math::is_equation(&text.text))
441 .map(|text| {
442 // Text typed after an equation, outside it, is not the editor's.
443 let mut runs = runs(&text.text);
444 runs.retain(|(_, object, _)| object.is_some());
445 runs
446 })
447 .collect()
448 }
449
450 /// Alt+= and typing, each space its own key, store what OneNote 2010's equation editor
451 /// stored for the same keys (`tools/native_math.py`), building up as they are typed.
452 #[test]
453 fn typed_equations_build_up_as_the_equation_editor_stores_them() {
454 let sessions: [(&[u8], &[Option<&str>]); 4] = [
455 (
456 include_bytes!("../../../../corpus/math-edit/native-editor/notebook/links.one"),
457 &[
458 Some("a_1+b_2"),
459 Some("x_i^2"),
460 Some("(a+b)"),
461 Some("\\int_0^1 x dx"),
462 Some("\\sum_(i=1)^n i"),
463 ],
464 ),
465 (
466 include_bytes!("../../../../corpus/math-edit/native-editor-2/notebook/links.one"),
467 &[
468 Some("\\sqrt x+1"),
469 Some("\\cbrt(x)"),
470 Some("\\sqrt(n&x)"),
471 Some("a/b"),
472 Some("\\lim_(x\\to 0) f(x)"),
473 Some("\\prod_(k=1)^n k"),
474 Some("[a+b]"),
475 Some("\\overline(x)"),
476 Some("x\\hat"),
477 ],
478 ),
479 (
480 include_bytes!("../../../../corpus/math-edit/native-editor-3/notebook/links.one"),
481 &[
482 Some("\\matrix(1&2@3&4)"),
483 Some("\\eqarray(x&=1@y&=2)"),
484 Some("x\\above 2"),
485 Some("x\\below 2"),
486 Some("\\box(x)"),
487 Some("\\rect(x)"),
488 None,
489 Some("\\iint x dx dy"),
490 Some("f(x)/(x^2+1)"),
491 Some("\\sum^n x"),
492 ],
493 ),
494 (
495 include_bytes!("../../../../corpus/math-edit/native-editor-4/notebook/links.one"),
496 &[
497 Some("a_1+b_2"),
498 // The editor was still starting this one and kept its placeholder.
499 None,
500 Some("\\sum_(i=1)^n i"),
501 Some("\\int_0^1 x dx"),
502 Some("\\sqrt x+1"),
503 Some("\\cbrt(x)"),
504 Some("\\sqrt(n&x)"),
505 Some("a/b"),
506 Some("\\prod_(k=1)^n k"),
507 Some("[a+b]"),
508 Some("\\overline(x)"),
509 Some("x\\hat"),
510 Some("\\matrix(1&2@3&4)"),
511 Some("\\eqarray(x&=1@y&=2)"),
512 Some("x\\above 2"),
513 Some("x\\below 2"),
514 Some("\\box(x)"),
515 Some("\\rect(x)"),
516 Some("f(x)/(x^2+1)"),
517 Some("\\sum^n x"),
518 Some("e^(x+1)"),
519 Some("\\alpha+\\beta"),
520 Some("(a+b)/(c+d)"),
521 Some("x^2+y^2=z^2"),
522 // A function applied to a limit, an object this reading does not build.
523 None,
524 Some("{a+b}"),
525 ],
526 ),
527 ];
528 let mut engine = TextEngine::default();
529 for (bytes, typed) in sessions {
530 let native = equations(bytes);
531 assert_eq!(native.len(), typed.len());
532 for (native, typed) in native.iter().zip(typed) {
533 let Some(typed) = typed else {
534 continue;
535 };
536 let mut editor = CanvasEditor::new(
537 &mut engine,
538 TextDocument::new(vec![Paragraph::new(String::new(), Format::default())])
539 .unwrap(),
540 400.0,
541 )
542 .unwrap();
543 editor.insert_equation(&mut engine).unwrap();
544 for (index, word) in format!("{typed} ").split(' ').enumerate() {
545 if index > 0 {
546 editor.insert(&mut engine, " ").unwrap();
547 }
548 if !word.is_empty() {
549 editor.insert(&mut engine, word).unwrap();
550 }
551 }
552 let text = editor.active_outline().document.paragraph(0).unwrap();
553 assert_eq!(&runs(text), native, "{typed}");
554 }
555 }
556 }
557}
crates/canvas/src/editor/evidence.rs+17-9
......@@ -1,9 +1,9 @@
11//! 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
44//! paragraph nesting.
55
6use super::format::{NoteTag, list_definition};
6use super::format::{ListStyle, NoteTag, list_definition};
77use super::*;
88use onestore::document::Tag;
99use onestore::page::ParagraphContent;
......@@ -11,7 +11,7 @@ use onestore::page::text::new_id;
1111
1212fn summary(name: &str) -> String {
1313 std::fs::read_to_string(format!(
14 "{}/../../evidence/structural-edits/summaries/{name}.txt",
14 "{}/../../corpus/structural-probe/summaries/{name}.txt",
1515 env!("CARGO_MANIFEST_DIR")
1616 ))
1717 .unwrap()
......@@ -76,7 +76,14 @@ fn open(engine: &mut TextEngine, summary: &str) -> CanvasEditor {
7676 .find(|parent| parent.level + 1 == level)
7777 .map(|parent| parent.id);
7878 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 );
8087 node.lists = vec![define(format!("list {}", nodes.len()), definition)];
8188 }
8289 for tag in tags.split(',').filter(|tag| !tag.is_empty()) {
......@@ -162,9 +169,10 @@ fn render(editor: &CanvasEditor) -> String {
162169 .paragraphs
163170 .iter()
164171 .find(|paragraph| paragraph.id == node.id)
165 .and_then(|paragraph| paragraph.number)
172 .and_then(|paragraph| paragraph.number.as_ref())
166173 .unwrap()
167 .0;
174 .0
175 .number;
168176 let numeral = crate::outline::numeral(rest.next(), number).unwrap();
169177 format!("num:{numeral}{}", rest.as_str())
170178 }
......@@ -274,7 +282,7 @@ fn replay(
274282 layout
275283 .paragraphs
276284 .iter()
277 .map(|paragraph| paragraph.number)
285 .map(|paragraph| paragraph.number.clone())
278286 .collect::<Vec<_>>()
279287 };
280288 assert_eq!(numbers(&outline.shaped), numbers(&fresh), "{after}");
......@@ -715,7 +723,7 @@ fn structural_edits_on_native_pages_survive_the_page_writer() {
715723 use onestore::{RevisionIndex, Store, document::Document};
716724 let section = std::fs::read(concat!(
717725 env!("CARGO_MANIFEST_DIR"),
718 "/../../evidence/structural-edits/probe-section/probe.one"
726 "/../../corpus/structural-probe/probe.one"
719727 ))
720728 .unwrap();
721729 let page = |bytes: &[u8], title: &str| {
crates/canvas/src/editor/format.rs+497-118
......@@ -23,12 +23,17 @@ pub enum Formatting {
2323 Highlight(Option<u32>),
2424 /// Returns text to its paragraph style.
2525 Clear,
26 /// Toggles OneNote's default bullet or number, as Ctrl+. and Ctrl+/ do.
2627 Bullets,
2728 Numbering,
29 /// Applies a style from OneNote's bullet or numbering library; `None` removes lists.
30 List(Option<ListStyle>),
2831 Indent,
2932 Outdent,
3033 Align(Alignment),
3134 Tag(NoteTag),
35 /// Removes every note tag from the selected paragraphs.
36 RemoveTags,
3237 /// Checks the selected paragraphs' check boxes, or clears them when all are checked.
3338 Check,
3439}
......@@ -52,8 +57,108 @@ pub enum Alignment {
5257 Right,
5358}
5459
60/// A style of OneNote 2010's bullet or numbering library, by its place in the gallery.
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62pub enum ListStyle {
63 Bullet(usize),
64 Number(usize),
65}
66
67/// OneNote 2010's bullet library in gallery order, as it stores each: font, glyph and
68/// ListMSAAIndex. Ctrl+. applies the third.
69pub const BULLET_LIBRARY: [(&str, &str, u16); 34] = [
70 ("Wingdings", "l", 3),
71 ("Symbol", "\u{b7}", 1),
72 ("Calibri", "\u{2022}", 1),
73 ("Courier New", "\u{25cb}", 4),
74 ("Wingdings 2", "\u{9d}", 6),
75 ("Wingdings 2", "\u{9c}", 5),
76 ("Wingdings", "\u{b2}", 13),
77 ("Tahoma", "\u{25ca}", 14),
78 ("Wingdings", "w", 15),
79 ("Wingdings 2", "\u{ae}", 34),
80 ("Wingdings", "v", 16),
81 ("Arial", "\u{25aa}", 7),
82 ("Verdana", "\u{25ab}", 8),
83 ("Wingdings", "\u{a7}", 9),
84 ("Arial", "\u{25a1}", 10),
85 ("Wingdings 3", "}", 11),
86 ("Arial", "\u{25ba}", 12),
87 ("Arial", "\u{2192}", 21),
88 ("Symbol", "\u{de}", 22),
89 ("Arial", ">", 20),
90 ("Wingdings", "\u{d8}", 23),
91 ("Arial", "*", 24),
92 ("Wingdings", "\u{ad}", 17),
93 ("Wingdings", "\u{ae}", 19),
94 ("Wingdings", "\u{af}", 18),
95 ("Arial", "-", 25),
96 ("Arial", "\u{2013}", 26),
97 ("Arial", "\u{2014}", 27),
98 ("Wingdings", "J", 28),
99 ("Wingdings", "K", 29),
100 ("Wingdings", "L", 30),
101 ("Wingdings", "\u{fc}", 31),
102 ("Wingdings", "(", 32),
103 ("Wingdings", "*", 33),
104];
105
106/// OneNote 2010's numbering library in gallery order, as NumberListFormat stores each: the
107/// number's sequence follows U+FFFD. Ctrl+/ applies the first.
108pub const NUMBER_LIBRARY: [&str; 19] = [
109 "\u{fffd}\u{0}.",
110 "\u{fffd}\u{5}.",
111 "\u{fffd}\u{4}.",
112 "\u{fffd}\u{3}.",
113 "\u{fffd}\u{2}.",
114 "\u{fffd}\u{1}.",
115 "\u{fffd}\u{0})",
116 "\u{fffd}\u{4})",
117 "\u{fffd}\u{3})",
118 "\u{fffd}\u{2})",
119 "\u{fffd}\u{1})",
120 "(\u{fffd}\u{0})",
121 "(\u{fffd}\u{3})",
122 "(\u{fffd}\u{4})",
123 "(\u{fffd}\u{2})",
124 "(\u{fffd}\u{1})",
125 "\u{fffd}\u{6}.",
126 "\u{fffd}\u{7}.",
127 "\u{fffd}\u{0}-",
128];
129
130impl ListStyle {
131 pub(super) const BULLET: Self = Self::Bullet(2);
132 pub(super) const NUMBER: Self = Self::Number(0);
133
134 /// The library style a stored list has, if it is one.
135 fn of(kind: &Kind) -> Option<Self> {
136 let Kind::List {
137 font,
138 format: Some(format),
139 bullet,
140 ..
141 } = kind
142 else {
143 return None;
144 };
145 match bullet {
146 Some(bullet) => BULLET_LIBRARY
147 .iter()
148 .position(|entry| {
149 (Some(entry.0), entry.1, entry.2) == (font.as_deref(), format.as_str(), *bullet)
150 })
151 .map(Self::Bullet),
152 None => NUMBER_LIBRARY
153 .iter()
154 .position(|known| known == format)
155 .map(Self::Number),
156 }
157 }
158}
159
55160/// 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.
57162#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58163pub enum NoteTag {
59164 ToDo,
......@@ -68,7 +173,7 @@ pub enum NoteTag {
68173}
69174
70175/// What the selection shows on the toolbar.
71#[derive(Clone, Debug, PartialEq)]
176#[derive(Clone, Debug, Default, PartialEq)]
72177pub struct FormatState {
73178 /// Attributes every selected character has.
74179 pub toggles: Vec<Toggle>,
......@@ -80,6 +185,8 @@ pub struct FormatState {
80185 /// Whether every selected paragraph is bulleted or numbered.
81186 pub bullets: bool,
82187 pub numbering: bool,
188 /// The library style every selected paragraph's list shares.
189 pub list: Option<ListStyle>,
83190 /// Tags every selected paragraph has.
84191 pub tags: Vec<NoteTag>,
85192}
......@@ -118,7 +225,7 @@ impl Toggle {
118225}
119226
120227impl NoteTag {
121 pub(super) const ALL: [Self; 9] = [
228 pub(crate) const ALL: [Self; 9] = [
122229 Self::ToDo,
123230 Self::Important,
124231 Self::Question,
......@@ -130,21 +237,36 @@ impl NoteTag {
130237 Self::PhoneNumber,
131238 ];
132239
240 /// The tag's name, as OneNote stores and lists it.
241 pub const fn label(self) -> &'static str {
242 match self {
243 Self::ToDo => "To Do",
244 Self::Important => "Important",
245 Self::Question => "Question",
246 Self::RememberForLater => "Remember for later",
247 Self::Definition => "Definition",
248 Self::Highlight => "Highlight",
249 Self::Contact => "Contact",
250 Self::Address => "Address",
251 Self::PhoneNumber => "Phone number",
252 }
253 }
254
133255 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),
144266 };
145267 Definition {
146268 kind: Kind::TagDefinition {
147 label: Some(label.into()),
269 label: Some(self.label().into()),
148270 action_type: Some(self as u16),
149271 shape: Some(shape),
150272 // The highlighting tags also set black text.
......@@ -161,7 +283,7 @@ type Ends = [(ExGuid, usize); 2];
161283
162284/// The selection's ends and the container range holding both; a selection ending at a
163285/// paragraph's start ends with the paragraph before.
164fn selected(
286pub(super) fn selected(
165287 document: &TextDocument,
166288 selection: Selection,
167289) -> Result<(Option<ExGuid>, Range<usize>, Ends), EditError> {
......@@ -224,7 +346,7 @@ fn covered(
224346 })
225347}
226348
227fn leaves_mut(nodes: &mut [PageParagraph], change: &mut impl FnMut(&mut PageParagraph)) {
349pub(super) fn leaves_mut(nodes: &mut [PageParagraph], change: &mut impl FnMut(&mut PageParagraph)) {
228350 for node in nodes {
229351 match &mut node.content {
230352 ParagraphContent::Text(_) => change(node),
......@@ -239,7 +361,11 @@ fn leaves_mut(nodes: &mut [PageParagraph], change: &mut impl FnMut(&mut PagePara
239361}
240362
241363/// `text` with `change` applied to the formats of bytes `range`, or to its only format when empty.
242fn restyle(text: &Paragraph, range: Range<usize>, change: impl Fn(&mut Format)) -> Paragraph {
364pub(super) fn restyle(
365 text: &Paragraph,
366 range: Range<usize>,
367 change: impl Fn(&mut Format),
368) -> Paragraph {
243369 if text.text().is_empty() {
244370 let mut format = text.spans()[0].format.clone();
245371 change(&mut format);
......@@ -316,7 +442,8 @@ fn tags(node: &PageParagraph) -> impl Iterator<Item = &Tag> {
316442
317443impl CanvasEditor {
318444 /// 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.
320447 pub(super) fn typing_format(&self, position: TextPosition) -> Result<Format, EditError> {
321448 if let Some((id, at, format)) = &self.pending
322449 && *id == self.active_outline().id
......@@ -324,13 +451,23 @@ impl CanvasEditor {
324451 {
325452 return Ok(format.clone());
326453 }
327 Ok(self
454 let text = self
328455 .active_outline()
329456 .document
330457 .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)
334471 }
335472
336473 fn list(&self, node: &PageParagraph) -> Option<Formatting> {
......@@ -349,6 +486,10 @@ impl CanvasEditor {
349486 }
350487 }
351488
489 fn list_style(&self, node: &PageParagraph) -> Option<ListStyle> {
490 ListStyle::of(&self.definitions.get(node.lists.last()?)?.kind)
491 }
492
352493 fn tag_kind(&self, tag: &Tag) -> Option<&Kind<'static>> {
353494 Some(&self.definitions.get(tag.definition.as_ref()?)?.kind)
354495 }
......@@ -395,6 +536,7 @@ impl CanvasEditor {
395536 })),
396537 bullets: list(Formatting::Bullets),
397538 numbering: list(Formatting::Numbering),
539 list: common(paragraphs.iter().map(|node| self.list_style(node))),
398540 tags: NoteTag::ALL
399541 .into_iter()
400542 .filter(|tag| {
......@@ -429,7 +571,9 @@ impl CanvasEditor {
429571 Formatting::Align(_)
430572 | Formatting::Bullets
431573 | Formatting::Numbering
574 | Formatting::List(_)
432575 | Formatting::Tag(_)
576 | Formatting::RemoveTags
433577 | Formatting::Check
434578 if title =>
435579 {
......@@ -455,13 +599,27 @@ impl CanvasEditor {
455599 }
456600 });
457601 }
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));
461617 // 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.
463620 let nodes = outline.document.container(container)?;
464621 if !remove
622 && nodes[range.start].lists.is_empty()
465623 && crate::document::previous_sibling(nodes, range.start, &BTreeSet::new())
466624 .is_some_and(|sibling| nodes[sibling].lists.is_empty())
467625 && let Some(edit) =
......@@ -472,13 +630,10 @@ impl CanvasEditor {
472630 }
473631 let wanted = covered(&replacement, ends)
474632 .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| {
477635 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)))
482637 })
483638 .collect::<Vec<_>>();
484639 // A list definition belongs to one paragraph, as OneNote stores it.
......@@ -560,6 +715,12 @@ impl CanvasEditor {
560715 }
561716 });
562717 }
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 }),
563724 Formatting::Check => self.check(&mut replacement, ranges, ends),
564725 Formatting::Toggle(_)
565726 | Formatting::Font(_)
......@@ -567,10 +728,20 @@ impl CanvasEditor {
567728 | Formatting::Color(_)
568729 | Formatting::Highlight(_)
569730 | 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()?;
570738 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)),
574745 _ => true,
575746 };
576747 let change = |base: &Format, format: &mut Format| match &command {
......@@ -606,10 +777,7 @@ impl CanvasEditor {
606777 }
607778 };
608779 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 {
613781 change(&bases[&ends[0].0], &mut format);
614782 self.pending = Some((id, caret, format));
615783 return Ok(());
......@@ -717,79 +885,47 @@ impl CanvasEditor {
717885 }
718886}
719887
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.
724892pub(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 {
738896 return None;
739897 };
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),
746902 };
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)?
776908 };
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 }
777913 Some(Definition {
778914 kind,
779915 format: definition.format.clone(),
780916 })
781917}
782918
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`).
786pub(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`).
922pub(super) fn list_definition(style: ListStyle, format: &Format) -> Definition {
787923 let font_size = Some(format.font_size.unwrap_or(11.0));
788 if numbering {
789 Definition {
924 match style {
925 ListStyle::Number(index) => Definition {
790926 kind: Kind::List {
791927 font: None,
792 format: Some("\u{fffd}\u{0}.".into()),
928 format: Some(NUMBER_LIBRARY[index].into()),
793929 restart: None,
794930 bullet: None,
795931 },
......@@ -802,20 +938,22 @@ pub(super) fn list_definition(numbering: bool, format: &Format) -> Definition {
802938 language: format.language,
803939 ..Format::default()
804940 },
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 }
819957 }
820958 }
821959}
......@@ -930,6 +1068,44 @@ mod tests {
9301068 assert_eq!(bold(&editor)[1], [run("ne", Some(true)), run("xt", None)]);
9311069 }
9321070
1071 #[test]
1072 fn a_second_toggle_at_a_caret_turns_the_typing_format_back_off() {
1073 for toggle in Toggle::ALL {
1074 for text in ["", "ab"] {
1075 let mut engine = TextEngine::default();
1076 let mut editor = plain(&mut engine, &[text]);
1077 let caret = at(0, text.len() as u32);
1078 editor.select([caret; 2].into()).unwrap();
1079 let press = |editor: &mut CanvasEditor, engine: &mut TextEngine| {
1080 editor.format(engine, Formatting::Toggle(toggle)).unwrap();
1081 editor.format_state().unwrap().toggles
1082 };
1083 assert_eq!(press(&mut editor, &mut engine), [toggle], "{toggle:?}");
1084 assert_eq!(press(&mut editor, &mut engine), [], "{toggle:?}");
1085 editor.insert(&mut engine, "x").unwrap();
1086 assert_eq!(
1087 runs(&editor, |format| toggle.get(format))[0],
1088 [run(&format!("{text}x"), None)]
1089 );
1090 }
1091 let mut engine = TextEngine::default();
1092 let mut on = calibri();
1093 *toggle.slot(&mut on) = Some(true);
1094 let mut editor = editor(&mut engine, vec![Paragraph::new("ab".into(), on)]);
1095 editor.select([at(0, 1); 2].into()).unwrap();
1096 editor
1097 .format(&mut engine, Formatting::Toggle(toggle))
1098 .unwrap();
1099 assert_eq!(editor.format_state().unwrap().toggles, [], "{toggle:?}");
1100 editor.insert(&mut engine, "x").unwrap();
1101 assert_eq!(
1102 runs(&editor, |format| toggle.get(format) == Some(true))[0],
1103 [("a".into(), true), ("x".into(), false), ("b".into(), true)],
1104 "{toggle:?}"
1105 );
1106 }
1107 }
1108
9331109 #[test]
9341110 fn a_caret_formats_the_text_typed_next_and_scripts_exclude_each_other() {
9351111 let mut engine = TextEngine::default();
......@@ -1112,7 +1288,7 @@ mod tests {
11121288 let markers = paragraph.markers.iter().map(|(marker, origin)| {
11131289 (*origin, marker.lines().next().unwrap().0.metrics().advance)
11141290 });
1115 (paragraph.number, markers.collect::<Vec<_>>())
1291 (paragraph.number.clone(), markers.collect::<Vec<_>>())
11161292 })
11171293 .collect::<Vec<_>>()
11181294 };
......@@ -1121,7 +1297,7 @@ mod tests {
11211297 .shaped
11221298 .paragraphs
11231299 .iter()
1124 .map(|paragraph| paragraph.number.map(|(number, _)| number))
1300 .map(|paragraph| paragraph.number.as_ref().map(|(count, _)| count.number))
11251301 .collect()
11261302 }
11271303
......@@ -1139,7 +1315,7 @@ mod tests {
11391315 let bullet = editor.active_outline().document.nodes()[0].lists[0];
11401316 assert_eq!(
11411317 editor.definitions[&bullet],
1142 list_definition(false, &calibri())
1318 list_definition(ListStyle::BULLET, &calibri())
11431319 );
11441320 editor.format(&mut engine, Formatting::Numbering).unwrap();
11451321 let state = editor.format_state().unwrap();
......@@ -1197,6 +1373,136 @@ mod tests {
11971373 assert_eq!(numbers(&mut engine, &editor), [None; 4]);
11981374 }
11991375
1376 #[test]
1377 fn library_styles_replace_any_list_and_none_removes_them() {
1378 let mut engine = TextEngine::default();
1379 let mut editor = plain(&mut engine, &["one", "two", "three"]);
1380 let original = editor.active_outline().document.clone();
1381 let all = Selection::from([at(0, 0), at(2, 5)]);
1382 editor.select(all).unwrap();
1383 editor.format(&mut engine, Formatting::Bullets).unwrap();
1384 assert_eq!(editor.format_state().unwrap().list, Some(ListStyle::BULLET));
1385 let pick = |style| Formatting::List(Some(style));
1386 editor
1387 .format(&mut engine, pick(ListStyle::Bullet(0)))
1388 .unwrap();
1389 let state = editor.format_state().unwrap();
1390 assert!(state.bullets && !state.numbering);
1391 assert_eq!(state.list, Some(ListStyle::Bullet(0)));
1392 let first = editor.active_outline().document.nodes()[0].lists[0];
1393 assert_eq!(
1394 editor.definitions[&first],
1395 list_definition(ListStyle::Bullet(0), &calibri())
1396 );
1397 // Picking the style again leaves the lists as they are.
1398 let stored = editor.active_outline().document.clone();
1399 editor
1400 .format(&mut engine, pick(ListStyle::Bullet(0)))
1401 .unwrap();
1402 assert_eq!(editor.active_outline().document, stored);
1403 // A number format replaces the bullets; "First." lays out as any number does.
1404 editor
1405 .format(&mut engine, pick(ListStyle::Number(17)))
1406 .unwrap();
1407 let state = editor.format_state().unwrap();
1408 assert!(state.numbering && !state.bullets);
1409 assert_eq!(state.list, Some(ListStyle::Number(17)));
1410 assert_eq!(numbers(&mut engine, &editor), [Some(1), Some(2), Some(3)]);
1411 // Paragraphs of different styles share none.
1412 editor.select([at(1, 0); 2].into()).unwrap();
1413 editor
1414 .format(&mut engine, pick(ListStyle::Number(0)))
1415 .unwrap();
1416 editor.select(all).unwrap();
1417 let state = editor.format_state().unwrap();
1418 assert!(state.numbering && state.list.is_none());
1419 editor.format(&mut engine, Formatting::List(None)).unwrap();
1420 let state = editor.format_state().unwrap();
1421 assert!(!state.bullets && !state.numbering && state.list.is_none());
1422 assert_eq!(numbers(&mut engine, &editor), [None; 3]);
1423 for _ in 0..6 {
1424 editor.undo(&mut engine).unwrap();
1425 }
1426 assert_eq!(editor.active_outline().document, original);
1427 }
1428
1429 #[test]
1430 fn restyling_a_list_after_a_plain_sibling_leaves_it_where_it_is() {
1431 let mut engine = TextEngine::default();
1432 let mut editor = plain(&mut engine, &["head", "item"]);
1433 let levels = |editor: &CanvasEditor| {
1434 editor
1435 .active_outline()
1436 .document
1437 .nodes()
1438 .iter()
1439 .map(|node| node.level)
1440 .collect::<Vec<_>>()
1441 };
1442 editor.select([at(1, 0); 2].into()).unwrap();
1443 editor.format(&mut engine, Formatting::Numbering).unwrap();
1444 assert_eq!(levels(&editor), [1, 2]);
1445 editor.format(&mut engine, Formatting::Outdent).unwrap();
1446 assert_eq!(levels(&editor), [1, 1]);
1447 editor.format(&mut engine, Formatting::Bullets).unwrap();
1448 editor
1449 .format(&mut engine, Formatting::List(Some(ListStyle::Bullet(0))))
1450 .unwrap();
1451 assert_eq!(levels(&editor), [1, 1]);
1452 assert_eq!(
1453 editor.format_state().unwrap().list,
1454 Some(ListStyle::Bullet(0))
1455 );
1456 }
1457
1458 #[test]
1459 fn tab_steps_through_onenotes_nested_bullets_and_numbers() {
1460 let chain = |start: ListStyle, steps: usize| {
1461 let mut definition = list_definition(start, &calibri());
1462 let mut styles = vec![ListStyle::of(&definition.kind).unwrap()];
1463 for _ in 0..steps {
1464 definition = nested_list(&definition, true).unwrap();
1465 styles.push(ListStyle::of(&definition.kind).unwrap());
1466 }
1467 (styles, definition)
1468 };
1469 let (bullets, deepest) = chain(ListStyle::BULLET, 9);
1470 assert_eq!(
1471 bullets,
1472 [2, 3, 13, 14, 9, 7, 15, 26, 8, 2].map(ListStyle::Bullet)
1473 );
1474 // Wingdings § is ListMSAAIndex 9 here, as the gallery stores it.
1475 assert_eq!(BULLET_LIBRARY[13], ("Wingdings", "\u{a7}", 9));
1476 assert_eq!(
1477 deepest.format,
1478 list_definition(ListStyle::BULLET, &calibri()).format
1479 );
1480 let (numbers, _) = chain(ListStyle::NUMBER, 8);
1481 assert_eq!(
1482 numbers,
1483 [0, 2, 4, 6, 7, 9, 16, 17, 0].map(ListStyle::Number)
1484 );
1485 let second = nested_list(&list_definition(ListStyle::NUMBER, &calibri()), true).unwrap();
1486 assert_eq!(
1487 ListStyle::of(&nested_list(&second, false).unwrap().kind),
1488 Some(ListStyle::NUMBER)
1489 );
1490 assert!(nested_list(&list_definition(ListStyle::BULLET, &calibri()), false).is_none());
1491 // A style off both paths keeps its list.
1492 assert!(nested_list(&list_definition(ListStyle::Bullet(33), &calibri()), true).is_none());
1493 }
1494
1495 #[test]
1496 fn every_library_style_reads_back_as_itself() {
1497 let styles = (0..BULLET_LIBRARY.len())
1498 .map(ListStyle::Bullet)
1499 .chain((0..NUMBER_LIBRARY.len()).map(ListStyle::Number));
1500 for style in styles {
1501 let definition = list_definition(style, &calibri());
1502 assert_eq!(ListStyle::of(&definition.kind), Some(style));
1503 }
1504 }
1505
12001506 #[test]
12011507 fn numbering_follows_stored_sequences_restarts_and_nesting() {
12021508 let mut engine = TextEngine::default();
......@@ -1207,10 +1513,10 @@ mod tests {
12071513 ('\u{1}', None, 1),
12081514 ('\u{4}', None, 2),
12091515 ('\u{1}', Some(9), 1),
1210 ('\u{0}', None, 1),
1516 ('\u{1}', None, 1),
12111517 ]) {
12121518 let id = new_id().unwrap();
1213 let mut definition = list_definition(true, &calibri());
1519 let mut definition = list_definition(ListStyle::NUMBER, &calibri());
12141520 let Kind::List {
12151521 format,
12161522 restart: value,
......@@ -1237,6 +1543,78 @@ mod tests {
12371543 );
12381544 }
12391545
1546 /// OneNote 2010's numbers for siblings and children in the formats and levels given, as
1547 /// its COM interface reports them (`evidence/toolbar-17/restart.txt`); `None` is a plain
1548 /// paragraph, `Some("•")` a bullet.
1549 #[test]
1550 fn numbers_restart_where_the_format_changes() {
1551 let numbered = |paragraphs: &[(Option<&str>, u32)]| {
1552 let mut engine = TextEngine::default();
1553 let texts = vec!["x"; paragraphs.len()];
1554 let mut editor = plain(&mut engine, &texts);
1555 let mut nodes = editor.active_outline().document.nodes().to_vec();
1556 for (node, (list, level)) in nodes.iter_mut().zip(paragraphs) {
1557 node.level = *level;
1558 let style = match list {
1559 None => continue,
1560 Some("•") => ListStyle::BULLET,
1561 Some(_) => ListStyle::NUMBER,
1562 };
1563 let mut definition = list_definition(style, &calibri());
1564 if let (Some(value), Kind::List { format, .. }) = (list, &mut definition.kind)
1565 && style == ListStyle::NUMBER
1566 {
1567 *format = Some((*value).into());
1568 }
1569 let id = new_id().unwrap();
1570 editor.definitions.insert(id, definition);
1571 node.lists = vec![id];
1572 }
1573 let outline = Outline {
1574 paragraphs: nodes,
1575 ..editor.active_outline().snapshot()
1576 };
1577 let editor =
1578 CanvasEditor::from_outlines(&mut engine, vec![outline], editor.definitions)
1579 .unwrap();
1580 numbers(&mut engine, &editor)
1581 };
1582 let [dot, paren, letter] = ["\u{fffd}\u{0}.", "\u{fffd}\u{0})", "\u{fffd}\u{4}."];
1583 // Another format starts again, and so does the first format after it.
1584 assert_eq!(
1585 numbered(&[
1586 (Some(dot), 1),
1587 (Some(dot), 1),
1588 (Some(paren), 1),
1589 (Some(dot), 1),
1590 (Some(paren), 1),
1591 ]),
1592 [Some(1), Some(2), Some(1), Some(1), Some(1)]
1593 );
1594 // Plain and bulleted siblings leave the count alone.
1595 assert_eq!(
1596 numbered(&[(Some(dot), 1), (None, 1), (Some("•"), 1), (Some(dot), 1)]),
1597 [Some(1), None, None, Some(2)]
1598 );
1599 // So does another sequence; children count apart from their parents, afresh under each.
1600 assert_eq!(
1601 numbered(&[(Some(dot), 1), (Some(letter), 1), (Some(dot), 1)]),
1602 [Some(1), Some(1), Some(1)]
1603 );
1604 assert_eq!(
1605 numbered(&[
1606 (Some(dot), 1),
1607 (Some(dot), 2),
1608 (Some(dot), 2),
1609 (Some(dot), 1),
1610 (Some(dot), 2),
1611 (None, 1),
1612 (Some(dot), 1),
1613 ]),
1614 [Some(1), Some(1), Some(2), Some(2), Some(1), None, Some(3)]
1615 );
1616 }
1617
12401618 fn text_tags(editor: &CanvasEditor, paragraph: usize) -> Vec<(String, u16, bool)> {
12411619 let (_, _, node) = editor.active_outline().document.leaf(paragraph).unwrap();
12421620 tags(node)
......@@ -1389,7 +1767,7 @@ mod tests {
13891767 #[test]
13901768 fn the_nine_default_tags_are_onenotes_stored_definitions_and_all_draw() {
13911769 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");
13931771 let store = Store::parse(bytes).unwrap();
13941772 let index = RevisionIndex::parse(&store).unwrap();
13951773 let document = Document::parse(&index).unwrap();
......@@ -1561,6 +1939,7 @@ mod tests {
15611939 alignment: Some(Alignment::Center),
15621940 bullets: false,
15631941 numbering: true,
1942 list: Some(ListStyle::NUMBER),
15641943 tags: vec![NoteTag::ToDo, NoteTag::Question],
15651944 }
15661945 );
crates/canvas/src/editor/link.rs created+681
......@@ -0,0 +1,681 @@
1//! Hyperlinks as OneNote 2010 edits them (`corpus/link-edit/native-typed`): the Link dialog
2//! stores the address as typed in a hidden `HYPERLINK` field code before the label, a URL
3//! typed and ended by a space or Enter becomes a link of its own text, typing after a label
4//! is plain text, and Remove Link leaves the label as plain text.
5
6use super::format::{leaves_mut, restyle, selected};
7use super::*;
8
9/// A link in a paragraph, in UTF-16 offsets of its stored text.
10#[derive(Clone, Debug, PartialEq, Eq)]
11pub struct Link {
12 pub paragraph: usize,
13 /// The hidden field code naming the address, before the label; none where the label is
14 /// its own address.
15 pub code: Option<Range<u32>>,
16 pub label: Range<u32>,
17 pub target: String,
18}
19
20impl Link {
21 fn range(&self) -> Range<u32> {
22 self.code
23 .as_ref()
24 .map_or(self.label.start, |code| code.start)..self.label.end
25 }
26}
27
28/// The links in `text`, the text of paragraph `paragraph`.
29pub(crate) fn links(text: &Paragraph, paragraph: usize) -> Vec<Link> {
30 let mut found: Vec<Link> = Vec::new();
31 let mut code: Option<(Range<u32>, String)> = None;
32 let (mut byte, mut unit) = (0, 0);
33 for span in text.spans() {
34 let fragment = &text.text()[byte..span.end];
35 let range = unit..unit + fragment.encode_utf16().count() as u32;
36 (byte, unit) = (span.end, range.end);
37 let format = &span.format;
38 if format.hyperlink != Some(true) {
39 code = None;
40 continue;
41 }
42 if format.hidden == Some(true) {
43 code = fragment
44 .strip_prefix("\u{fddf}HYPERLINK \"")
45 .and_then(|target| target.strip_suffix('"'))
46 .map(|target| (range, target.to_owned()));
47 continue;
48 }
49 let labelled = format.hyperlink_label == Some(true);
50 match (code.take(), found.last_mut()) {
51 (Some((code, target)), _) if code.end == range.start => found.push(Link {
52 paragraph,
53 code: Some(code),
54 label: range,
55 target,
56 }),
57 // A label runs on across format changes; a bare link is its own text.
58 (_, Some(last)) if last.label.end == range.start && last.code.is_some() == labelled => {
59 last.label.end = range.end;
60 }
61 _ => found.push(Link {
62 paragraph,
63 code: None,
64 label: range,
65 target: String::new(),
66 }),
67 }
68 }
69 for link in &mut found {
70 if link.code.is_none() {
71 let start = text.byte_offset(link.label.start).unwrap_or(0);
72 let end = text.byte_offset(link.label.end).unwrap_or(start);
73 link.target = text.text()[start..end].to_owned();
74 }
75 }
76 found
77}
78
79/// Where a typed URL lies in `word`, a run of text ended by a space or Enter, as OneNote
80/// links it: from a scheme it knows (or `www.`, or a share's `\\`) preceded by punctuation
81/// only, to before any trailing punctuation or unmatched closing bracket.
82pub(crate) fn typed_url(word: &str) -> Option<Range<usize>> {
83 const SCHEMES: [&str; 9] = [
84 "http://", "https://", "ftp://", "file://", "mailto:", "news:", "onenote:", "www.", "\\\\",
85 ];
86 let lower = word.to_ascii_lowercase();
87 let (start, scheme) = SCHEMES
88 .iter()
89 .filter_map(|scheme| Some((lower.find(scheme)?, scheme.len())))
90 .min()?;
91 if word[..start].chars().any(char::is_alphanumeric) {
92 return None;
93 }
94 let mut end = word.len();
95 while let Some(last) = word[start..end].chars().last() {
96 let count = |c: char| word[start..end].matches(c).count();
97 let trailing = match last {
98 '.' | ',' | ';' | ':' | '!' | '?' | '\'' | '"' => true,
99 ')' => count('(') < count(')'),
100 ']' => count('[') < count(']'),
101 '}' => count('{') < count('}'),
102 _ => false,
103 };
104 if !trailing {
105 break;
106 }
107 end -= last.len_utf8();
108 }
109 (end > start + scheme).then_some(start..end)
110}
111
112/// The URLs written as text in `text`, as byte ranges: OneNote links them when it opens a
113/// page, so they show and open as links without being stored as ones.
114pub fn shown_urls(text: &Paragraph) -> Vec<Range<usize>> {
115 let plain =
116 |format: &Format| ![format.hyperlink, format.hidden, format.math].contains(&Some(true));
117 let mut urls = Vec::new();
118 let mut start = 0;
119 for span in text.spans() {
120 if !plain(&span.format) {
121 start = span.end;
122 continue;
123 }
124 let run = &text.text()[start..span.end];
125 let mut at = 0;
126 for word in run.split(char::is_whitespace) {
127 // Most words hold none of what a URL starts with.
128 let candidate = word.contains([':', '\\'])
129 || word
130 .as_bytes()
131 .windows(4)
132 .any(|w| w.eq_ignore_ascii_case(b"www."));
133 if candidate && let Some(url) = typed_url(word) {
134 urls.push(start + at + url.start..start + at + url.end);
135 }
136 at += word.len()
137 + run[at + word.len()..]
138 .chars()
139 .next()
140 .map_or(0, char::len_utf8);
141 }
142 start = span.end;
143 }
144 urls
145}
146
147/// The format of the character at UTF-16 `offset`.
148pub(crate) fn format_after(text: &Paragraph, offset: u32) -> Option<&Format> {
149 let byte = text.byte_offset(offset).ok()?;
150 let length = text.text()[byte..].chars().next()?.len_utf16() as u32;
151 text.format_at(offset + length).ok()
152}
153
154/// Text that follows a link rather than extending it.
155pub(crate) fn unlinked(format: &mut Format) {
156 format.hidden = format.hidden.map(|_| false);
157 format.hyperlink = format.hyperlink.map(|_| false);
158 format.hyperlink_label = None;
159}
160
161/// A field code naming `address`, which cannot hold its quotes or line breaks.
162fn field_code(address: &str) -> String {
163 let address: String = address
164 .trim()
165 .chars()
166 .filter(|c| !c.is_control() && *c != '\u{fddf}')
167 .map(|c| {
168 if c == '"' {
169 "%22".to_owned()
170 } else {
171 c.to_string()
172 }
173 })
174 .collect();
175 format!("\u{fddf}HYPERLINK \"{address}\"")
176}
177
178impl CanvasEditor {
179 /// The link in the active outline at `position`, its ends included.
180 pub fn link_at(&self, position: TextPosition) -> Option<Link> {
181 let text = self
182 .active_outline()
183 .document
184 .paragraph(position.paragraph)?;
185 links(text, position.paragraph).into_iter().find(|link| {
186 link.range().contains(&position.offset) || link.label.end == position.offset
187 })
188 }
189
190 /// The address of the link drawn under document point `point` of outline `id`, which a
191 /// click opens as OneNote's does.
192 pub fn link_under(&self, id: ExGuid, [x, y]: [f32; 2]) -> Option<String> {
193 let outline = self.visible_outlines().find(|outline| outline.id == id)?;
194 let index = outline.paragraph_at(x, y).ok()?;
195 let paragraph = &outline.shaped.paragraphs[index];
196 let point = [x - paragraph.origin[0], y - paragraph.origin[1]];
197 let cursor = paragraph.text.hit_test(point[0], point[1]);
198 let edge = paragraph.text.caret(cursor, 0.0).x0 as f32;
199 let visible = paragraph.projection.text();
200 let byte = cursor.index();
201 // The character under the point lies on the side of the nearest boundary it is on.
202 let at = if point[0] < edge {
203 visible.text()[..byte].char_indices().next_back()?.0
204 } else {
205 visible.text()[byte..].chars().next()?;
206 byte
207 };
208 let (line, _) = paragraph
209 .text
210 .lines()
211 .find(|(_, bounds)| bounds.top <= point[1] && point[1] < bounds.top + bounds.height)?;
212 if point[0] > line.metrics().advance {
213 return None;
214 }
215 let source = paragraph
216 .projection
217 .source_offset(
218 visible.utf16_offset(at).ok()?,
219 onestore::page::text::Affinity::Downstream,
220 )
221 .ok()?;
222 let paragraph = outline.source_index(index);
223 links(outline.document.paragraph(paragraph)?, paragraph)
224 .into_iter()
225 .find(|link| link.label.contains(&source))
226 .map(|link| link.target)
227 .or_else(|| {
228 shown_urls(visible)
229 .into_iter()
230 .find(|url| url.contains(&at))
231 .map(|url| visible.text()[url].to_owned())
232 })
233 }
234
235 /// What the Link dialog links, as OneNote 2010 picks it: the selection within one
236 /// paragraph, else the link or word at the caret, else nothing at the caret. With the
237 /// text it shows and the address it holds.
238 fn link_range(&self) -> Result<(usize, Range<u32>, String, String), EditError> {
239 let [anchor, focus] = self.active_outline().selection.positions;
240 let (start, end) = (anchor.min(focus), anchor.max(focus));
241 if start.paragraph != end.paragraph {
242 return Err(EditError::UnsupportedContent);
243 }
244 let text = self
245 .active_outline()
246 .document
247 .paragraph(start.paragraph)
248 .ok_or(EditError::InvalidRange)?;
249 let shown = |range: Range<u32>| -> Result<String, EditError> {
250 Ok(text.slice(range)?.project()?.text().text().to_owned())
251 };
252 if start != end {
253 let mut range = start.offset..end.offset;
254 // Links the selection touches are relinked whole.
255 for link in links(text, start.paragraph) {
256 let whole = link.range();
257 if whole.start < range.end && range.start < whole.end {
258 range = range.start.min(whole.start)..range.end.max(whole.end);
259 }
260 }
261 let address = self
262 .link_at(start)
263 .filter(|link| link.range().start <= range.start && range.end <= link.label.end)
264 .map(|link| link.target)
265 .unwrap_or_default();
266 return Ok((start.paragraph, range.clone(), shown(range)?, address));
267 }
268 if let Some(link) = self.link_at(start) {
269 let label = shown(link.label.clone())?;
270 return Ok((start.paragraph, link.range(), label, link.target));
271 }
272 // The word at the caret, within plain visible text.
273 let byte = text.byte_offset(start.offset)?;
274 let plain = |at: usize, c: char| {
275 !c.is_whitespace()
276 && text
277 .utf16_offset(at)
278 .ok()
279 .and_then(|at| format_after(text, at))
280 .is_some_and(|format| {
281 ![format.hidden, format.hyperlink, format.math].contains(&Some(true))
282 })
283 };
284 let from = text.text()[..byte]
285 .char_indices()
286 .rev()
287 .take_while(|&(at, c)| plain(at, c))
288 .last()
289 .map_or(byte, |(at, _)| at);
290 let to = text.text()[byte..]
291 .char_indices()
292 .take_while(|&(at, c)| plain(byte + at, c))
293 .last()
294 .map_or(byte, |(at, c)| byte + at + c.len_utf8());
295 let range = text.utf16_offset(from)?..text.utf16_offset(to)?;
296 Ok((start.paragraph, range.clone(), shown(range)?, String::new()))
297 }
298
299 /// The text and address the Link dialog opens with.
300 pub fn link_prefill(&self) -> (String, String) {
301 self.link_range()
302 .map(|(_, _, text, address)| (text, address))
303 .unwrap_or_default()
304 }
305
306 /// OK in the Link dialog: what [`Self::link_prefill`] picked becomes `text`, or the
307 /// address where `text` is empty, linked to `address`, and the caret follows it.
308 pub fn set_link(
309 &mut self,
310 engine: &mut TextEngine,
311 text: &str,
312 address: &str,
313 ) -> Result<(), EditorError> {
314 if address.trim().is_empty() {
315 return Ok(());
316 }
317 let (paragraph, range, _, _) = self.link_range()?;
318 let current = self
319 .active_outline()
320 .document
321 .paragraph(paragraph)
322 .ok_or(EditError::InvalidRange)?;
323 let label: String = if text.is_empty() {
324 address.trim()
325 } else {
326 text
327 }
328 .chars()
329 .map(|c| if c.is_control() { ' ' } else { c })
330 .collect();
331 let mut base = match format_after(current, range.start).filter(|_| !range.is_empty()) {
332 Some(format) => format.clone(),
333 None => self.typing_format(TextPosition {
334 paragraph,
335 offset: range.start,
336 })?,
337 };
338 unlinked(&mut base);
339 base.hidden = Some(false);
340 let mut code = base.clone();
341 code.hidden = Some(true);
342 code.hyperlink = Some(true);
343 code.hyperlink_label = Some(true);
344 let mut shown = base;
345 shown.hyperlink = Some(true);
346 shown.hyperlink_label = Some(true);
347 let replacement = Paragraph::from_runs([(field_code(address), code), (label, shown)]);
348 let length = replacement.utf16_offset(replacement.text().len())?;
349 let mut updated = current.clone();
350 updated.apply(onestore::page::text::Edit {
351 range: range.clone(),
352 replacement,
353 })?;
354 let caret = TextPosition {
355 paragraph,
356 offset: range.start + length,
357 };
358 self.rewrite(engine, paragraph, updated, [caret; 2].into())
359 }
360
361 /// Remove Link: the link at the caret keeps its label as plain text.
362 pub fn remove_link(&mut self, engine: &mut TextEngine) -> Result<bool, EditorError> {
363 let [anchor, focus] = self.active_outline().selection.positions;
364 let Some(link) = self.link_at(anchor.min(focus)) else {
365 return Ok(false);
366 };
367 let text = self
368 .active_outline()
369 .document
370 .paragraph(link.paragraph)
371 .ok_or(EditError::InvalidRange)?;
372 let start = text.byte_offset(link.label.start)?;
373 let end = text.byte_offset(link.label.end)?;
374 let mut updated = restyle(text, start..end, unlinked);
375 let mut label = link.label.clone();
376 if let Some(code) = &link.code {
377 updated.apply(onestore::page::text::Edit {
378 range: code.clone(),
379 replacement: Paragraph::new(String::new(), Format::default()),
380 })?;
381 label = label.start - code.len() as u32..label.end - code.len() as u32;
382 }
383 let at = |offset| TextPosition {
384 paragraph: link.paragraph,
385 offset,
386 };
387 self.rewrite(
388 engine,
389 link.paragraph,
390 updated,
391 [at(label.start), at(label.end)].into(),
392 )?;
393 Ok(true)
394 }
395
396 /// Select Link: the label of the link at the caret.
397 pub fn select_link(&mut self) -> Result<bool, EditError> {
398 let [anchor, focus] = self.active_outline().selection.positions;
399 let Some(link) = self.link_at(anchor.min(focus)) else {
400 return Ok(false);
401 };
402 let at = |offset| TextPosition {
403 paragraph: link.paragraph,
404 offset,
405 };
406 self.select([at(link.label.start), at(link.label.end)].into())?;
407 Ok(true)
408 }
409
410 /// Links the URL typed before `end` once a space or Enter ends it, as its own undo step.
411 pub(super) fn link_typed_url(
412 &mut self,
413 engine: &mut TextEngine,
414 end: TextPosition,
415 ) -> Result<(), EditorError> {
416 let Some(text) = self.active_outline().document.paragraph(end.paragraph) else {
417 return Ok(());
418 };
419 let byte = text.byte_offset(end.offset)?;
420 let from = text.text()[..byte]
421 .char_indices()
422 .rev()
423 .take_while(|&(_, c)| !c.is_whitespace())
424 .last()
425 .map_or(byte, |(at, _)| at);
426 let Some(url) = typed_url(&text.text()[from..byte]) else {
427 return Ok(());
428 };
429 let url = from + url.start..from + url.end;
430 // Only plain text links; a link, field code or equation there stays as it is.
431 let mut offset = text.utf16_offset(url.start)?;
432 for c in text.text()[url.clone()].chars() {
433 let format = format_after(text, offset).ok_or(EditError::InvalidRange)?;
434 if [format.hyperlink, format.hidden, format.math].contains(&Some(true)) {
435 return Ok(());
436 }
437 offset += c.len_utf16() as u32;
438 }
439 let updated = restyle(text, url, |format| format.hyperlink = Some(true));
440 let selection = self.active_outline().selection;
441 self.rewrite(engine, end.paragraph, updated, selection)
442 }
443
444 /// Moves a caret between a field code and its label to before the code, so what is
445 /// typed there stays outside the link.
446 pub(super) fn leave_link_code(&mut self) -> Result<(), EditError> {
447 let selection = self.active_outline().selection;
448 let [anchor, focus] = selection.positions;
449 if anchor != focus {
450 return Ok(());
451 }
452 let Some(link) = self.link_at(focus) else {
453 return Ok(());
454 };
455 if let Some(code) = link.code.filter(|code| code.end == focus.offset) {
456 let at = TextPosition {
457 paragraph: focus.paragraph,
458 offset: code.start,
459 };
460 self.active_outline_mut().selection = [at; 2].into();
461 }
462 Ok(())
463 }
464
465 /// Replaces the text of paragraph `paragraph` of the active outline as one undo step
466 /// ending at `selection`.
467 pub(super) fn rewrite(
468 &mut self,
469 engine: &mut TextEngine,
470 paragraph: usize,
471 text: Paragraph,
472 selection: Selection,
473 ) -> Result<(), EditorError> {
474 let document = &self.active_outline().document;
475 let at = TextPosition {
476 paragraph,
477 offset: 0,
478 };
479 let (container, range, [(id, _), _]) = selected(document, [at; 2].into())?;
480 let mut replacement = document.container(container)?[range.clone()].to_vec();
481 leaves_mut(&mut replacement, &mut |node| {
482 if node.id == id {
483 node.text_mut().unwrap().text = text.clone();
484 }
485 });
486 self.commit(
487 engine,
488 DocumentEdit {
489 columns: BTreeMap::new(),
490 container,
491 range,
492 replacement,
493 },
494 selection,
495 )
496 }
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502 use onestore::page::PageObject;
503
504 /// A paragraph's text as runs of (text, hyperlink, label, hidden), runs with the same
505 /// flags merged.
506 fn flags(text: &Paragraph) -> Vec<(String, bool, bool, bool)> {
507 let mut runs: Vec<(String, bool, bool, bool)> = Vec::new();
508 let mut start = 0;
509 for span in text.spans() {
510 let format = &span.format;
511 let run = (
512 text.text()[start..span.end].to_owned(),
513 format.hyperlink == Some(true),
514 format.hyperlink_label == Some(true),
515 format.hidden == Some(true),
516 );
517 start = span.end;
518 match runs.last_mut() {
519 Some(last) if (last.1, last.2, last.3) == (run.1, run.2, run.3) => {
520 last.0.push_str(&run.0);
521 }
522 _ => runs.push(run),
523 }
524 }
525 runs
526 }
527
528 fn native_paragraphs() -> Vec<Paragraph> {
529 let bytes = include_bytes!("../../../../corpus/link-edit/native-typed/notebook/links.one");
530 let store = onestore::Store::parse(bytes).unwrap();
531 let index = onestore::RevisionIndex::parse(&store).unwrap();
532 let document = onestore::document::Document::parse(&index).unwrap();
533 let (space, _) = document.pages().unwrap()[0];
534 let page = Page::from_space(&document, space).unwrap();
535 page.objects
536 .iter()
537 .filter_map(|object| match object {
538 PageObject::Outline(outline) if !outline.title => Some(outline),
539 _ => None,
540 })
541 .flat_map(|outline| &outline.paragraphs)
542 .filter_map(|paragraph| paragraph.text().map(|text| text.text.clone()))
543 .skip(1)
544 .collect()
545 }
546
547 /// Types `text`, each space on its own as a key does.
548 fn typed(editor: &mut CanvasEditor, engine: &mut TextEngine, text: &str) {
549 for (index, word) in text.split(' ').enumerate() {
550 if index > 0 {
551 editor.insert(engine, " ").unwrap();
552 }
553 if !word.is_empty() {
554 editor.insert(engine, word).unwrap();
555 }
556 }
557 }
558
559 fn at(paragraph: usize, offset: u32) -> Selection {
560 [TextPosition { paragraph, offset }; 2].into()
561 }
562
563 /// The editor replays what `tools/native_links.py` typed into OneNote 2010 and stores
564 /// the same links: typed URLs, the Link dialog on a word, on a selection and on nothing,
565 /// typing after a link, and Remove Link.
566 #[test]
567 fn link_editing_stores_what_onenote_stored() {
568 let native = native_paragraphs();
569 let mut engine = TextEngine::default();
570 let mut editor = CanvasEditor::new(
571 &mut engine,
572 TextDocument::new(vec![Paragraph::new(String::new(), Format::default())]).unwrap(),
573 400.0,
574 )
575 .unwrap();
576 let engine = &mut engine;
577 for url in [
578 "www.example.com",
579 "http://example.org/a?b=1",
580 "https://a.example/p.",
581 "(http://b.example/q)",
582 "ftp://c.example/f",
583 "mailto:me@example.com",
584 "me@example.com",
585 "example.com",
586 "onenote:#Page&section-id={x}",
587 "file://c:/windows",
588 "\\\\server\\share\\f",
589 "news:comp.lang",
590 "www.d.example,",
591 "HTTP://E.EXAMPLE/UP",
592 "https://f.example/a_b-c~d%20e#frag",
593 ] {
594 // OneNote capitalized the first letter of each sentence as it was typed.
595 let after = if url.ends_with('.') { "Z" } else { "z" };
596 typed(&mut editor, engine, &format!("T {url} {after}"));
597 editor.enter(engine, false).unwrap();
598 }
599 typed(&mut editor, engine, "See http://g.example/end");
600 editor.enter(engine, false).unwrap();
601 typed(&mut editor, engine, "Zplain words here");
602 let paragraph = editor.selection().positions[0].paragraph;
603 editor.select(at(paragraph, 13)).unwrap();
604 assert_eq!(editor.link_prefill(), ("here".into(), String::new()));
605 editor.set_link(engine, "here", "example.net/x").unwrap();
606 typed(&mut editor, engine, "Q");
607 editor.enter(engine, false).unwrap();
608 typed(&mut editor, engine, "Select me please");
609 let paragraph = editor.selection().positions[0].paragraph;
610 editor
611 .select(
612 [
613 TextPosition {
614 paragraph,
615 offset: 7,
616 },
617 TextPosition {
618 paragraph,
619 offset: 16,
620 },
621 ]
622 .into(),
623 )
624 .unwrap();
625 assert_eq!(editor.link_prefill(), ("me please".into(), String::new()));
626 editor
627 .set_link(engine, "me please", "https://example.com/selected")
628 .unwrap();
629 editor.enter(engine, false).unwrap();
630 assert_eq!(editor.link_prefill(), (String::new(), String::new()));
631 editor
632 .set_link(engine, "", "https://example.com/only")
633 .unwrap();
634 typed(&mut editor, engine, " tail");
635 editor.enter(engine, false).unwrap();
636 typed(&mut editor, engine, "Gone ftp://h.example/f z");
637 let paragraph = editor.selection().positions[0].paragraph;
638 editor.select(at(paragraph, 8)).unwrap();
639 assert!(editor.remove_link(engine).unwrap());
640 let written: Vec<_> = editor
641 .active_outline()
642 .document
643 .text_nodes()
644 .map(|node| flags(&node.text().unwrap().text))
645 .collect();
646 let expected: Vec<_> = native.iter().map(flags).collect();
647 assert_eq!(written, expected);
648 }
649
650 /// Every URL OneNote 2010 linked or left as typed in `corpus/link-edit/native-typed`.
651 #[test]
652 fn typed_urls_link_as_onenote_links_them() {
653 for (word, linked) in [
654 ("www.example.com", Some("www.example.com")),
655 ("http://example.org/a?b=1", Some("http://example.org/a?b=1")),
656 ("https://a.example/p.", Some("https://a.example/p")),
657 ("(http://b.example/q)", Some("http://b.example/q")),
658 ("ftp://c.example/f", Some("ftp://c.example/f")),
659 ("mailto:me@example.com", Some("mailto:me@example.com")),
660 ("me@example.com", None),
661 ("example.com", None),
662 (
663 "onenote:#Page&section-id={x}",
664 Some("onenote:#Page&section-id={x}"),
665 ),
666 ("file://c:/windows", Some("file://c:/windows")),
667 ("\\\\server\\share\\f", Some("\\\\server\\share\\f")),
668 ("news:comp.lang", Some("news:comp.lang")),
669 ("www.d.example,", Some("www.d.example")),
670 ("HTTP://E.EXAMPLE/UP", Some("HTTP://E.EXAMPLE/UP")),
671 (
672 "https://f.example/a_b-c~d%20e#frag",
673 Some("https://f.example/a_b-c~d%20e#frag"),
674 ),
675 ("wwWw.example.com", None),
676 ("http://", None),
677 ] {
678 assert_eq!(typed_url(word).map(|range| &word[range]), linked, "{word}");
679 }
680 }
681}
crates/canvas/src/editor/ops.rs+56
......@@ -596,6 +596,62 @@ mod tests {
596596 assert_eq!((*target, range.clone()), (text(0), 1..4));
597597 }
598598
599 /// Alt+Shift+Up and Down swap a paragraph, children and all, with its sibling as moves,
600 /// keeping the caret on the moved text, and stop at the first and last sibling.
601 #[test]
602 fn moving_paragraphs_swaps_sibling_subtrees_as_move_ops() {
603 let mut engine = TextEngine::default();
604 let lines = ["A", "a", "B"].map(|line| Paragraph::new(line.into(), Format::default()));
605 let document = crate::document::TextDocument::new(lines.to_vec()).unwrap();
606 let mut editor = CanvasEditor::new(&mut engine, document, 400.0).unwrap();
607 editor
608 .select(
609 [TextPosition {
610 paragraph: 1,
611 offset: 0,
612 }; 2]
613 .into(),
614 )
615 .unwrap();
616 editor.indent(&mut engine, false).unwrap();
617 editor.take_ops().unwrap();
618 let texts = |editor: &CanvasEditor| {
619 editor.outlines()[0]
620 .document()
621 .nodes()
622 .iter()
623 .map(|node| (node.text().unwrap().text.text().to_owned(), node.level))
624 .collect::<Vec<_>>()
625 };
626 let caret = TextPosition {
627 paragraph: 2,
628 offset: 1,
629 };
630 editor.select([caret; 2].into()).unwrap();
631 assert!(!editor.move_paragraphs(&mut engine, false).unwrap());
632 assert!(editor.move_paragraphs(&mut engine, true).unwrap());
633 let moved = [("B".into(), 1), ("A".into(), 1), ("a".into(), 2)];
634 assert_eq!(texts(&editor), moved);
635 assert_eq!(
636 editor.selection().positions,
637 [TextPosition {
638 paragraph: 0,
639 offset: 1
640 }; 2]
641 );
642 let ops = editor.take_ops().unwrap();
643 assert!(
644 !ops.is_empty() && ops.iter().all(|op| matches!(op, PageOp::Move { .. })),
645 "{ops:?}"
646 );
647 assert!(!editor.move_paragraphs(&mut engine, true).unwrap());
648 assert!(editor.move_paragraphs(&mut engine, false).unwrap());
649 assert_eq!(texts(&editor)[2], ("B".into(), 1));
650 assert_eq!(editor.selection().positions[0].paragraph, 2);
651 editor.undo(&mut engine).unwrap();
652 assert_eq!(texts(&editor), moved);
653 }
654
599655 /// A change from elsewhere reaches only what it changed: a refresh whose stored page is
600656 /// what the editor's ops left (though storage normalized it) changes nothing, history
601657 /// 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 {
273273 if self.active_outline().title {
274274 return self.leave_title(engine);
275275 }
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;
276286 let outline = self.active_outline();
277 let [anchor, focus] = outline.selection.positions;
278287 let (cell, local, source) = outline
279288 .document
280289 .leaf(focus.paragraph)
crates/canvas/src/gpu/mod.rs+15-1
......@@ -33,6 +33,7 @@ impl Viewport {
3333 origin: self.origin,
3434 clip: None,
3535 backdrop: None,
36 motion: None,
3637 primitives,
3738 }
3839 }
......@@ -151,6 +152,8 @@ const EXCLAMATION: &str = include_str!("../../assets/tags/exclamation.svg");
151152const RED_SQUARE: &str = include_str!("../../assets/tags/red-square.svg");
152153const YELLOW_SQUARE: &str = include_str!("../../assets/tags/yellow-square.svg");
153154const BLUE_SQUARE: &str = include_str!("../../assets/tags/blue-square.svg");
155const FLAG: &str = include_str!("../../assets/tags/flag.svg");
156const TAG: &str = include_str!("../../assets/tags/tag.svg");
154157
155158/// The tag's artwork, drawn in order.
156159pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] {
......@@ -168,6 +171,8 @@ pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] {
168171 TagIcon::RedSquare => &[RED_SQUARE],
169172 TagIcon::YellowSquare => &[YELLOW_SQUARE],
170173 TagIcon::BlueSquare => &[BLUE_SQUARE],
174 TagIcon::Flag => &[FLAG],
175 TagIcon::Other => &[TAG],
171176 }
172177}
173178
......@@ -190,7 +195,13 @@ mod tests {
190195 #[test]
191196 fn page_colours_colour_the_paper() {
192197 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 );
194205 let dark = Paper {
195206 color: draw::srgb(0x1f, 0x20, 0x22),
196207 ink: draw::srgb(0xe6, 0xe6, 0xe6),
......@@ -290,6 +301,8 @@ mod tests {
290301 TagIcon::RedSquare,
291302 TagIcon::YellowSquare,
292303 TagIcon::BlueSquare,
304 TagIcon::Flag,
305 TagIcon::Other,
293306 ];
294307 let text = TextEngine::default()
295308 .layout(
......@@ -322,6 +335,7 @@ mod tests {
322335 origin: [2.0 + 16.0 * index as f32, 2.0],
323336 size: crate::outline::ParagraphTag::SIZE,
324337 tint: [1.0; 4],
338 palette: draw::Palette::default(),
325339 })
326340 .collect();
327341 primitives.push(Primitive::Text {
crates/canvas/src/gpu/page.rs+91-72
......@@ -838,7 +838,7 @@ impl crate::outline::OutlineLayout {
838838 paragraph.origin[1] - OVERHANG,
839839 paragraph.origin[1] + paragraph.text.height() + OVERHANG,
840840 ];
841 paragraph.math.is_some() || (end >= rows[0] && start <= rows[1])
841 end >= rows[0] && start <= rows[1]
842842 })
843843 }
844844
......@@ -925,50 +925,50 @@ impl crate::outline::OutlineLayout {
925925 bottom + origin[1],
926926 ]
927927 });
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 {
957956 from: [x + from[0], y + from[1]],
958957 to: [x + to[0], y + to[1]],
959 width: *width,
960 round: true,
958 width,
959 round: false,
961960 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 });
965971 }
966 None => primitives.push(Primitive::Text {
967 clip,
968 text: &paragraph.text,
969 origin: [x, y],
970 ink,
971 }),
972972 }
973973 for (layout, marker) in &paragraph.markers {
974974 primitives.push(Primitive::Text {
......@@ -987,6 +987,7 @@ impl crate::outline::OutlineLayout {
987987 ],
988988 size: tag.size,
989989 tint: [1.0, 1.0, 1.0, if tag.disabled { 0.45 } else { 1.0 }],
990 palette: draw::Palette::default(),
990991 });
991992 }
992993 }
......@@ -1621,15 +1622,15 @@ mod tests {
16211622 }
16221623
16231624 #[test]
1624 fn unsupported_tags_and_titles_leave_supported_outlines_editable() {
1625 fn unsupported_outlines_and_titles_leave_supported_outlines_editable() {
16251626 use crate::document::TextDocument;
16261627 use onestore::document::{Kind, Tag};
16271628 use onestore::page::{Definition, Outline, Title};
16281629 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],
16311632 n: 1,
1632 };
1633 });
16331634 let make_outline = |text: &str| {
16341635 let document =
16351636 TextDocument::new(vec![Paragraph::new(text.into(), Default::default())])
......@@ -1650,18 +1651,8 @@ mod tests {
16501651 unsupported: Vec::new(),
16511652 }
16521653 };
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);
16651656 let unsupported = if title {
16661657 PageObject::Title(Title {
16671658 date: None,
......@@ -1671,31 +1662,59 @@ mod tests {
16711662 y: Some(20.0),
16721663 ..Default::default()
16731664 },
1674 outlines: vec![tagged],
1665 outlines: vec![numbered],
16751666 })
16761667 } else {
1677 PageObject::Outline(tagged)
1668 PageObject::Outline(numbered)
16781669 };
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 });
16791683 let page = Page {
16801684 identity: None,
16811685 created: None,
16821686 title: String::new(),
16831687 margin_origin: [36.0, 14.0],
16841688 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(),
16951702 },
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 ]),
16991718 };
17001719 let mut engine = TextEngine::default();
17011720 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(
551551 [view.width() * 0.75, view.height() * 0.75]
552552 });
553553 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 ))
555558}
556559
557560/// 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>
608608 TagIcon::RedSquare => "Project A",
609609 TagIcon::YellowSquare => "Project B",
610610 TagIcon::BlueSquare => "Project C",
611 TagIcon::Flag => "Outlook task",
612 TagIcon::Other => "Tag",
611613 };
612614 let label = if tag.label.is_empty() {
613615 fallback
crates/canvas/src/interaction/mod.rs+221-126
......@@ -53,8 +53,20 @@ pub enum Request {
5353 Copy(String),
5454 /// Read the clipboard and hand its text to `PageView::commit_text`.
5555 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)]
62pub struct Context {
63 /// The address of the link at the caret.
64 pub link: Option<String>,
65 pub equation: bool,
66 /// Whether text is selected, which Cut and Copy take.
67 pub selected: bool,
68 /// The paragraph at the caret, which Copy Link to Paragraph names.
69 pub paragraph: Option<onestore::ExGuid>,
5870}
5971
6072/// What an event did: `changed` means the page or selection changed (the host saves,
......@@ -167,10 +179,7 @@ impl ObjectFocus {
167179}
168180
169181fn 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))
174183}
175184
176185/// Where a page was left: its scroll and the focused outline's selection.
......@@ -750,6 +759,11 @@ impl PageView {
750759 (Some(Drag::Resize { .. }), _) | (None, Some(Hit::Resize { .. })) => Cursor::EwResize,
751760 (Some(Drag::Outline { .. }), _) | (None, Some(Hit::Handle { .. })) => Cursor::Move,
752761 (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 }
753767 (None, Some(Hit::ReadOnly(_) | Hit::Check { .. })) => Cursor::Default,
754768 _ => Cursor::Text,
755769 }
......@@ -902,6 +916,17 @@ impl PageView {
902916 grab,
903917 });
904918 }
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 }
905930 Some(Hit::Text { id, point }) => {
906931 let extend = self.modifiers.shift
907932 && self.object_focus.is_none()
......@@ -1050,6 +1075,118 @@ impl PageView {
10501075 self.edited()
10511076 }
10521077
1078 /// Copy, or Cut with `cut`: the selected text goes to the clipboard.
1079 pub fn copy(&mut self, cut: bool) -> Result<Response> {
1080 let [anchor, focus] = self.editor.selection().positions;
1081 let selected = self
1082 .editor
1083 .active_outline()
1084 .document()
1085 .slice(anchor.min(focus)..anchor.max(focus))?;
1086 let text = selected
1087 .iter()
1088 .map(|paragraph| {
1089 paragraph
1090 .project()
1091 .map(|projection| projection.text().text().to_owned())
1092 })
1093 .collect::<std::result::Result<Vec<_>, _>>()?
1094 .join("\n");
1095 if text.is_empty() {
1096 return Ok(Response::default());
1097 }
1098 if cut {
1099 self.editor.insert(&mut self.engine, "")?;
1100 }
1101 Ok(Response {
1102 request: Some(Request::Copy(text)),
1103 ..self.edited()?
1104 })
1105 }
1106
1107 /// A secondary press at the pointer: text there takes the caret unless it lies in the
1108 /// selection, as OneNote's context menu acts where it opens. `None` off text.
1109 pub fn context(&mut self) -> Result<Option<(Response, Context)>> {
1110 let point = self.viewport.document_point(self.pointer);
1111 let Some(Hit::Text { id, point }) = self.hit_test(point) else {
1112 return Ok(None);
1113 };
1114 if self.object_focus.is_some() || !self.accepts_text() {
1115 return Ok(None);
1116 }
1117 let outline = self.editor.active_outline().id;
1118 let [anchor, focus] = self.editor.selection().positions;
1119 self.editor.focus_outline(id)?;
1120 let at = self
1121 .editor
1122 .selection_at(point[0], point[1], SelectionUnit::Grapheme)?;
1123 let inside = outline == id
1124 && anchor.min(focus) <= at.positions[0]
1125 && at.positions[0] <= anchor.max(focus);
1126 if !inside {
1127 self.editor.select(at)?;
1128 }
1129 let [anchor, focus] = self.editor.selection().positions;
1130 let context = Context {
1131 link: self
1132 .editor
1133 .link_at(anchor.min(focus))
1134 .map(|link| link.target),
1135 equation: self.editor.in_equation(),
1136 selected: anchor != focus,
1137 paragraph: self
1138 .editor
1139 .active_outline()
1140 .document()
1141 .leaf(anchor.min(focus).paragraph)
1142 .map(|(_, _, node)| node.id),
1143 };
1144 Ok(Some((self.changed()?, context)))
1145 }
1146
1147 /// The text and address the Link dialog opens with for the selection.
1148 pub fn link_prefill(&self) -> (String, String) {
1149 self.editor.link_prefill()
1150 }
1151
1152 /// OK in the Link dialog: links what [`Self::link_prefill`] picked, shown as `text`.
1153 pub fn set_link(&mut self, text: &str, address: &str) -> Result<Response> {
1154 if !self.accepts_text() {
1155 return Ok(Response::default());
1156 }
1157 self.editor.set_link(&mut self.engine, text, address)?;
1158 self.edited()
1159 }
1160
1161 /// Remove Link, or Select Link with `select`, on the link at the caret.
1162 pub fn unlink(&mut self, select: bool) -> Result<Response> {
1163 if select {
1164 self.editor.select_link()?;
1165 } else {
1166 self.editor.remove_link(&mut self.engine)?;
1167 }
1168 self.edited()
1169 }
1170
1171 /// Alt+= and the toolbar's Equation: see [`CanvasEditor::insert_equation`].
1172 pub fn insert_equation(&mut self) -> Result<Response> {
1173 if !self.accepts_text() || self.editor.marked_range().is_some() {
1174 return Ok(Response::default());
1175 }
1176 self.editor.insert_equation(&mut self.engine)?;
1177 self.edited()
1178 }
1179
1180 /// Professional, or Linear with `linear`, on the equation at the caret.
1181 pub fn switch_equation(&mut self, linear: bool) -> Result<Response> {
1182 if linear {
1183 self.editor.linear_equation(&mut self.engine)?;
1184 } else {
1185 self.editor.build_equation(&mut self.engine)?;
1186 }
1187 self.edited()
1188 }
1189
10531190 /// Undoes the last edit, or redoes the last undone one.
10541191 pub fn undo(&mut self, redo: bool) -> Result<Response> {
10551192 if redo {
......@@ -1065,6 +1202,12 @@ impl PageView {
10651202 self.edited()
10661203 }
10671204
1205 /// Select All, widening the selection a unit at a time as OneNote 2010's Ctrl+A does.
1206 pub fn widen_selection(&mut self) -> Result<Response> {
1207 self.editor.widen_selection()?;
1208 self.edited()
1209 }
1210
10681211 /// The date or time chosen after `Request::EditDate`.
10691212 pub fn change_date(&mut self, timestamp: u64, text: [String; 2]) -> Result<Response> {
10701213 self.editor.change_date(&mut self.engine, timestamp, text)?;
......@@ -1113,11 +1256,8 @@ impl PageView {
11131256 self.set_object_focus(None);
11141257 return self.edited();
11151258 }
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) {
11211261 return Ok(Response::default());
11221262 }
11231263 if key == &Key::Named(NamedKey::Escape) {
......@@ -1125,12 +1265,6 @@ impl PageView {
11251265 return self.edited();
11261266 }
11271267 }
1128 if command && control && key == &Key::Named(NamedKey::Space) {
1129 return Ok(Response {
1130 request: Some(Request::CharacterPalette),
1131 ..Response::default()
1132 });
1133 }
11341268 if matches!(
11351269 self.drag,
11361270 Some(Drag::Outline { .. } | Drag::Resize { .. } | Drag::Image { .. })
......@@ -1140,6 +1274,12 @@ impl PageView {
11401274 return self.changed();
11411275 }
11421276 }
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 }
11431283 if command && option {
11441284 let delta = match key {
11451285 Key::Named(NamedKey::ArrowLeft) => Some([-1.0, 0.0]),
......@@ -1195,124 +1335,75 @@ impl PageView {
11951335 }
11961336 return self.edited();
11971337 }
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)?;
12151357 }
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)?;
12201360 }
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)?;
12421364 }
12431365 }
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 };
12471373 return self.moved();
12481374 }
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)?;
12521390 }
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)?;
12561393 }
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)?;
12751399 }
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 _ => {}
13071400 }
1308 } else if key == &Key::Named(NamedKey::Escape) {
1309 self.editor.cancel_composition(&mut self.engine)?;
1401 _ => {}
13101402 }
1403 } else if key == &Key::Named(NamedKey::Escape) {
1404 self.editor.cancel_composition(&mut self.engine)?;
13111405 }
1312 Ok(Response {
1313 request,
1314 ..self.edited()?
1315 })
1406 self.edited()
13161407 }
13171408}
13181409
......@@ -1526,7 +1617,9 @@ fn page_primitives<'a>(
15261617 Some(PointerFeedback::Move(id, origin)) if id == outline.id => origin,
15271618 _ => outline.origin(),
15281619 };
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)
15301623 || matches!(preview, Some(PointerFeedback::Hover(id) | PointerFeedback::Move(id, _)) if id == outline.id)
15311624 || matches!(preview, Some(PointerFeedback::Resize(resized)) if resized.id == outline.id)
15321625 {
......@@ -1860,8 +1953,10 @@ fn append_outline_chrome(
18601953 if outline.title {
18611954 primitives.push(Primitive::RoundedRect {
18621955 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))),
18651960 color: paper.shade(crate::gpu::colorref(0x007f7f7f)),
18661961 });
18671962 return;
crates/canvas/src/interaction/tests.rs+76-44
......@@ -171,7 +171,7 @@ fn title_chrome_selects_text_and_exposes_a_named_editable_field() {
171171 })
172172 .collect::<Vec<_>>();
173173 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])));
175175 let mut access = accessibility::Accessibility::default();
176176 let update = access
177177 .update(
......@@ -192,6 +192,49 @@ fn title_chrome_selects_text_and_exposes_a_named_editable_field() {
192192 && node.label() == Some("Page title")));
193193}
194194
195#[test]
196fn title_frame_shows_unfocused_and_its_pen_scales_only_when_zoomed_in() {
197 let mut engine = TextEngine::default();
198 let mut outline = |text: &str, origin, title| {
199 let mut source = TextOutline::new(
200 &mut engine,
201 TextDocument::new(vec![Paragraph::new(text.into(), Format::default())]).unwrap(),
202 468.0,
203 origin,
204 )
205 .unwrap()
206 .snapshot();
207 source.title = title;
208 TextOutline::from_outline(&mut engine, &source, &Default::default()).unwrap()
209 };
210 let title = outline("Title", [36.0, 14.4], true);
211 let body = outline("Body", [36.0, 120.0], false);
212 let body_id = body.id;
213 let mut editor =
214 CanvasEditor::from_text_outlines(vec![title, body], Default::default(), None).unwrap();
215 editor.focus_outline(body_id).unwrap();
216 // OneNote 2010: a 4 px dash period at 100% and below, 4 px times the zoom above it.
217 for (zoom, pen) in [(0.5, 1.5), (1.0, 0.75), (2.0, 0.75), (3.0, 0.75)] {
218 let scale = zoom * 96.0 / 72.0;
219 let primitives =
220 page_primitives(&editor, None, None, None, paint(false, scale, 1.0)).unwrap();
221 let dashed = primitives
222 .iter()
223 .filter_map(|primitive| match primitive {
224 Primitive::RoundedRect {
225 stroke: Some(Stroke::Dashed(width)),
226 ..
227 } => Some(*width),
228 _ => None,
229 })
230 .collect::<Vec<_>>();
231 assert!(
232 matches!(dashed[..], [width] if (width - pen).abs() < 1e-5),
233 "zoom {zoom}: {dashed:?}"
234 );
235 }
236}
237
195238#[test]
196239fn provisional_lines_share_one_drawn_hit_tested_and_accessible_outline() {
197240 let mut engine = TextEngine::default();
......@@ -1339,45 +1382,9 @@ fn read_only_focus_accepts_navigation_without_edit_shortcuts() {
13391382 ..Modifiers::default()
13401383 },
13411384 ),
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 ),
13711385 ] {
13721386 assert!(read_only_shortcut(&key, modifiers));
13731387 }
1374 assert!(!read_only_shortcut(
1375 &Key::Character("n".into()),
1376 Modifiers {
1377 command: true,
1378 ..Modifiers::default()
1379 }
1380 ));
13811388 assert!(!read_only_shortcut(
13821389 &Key::Named(NamedKey::Tab),
13831390 Modifiers::default()
......@@ -1611,7 +1618,8 @@ fn events_route_through_the_view_as_the_host_delivers_them() {
16111618 .starts_with('X')
16121619 );
16131620
1614 // Command-C asks the host to copy; Command-V asks it to paste, then commits its text.
1621 // Copy asks the host to copy; the host's paste commits its text. The shortcut
1622 // modifier's chords are the host's, so the page takes none of them as typing.
16151623 let _ = view
16161624 .modifiers_changed(Modifiers {
16171625 shift: true,
......@@ -1619,6 +1627,10 @@ fn events_route_through_the_view_as_the_host_delivers_them() {
16191627 })
16201628 .unwrap();
16211629 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 );
16221634 let _ = view
16231635 .modifiers_changed(Modifiers {
16241636 command: true,
......@@ -1626,18 +1638,38 @@ fn events_route_through_the_view_as_the_host_delivers_them() {
16261638 })
16271639 .unwrap();
16281640 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()
16351643 );
16361644 assert!(view.commit_text("line\nbreak".into()).unwrap().changed);
16371645 assert_eq!(
16381646 view.editor.active_outline().document().paragraphs().count(),
16391647 3
16401648 );
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);
16411673 let _ = view.modifiers_changed(Modifiers::default()).unwrap();
16421674
16431675 // 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 {
6262 id: u64,
6363 pub(crate) shaped: Arc<Layout<TextBrush>>,
6464 lines: Vec<LineBox>,
65 /// Where each inline space lies: its index in the spaces laid out, its x, and its line.
66 spaces: Vec<(usize, f32, usize)>,
67}
68
69/// Room kept in a line for something drawn inline, such as an equation, before the text at
70/// byte `index`; the line grows to its ascent and descent.
71#[derive(Clone, Copy, Debug, PartialEq)]
72pub struct InlineSpace {
73 pub index: usize,
74 pub width: f32,
75 pub ascent: f32,
76 pub descent: f32,
6577}
6678
6779#[derive(Clone, Copy, Debug, PartialEq, Eq)]
......@@ -255,6 +267,7 @@ impl TextEngine {
255267 &mut self,
256268 paragraph: &Paragraph,
257269 width: f32,
270 spaces: &[InlineSpace],
258271 ) -> Result<Layout<TextBrush>, LayoutError> {
259272 if !width.is_finite() || width <= 0.0 {
260273 return Err(LayoutError::InvalidWidth);
......@@ -335,6 +348,16 @@ impl TextEngine {
335348 }
336349 start = span.end;
337350 }
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 }
338361 let mut shaped = builder.build(text);
339362 shaped.break_all_lines(Some(width));
340363 let alignment = match paragraph.spans()[0].format.alignment {
......@@ -362,6 +385,19 @@ impl TextEngine {
362385 f32::from(hhea.ascender().to_i16()) * scale,
363386 -f32::from(hhea.descender().to_i16()) * scale,
364387 )
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 )
365401 } else if let (Ok(head), Ok(os2)) = (font.head(), font.os2())
366402 && head.units_per_em() != 0
367403 && (os2.us_win_ascent() != 0 || os2.us_win_descent() != 0)
......@@ -378,7 +414,18 @@ impl TextEngine {
378414 }
379415
380416 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();
382429 let text = paragraph.text();
383430 let mut lines = Vec::with_capacity(shaped.len());
384431 let mut top = 0.0_f64;
......@@ -417,7 +464,7 @@ impl TextEngine {
417464 .unwrap_or_else(|| paragraph.spans().last().unwrap())
418465 .format;
419466 let sample =
420 self.shape(&Paragraph::new("Mg".into(), format.clone()), f32::MAX)?;
467 self.shape(&Paragraph::new("Mg".into(), format.clone()), f32::MAX, &[])?;
421468 sample.lines().flat_map(|line| line.runs()).try_fold(
422469 (0.0_f32, 0.0_f32),
423470 |(a, d), run| {
......@@ -434,6 +481,14 @@ impl TextEngine {
434481 ascent = ascent.max(a);
435482 descent = descent.max(d);
436483 }
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 }
437492 let height = ascent + descent;
438493 if height <= 0.0 || !height.is_finite() {
439494 return Err(LayoutError::InvalidFontMetrics);
......@@ -460,6 +515,7 @@ impl TextEngine {
460515 id,
461516 shaped: Arc::new(shaped),
462517 lines,
518 spaces: placed,
463519 })
464520 }
465521}
......@@ -491,6 +547,13 @@ impl TextLayout {
491547 Ok(())
492548 }
493549
550 /// Each inline space laid out, by its index, with its x and its line's baseline.
551 pub fn spaces(&self) -> impl Iterator<Item = (usize, [f32; 2])> + '_ {
552 self.spaces
553 .iter()
554 .map(|&(id, x, line)| (id, [x, self.lines[line].baseline]))
555 }
556
494557 pub fn lines(&self) -> impl Iterator<Item = (parley::Line<'_, TextBrush>, &LineBox)> {
495558 self.shaped.lines().zip(&self.lines)
496559 }
crates/canvas/src/math.rs+22-2
......@@ -590,6 +590,24 @@ fn ink(layout: &TextLayout) -> [f32; 2] {
590590 extent.map(|v| if v.is_finite() { v } else { 0.0 })
591591}
592592
593/// The byte ranges of `paragraph`'s runs of math holding objects, which draw in two
594/// dimensions; math without objects is text.
595pub(crate) fn built(paragraph: &Paragraph) -> Vec<std::ops::Range<usize>> {
596 let mut zones: Vec<std::ops::Range<usize>> = Vec::new();
597 let mut start = 0;
598 for span in paragraph.spans() {
599 if span.format.math == Some(true) {
600 match zones.last_mut() {
601 Some(zone) if zone.end == start => zone.end = span.end,
602 _ => zones.push(start..span.end),
603 }
604 }
605 start = span.end;
606 }
607 zones.retain(|zone| paragraph.text()[zone.clone()].contains('\u{fdd0}'));
608 zones
609}
610
593611/// Lays out an equation paragraph at the font size and colour of its first run.
594612pub fn layout(engine: &mut TextEngine, paragraph: &Paragraph) -> Result<MathLayout, LayoutError> {
595613 let nodes = Math::parse(paragraph).map_err(|_| LayoutError::UnsupportedContent)?;
......@@ -612,8 +630,10 @@ pub fn layout(engine: &mut TextEngine, paragraph: &Paragraph) -> Result<MathLayo
612630 .next()
613631 .ok_or(LayoutError::InvalidFontMetrics)?;
614632 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);
617637 Ok(MathLayout {
618638 size: [body.width, body.height()],
619639 color,
crates/canvas/src/outline.rs+431-113
......@@ -1,6 +1,6 @@
11use crate::{
22 document::{DocumentEdit, edited_nodes, leaves},
3 layout::{LayoutError, TextEngine, TextLayout},
3 layout::{InlineSpace, LayoutError, TextEngine, TextLayout},
44};
55use onestore::page::text::{Paragraph, TextProjection};
66use onestore::page::{Definition, Outline, PageParagraph, ParagraphContent, Table, Title};
......@@ -11,6 +11,7 @@ use onestore::{
1111use std::{
1212 collections::{BTreeMap, BTreeSet},
1313 ops::Range,
14 sync::Arc,
1415};
1516
1617pub(crate) const TITLE_WIDTH: f32 = 468.0;
......@@ -43,7 +44,7 @@ struct Block {
4344 /// A table's vertical coordinates before its offset.
4445 rel: Vec<f32>,
4546 /// The number the next numbered sibling at the node's level continues from.
46 count: Option<u32>,
47 count: Option<Count>,
4748}
4849
4950/// A layout of root nodes `range` once an edit applies, and how the nodes after them move.
......@@ -196,13 +197,13 @@ pub struct ParagraphLayout {
196197 pub markers: Vec<(TextLayout, [f32; 2])>,
197198 /// A numbered paragraph's number, and whether its list restarts the count instead of
198199 /// continuing from the previous sibling's.
199 pub(crate) number: Option<(u32, bool)>,
200 pub(crate) number: Option<(Count, bool)>,
200201 pub tags: Vec<ParagraphTag>,
201202 /// Its siblings' group, whose list markers its tags clear: the parent paragraph, or the
202203 /// table cell holding a cell's top-level paragraph.
203204 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>,
206207 /// A highlight of the whole paragraph (its own `highlight`), COLORREF: a band across its
207208 /// outline behind its lines, as OneNote marks a conflict page's conflicting changes.
208209 pub band: Option<u32>,
......@@ -210,7 +211,9 @@ pub struct ParagraphLayout {
210211
211212#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
212213pub enum TagIcon {
213 CheckBox { checked: bool },
214 CheckBox {
215 checked: bool,
216 },
214217 Star,
215218 Question,
216219 Highlight,
......@@ -222,6 +225,10 @@ pub enum TagIcon {
222225 RedSquare,
223226 YellowSquare,
224227 BlueSquare,
228 /// An Outlook task flag.
229 Flag,
230 /// A tag symbol without artwork of its own.
231 Other,
225232}
226233
227234#[derive(Clone, Debug, PartialEq)]
......@@ -270,6 +277,8 @@ impl ParagraphTag {
270277
271278/// How much further than its list spacing OneNote 2010 sets a marker's advance from its text.
272279const MARKER_OFFSET: f32 = 3.9;
280/// Points an equation's linear text lays out at, holding the caret without showing.
281const HELD: f32 = 0.01;
273282
274283/// A one-run paragraph standing in for an object's caption, so it lays out through the same
275284/// shaping (and caching) as the outline's text.
......@@ -327,20 +336,38 @@ pub(crate) fn indentation(level: u32, indents: &[f32], width: f32) -> Result<f32
327336 Ok(indent)
328337}
329338
330/// The number after a previous sibling's, or the first when the sibling is not numbered.
331fn 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)]
341pub(crate) struct Count {
342 pub(crate) number: u32,
343 format: Arc<str>,
344}
345
346/// The number after a previous sibling's in the same `format`, or the first. A number in
347/// another format starts again at 1 even where one in this format came before it; siblings
348/// without a number leave the count alone (`evidence/toolbar-17/restart.txt`).
349fn next(previous: Option<&Count>, format: &str) -> u32 {
350 previous
351 .filter(|count| &*count.format == format)
352 .map_or(1, |count| count.number.saturating_add(1))
333353}
334354
335355/// The number a paragraph laid out as `number` takes once it follows `previous`.
336fn follows(number: Option<(u32, bool)>, previous: Option<u32>) -> Option<u32> {
337 number.map(|(number, restart)| if restart { number } else { next(previous) })
356fn 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 })
338365}
339366
340367/// What the numbered sibling after `node`, numbered `number`, continues from: siblings without
341368/// a number leave the count alone, as do empty numbered ones after another number
342369/// (`evidence/structural-edits/xml/c8-bs-1.xml`, `c8-enter-empty-1.xml`, `c2s-num-1.xml`).
343fn tally(node: &PageParagraph, number: Option<u32>, previous: Option<u32>) -> Option<u32> {
370fn tally(node: &PageParagraph, number: Option<Count>, previous: Option<Count>) -> Option<Count> {
344371 number
345372 .filter(|_| {
346373 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
350377
351378/// Removes deeper paragraphs' entries from a flow's `(level, count)` stack of the latest
352379/// paragraph at each level, and the previous sibling's at `level`, returning that one's count.
353fn sibling(siblings: &mut Vec<(u32, Option<u32>)>, level: u32) -> Option<u32> {
380fn sibling(siblings: &mut Vec<(u32, Option<Count>)>, level: u32) -> Option<Count> {
354381 while siblings.last().is_some_and(|(deeper, _)| *deeper > level) {
355382 siblings.pop();
356383 }
......@@ -360,8 +387,56 @@ fn sibling(siblings: &mut Vec<(u32, Option<u32>)>, level: u32) -> Option<u32> {
360387 }
361388}
362389
390/// `text` in `font` as a Unicode font shows it. Windows' Symbol font is symbol-encoded, so
391/// its bytes map through the Adobe Symbol encoding (a bullet is `U+00B7` there); Wingdings'
392/// map into the private use area their symbol cmaps cover, which also keeps a shaper from
393/// dropping `U+00AD` as a soft hyphen.
394pub fn symbol_text(font: &str, text: &str) -> String {
395 /// The Adobe Symbol encoding from 0x20 and from 0xA0, after Unicode's `SYMBOL.TXT`.
396 const SYMBOL: [&str; 2] = [
397 " !∀#∃%&∋()∗+,−./0123456789:;<=>?≅ΑΒΧΔΕΦΓΗΙϑΚΛΜΝΟΠΘΡΣΤΥςΩΞΨΖ[∴]⊥_‾αβχδεφγηιϕκλμνοπθρστυϖωξψζ{|}∼",
398 "€ϒ′≤⁄∞ƒ♣♦♥♠↔←↑→↓°±″≥×∝∂•÷≠≡≈…⏐⎯↵ℵℑℜ℘⊗⊕∅∩∪⊃⊇⊄⊂⊆∈∉∠∇®©™∏√⋅¬∧∨⇔⇐⇑⇒⇓◊〈®©™∑⎛⎜⎝⎡⎢⎣⎧⎨⎩⎪\u{f8ff}〉∫⌠⎮⌡⎞⎟⎠⎤⎥⎦⎫⎬⎭",
399 ];
400 if font.eq_ignore_ascii_case("Symbol") {
401 text.chars()
402 .map(|c| {
403 let (range, from) = match u32::from(c) {
404 code @ 0x20..0x7f => (SYMBOL[0], code - 0x20),
405 code @ 0xa0..0xff => (SYMBOL[1], code - 0xa0),
406 _ => return c,
407 };
408 range.chars().nth(from as usize).unwrap_or(c)
409 })
410 .collect()
411 } else if font.to_ascii_lowercase().starts_with("wingdings") {
412 text.chars()
413 .map(|c| match u32::from(c) {
414 code @ 0x20..0x100 => char::from_u32(0xf000 + code).unwrap_or(c),
415 _ => c,
416 })
417 .collect()
418 } else {
419 text.to_owned()
420 }
421}
422
423/// The marker NumberListFormat `format` shows for `number`.
424pub fn numbered(format: &str, number: u32) -> Result<String, LayoutError> {
425 let (prefix, rest) = format
426 .split_once('\u{fffd}')
427 .ok_or(LayoutError::InvalidList)?;
428 let mut rest = rest.chars();
429 Ok(format!(
430 "{prefix}{}{}",
431 numeral(rest.next(), number)?,
432 rest.as_str()
433 ))
434}
435
363436/// `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`).
365440pub(crate) fn numeral(sequence: Option<char>, number: u32) -> Result<String, LayoutError> {
366441 let roman = |number: u32| {
367442 const DIGITS: [(u32, &str); 13] = [
......@@ -379,7 +454,9 @@ pub(crate) fn numeral(sequence: Option<char>, number: u32) -> Result<String, Lay
379454 (4, "IV"),
380455 (1, "I"),
381456 ];
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) {
383460 return Err(LayoutError::UnsupportedContent);
384461 }
385462 let mut rest = number;
......@@ -392,18 +469,114 @@ pub(crate) fn numeral(sequence: Option<char>, number: u32) -> Result<String, Lay
392469 }
393470 Ok(text)
394471 };
472 // Past Z a letter repeats, AA to ZZ, up to thirty times, then starts over at A.
395473 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>())
400479 };
401480 match sequence.map(u32::from) {
402481 Some(0) => Ok(number.to_string()),
482 Some(22) => Ok(format!("{number:02}")),
403483 Some(1) => roman(number),
404484 Some(2) => roman(number).map(|text| text.to_lowercase()),
405485 Some(3) => letter(number),
406486 Some(4) => letter(number).map(|text| text.to_lowercase()),
487 Some(5) => {
488 let suffix = match (number % 10, number % 100) {
489 (_, 11..=13) => "th",
490 (1, _) => "st",
491 (2, _) => "nd",
492 (3, _) => "rd",
493 _ => "th",
494 };
495 Ok(format!("{number}{suffix}"))
496 }
497 Some(sequence @ (6 | 7)) => {
498 let mut words = cardinal(number)?;
499 if sequence == 7 {
500 // The last word turns ordinal: one to first, twenty to twentieth.
501 let at = words.rfind([' ', '-']).map_or(0, |at| at + 1);
502 let last = &words[at..];
503 let ordinal = match last {
504 "one" => "first".to_owned(),
505 "two" => "second".to_owned(),
506 "three" => "third".to_owned(),
507 "five" => "fifth".to_owned(),
508 "eight" => "eighth".to_owned(),
509 "nine" => "ninth".to_owned(),
510 "twelve" => "twelfth".to_owned(),
511 _ if last.ends_with('y') => format!("{}ieth", &last[..last.len() - 1]),
512 _ => format!("{last}th"),
513 };
514 words.replace_range(at.., &ordinal);
515 }
516 let mut chars = words.chars();
517 let first = chars.next().unwrap_or_default();
518 Ok(first.to_uppercase().chain(chars).collect())
519 }
520 _ => Err(LayoutError::UnsupportedContent),
521 }
522}
523
524/// `number` in lowercase English words, "one hundred twenty-one", below a million.
525fn cardinal(number: u32) -> Result<String, LayoutError> {
526 const SMALL: [&str; 20] = [
527 "zero",
528 "one",
529 "two",
530 "three",
531 "four",
532 "five",
533 "six",
534 "seven",
535 "eight",
536 "nine",
537 "ten",
538 "eleven",
539 "twelve",
540 "thirteen",
541 "fourteen",
542 "fifteen",
543 "sixteen",
544 "seventeen",
545 "eighteen",
546 "nineteen",
547 ];
548 const TENS: [&str; 10] = [
549 "", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety",
550 ];
551 fn below_thousand(number: u32) -> String {
552 let (hundreds, rest) = (number / 100, number % 100);
553 let rest = match rest {
554 0 => None,
555 1..20 => Some(SMALL[rest as usize].to_owned()),
556 _ if rest % 10 == 0 => Some(TENS[(rest / 10) as usize].to_owned()),
557 _ => Some(format!(
558 "{}-{}",
559 TENS[(rest / 10) as usize],
560 SMALL[(rest % 10) as usize]
561 )),
562 };
563 match (hundreds, rest) {
564 (0, rest) => rest.unwrap_or_default(),
565 (hundreds, None) => format!("{} hundred", SMALL[hundreds as usize]),
566 (hundreds, Some(rest)) => format!("{} hundred {rest}", SMALL[hundreds as usize]),
567 }
568 }
569 match number {
570 1..1000 => Ok(below_thousand(number)),
571 1000..1_000_000 => {
572 let (thousands, rest) = (number / 1000, number % 1000);
573 let head = format!("{} thousand", below_thousand(thousands));
574 Ok(if rest == 0 {
575 head
576 } else {
577 format!("{head} {}", below_thousand(rest))
578 })
579 }
407580 _ => Err(LayoutError::UnsupportedContent),
408581 }
409582}
......@@ -438,9 +611,6 @@ impl ParagraphLayout {
438611 }
439612
440613 fn size(&self) -> [f32; 2] {
441 if let Some(math) = &self.math {
442 return [self.origin[0] + math.size[0], math.size[1]];
443 }
444614 [
445615 self.origin[0] + self.text.shaped.width(),
446616 self.markers
......@@ -454,7 +624,7 @@ impl ParagraphLayout {
454624 pub(crate) fn shape(
455625 engine: &mut TextEngine,
456626 paragraph: &PageParagraph,
457 previous: Option<u32>,
627 previous: Option<&Count>,
458628 width: f32,
459629 indents: &[f32],
460630 definitions: &BTreeMap<ExGuid, Definition>,
......@@ -474,53 +644,111 @@ impl ParagraphLayout {
474644 return Err(LayoutError::InvalidSpacing);
475645 }
476646 // 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
478650 .tags
479651 .iter()
480652 .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,
492663 ..
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();
498669 tag_definitions.reverse();
499670 // The newest tag that sets a colour wins.
500671 let color = tag_definitions.iter().rev().find_map(|tag| tag.3);
501672 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)?
505709 } else {
506 let visible = projection.text();
507710 let mut start = 0;
508 let runs = visible.spans().iter().map(|span| {
711 let runs = visible.spans().iter().flat_map(|span| {
509712 let mut format = span.format.clone();
510713 format.color = color.or(format.color);
511714 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 }
516740 }
517 let run = (visible.text()[start..span.end].to_owned(), format);
741 runs.push((visible.text()[from..span.end].to_owned(), format));
518742 start = span.end;
519 run
743 runs
520744 });
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 ),
523750 width - indent,
751 &spaces,
524752 )?
525753 };
526754 let mut markers = Vec::new();
......@@ -538,34 +766,39 @@ impl ParagraphLayout {
538766 return Err(LayoutError::InvalidList);
539767 };
540768 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);
555773 }
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()
557786 };
558787 if value.contains('\u{fffd}') {
559788 return Err(LayoutError::UnsupportedContent);
560789 }
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));
561798 let marker = Paragraph::new(
562799 value,
563800 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,
569802 font_size: definition.format.font_size.or(format.font_size),
570803 color,
571804 ..Format::default()
......@@ -589,22 +822,24 @@ impl ParagraphLayout {
589822 for tag in &tag_definitions {
590823 let (tag, shape, label) = (tag.0, tag.1, tag.2);
591824 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 {
594828 checked: tag.status & 1 != 0,
595829 },
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,
608843 };
609844 let size = format.font_size.unwrap_or(11.0);
610845 let side = ParagraphTag::side(size);
......@@ -612,13 +847,11 @@ impl ParagraphLayout {
612847 icon,
613848 origin: [0.0, ParagraphTag::top(baseline, size, side)],
614849 size: side,
615 label: label.clone().unwrap_or_default(),
850 label: label.unwrap_or_default().to_owned(),
616851 disabled: tag.status & 2 != 0,
617852 });
618853 }
619 let math = equation
620 .then(|| crate::math::layout(engine, &source.text))
621 .transpose()?;
854 let math = equations.into_iter().map(|(_, math)| math).collect();
622855 let mut result = Self {
623856 id: paragraph.id,
624857 origin: [indent, 0.0],
......@@ -732,7 +965,7 @@ impl OutlineLayout {
732965 edit: Option<&DocumentEdit>,
733966 shape: &mut impl FnMut(
734967 &PageParagraph,
735 Option<u32>,
968 Option<&Count>,
736969 f32,
737970 &[f32],
738971 ) -> Result<ParagraphLayout, LayoutError>,
......@@ -779,10 +1012,10 @@ impl OutlineLayout {
7791012 edit: Option<&DocumentEdit>,
7801013 mut hiding: BTreeSet<ExGuid>,
7811014 mut state: (f64, Option<f32>),
782 siblings: &mut Vec<(u32, Option<u32>)>,
1015 siblings: &mut Vec<(u32, Option<Count>)>,
7831016 shape: &mut impl FnMut(
7841017 &PageParagraph,
785 Option<u32>,
1018 Option<&Count>,
7861019 f32,
7871020 &[f32],
7881021 ) -> Result<ParagraphLayout, LayoutError>,
......@@ -820,8 +1053,8 @@ impl OutlineLayout {
8201053 if !hidden {
8211054 let (space, height, flow, extent) = match &node.content {
8221055 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);
8251058 let space = spacing(&paragraph.projection.text().spans()[0].format)?;
8261059 paragraph.origin[1] = top(&mut state, space);
8271060 let size = paragraph.size();
......@@ -951,7 +1184,7 @@ impl OutlineLayout {
9511184 }
9521185 }
9531186 block.count = tally(node, number, previous);
954 siblings.push((node.level, block.count));
1187 siblings.push((node.level, block.count.clone()));
9551188 block.state = state;
9561189 if depth == 0 {
9571190 result.blocks.push(block);
......@@ -963,10 +1196,10 @@ impl OutlineLayout {
9631196 }
9641197
9651198 /// 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)> {
9671200 let block = &self.blocks[root];
9681201 (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())
9701203 .flatten()
9711204 }
9721205
......@@ -996,7 +1229,7 @@ impl OutlineLayout {
9961229 fixed_width: bool,
9971230 shape: &mut impl FnMut(
9981231 &PageParagraph,
999 Option<u32>,
1232 Option<&Count>,
10001233 f32,
10011234 &[f32],
10021235 ) -> Result<ParagraphLayout, LayoutError>,
......@@ -1047,7 +1280,7 @@ impl OutlineLayout {
10471280 for root in (0..range.start).rev() {
10481281 if nodes[root].level < level {
10491282 level = nodes[root].level;
1050 siblings.push((level, self.blocks[root].count));
1283 siblings.push((level, self.blocks[root].count.clone()));
10511284 if level <= 1 {
10521285 break;
10531286 }
......@@ -1067,7 +1300,8 @@ impl OutlineLayout {
10671300 let indent = indentation(node.level, indents, width)?;
10681301 if let Some((source, cached)) = cached.get(&node.id)
10691302 && *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)
10711305 && cached.text.shaped.layout_max_advance() == width - indent
10721306 && cached
10731307 .markers
......@@ -1104,9 +1338,11 @@ impl OutlineLayout {
11041338 for root in range.end..nodes.len() {
11051339 let previous = sibling(&mut siblings, nodes[root].level);
11061340 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 {
11101346 renumbered = root + 1;
11111347 }
11121348 siblings.push((nodes[root].level, counted));
......@@ -1275,7 +1511,7 @@ impl OutlineLayout {
12751511 edit: Option<&DocumentEdit>,
12761512 shape: &mut impl FnMut(
12771513 &PageParagraph,
1278 Option<u32>,
1514 Option<&Count>,
12791515 f32,
12801516 &[f32],
12811517 ) -> Result<ParagraphLayout, LayoutError>,
......@@ -1885,17 +2121,26 @@ mod tests {
18852121 unreachable!()
18862122 };
18872123 *shape = Some(999);
1888 assert!(matches!(
2124 let mut shaped = |node: &PageParagraph, definitions| {
18892125 ParagraphLayout::shape(
18902126 &mut engine,
1891 &node,
2127 node,
18922128 None,
18932129 120.0,
18942130 &[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());
18992144 }
19002145
19012146 /// OneNote 2010 at 400% (Calibri bullets and numbers from 8 to 24 pt, default list
......@@ -2475,6 +2720,17 @@ mod tests {
24752720 assert_eq!(layout.size[0], 110.0);
24762721 }
24772722
2723 #[test]
2724 fn symbol_fonts_show_what_windows_draws() {
2725 // OneNote's bullet library stores these (`BULLET_LIBRARY`).
2726 assert_eq!(symbol_text("Symbol", "\u{b7}"), "\u{2022}");
2727 assert_eq!(symbol_text("Symbol", "\u{de}"), "\u{21d2}");
2728 assert_eq!(symbol_text("Symbol", "ap\u{f0}"), "\u{3b1}\u{3c0}\u{f8ff}");
2729 assert_eq!(symbol_text("Wingdings", "l\u{ad}"), "\u{f06c}\u{f0ad}");
2730 assert_eq!(symbol_text("Wingdings 2", "\u{9d}"), "\u{f09d}");
2731 assert_eq!(symbol_text("Calibri", "\u{b7}"), "\u{b7}");
2732 }
2733
24782734 #[test]
24792735 fn numerals_follow_onenote_number_sequences() {
24802736 assert_eq!(numeral(Some('\u{0}'), 12).unwrap(), "12");
......@@ -2482,8 +2738,70 @@ mod tests {
24822738 assert_eq!(numeral(Some('\u{2}'), 4).unwrap(), "iv");
24832739 assert_eq!(numeral(Some('\u{3}'), 3).unwrap(), "C");
24842740 assert_eq!(numeral(Some('\u{4}'), 26).unwrap(), "z");
2485 assert!(numeral(Some('\u{4}'), 27).is_err());
24862741 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");
24872753 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 );
24882806 }
24892807}
crates/canvas/tests/links_equations.rs created+537
......@@ -0,0 +1,537 @@
1//! Links and equations made as a user makes them, through the editor's typing, Link
2//! dialog, Remove Link, Alt+=, Linear and Professional, save as the ops the editor recorded
3//! and read back as the page it saved. `CANVAS_LINKS_EQUATIONS_EXPORT` names a new directory
4//! receiving the section for a cold OneNote reopen, with the links and MathML its read must
5//! show (`corpus/link-edit/editor`).
6
7use canvas::{document::TextPosition, editor::CanvasEditor, layout::TextEngine};
8use onestore::{
9 Arena, ExGuid, RevisionIndex, Section, Store,
10 document::Document,
11 page::{
12 Math, Page, PageObject,
13 link::{LinkTarget, internal_link},
14 },
15};
16use std::path::Path;
17
18const BASE_PATH: &str = r"C:\one-tests\runs\capture\notebook\links.one";
19
20fn pages(section: &[u8]) -> Vec<(ExGuid, Page)> {
21 let store = Store::parse(section).unwrap();
22 let index = RevisionIndex::parse(&store).unwrap();
23 let document = Document::parse(&index).unwrap();
24 document
25 .pages()
26 .unwrap()
27 .into_iter()
28 .map(|(space, _)| (space, Page::from_space(&document, space).unwrap()))
29 .collect()
30}
31
32/// Types `text`, each space on its own as a key does.
33fn typed(editor: &mut CanvasEditor, engine: &mut TextEngine, text: &str) {
34 for (index, word) in text.split(' ').enumerate() {
35 if index > 0 {
36 editor.insert(engine, " ").unwrap();
37 }
38 if !word.is_empty() {
39 editor.insert(engine, word).unwrap();
40 }
41 }
42}
43
44fn caret(editor: &CanvasEditor) -> TextPosition {
45 editor.selection().positions[1]
46}
47
48/// End, then Enter, returning the new paragraph.
49fn line(editor: &mut CanvasEditor, engine: &mut TextEngine) -> usize {
50 let paragraph = caret(editor).paragraph;
51 let text = editor
52 .active_outline()
53 .document()
54 .paragraphs()
55 .nth(paragraph)
56 .unwrap();
57 let offset = text.utf16_offset(text.text().len()).unwrap();
58 editor
59 .select([TextPosition { paragraph, offset }; 2].into())
60 .unwrap();
61 editor.enter(engine, false).unwrap();
62 caret(editor).paragraph
63}
64
65fn select(editor: &mut CanvasEditor, paragraph: usize, range: std::ops::Range<u32>) {
66 editor
67 .select(
68 [
69 TextPosition {
70 paragraph,
71 offset: range.start,
72 },
73 TextPosition {
74 paragraph,
75 offset: range.end,
76 },
77 ]
78 .into(),
79 )
80 .unwrap();
81}
82
83#[test]
84fn links_and_equations_save_as_the_editor_shows_them() {
85 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
86 let source = std::fs::read(root.join("corpus/link-edit/internal/candidate/links.one")).unwrap();
87 let section_id = Store::parse(&source).unwrap().header.file_id;
88 let all = pages(&source);
89 let (space, page) = all
90 .iter()
91 .find(|(_, page)| page.title.starts_with("Linking page"))
92 .cloned()
93 .unwrap();
94 let target = all
95 .iter()
96 .map(|(_, page)| page)
97 .find(|page| page.title == "Link target")
98 .unwrap();
99 let paragraph_id = page
100 .objects
101 .iter()
102 .find_map(|object| match object {
103 PageObject::Outline(outline) if !outline.title => Some(outline.paragraphs[0].id),
104 _ => None,
105 })
106 .unwrap();
107 let mut engine = TextEngine::default();
108 let engine = &mut engine;
109 let mut editor = CanvasEditor::from_page(page.clone(), engine).unwrap();
110 let body = editor
111 .outlines()
112 .iter()
113 .find(|outline| !outline.title)
114 .unwrap()
115 .id;
116 editor.focus_outline(body).unwrap();
117 let end = editor
118 .active_outline()
119 .document()
120 .paragraphs()
121 .next()
122 .unwrap();
123 let end = end.utf16_offset(end.text().len()).unwrap();
124 editor
125 .select(
126 [TextPosition {
127 paragraph: 0,
128 offset: end,
129 }; 2]
130 .into(),
131 )
132 .unwrap();
133 let mut hrefs = vec![internal_link(
134 section_id,
135 BASE_PATH,
136 LinkTarget::Page {
137 identity: target.identity.unwrap(),
138 title: "Link target",
139 },
140 )];
141 // Typed URLs ended by a space and by Enter.
142 line(&mut editor, engine);
143 typed(
144 &mut editor,
145 engine,
146 "Visit www.example.com or (https://example.org/a?b=1). ",
147 );
148 hrefs.extend([
149 "http://www.example.com".into(),
150 "https://example.org/a?b=1".into(),
151 ]);
152 line(&mut editor, engine);
153 typed(&mut editor, engine, "Ends with http://example.net/end");
154 hrefs.push("http://example.net/end".into());
155 // The Link dialog on a selection, on the word at the caret and on nothing.
156 let at = line(&mut editor, engine);
157 typed(&mut editor, engine, "Read the Rust book today");
158 select(&mut editor, at, 9..18);
159 assert_eq!(editor.link_prefill(), ("Rust book".into(), String::new()));
160 editor
161 .set_link(engine, "Rust book", "https://doc.rust-lang.org/book/")
162 .unwrap();
163 hrefs.push("https://doc.rust-lang.org/book/".into());
164 let at = line(&mut editor, engine);
165 typed(&mut editor, engine, "A word here");
166 select(&mut editor, at, 3..3);
167 editor.set_link(engine, "word", "example.net/x").unwrap();
168 hrefs.push("http://example.net/x".into());
169 line(&mut editor, engine);
170 editor
171 .set_link(engine, "", "https://example.com/only")
172 .unwrap();
173 typed(&mut editor, engine, " tail");
174 hrefs.push("https://example.com/only".into());
175 // A link to the other page and one to this page's first paragraph, as picked or pasted.
176 line(&mut editor, engine);
177 typed(&mut editor, engine, "See ");
178 editor
179 .set_link(engine, "the target page", &hrefs[0].clone())
180 .unwrap();
181 hrefs.push(hrefs[0].clone());
182 line(&mut editor, engine);
183 let paragraph_link = internal_link(
184 section_id,
185 BASE_PATH,
186 LinkTarget::Object {
187 identity: page.identity.unwrap(),
188 title: "Linking page",
189 object: paragraph_id,
190 },
191 );
192 editor
193 .set_link(engine, "Back to the top", &paragraph_link)
194 .unwrap();
195 hrefs.push(paragraph_link);
196 // Remove Link.
197 let at = line(&mut editor, engine);
198 typed(&mut editor, engine, "Gone ftp://h.example/f z");
199 select(&mut editor, at, 8..8);
200 assert!(editor.remove_link(engine).unwrap());
201 // Equations typed after Alt+=, one shown in its linear form, one after text.
202 for linear in [
203 "a_1+b_2",
204 "\\int_0^1 x dx",
205 "(a+b)/(c+d)",
206 "\\sqrt x+1",
207 "\\sum_(i=1)^n i",
208 "\\matrix(1&2@3&4)",
209 "e^(x+1)",
210 ] {
211 line(&mut editor, engine);
212 editor.insert_equation(engine).unwrap();
213 typed(&mut editor, engine, &format!("{linear} "));
214 }
215 assert!(editor.linear_equation(engine).unwrap());
216 let at = line(&mut editor, engine);
217 typed(&mut editor, engine, "Area ");
218 editor.insert_equation(engine).unwrap();
219 typed(&mut editor, engine, "\\pi r^2 ");
220 // Linear, then Professional, rebuilds an equation as it was.
221 let built = editor
222 .active_outline()
223 .document()
224 .paragraphs()
225 .nth(at)
226 .unwrap()
227 .clone();
228 select(&mut editor, at, 7..7);
229 assert!(editor.linear_equation(engine).unwrap());
230 assert!(editor.build_equation(engine).unwrap());
231 assert_eq!(
232 editor
233 .active_outline()
234 .document()
235 .paragraphs()
236 .nth(at)
237 .unwrap(),
238 &built
239 );
240 // Enter at an equation's start, inside its row, inside a fraction's argument, and at a
241 // link's start.
242 let paragraph = |editor: &CanvasEditor, at: usize| {
243 editor
244 .active_outline()
245 .document()
246 .paragraphs()
247 .nth(at)
248 .unwrap()
249 .clone()
250 };
251 let at = line(&mut editor, engine);
252 typed(&mut editor, engine, "Split ");
253 editor.insert_equation(engine).unwrap();
254 typed(&mut editor, engine, "y^2+1 ");
255 select(&mut editor, at, 6..6);
256 editor.enter(engine, false).unwrap();
257 let text = paragraph(&editor, at + 1);
258 let one = text.utf16_offset(text.text().find('1').unwrap()).unwrap();
259 select(&mut editor, at + 1, one..one);
260 editor.enter(engine, false).unwrap();
261 let at = line(&mut editor, engine);
262 editor.insert_equation(engine).unwrap();
263 typed(&mut editor, engine, "(a+b)/(c+d) ");
264 let text = paragraph(&editor, at);
265 let plus = text.utf16_offset(text.text().rfind('+').unwrap()).unwrap();
266 select(&mut editor, at, plus..plus);
267 editor.enter(engine, false).unwrap();
268 let at = line(&mut editor, engine);
269 typed(&mut editor, engine, "Moved ");
270 editor
271 .set_link(engine, "down", "https://example.com/moved")
272 .unwrap();
273 hrefs.push("https://example.com/moved".into());
274 let text = paragraph(&editor, at);
275 let label = text
276 .utf16_offset(text.text().find("down").unwrap())
277 .unwrap();
278 select(&mut editor, at, label..label);
279 editor.enter(engine, false).unwrap();
280
281 let after = editor.page().unwrap();
282 let arena = Arena::default();
283 let mut stored = Section::open(&arena, source.clone()).unwrap();
284 let edit = onestore::op::Edit {
285 at: 133_000_000_000_000_000,
286 ops: editor
287 .take_ops()
288 .unwrap()
289 .into_iter()
290 .map(|op| onestore::op::Op::Page { space, op })
291 .collect(),
292 };
293 stored.apply("Snowbound", &edit).unwrap();
294 stored.seal().unwrap();
295 let saved = stored.image();
296 let (_, reread) = pages(&saved)
297 .into_iter()
298 .find(|(id, _)| *id == space)
299 .unwrap();
300 let body = |page: &Page| -> Vec<onestore::page::PageParagraph> {
301 page.objects
302 .iter()
303 .filter_map(|object| match object {
304 PageObject::Outline(outline) if !outline.title => Some(outline),
305 _ => None,
306 })
307 .flat_map(|outline| outline.paragraphs.clone())
308 .collect()
309 };
310 for (stored, shown) in body(&reread).iter().zip(body(&after)) {
311 assert_eq!(stored, &shown);
312 }
313 assert_eq!(
314 Page {
315 title: after.title.clone(),
316 ..reread.clone()
317 },
318 after
319 );
320
321 let paragraphs: Vec<_> = reread
322 .objects
323 .iter()
324 .filter_map(|object| match object {
325 PageObject::Outline(outline) if !outline.title => Some(outline),
326 _ => None,
327 })
328 .flat_map(|outline| &outline.paragraphs)
329 .filter_map(|paragraph| paragraph.text())
330 .collect();
331 let stored_hrefs: Vec<String> = paragraphs
332 .iter()
333 .flat_map(|text| {
334 text.text
335 .spans()
336 .iter()
337 .scan(0, |start, span| {
338 let run = &text.text.text()[*start..span.end];
339 *start = span.end;
340 Some((run, span.format.clone()))
341 })
342 .filter(|(_, format)| format.hyperlink == Some(true) && format.hidden != Some(true))
343 .map(|(run, _)| run.to_owned())
344 .collect::<Vec<_>>()
345 })
346 .collect();
347 assert_eq!(stored_hrefs.len(), hrefs.len(), "{stored_hrefs:?}");
348 // OneNote links URL text again when it opens a page, after its own Remove Link too
349 // (`corpus/link-edit/native-typed`, cold read).
350 let moved = hrefs.len() - 1;
351 hrefs.insert(moved, "ftp://h.example/f".into());
352 // The MathML OneNote exports for each run of math, an equation of its own.
353 let mut mathml = Vec::new();
354 for text in &paragraphs {
355 let mut start = 0;
356 let mut zone = Vec::new();
357 for span in text.text.spans() {
358 let run = text.text.text()[start..span.end].to_owned();
359 start = span.end;
360 if span.format.math == Some(true) {
361 zone.push((run, span.format.clone()));
362 }
363 let ends = span.format.math != Some(true) || start == text.text.text().len();
364 if ends && !zone.is_empty() {
365 let math = onestore::page::Paragraph::from_runs(std::mem::take(&mut zone));
366 mathml.push(Math::mathml(&Math::parse(&math).unwrap()));
367 }
368 }
369 }
370 assert_eq!(mathml.len(), 11);
371 if let Some(directory) = std::env::var_os("CANVAS_LINKS_EQUATIONS_EXPORT") {
372 let directory = Path::new(&directory);
373 std::fs::create_dir(directory).unwrap();
374 std::fs::write(directory.join("links.one"), &saved).unwrap();
375 std::fs::copy(
376 root.join("corpus/link-edit/internal/candidate/Open Notebook.onetoc2"),
377 directory.join("Open Notebook.onetoc2"),
378 )
379 .unwrap();
380 std::fs::write(
381 directory.join("expected.json"),
382 serde_json::to_string_pretty(&serde_json::json!({
383 "hrefs": hrefs,
384 "mathml": mathml,
385 }))
386 .unwrap(),
387 )
388 .unwrap();
389 }
390}
391
392/// A paragraph's runs as (text, hidden, hyperlink, math, italic, run data), with a run
393/// between objects stored either with or without run data.
394type Runs = Vec<(String, bool, bool, bool, bool, Option<(u32, Option<u32>)>)>;
395
396fn runs(text: &onestore::page::Paragraph) -> Runs {
397 let mut start = 0;
398 text.spans()
399 .iter()
400 .map(|span| {
401 let run = text.text()[start..span.end].to_owned();
402 start = span.end;
403 let format = &span.format;
404 let object = format
405 .math_object
406 .as_ref()
407 .map(|object| (object.kind, object.arguments))
408 .filter(|(kind, _)| *kind != 0x9000_0000);
409 (
410 run,
411 format.hidden == Some(true),
412 format.hyperlink == Some(true),
413 format.math == Some(true),
414 format.italic == Some(true),
415 object,
416 )
417 })
418 .collect()
419}
420
421/// Enter at an equation's start, inside its row and inside a fraction's argument, and at a
422/// link's start, stores what OneNote 2010 stored for the same keys (`tools/native_enter.py`,
423/// `corpus/math-edit/native-enter`).
424#[test]
425fn enter_at_and_inside_equations_and_links_stores_what_onenote_stored() {
426 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
427 let native =
428 std::fs::read(root.join("corpus/math-edit/native-enter/notebook/links.one")).unwrap();
429 let (_, page) = pages(&native).remove(0);
430 let native: Vec<Runs> = page
431 .objects
432 .iter()
433 .filter_map(|object| match object {
434 PageObject::Outline(outline) if !outline.title => Some(outline),
435 _ => None,
436 })
437 .flat_map(|outline| &outline.paragraphs)
438 .filter_map(|paragraph| paragraph.text())
439 .skip(1)
440 .map(|text| runs(&text.text))
441 .collect();
442 let mut engine = TextEngine::default();
443 let engine = &mut engine;
444 let mut editor = CanvasEditor::new(
445 engine,
446 canvas::document::TextDocument::new(vec![onestore::page::Paragraph::new(
447 String::new(),
448 onestore::document::Format {
449 font: Some("Calibri".into()),
450 ..Default::default()
451 },
452 )])
453 .unwrap(),
454 400.0,
455 )
456 .unwrap();
457 let text = |editor: &CanvasEditor, paragraph: usize| {
458 editor
459 .active_outline()
460 .document()
461 .paragraphs()
462 .nth(paragraph)
463 .unwrap()
464 .clone()
465 };
466 let before = |editor: &CanvasEditor, paragraph: usize, needle: &str| {
467 let text = text(editor, paragraph);
468 let at = text.text().find(needle).unwrap();
469 text.utf16_offset(at).unwrap()
470 };
471 typed(&mut editor, engine, "Before ");
472 editor.insert_equation(engine).unwrap();
473 typed(&mut editor, engine, "y^2+1 ");
474 select(&mut editor, 0, 7..7);
475 editor.enter(engine, false).unwrap();
476 let at = before(&editor, 1, "1");
477 select(&mut editor, 1, at..at);
478 editor.enter(engine, false).unwrap();
479 line(&mut editor, engine);
480 editor.insert_equation(engine).unwrap();
481 typed(&mut editor, engine, "(a+b)/(c+d) ");
482 let at = before(&editor, 2, "+𝑑");
483 select(&mut editor, 2, at..at);
484 editor.enter(engine, false).unwrap();
485 let paragraph = line(&mut editor, engine);
486 typed(&mut editor, engine, "Select me please");
487 select(&mut editor, paragraph, 7..16);
488 editor
489 .set_link(engine, "me please", "https://example.com/selected")
490 .unwrap();
491 let start = before(&editor, paragraph, "me please");
492 select(&mut editor, paragraph, start..start);
493 editor.enter(engine, false).unwrap();
494 let written: Vec<Runs> = editor
495 .active_outline()
496 .document()
497 .paragraphs()
498 .map(runs)
499 .collect();
500 assert_eq!(written, native);
501}
502
503/// An equation among text draws in its line, which grows to hold it, and URL text shows as a
504/// link without being stored as one.
505#[test]
506fn equations_draw_inline_and_url_text_shows_as_a_link() {
507 let mut engine = TextEngine::default();
508 let engine = &mut engine;
509 let mut editor = CanvasEditor::new(
510 engine,
511 canvas::document::TextDocument::new(vec![onestore::page::Paragraph::new(
512 String::new(),
513 Default::default(),
514 )])
515 .unwrap(),
516 400.0,
517 )
518 .unwrap();
519 typed(&mut editor, engine, "Area ");
520 editor.insert_equation(engine).unwrap();
521 typed(&mut editor, engine, "(a+b)/(c+d) ");
522 let outline = editor.active_outline();
523 let layout = outline.paragraph_layout(0).unwrap();
524 assert_eq!(layout.math.len(), 1);
525 let spaces: Vec<_> = layout.text.spaces().collect();
526 let [(0, [x, baseline])] = spaces[..] else {
527 panic!("{spaces:?}")
528 };
529 let (_, line) = layout.text.lines().next().unwrap();
530 assert!(x > 20.0, "the equation follows the text");
531 assert!(line.height >= layout.math[0].size[1]);
532 assert!(baseline - line.top >= layout.math[0].baseline);
533
534 let url = onestore::page::Paragraph::new("Gone ftp://h.example/f z".into(), Default::default());
535 assert_eq!(canvas::editor::shown_urls(&url), vec![5..22]);
536 assert!(url.spans()[0].format.hyperlink.is_none());
537}
crates/canvas/tests/ops_differential.rs+39-2
......@@ -22,7 +22,7 @@ use std::{collections::BTreeMap, path::Path};
2222#[path = "../../onestore/tests/support/sweep.rs"]
2323mod sweep;
2424
25const SECTIONS: [&str; 15] = [
25const SECTIONS: [&str; 18] = [
2626 "corpus/outline-edit/before/notebook/synthetic.one",
2727 "corpus/paragraph-edit/before/notebook/synthetic.one",
2828 "corpus/outline-edit/tree/before/notebook/synthetic.one",
......@@ -36,6 +36,9 @@ const SECTIONS: [&str; 15] = [
3636 "corpus/ink-edit/drawing/cold/notebook/ink.one",
3737 "corpus/math-edit/native-editor/notebook/links.one",
3838 "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",
3942 "corpus/paragraph-format/cold/notebook/synthetic.one",
4043 "corpus/canvas/baseline-anchors.one",
4144];
......@@ -82,11 +85,17 @@ enum Kind {
8285 PlaceImage,
8386 RemoveImage,
8487 Date,
88 Link,
89 Unlink,
90 TypedUrl,
91 Equation,
92 Linear,
93 Professional,
8594 Undo,
8695 Redo,
8796}
8897
89const KINDS: [Kind; 42] = [
98const KINDS: [Kind; 48] = [
9099 Kind::Type,
91100 Kind::TypeEnd,
92101 Kind::Compose,
......@@ -127,6 +136,12 @@ const KINDS: [Kind; 42] = [
127136 Kind::PlaceImage,
128137 Kind::RemoveImage,
129138 Kind::Date,
139 Kind::Link,
140 Kind::Unlink,
141 Kind::TypedUrl,
142 Kind::Equation,
143 Kind::Linear,
144 Kind::Professional,
130145 Kind::Undo,
131146 Kind::Redo,
132147];
......@@ -372,6 +387,28 @@ fn perform(
372387 )
373388 .unwrap_or(false)
374389 }
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 }
375412 Kind::Undo => editor.undo(engine).unwrap_or(false),
376413 Kind::Redo => editor.redo(engine).unwrap_or(false),
377414 }
crates/canvas/tests/structural_roundtrip.rs+2-2
......@@ -384,7 +384,7 @@ fn sweep(
384384#[test]
385385fn structural_edits_reread_as_saved() {
386386 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")];
388388 if let Some(extra) = std::env::var_os("CANVAS_SWEEP_SECTIONS") {
389389 paths.extend(std::env::split_paths(&extra));
390390 }
......@@ -522,7 +522,7 @@ fn structural_edits_write_what_onenote_reads() {
522522 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
523523 let read = |path: &str| std::fs::read(root.join(path)).unwrap();
524524 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");
526526 for (title, keys) in [
527527 ("c6-plain", vec![(Before("Above"), Press::Tab)]),
528528 ("c6-first", vec![(Before("Target text"), Press::ShiftTab)]),
crates/draw/src/edit.rs+37-2
......@@ -87,6 +87,9 @@ pub enum Command {
8787 ScrollPage { up: bool },
8888 /// Moves the caret a page and the view with it, extending the selection with Shift.
8989 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 },
9093}
9194
9295impl Command {
......@@ -285,6 +288,13 @@ fn mac_command(key: &Key, modifiers: Modifiers) -> Option<Command> {
285288 }))
286289 };
287290 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 }
288298 Key::Named(NamedKey::ArrowLeft) => {
289299 pick(Movement::Left, Movement::WordLeft, Movement::LineStart)
290300 }
......@@ -341,10 +351,18 @@ fn mac_command(key: &Key, modifiers: Modifiers) -> Option<Command> {
341351/// and End reaches the document's ends; Control with Up and Down moves by paragraphs.
342352fn pc_command(key: &Key, modifiers: Modifiers) -> Option<Command> {
343353 let Modifiers {
344 control, option, ..
354 shift,
355 control,
356 option,
357 ..
345358 } = modifiers;
346359 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 };
348366 }
349367 let pick =
350368 |plain, with_control| Some(Command::Move(if control { with_control } else { plain }));
......@@ -643,6 +661,16 @@ mod tests {
643661 None,
644662 ),
645663 (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 ),
646674 ];
647675 for (key, modifiers, command) in cases {
648676 assert_eq!(
......@@ -666,8 +694,15 @@ mod tests {
666694 option: true,
667695 ..Modifiers::default()
668696 };
697 let alt_shift = Modifiers { shift: true, ..alt };
669698 let named = |named| Key::Named(named);
670699 let cases = [
700 (
701 named(NamedKey::ArrowUp),
702 alt_shift,
703 Some(Command::MoveParagraphs { up: true }),
704 ),
705 (named(NamedKey::ArrowUp), alt, None),
671706 (
672707 named(NamedKey::ArrowLeft),
673708 control,
crates/draw/src/render.rs+229-22
......@@ -1,7 +1,11 @@
11mod icon;
2
3pub use icon::Palette;
24mod text;
5mod translucent;
36
47pub use text::{Decoration, Glyph, GlyphRun, Glyphs, paint_parley_run};
8pub use translucent::Translucent;
59
610use bytemuck::{Pod, Zeroable};
711use image::ImageDecoder;
......@@ -150,10 +154,20 @@ struct CachedImage {
150154
151155struct Batch {
152156 vertices: Range<u32>,
153 image: Option<u64>,
157 blend: Blend,
154158 scissor: [u32; 4],
155159}
156160
161/// How a batch meets what lies beneath it.
162#[derive(Clone, Copy, PartialEq)]
163enum Blend {
164 Over,
165 /// A premultiplied picture over it.
166 Image(u64),
167 /// Clearing it by the batch's coverage.
168 Erase,
169}
170
157171#[repr(C)]
158172#[derive(Clone, Copy, Pod, Zeroable)]
159173struct Vertex {
......@@ -181,6 +195,9 @@ enum AtlasKey {
181195 sources: &'static [&'static str],
182196 size: u32,
183197 phase: [u8; 2],
198 /// The bits of the colour `currentColor` paints.
199 ink: [u32; 3],
200 palette: [Option<[u32; 3]>; 3],
184201 },
185202 Path {
186203 data: String,
......@@ -213,9 +230,47 @@ pub struct Layer<'a> {
213230 /// The colour behind the layer: on a dark one, text in dark colours of its own is
214231 /// lifted to stay legible against what lies behind it, as OneNote's dark page does.
215232 pub backdrop: Option<[f32; 4]>,
233 pub motion: Option<Motion>,
216234 pub primitives: &'a [Primitive<'a>],
217235}
218236
237/// A layer part of the way through appearing: faded, and leaned back in perspective.
238#[derive(Clone, Copy, Debug, PartialEq)]
239pub struct Motion {
240 pub opacity: f32,
241 /// Radians the layer turns about the horizontal line through `pivot`, its part below
242 /// the line leaning away from the viewer.
243 pub tilt: f32,
244 /// Device point the layer turns about.
245 pub pivot: [f32; 2],
246}
247
248impl Motion {
249 fn apply(&self, vertex: &mut Vertex, size: [u32; 2], blend: Blend) {
250 match blend {
251 Blend::Image(_) => vertex.color = vertex.color.map(|v| v * self.opacity),
252 Blend::Over | Blend::Erase => vertex.color[3] *= self.opacity,
253 }
254 if self.tilt == 0.0 {
255 return;
256 }
257 let [width, height] = size.map(|v| v as f32);
258 // Seen from twice the target's height away, so a small turn reads as depth.
259 let distance = 2.0 * height;
260 let [x, y] = [
261 (vertex.position[0] + 1.0) * width / 2.0,
262 (1.0 - vertex.position[1]) * height / 2.0,
263 ];
264 let below = y - self.pivot[1];
265 let near = distance / (distance + below * self.tilt.sin());
266 let [x, y] = [
267 self.pivot[0] + (x - self.pivot[0]) * near,
268 self.pivot[1] + below * self.tilt.cos() * near,
269 ];
270 vertex.position = [x * 2.0 / width - 1.0, 1.0 - y * 2.0 / height];
271 }
272}
273
219274/// A layer's transform onto the target.
220275#[derive(Clone, Copy)]
221276struct Space {
......@@ -223,6 +278,7 @@ struct Space {
223278 scale: f32,
224279 origin: [f32; 2],
225280 backdrop: Option<[f32; 4]>,
281 motion: Option<Motion>,
226282}
227283
228284impl Space {
......@@ -271,6 +327,9 @@ pub enum PathStyle {
271327 Stroke(f32),
272328 /// Filled and blurred this far, as a soft shadow.
273329 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,
274333}
275334
276335#[derive(Clone, Copy, Debug, PartialEq)]
......@@ -291,12 +350,14 @@ pub enum Primitive<'a> {
291350 ink: [f32; 4],
292351 },
293352 /// 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.
295355 Icon {
296356 sources: &'static [&'static str],
297357 origin: [f32; 2],
298358 size: f32,
299359 tint: [f32; 4],
360 palette: Palette,
300361 },
301362 /// SVG path data in layer units from `origin`, shaded from `colors[0]` at the top of
302363 /// what it paints to `colors[1]` at the bottom. Each distinct path is rasterized once
......@@ -378,6 +439,7 @@ pub struct Renderer {
378439 pub queue: wgpu::Queue,
379440 pipeline: wgpu::RenderPipeline,
380441 image_pipeline: wgpu::RenderPipeline,
442 erase_pipeline: wgpu::RenderPipeline,
381443 atlas: wgpu::Texture,
382444 bind_group: wgpu::BindGroup,
383445 image_sampler: wgpu::Sampler,
......@@ -451,8 +513,21 @@ impl Renderer {
451513 cache: None,
452514 })
453515 };
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 });
455521 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 });
456531 let atlas = device.create_texture(&wgpu::TextureDescriptor {
457532 label: Some("Glyph atlas"),
458533 size: wgpu::Extent3d {
......@@ -518,6 +593,7 @@ impl Renderer {
518593 queue,
519594 pipeline,
520595 image_pipeline,
596 erase_pipeline,
521597 atlas,
522598 bind_group,
523599 image_sampler,
......@@ -601,6 +677,7 @@ impl Renderer {
601677 scale: layer.scale,
602678 origin: layer.origin,
603679 backdrop: layer.backdrop,
680 motion: layer.motion,
604681 };
605682 for primitive in layer.primitives {
606683 if let Primitive::Image { image, rect } = primitive
......@@ -647,15 +724,12 @@ impl Renderer {
647724 for batch in &self.batches {
648725 let [x, y, width, height] = batch.scissor;
649726 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);
659733 pass.set_bind_group(0, binding, &[]);
660734 pass.draw(batch.vertices.clone(), 0..1);
661735 }
......@@ -676,6 +750,7 @@ impl Renderer {
676750 scale: layer.scale,
677751 origin: layer.origin,
678752 backdrop: layer.backdrop,
753 motion: layer.motion,
679754 };
680755 let bounds = match layer.clip {
681756 Some(clip) => space.scissor(clip),
......@@ -699,7 +774,7 @@ impl Renderer {
699774 active_images: &HashSet<u64>,
700775 ) -> Result<(), RenderError> {
701776 let start = self.vertices.len() as u32;
702 let mut image_id = None;
777 let mut blend = Blend::Over;
703778 let mut scissor = bounds;
704779 match primitive {
705780 Primitive::Icon {
......@@ -707,19 +782,25 @@ impl Renderer {
707782 origin,
708783 size,
709784 tint,
785 palette,
710786 } => {
711787 let origin = [
712788 space.origin[0] + origin[0] * space.scale,
713789 space.origin[1] + origin[1] * space.scale,
714790 ];
715 self.icon(Space { origin, ..space }, sources, *size, *tint)?;
791 self.icon(Space { origin, ..space }, sources, *size, *tint, palette)?;
716792 }
717793 Primitive::Path {
718794 data,
719795 origin,
720796 style,
721797 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 }
723804 Primitive::Text {
724805 text,
725806 origin,
......@@ -779,22 +860,27 @@ impl Renderer {
779860 Primitive::Image { image, rect } => {
780861 if let Some(rect) = space.visible_rect(*rect)? {
781862 self.image(image, active_images)?;
782 image_id = Some(image.id());
863 blend = Blend::Image(image.id());
783864 self.quad(space, rect, [0.0, 0.0, 1.0, 1.0], [1.0; 4])?;
784865 }
785866 }
786867 }
787868 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 }
788874 if start != end {
789875 if let Some(last) = self.batches.last_mut()
790 && last.image == image_id
876 && last.blend == blend
791877 && last.scissor == scissor
792878 {
793879 last.vertices.end = end;
794880 } else {
795881 self.batches.push(Batch {
796882 vertices: start..end,
797 image: image_id,
883 blend,
798884 scissor,
799885 });
800886 }
......@@ -1073,6 +1159,7 @@ impl Renderer {
10731159 sources: &'static [&'static str],
10741160 size: f32,
10751161 tint: [f32; 4],
1162 palette: &Palette,
10761163 ) -> Result<(), RenderError> {
10771164 let size = size * space.scale;
10781165 let [x, y] = space.origin.map(|v| (v * 4.0).round() * 0.25);
......@@ -1090,10 +1177,13 @@ impl Renderer {
10901177 return Err(RenderError::AtlasFull);
10911178 }
10921179 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]];
10931181 let key = AtlasKey::Icon {
10941182 sources,
10951183 size: size.to_bits(),
10961184 phase,
1185 ink: ink.map(f32::to_bits),
1186 palette: palette.bits(),
10971187 };
10981188 let glyph = if let Some(Some(glyph)) = self.glyphs.get(&key) {
10991189 *glyph
......@@ -1101,7 +1191,7 @@ impl Renderer {
11011191 if self.glyphs.len() >= MAX_GLYPHS {
11021192 return Err(RenderError::AtlasFull);
11031193 }
1104 let glyph = self.upload(icon::rasterize(sources, size, phase))?;
1194 let glyph = self.upload(icon::rasterize(sources, size, phase, ink, palette))?;
11051195 self.glyphs.insert(key, Some(glyph));
11061196 glyph
11071197 };
......@@ -1122,7 +1212,7 @@ impl Renderer {
11221212 (glyph.x + glyph.width) as f32 / atlas,
11231213 (glyph.y + glyph.height) as f32 / atlas,
11241214 ],
1125 tint,
1215 [1.0, 1.0, 1.0, tint[3]],
11261216 )
11271217 }
11281218
......@@ -1197,7 +1287,7 @@ impl Renderer {
11971287 let [x, y] = [0, 1]
11981288 .map(|axis| ((space.origin[axis] + origin[axis] * space.scale) * 4.0).round() * 0.25);
11991289 let (kind, width) = match style {
1200 PathStyle::Fill => (0, 0.0),
1290 PathStyle::Fill | PathStyle::Erase => (0, 0.0),
12011291 PathStyle::Stroke(width) => (1, width),
12021292 PathStyle::Shadow(width) => (2, width),
12031293 };
......@@ -1729,6 +1819,10 @@ mod tests {
17291819
17301820 impl Target {
17311821 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 {
17321826 Self {
17331827 texture: device.create_texture(&wgpu::TextureDescriptor {
17341828 label: Some("Draw readback test"),
......@@ -1740,7 +1834,7 @@ mod tests {
17401834 mip_level_count: 1,
17411835 sample_count: 1,
17421836 dimension: wgpu::TextureDimension::D2,
1743 format: wgpu::TextureFormat::Rgba8UnormSrgb,
1837 format,
17441838 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
17451839 view_formats: &[],
17461840 }),
......@@ -1809,6 +1903,7 @@ mod tests {
18091903 origin: [24.0; 2],
18101904 clip: None,
18111905 backdrop: None,
1906 motion: None,
18121907 primitives,
18131908 }]
18141909 }
......@@ -1895,6 +1990,7 @@ mod tests {
18951990 origin: [128.125 + 20.0 * index as f32, 80.25],
18961991 size: 12.0,
18971992 tint: [1.0; 4],
1993 palette: Palette::default(),
18981994 });
18991995 }
19001996 primitives.extend((0..9).map(|step| Primitive::Text {
......@@ -1998,6 +2094,7 @@ mod tests {
19982094 origin: [10000.0; 2],
19992095 size: 12.0,
20002096 tint: [1.0; 4],
2097 palette: Palette::default(),
20012098 }]),
20022099 )
20032100 .unwrap();
......@@ -2210,6 +2307,7 @@ mod tests {
22102307 origin: [0.0; 2],
22112308 clip: None,
22122309 backdrop: None,
2310 motion: None,
22132311 primitives: &primitives,
22142312 }],
22152313 )
......@@ -2246,6 +2344,112 @@ mod tests {
22462344 assert_eq!(beyond, [255; 4]);
22472345 }
22482346
2347 #[test]
2348 #[ignore = "requires a native GPU adapter"]
2349 fn transparent_targets_hold_premultiplied_coverage_and_erase() {
2350 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
2351 let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
2352 let (device, queue) =
2353 pollster::block_on(adapter.request_device(&Default::default())).unwrap();
2354 let target = Target::new(&device);
2355 let mut renderer = Renderer::new(device, queue, wgpu::TextureFormat::Rgba8UnormSrgb);
2356 let primitives = [
2357 Primitive::Rect {
2358 rect: [0.0, 0.0, 10.0, 10.0],
2359 color: [1.0, 1.0, 1.0, 0.5],
2360 },
2361 Primitive::Rect {
2362 rect: [20.0, 0.0, 40.0, 10.0],
2363 color: [0.0, 0.0, 1.0, 1.0],
2364 },
2365 Primitive::Path {
2366 data: "M0 0H10V10H0Z",
2367 origin: [30.0, 0.0],
2368 style: PathStyle::Erase,
2369 colors: [[0.0, 0.0, 0.0, 1.0]; 2],
2370 },
2371 ];
2372 renderer
2373 .draw(
2374 &target.texture.create_view(&Default::default()),
2375 [512, 256],
2376 [0.0; 4],
2377 &[Layer {
2378 scale: 1.0,
2379 origin: [0.0; 2],
2380 clip: None,
2381 backdrop: None,
2382 motion: None,
2383 primitives: &primitives,
2384 }],
2385 )
2386 .unwrap();
2387 let capture = target.capture(&renderer);
2388 let pixel = |x: usize, y: usize| &capture[(y * 512 + x) * 4..(y * 512 + x) * 4 + 4];
2389 assert_eq!(pixel(5, 5)[3], 128, "half coverage is half opaque");
2390 assert_eq!(pixel(5, 5)[0], 188, "over premultiplied colour");
2391 assert_eq!(pixel(25, 5), [0, 0, 255, 255]);
2392 assert_eq!(pixel(35, 5), [0; 4], "erased back to transparency");
2393 assert_eq!(pixel(50, 5), [0; 4]);
2394 }
2395
2396 #[test]
2397 #[ignore = "requires a native GPU adapter"]
2398 fn translucent_windows_take_colour_premultiplied_in_srgb() {
2399 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
2400 let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
2401 let (device, queue) =
2402 pollster::block_on(adapter.request_device(&Default::default())).unwrap();
2403 let format = wgpu::TextureFormat::Rgba8UnormSrgb;
2404 let mut renderer = Renderer::new(device.clone(), queue.clone(), format);
2405 let mut translucent = Translucent::new(&device, format, wgpu::TextureFormat::Rgba8Unorm);
2406 let primitives = [
2407 Primitive::Rect {
2408 rect: [0.0, 0.0, 10.0, 10.0],
2409 color: [1.0, 1.0, 1.0, 0.2],
2410 },
2411 Primitive::Rect {
2412 rect: [20.0, 0.0, 30.0, 10.0],
2413 color: [0.2, 0.2, 0.2, 1.0],
2414 },
2415 ];
2416 let frame = translucent.target(&device, [512, 256]);
2417 renderer
2418 .draw(
2419 &frame,
2420 [512, 256],
2421 [0.0; 4],
2422 &[Layer {
2423 scale: 1.0,
2424 origin: [0.0; 2],
2425 clip: None,
2426 backdrop: None,
2427 motion: None,
2428 primitives: &primitives,
2429 }],
2430 )
2431 .unwrap();
2432 let window = Target::with_format(&device, wgpu::TextureFormat::Rgba8Unorm);
2433 translucent.present(
2434 &device,
2435 &queue,
2436 &window.texture.create_view(&Default::default()),
2437 );
2438 let capture = window.capture(&renderer);
2439 let pixel = |x: usize, y: usize| &capture[(y * 512 + x) * 4..(y * 512 + x) * 4 + 4];
2440 assert_eq!(
2441 pixel(5, 5),
2442 [51, 51, 51, 51],
2443 "white at a fifth, premultiplied in sRGB"
2444 );
2445 assert_eq!(
2446 pixel(25, 5),
2447 [124, 124, 124, 255],
2448 "opaque colours encode as ever"
2449 );
2450 assert_eq!(pixel(50, 5), [0; 4]);
2451 }
2452
22492453 #[test]
22502454 #[ignore = "requires a native GPU adapter"]
22512455 fn layers_transform_and_clip_independently() {
......@@ -2272,6 +2476,7 @@ mod tests {
22722476 origin: [0.0; 2],
22732477 clip: Some([100.0, 50.0, 200.5, 80.0]),
22742478 backdrop: None,
2479 motion: None,
22752480 primitives: &fill,
22762481 },
22772482 Layer {
......@@ -2279,6 +2484,7 @@ mod tests {
22792484 origin: [300.0, 10.0],
22802485 clip: None,
22812486 backdrop: None,
2487 motion: None,
22822488 primitives: &chrome,
22832489 },
22842490 ],
......@@ -2306,6 +2512,7 @@ mod tests {
23062512 origin: [0.0; 2],
23072513 clip: None,
23082514 backdrop: None,
2515 motion: None,
23092516 primitives: &fill,
23102517 }]
23112518 ),
crates/draw/src/render/icon.rs+469-34
......@@ -1,27 +1,62 @@
11use swash::{
22 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 },
47};
58
9/// Colours for an icon's slots, linear RGB: paths of `class="accent"`, `"highlight"` or
10/// `"badge"` are recoloured to their slot's colour; an empty slot keeps the art's colours.
11#[derive(Clone, Copy, Debug, Default, PartialEq)]
12pub struct Palette {
13 pub accent: Option<[f32; 3]>,
14 pub highlight: Option<[f32; 3]>,
15 pub badge: Option<[f32; 3]>,
16}
17
18impl Palette {
19 fn slot(&self, class: &str) -> Option<[f32; 3]> {
20 match class {
21 "accent" => self.accent,
22 "highlight" => self.highlight,
23 "badge" => self.badge,
24 _ => None,
25 }
26 }
27
28 /// The palette as hashable bits, for caching icons per distinct palette.
29 pub(crate) fn bits(&self) -> [Option<[u32; 3]>; 3] {
30 [self.accent, self.highlight, self.badge]
31 .map(|slot| slot.map(|color| color.map(f32::to_bits)))
32 }
33}
34
635/// 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.
9pub(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.
43pub(crate) fn rasterize(
44 sources: &[&str],
45 size: f32,
46 phase: [u8; 2],
47 ink: [f32; 3],
48 palette: &Palette,
49) -> Image {
1050 let side = (size + 1.0).ceil() as u32;
1151 let mut pixels = vec![[0.0_f32; 4]; (side * side) as usize];
52 let offset = phase.map(|quarters| f32::from(quarters) * 0.25);
1253 for source in sources {
1354 let svg = roxmltree::Document::parse(source).expect("Bundled icon SVG must be valid");
1455 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> {
2257 match value {
2358 "none" => None,
24 "currentColor" => Some([[1.0; 4]; 2]),
59 "currentColor" => Some(Paint::Flat([ink[0], ink[1], ink[2], 1.0])),
2560 _ => Some(match value.strip_prefix("url(#") {
2661 Some(id) => {
2762 let id = id.strip_suffix(')').unwrap();
......@@ -29,18 +64,38 @@ pub(crate) fn rasterize(sources: &[&str], size: f32, phase: [u8; 2]) -> Image {
2964 .descendants()
3065 .find(|node| node.attribute("id") == Some(id))
3166 .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)
3868 }
39 None => [hex(value); 2],
69 None => Paint::Flat(hex(value)),
4070 }),
4171 }
4272 };
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);
4499 let fill = match path.attribute("fill-rule") {
45100 Some("evenodd") => Fill::EvenOdd,
46101 _ => Fill::NonZero,
......@@ -48,13 +103,19 @@ pub(crate) fn rasterize(sources: &[&str], size: f32, phase: [u8; 2]) -> Image {
48103 let pen = path
49104 .attribute("stroke-width")
50105 .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 };
51110 let layers = [
52111 (
53112 path.attribute("fill").unwrap_or("#000000"),
113 opacity("fill-opacity"),
54114 Style::from(fill),
55115 ),
56116 (
57117 path.attribute("stroke").unwrap_or("none"),
118 opacity("stroke-opacity"),
58119 Style::from(Stroke {
59120 start_cap: Cap::Round,
60121 end_cap: Cap::Round,
......@@ -63,22 +124,32 @@ pub(crate) fn rasterize(sources: &[&str], size: f32, phase: [u8; 2]) -> Image {
63124 }),
64125 ),
65126 ];
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 {
68129 continue;
69130 };
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)
71136 .style(style)
72137 .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]))
74139 .size(side, side)
75140 .render();
76141 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]);
79151 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);
82153 }
83154 pixel[3] = alpha + pixel[3] * (1.0 - alpha);
84155 }
......@@ -114,6 +185,270 @@ pub(crate) fn rasterize(sources: &[&str], size: f32, phase: [u8; 2]) -> Image {
114185 }
115186}
116187
188fn hex(value: &str) -> [f32; 4] {
189 let [_, r, g, b] = u32::from_str_radix(value.strip_prefix('#').unwrap(), 16)
190 .unwrap()
191 .to_be_bytes();
192 super::srgb(r, g, b)
193}
194
195/// A number or a percentage of `whole`.
196fn number(value: &str, whole: f32) -> f32 {
197 match value.strip_suffix('%') {
198 Some(percent) => percent.parse::<f32>().unwrap() / 100.0 * whole,
199 None => value.parse().unwrap(),
200 }
201}
202
203enum Paint {
204 Flat([f32; 4]),
205 Linear {
206 /// Colour and offset of each stop.
207 stops: [([f32; 4], f32); 2],
208 /// Where offsets 0 and 1 lie, in icon units or fractions of the shape's box.
209 line: [[f32; 2]; 2],
210 bounding: bool,
211 },
212}
213
214impl Paint {
215 fn colors(&self) -> Vec<[f32; 4]> {
216 match self {
217 Self::Flat(color) => vec![*color],
218 Self::Linear { stops, .. } => stops.iter().map(|(color, _)| *color).collect(),
219 }
220 }
221
222 fn recolor(&mut self, change: impl Fn([f32; 4]) -> [f32; 4]) {
223 match self {
224 Self::Flat(color) => *color = change(*color),
225 Self::Linear { stops, .. } => {
226 for (color, _) in stops {
227 *color = change(*color);
228 }
229 }
230 }
231 }
232
233 fn linear(gradient: roxmltree::Node) -> Self {
234 assert!(
235 gradient.attribute("gradientTransform").is_none(),
236 "Icon gradients have no gradientTransform"
237 );
238 let stops: Vec<_> = gradient
239 .children()
240 .filter(|node| node.has_tag_name("stop"))
241 .map(|node| {
242 (
243 hex(node.attribute("stop-color").unwrap()),
244 node.attribute("offset")
245 .map_or(0.0, |offset| number(offset, 1.0)),
246 )
247 })
248 .collect();
249 let bounding = gradient.attribute("gradientUnits") != Some("userSpaceOnUse");
250 // Percentages are of the shape's box or of the icon.
251 let whole = if bounding { 1.0 } else { 16.0 };
252 let at = |name, default| {
253 gradient
254 .attribute(name)
255 .map_or(default * whole, |value| number(value, whole))
256 };
257 Self::Linear {
258 stops: stops.try_into().expect("Icon gradients have two stops"),
259 line: [
260 [at("x1", 0.0), at("y1", 0.0)],
261 [at("x2", 1.0), at("y2", 0.0)],
262 ],
263 bounding,
264 }
265 }
266
267 /// The colour at each point of `data` in icon units.
268 fn shader(&self, data: &str) -> impl Fn([f32; 2]) -> [f32; 4] {
269 let (stops, [from, to], frame) = match *self {
270 Self::Flat(color) => ([(color, 0.0), (color, 1.0)], [[0.0; 2], [1.0, 0.0]], None),
271 Self::Linear {
272 stops,
273 line,
274 bounding,
275 } => (stops, line, bounding.then(|| bounds(data))),
276 };
277 let direction = [to[0] - from[0], to[1] - from[1]];
278 let length = direction[0] * direction[0] + direction[1] * direction[1];
279 move |point| {
280 let [x, y] = match frame {
281 // A flat box has no extent to spread across on that axis.
282 Some([min, max]) => std::array::from_fn(|axis| {
283 let extent = max[axis] - min[axis];
284 if extent > 0.0 {
285 (point[axis] - min[axis]) / extent
286 } else {
287 0.0
288 }
289 }),
290 None => point,
291 };
292 // A gradient of no length paints its last stop.
293 let along = if length > 0.0 {
294 ((x - from[0]) * direction[0] + (y - from[1]) * direction[1]) / length
295 } else {
296 1.0
297 };
298 let [(first, start), (last, end)] = stops;
299 let t = if end > start {
300 ((along - start) / (end - start)).clamp(0.0, 1.0)
301 } else {
302 f32::from(along >= start)
303 };
304 std::array::from_fn(|channel| first[channel] + (last[channel] - first[channel]) * t)
305 }
306 }
307}
308
309/// A turn of hue, scale of saturation and shift of lightness in sRGB's HSL.
310struct Shift([f32; 3]);
311
312impl Shift {
313 /// The shift carrying the mean of linear `colors` onto linear `target`.
314 fn new(colors: &[[f32; 4]], target: [f32; 3]) -> Option<Self> {
315 let count = colors.len() as f32;
316 let mean: [f32; 3] = std::array::from_fn(|channel| {
317 colors
318 .iter()
319 .map(|color| encode(color[channel]))
320 .sum::<f32>()
321 / count
322 });
323 let [from, to] = [mean, target.map(encode)].map(hsl);
324 (count > 0.0).then(|| {
325 Self([
326 to[0] - from[0],
327 if from[1] > 0.0 { to[1] / from[1] } else { 0.0 },
328 to[2] - from[2],
329 ])
330 })
331 }
332
333 fn apply(&self, color: [f32; 4]) -> [f32; 4] {
334 let [hue, saturation, lightness] = hsl([color[0], color[1], color[2]].map(encode));
335 let [turn, scale, lift] = self.0;
336 let [r, g, b] = rgb([
337 (hue + turn).rem_euclid(360.0),
338 (saturation * scale).clamp(0.0, 1.0),
339 (lightness + lift).clamp(0.0, 1.0),
340 ])
341 .map(decode);
342 [r, g, b, color[3]]
343 }
344}
345
346fn encode(linear: f32) -> f32 {
347 if linear <= 0.003_130_8 {
348 linear * 12.92
349 } else {
350 1.055 * linear.powf(1.0 / 2.4) - 0.055
351 }
352}
353
354fn decode(encoded: f32) -> f32 {
355 if encoded <= 0.040_45 {
356 encoded / 12.92
357 } else {
358 ((encoded + 0.055) / 1.055).powf(2.4)
359 }
360}
361
362/// Hue in degrees, saturation and lightness of an encoded sRGB colour.
363fn hsl([r, g, b]: [f32; 3]) -> [f32; 3] {
364 let [max, min] = [r.max(g).max(b), r.min(g).min(b)];
365 let lightness = (max + min) / 2.0;
366 let range = max - min;
367 if range == 0.0 {
368 return [0.0, 0.0, lightness];
369 }
370 let saturation = range / (1.0 - (2.0 * lightness - 1.0).abs());
371 let sector = if max == r {
372 (g - b) / range
373 } else if max == g {
374 (b - r) / range + 2.0
375 } else {
376 (r - g) / range + 4.0
377 };
378 [(sector * 60.0).rem_euclid(360.0), saturation, lightness]
379}
380
381fn rgb([hue, saturation, lightness]: [f32; 3]) -> [f32; 3] {
382 let chroma = (1.0 - (2.0 * lightness - 1.0).abs()) * saturation;
383 [0.0, 8.0, 4.0].map(|offset: f32| {
384 let k = (offset + hue / 30.0).rem_euclid(12.0);
385 lightness - chroma / 2.0 * (k - 3.0).min(9.0 - k).clamp(-1.0, 1.0)
386 })
387}
388
389/// The tight box around a path's geometry, curves included, as SVG's bounding box is.
390fn bounds(data: &str) -> [[f32; 2]; 2] {
391 struct Sampled {
392 current: Point,
393 min: [f32; 2],
394 max: [f32; 2],
395 }
396 impl Sampled {
397 fn add(&mut self, point: Point) {
398 self.min = [self.min[0].min(point.x), self.min[1].min(point.y)];
399 self.max = [self.max[0].max(point.x), self.max[1].max(point.y)];
400 }
401 }
402 impl PathBuilder for Sampled {
403 fn current_point(&self) -> Point {
404 self.current
405 }
406 fn move_to(&mut self, to: impl Into<Point>) -> &mut Self {
407 self.current = to.into();
408 self.add(self.current);
409 self
410 }
411 fn line_to(&mut self, to: impl Into<Point>) -> &mut Self {
412 self.move_to(to)
413 }
414 fn quad_to(&mut self, control: impl Into<Point>, to: impl Into<Point>) -> &mut Self {
415 let [start, control, to] = [self.current, control.into(), to.into()];
416 let lift = |end: Point| end + (control - end) * (2.0 / 3.0);
417 self.curve_to(lift(start), lift(to), to)
418 }
419 fn curve_to(
420 &mut self,
421 first: impl Into<Point>,
422 second: impl Into<Point>,
423 to: impl Into<Point>,
424 ) -> &mut Self {
425 let [start, first, second, to] = [self.current, first.into(), second.into(), to.into()];
426 for step in 1..=32 {
427 let t = step as f32 / 32.0;
428 let u = 1.0 - t;
429 self.add(
430 start * (u * u * u)
431 + first * (3.0 * u * u * t)
432 + second * (3.0 * u * t * t)
433 + to * (t * t * t),
434 );
435 }
436 self.current = to;
437 self
438 }
439 fn close(&mut self) -> &mut Self {
440 self
441 }
442 }
443 let mut sampled = Sampled {
444 current: Point::ZERO,
445 min: [f32::INFINITY; 2],
446 max: [f32::NEG_INFINITY; 2],
447 };
448 zeno::apply(data, Fill::NonZero, None, &mut sampled);
449 [sampled.min, sampled.max]
450}
451
117452#[cfg(test)]
118453mod tests {
119454 use super::*;
......@@ -121,23 +456,123 @@ mod tests {
121456 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>"##;
122457 const MARK: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><linearGradient id="b" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ff2000"/><stop offset="1" stop-color="#a00000"/></linearGradient></defs><path fill="url(#b)" d="M4 8l3 3 5-7-1-1-4 5-2-2z"/></svg>"##;
123458
459 fn gradient(attributes: &str, stops: &str) -> String {
460 format!(
461 r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><linearGradient id="g" {attributes}>{stops}</linearGradient></defs><path fill="url(#g)" d="M0 4H16V12H0Z"/></svg>"##
462 )
463 }
464
465 const BLACK_WHITE: &str =
466 r##"<stop offset="0" stop-color="#000000"/><stop offset="1" stop-color="#ffffff"/>"##;
467
468 /// The grey painted at a pixel of `source` rasterized at 16 pixels, and the grey a
469 /// black-to-white ramp paints at fraction `t`.
470 fn grey(source: &str, x: usize, y: usize) -> u8 {
471 rasterize(&[source], 16.0, [0, 0], [1.0; 3], &Palette::default()).data[(y * 17 + x) * 4]
472 }
473
474 fn ramp(t: f32) -> u8 {
475 super::super::srgb_byte(t)
476 }
477
478 #[test]
479 fn gradients_span_the_shape_unless_in_user_space() {
480 let vertical = gradient(r#"x2="0" y2="1""#, BLACK_WHITE);
481 // Pixel centres 4.5 and 11.5 lie 0.5 inside the shape's 8-unit box.
482 assert_eq!(grey(&vertical, 8, 4), ramp(0.5 / 8.0));
483 assert_eq!(grey(&vertical, 8, 11), ramp(7.5 / 8.0));
484 let user = gradient(
485 r#"gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="0" y2="16""#,
486 BLACK_WHITE,
487 );
488 assert_eq!(grey(&user, 8, 4), ramp(4.5 / 16.0));
489 assert_eq!(grey(&user, 8, 11), ramp(11.5 / 16.0));
490 }
491
492 #[test]
493 fn gradients_default_to_horizontal_and_pad_past_their_stops() {
494 let across = gradient("", BLACK_WHITE);
495 assert_eq!(grey(&across, 3, 4), ramp(3.5 / 16.0));
496 assert_eq!(grey(&across, 3, 11), ramp(3.5 / 16.0));
497 let inner = gradient(
498 r#"x2="0" y2="1""#,
499 r##"<stop offset="25%" stop-color="#000000"/><stop offset="75%" stop-color="#ffffff"/>"##,
500 );
501 assert_eq!(grey(&inner, 8, 4), 0);
502 assert_eq!(grey(&inner, 8, 11), 255);
503 assert_eq!(grey(&inner, 8, 7), ramp((3.5 / 8.0 - 0.25) / 0.5));
504 }
505
124506 #[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 );
128552 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]);
130555 assert_eq!(pixel(8, 2)[3], 0);
131556 // Round caps reach past the path's ends.
132557 assert!(pixel(1, 7)[3] > 0);
133558 }
134559
560 #[test]
561 fn opacity_scales_each_paint_on_its_own() {
562 const HALF: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M2 2H14V14H2Z" fill="#ffffff" fill-opacity="0.5" stroke="#000000" stroke-width="2" stroke-opacity="0.25"/></svg>"##;
563 let image = rasterize(&[HALF], 16.0, [0, 0], [1.0; 3], &Palette::default());
564 let alpha = |x: usize, y: usize| image.data[(y * 17 + x) * 4 + 3];
565 assert_eq!(alpha(8, 8), 128);
566 // The stroke's outer half covers only what the fill leaves.
567 assert_eq!(alpha(1, 8), 64);
568 }
569
135570 #[test]
136571 fn icons_keep_transparency_and_overlays_across_scales() {
137572 for size in [8.0, 16.0, 32.0, 57.5] {
138573 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());
141576 for image in [&plain, &marked] {
142577 assert_eq!(
143578 image.data.len(),
crates/draw/src/render/translucent.rs created+110
......@@ -0,0 +1,110 @@
1/// Carries frames to a window whose compositor blends premultiplied sRGB, as Core Animation's
2/// transparent layers do. The renderer blends in linear light into a texture of its own; on
3/// the way to the window each pixel is premultiplied again in sRGB, so a translucent colour
4/// over whatever shows through the window looks as it would over an opaque one.
5pub struct Translucent {
6 frame: Option<wgpu::Texture>,
7 format: wgpu::TextureFormat,
8 pipeline: wgpu::RenderPipeline,
9}
10
11impl Translucent {
12 /// Frames the renderer draws in `format` for a window view in `window`, which stores its
13 /// bytes as given.
14 pub fn new(
15 device: &wgpu::Device,
16 format: wgpu::TextureFormat,
17 window: wgpu::TextureFormat,
18 ) -> Self {
19 let shader = device.create_shader_module(wgpu::include_wgsl!("translucent.wgsl"));
20 let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
21 label: Some("Translucent window"),
22 layout: None,
23 vertex: wgpu::VertexState {
24 module: &shader,
25 entry_point: Some("vertex"),
26 compilation_options: Default::default(),
27 buffers: &[],
28 },
29 fragment: Some(wgpu::FragmentState {
30 module: &shader,
31 entry_point: Some("fragment"),
32 compilation_options: Default::default(),
33 targets: &[Some(window.into())],
34 }),
35 primitive: Default::default(),
36 depth_stencil: None,
37 multisample: Default::default(),
38 multiview_mask: None,
39 cache: None,
40 });
41 Self {
42 frame: None,
43 format,
44 pipeline,
45 }
46 }
47
48 /// The view to draw a frame `size` pixels large into.
49 pub fn target(&mut self, device: &wgpu::Device, size: [u32; 2]) -> wgpu::TextureView {
50 let extent = wgpu::Extent3d {
51 width: size[0],
52 height: size[1],
53 depth_or_array_layers: 1,
54 };
55 let frame = match &self.frame {
56 Some(frame) if frame.size() == extent => frame,
57 _ => self
58 .frame
59 .insert(device.create_texture(&wgpu::TextureDescriptor {
60 label: Some("Translucent frame"),
61 size: extent,
62 mip_level_count: 1,
63 sample_count: 1,
64 dimension: wgpu::TextureDimension::D2,
65 format: self.format,
66 usage: wgpu::TextureUsages::RENDER_ATTACHMENT
67 | wgpu::TextureUsages::TEXTURE_BINDING,
68 view_formats: &[],
69 })),
70 };
71 frame.create_view(&Default::default())
72 }
73
74 /// Copies the frame last drawn into `target` to `window`.
75 pub fn present(&self, device: &wgpu::Device, queue: &wgpu::Queue, window: &wgpu::TextureView) {
76 let Some(frame) = &self.frame else {
77 return;
78 };
79 let binding = device.create_bind_group(&wgpu::BindGroupDescriptor {
80 label: Some("Translucent frame"),
81 layout: &self.pipeline.get_bind_group_layout(0),
82 entries: &[wgpu::BindGroupEntry {
83 binding: 0,
84 resource: wgpu::BindingResource::TextureView(
85 &frame.create_view(&Default::default()),
86 ),
87 }],
88 });
89 let mut encoder = device.create_command_encoder(&Default::default());
90 {
91 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
92 label: Some("Translucent window"),
93 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
94 view: window,
95 depth_slice: None,
96 resolve_target: None,
97 ops: wgpu::Operations {
98 load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
99 store: wgpu::StoreOp::Store,
100 },
101 })],
102 ..Default::default()
103 });
104 pass.set_pipeline(&self.pipeline);
105 pass.set_bind_group(0, &binding, &[]);
106 pass.draw(0..3, 0..1);
107 }
108 queue.submit([encoder.finish()]);
109 }
110}
crates/draw/src/render/translucent.wgsl created+21
......@@ -0,0 +1,21 @@
1@group(0) @binding(0) var frame: texture_2d<f32>;
2
3@vertex
4fn vertex(@builtin(vertex_index) index: u32) -> @builtin(position) vec4<f32> {
5 let corner = vec2<f32>(f32((index << 1u) & 2u), f32(index & 2u));
6 return vec4<f32>(corner * 2.0 - 1.0, 0.0, 1.0);
7}
8
9fn encode(linear: vec3<f32>) -> vec3<f32> {
10 return select(1.055 * pow(linear, vec3<f32>(1.0 / 2.4)) - 0.055, linear * 12.92,
11 linear <= vec3<f32>(0.0031308));
12}
13
14@fragment
15fn fragment(@builtin(position) at: vec4<f32>) -> @location(0) vec4<f32> {
16 let pixel = textureLoad(frame, vec2<i32>(at.xy), 0);
17 if pixel.a == 0.0 {
18 return vec4<f32>(0.0);
19 }
20 return vec4<f32>(encode(pixel.rgb / pixel.a) * pixel.a, pixel.a);
21}
crates/snowbound/Cargo.toml+3-1
......@@ -24,7 +24,7 @@ accesskit_winit = { version = "0.34.0", default-features = false, features = ["r
2424[target.'cfg(target_os = "macos")'.dependencies]
2525libc = "0.2"
2626objc2 = "0.5.2"
27objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAlert", "NSButton", "NSControl", "NSView", "NSResponder", "NSWindow", "NSApplication", "NSEvent", "NSMenu", "NSMenuItem", "NSColor", "NSColorSpace", "NSDatePicker", "NSDatePickerCell", "NSOpenPanel", "NSPanel", "NSSavePanel"] }
27objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAlert", "NSButton", "NSCell", "NSControl", "NSView", "NSResponder", "NSWindow", "NSApplication", "NSEvent", "NSMenu", "NSMenuItem", "NSColor", "NSColorSpace", "NSDatePicker", "NSDatePickerCell", "NSOpenPanel", "NSPanel", "NSSavePanel"] }
2828objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSGeometry", "NSLocale", "NSPathUtilities", "NSTimeZone", "NSURL", "NSArray"] }
2929
3030[target.'cfg(target_os = "linux")'.dependencies]
......@@ -34,3 +34,5 @@ smithay-clipboard = "0.7.3"
3434fontique = { workspace = true, features = ["fontconfig-dlopen"] }
3535libc = "0.2"
3636zbus = { version = "5.19", default-features = false, features = ["async-io", "blocking-api"] }
37# The colours of winit's own window frame, which the toolbar continues.
38sctk-adwaita = { version = "0.10.1", default-features = false }
crates/snowbound/assets/icons/align-center.svg+1-1
......@@ -1,3 +1,3 @@
11<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"/>
33</svg>
crates/snowbound/assets/icons/align-left.svg+1-1
......@@ -1,3 +1,3 @@
11<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"/>
33</svg>
crates/snowbound/assets/icons/align-right.svg+1-1
......@@ -1,3 +1,3 @@
11<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"/>
33</svg>
crates/snowbound/assets/icons/attachment.svg+6-1
......@@ -1,3 +1,8 @@
11<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"/>
38</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 @@
11<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"/>
33</svg>
crates/snowbound/assets/icons/bullets.svg+12-4
......@@ -1,6 +1,14 @@
11<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"/>
614</svg>
crates/snowbound/assets/icons/calendar.svg+14-2
......@@ -1,4 +1,16 @@
11<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"/>
416</svg>
crates/snowbound/assets/icons/clear-formatting.svg+9-1
......@@ -1,3 +1,11 @@
11<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"/>
311</svg>
crates/snowbound/assets/icons/clock.svg+10-1
......@@ -1,3 +1,12 @@
11<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"/>
312</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 @@
11<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"/>
37</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 @@
11<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"/>
33</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 @@
11<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"/>
314</svg>
crates/snowbound/assets/icons/indent.svg+6-2
......@@ -1,4 +1,8 @@
11<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"/>
48</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 @@
11<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"/>
33</svg>
crates/snowbound/assets/icons/link.svg+14-1
......@@ -1,3 +1,16 @@
11<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"/>
316</svg>
crates/snowbound/assets/icons/notebook.svg+18-1
......@@ -1,3 +1,20 @@
11<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"/>
320</svg>
crates/snowbound/assets/icons/numbering.svg+2-3
......@@ -1,5 +1,4 @@
11<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"/>
54</svg>
crates/snowbound/assets/icons/options.svg+6-3
......@@ -1,5 +1,8 @@
11<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"/>
58</svg>
crates/snowbound/assets/icons/outdent.svg+6-2
......@@ -1,4 +1,8 @@
11<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"/>
48</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 @@
11<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"/>
312</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 @@
11<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)"/>
415</svg>
crates/snowbound/assets/icons/plus.svg+7-1
......@@ -1,3 +1,9 @@
11<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"/>
39</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 @@
11<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"/>
38</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 @@
11<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"/>
315</svg>
crates/snowbound/assets/icons/section-group.svg+12-1
......@@ -1,3 +1,14 @@
11<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"/>
314</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 @@
11<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"/>
313</svg>
crates/snowbound/assets/icons/sidebar-expand.svg+11-1
......@@ -1,3 +1,13 @@
11<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"/>
313</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 @@
11<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"/>
34</svg>
crates/snowbound/assets/icons/subscript.svg+2-2
......@@ -1,4 +1,4 @@
11<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"/>
44</svg>
crates/snowbound/assets/icons/superscript.svg+2-2
......@@ -1,4 +1,4 @@
11<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"/>
44</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 @@
11<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"/>
311</svg>
crates/snowbound/assets/icons/underline.svg+2-1
......@@ -1,3 +1,4 @@
11<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"/>
34</svg>
crates/snowbound/assets/icons/undo.svg+6-1
......@@ -1,3 +1,8 @@
11<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"/>
38</svg>
crates/snowbound/assets/icons/zoom-in.svg+14-1
......@@ -1,3 +1,16 @@
11<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"/>
316</svg>
crates/snowbound/assets/icons/zoom-out.svg+14-1
......@@ -1,3 +1,16 @@
11<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"/>
316</svg>
crates/snowbound/src/art.rs+38
......@@ -33,6 +33,7 @@ pub const PICTURE: &[&str] = art!("icons/picture");
3333pub const PLUS: &[&str] = art!("icons/plus");
3434pub const REDO: &[&str] = art!("icons/redo");
3535pub const SEARCH: &[&str] = art!("icons/search");
36pub const SECTION: &[&str] = art!("icons/section");
3637pub const SECTION_GROUP: &[&str] = art!("icons/section-group");
3738pub const SIDEBAR_COLLAPSE: &[&str] = art!("icons/sidebar-collapse");
3839pub const SIDEBAR_EXPAND: &[&str] = art!("icons/sidebar-expand");
......@@ -45,5 +46,42 @@ pub const UNDO: &[&str] = art!("icons/undo");
4546pub const ZOOM_IN: &[&str] = art!("icons/zoom-in");
4647pub const ZOOM_OUT: &[&str] = art!("icons/zoom-out");
4748
49// The toolbar layout's new buttons (resources/toolbar-spec.md); each loses its `expect`
50// when its button lands.
51#[expect(dead_code, reason = "Its toolbar button has not landed")]
52pub const BACK: &[&str] = art!("icons/back");
53#[expect(dead_code, reason = "Its toolbar button has not landed")]
54pub const COPY: &[&str] = art!("icons/copy");
55#[expect(dead_code, reason = "Its toolbar button has not landed")]
56pub const CUT: &[&str] = art!("icons/cut");
57#[expect(dead_code, reason = "Its toolbar button has not landed")]
58pub const DATE_TIME: &[&str] = art!("icons/date-time");
59#[expect(dead_code, reason = "Its toolbar button has not landed")]
60pub const FIND_TAGS: &[&str] = art!("icons/find-tags");
61#[expect(dead_code, reason = "Its toolbar button has not landed")]
62pub const FORMAT_PAINTER: &[&str] = art!("icons/format-painter");
63#[expect(dead_code, reason = "Its toolbar button has not landed")]
64pub const FORWARD: &[&str] = art!("icons/forward");
65#[expect(dead_code, reason = "Its toolbar button has not landed")]
66pub const FULL_PAGE_VIEW: &[&str] = art!("icons/full-page-view");
67#[expect(dead_code, reason = "Its toolbar button has not landed")]
68pub const INSERT_SPACE: &[&str] = art!("icons/insert-space");
69#[expect(dead_code, reason = "Its toolbar button has not landed")]
70pub const PAGE_COLOR: &[&str] = art!("icons/page-color");
71#[expect(dead_code, reason = "Its toolbar button has not landed")]
72pub const PASTE: &[&str] = art!("icons/paste");
73#[expect(dead_code, reason = "Its toolbar button has not landed")]
74pub const RECORD_AUDIO: &[&str] = art!("icons/record-audio");
75#[expect(dead_code, reason = "Its toolbar button has not landed")]
76pub const RECORD_VIDEO: &[&str] = art!("icons/record-video");
77#[expect(dead_code, reason = "Its toolbar button has not landed")]
78pub const RULE_LINES: &[&str] = art!("icons/rule-lines");
79#[expect(dead_code, reason = "Its toolbar button has not landed")]
80pub const SCREEN_CLIPPING: &[&str] = art!("icons/screen-clipping");
81#[expect(dead_code, reason = "Its toolbar button has not landed")]
82pub const SPELLING: &[&str] = art!("icons/spelling");
83#[expect(dead_code, reason = "Its toolbar button has not landed")]
84pub const SYMBOL: &[&str] = art!("icons/symbol");
85
4886pub const TAG_REMEMBER: &[&str] = art!("tags/remember");
4987pub const TAG_DEFINITION: &[&str] = art!("tags/definition");
crates/snowbound/src/commands.rs created+1015
......@@ -0,0 +1,1015 @@
1//! The commands the menu bar, the toolbar and the keyboard share: each one's title, its
2//! chords on each platform, when it applies and what it does.
3
4use crate::{Command as Work, FONTS, HIGHLIGHTS, SIZES, State, page, platform, search};
5use canvas::editor::{Alignment, FormatState, Formatting, ListStyle, NoteTag, Toggle};
6use canvas::interaction::Request;
7use draw::edit::{Key, Modifiers, NamedKey, Platform};
8use std::{error::Error, sync::Arc};
9
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum Id {
12 Settings,
13 NewNotebook,
14 OpenNotebook,
15 CloseNotebook,
16 NewSection,
17 NewSectionGroup,
18 NewPage,
19 NewSubpage,
20 PageVersions,
21 CopyPageLink,
22 ShowNotebook,
23 Undo,
24 Redo,
25 Cut,
26 Copy,
27 Paste,
28 SelectAll,
29 Find,
30 Search,
31 Back,
32 Forward,
33 ZoomIn,
34 ZoomOut,
35 ActualSize,
36 Sidebar,
37 PageList,
38 DarkPages,
39 Table,
40 Picture,
41 Attachment,
42 Link,
43 Equation,
44 Date,
45 Time,
46 DateTime,
47 Toggle(Toggle),
48 Highlight,
49 FontColor,
50 Bullets,
51 Numbering,
52 Align(Alignment),
53 Indent,
54 Outdent,
55 ClearFormatting,
56 Tag(NoteTag),
57 RemoveTags,
58 Help,
59}
60
61/// What a menu or toolbar offers: a command, or one of a list's entries.
62#[derive(Clone, Debug, PartialEq)]
63pub enum Choice {
64 Command(Id),
65 Font(String),
66 Size(f32),
67 /// A COLORREF, or `None` for no highlight.
68 Highlight(Option<u32>),
69 /// A COLORREF, or `None` for automatic.
70 Color(Option<u32>),
71 List(Option<ListStyle>),
72}
73
74#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
75pub struct Status {
76 pub enabled: bool,
77 pub checked: bool,
78}
79
80/// A key with modifiers; `command` is the shortcut modifier, Control off macOS.
81#[derive(Clone, Copy, Debug, PartialEq)]
82pub struct Chord {
83 pub key: Press,
84 pub shift: bool,
85 pub option: bool,
86 pub control: bool,
87 pub command: bool,
88}
89
90#[derive(Clone, Copy, Debug, PartialEq)]
91pub enum Press {
92 /// Unshifted and lowercase, as on a US keyboard.
93 Char(char),
94 Named(NamedKey),
95}
96
97pub struct Command {
98 pub id: Id,
99 pub title: &'static str,
100 pub mac: &'static [Chord],
101 pub pc: &'static [Chord],
102}
103
104const fn key(key: char) -> Chord {
105 press(Press::Char(key))
106}
107
108pub(crate) const fn named(key: NamedKey) -> Chord {
109 press(Press::Named(key))
110}
111
112const fn press(key: Press) -> Chord {
113 Chord {
114 key,
115 shift: false,
116 option: false,
117 control: false,
118 command: false,
119 }
120}
121
122/// `key` with the shortcut modifier.
123pub(crate) const fn cmd(key: char) -> Chord {
124 self::key(key).command()
125}
126
127impl Chord {
128 pub(crate) const fn command(mut self) -> Self {
129 self.command = true;
130 self
131 }
132
133 const fn shift(mut self) -> Self {
134 self.shift = true;
135 self
136 }
137
138 pub(crate) const fn option(mut self) -> Self {
139 self.option = true;
140 self
141 }
142
143 pub(crate) const fn control(mut self) -> Self {
144 self.control = true;
145 self
146 }
147}
148
149macro_rules! row {
150 ($id:expr, $title:expr, $mac:expr, $pc:expr $(,)?) => {
151 Command {
152 id: $id,
153 title: $title,
154 mac: $mac,
155 pc: $pc,
156 }
157 };
158}
159
160macro_rules! tag {
161 ($tag:expr, $digit:literal) => {
162 row!(Id::Tag($tag), $tag.label(), &[cmd($digit)], &[cmd($digit)])
163 };
164}
165
166const NONE: &[Chord] = &[];
167
168/// OneNote 2010's chords off macOS; there, AppKit's and OneNote for Mac's.
169pub const COMMANDS: &[Command] = &[
170 row!(Id::Settings, "Settings…", &[cmd(',')], &[cmd(',')]),
171 row!(Id::NewNotebook, "New Notebook…", NONE, NONE),
172 row!(Id::OpenNotebook, "Open Notebook…", &[cmd('o')], &[cmd('o')]),
173 row!(Id::CloseNotebook, "Close This Notebook", NONE, NONE),
174 row!(Id::NewSection, "New Section", &[cmd('t')], &[cmd('t')]),
175 row!(Id::NewSectionGroup, "New Section Group", NONE, NONE),
176 row!(Id::NewPage, "New Page", &[cmd('n')], &[cmd('n')]),
177 row!(
178 Id::NewSubpage,
179 "New Subpage",
180 &[cmd('n').shift().option()],
181 &[cmd('n').shift().option()],
182 ),
183 row!(Id::PageVersions, "Page Versions", NONE, NONE),
184 row!(Id::CopyPageLink, "Copy Link to Page", NONE, NONE),
185 row!(
186 Id::ShowNotebook,
187 if cfg!(target_os = "macos") {
188 "Show in Finder"
189 } else {
190 "Open Notebook Folder"
191 },
192 NONE,
193 NONE,
194 ),
195 row!(Id::Undo, "Undo", &[cmd('z')], &[cmd('z')]),
196 row!(
197 Id::Redo,
198 "Redo",
199 &[cmd('z').shift()],
200 if cfg!(windows) {
201 &[cmd('y'), cmd('z').shift()]
202 } else {
203 &[cmd('z').shift()]
204 },
205 ),
206 row!(Id::Cut, "Cut", &[cmd('x')], &[cmd('x')]),
207 row!(Id::Copy, "Copy", &[cmd('c')], &[cmd('c')]),
208 row!(Id::Paste, "Paste", &[cmd('v')], &[cmd('v')]),
209 row!(Id::SelectAll, "Select All", &[cmd('a')], &[cmd('a')]),
210 row!(Id::Find, "Find on This Page", &[cmd('f')], &[cmd('f')]),
211 row!(Id::Search, "Search Notebooks", &[cmd('e')], &[cmd('e')]),
212 // Option-Command-arrows move the caret; Xcode goes back with Control-Command.
213 row!(
214 Id::Back,
215 "Back",
216 &[named(NamedKey::ArrowLeft).command().control()],
217 &[named(NamedKey::ArrowLeft).option()],
218 ),
219 row!(
220 Id::Forward,
221 "Forward",
222 &[named(NamedKey::ArrowRight).command().control()],
223 &[named(NamedKey::ArrowRight).option()],
224 ),
225 // OneNote 2010 leaves Control with = and - to subscript and strikethrough.
226 row!(
227 Id::ZoomIn,
228 "Zoom In",
229 &[cmd('='), cmd('=').shift()],
230 &[cmd('=').option(), cmd('=').option().shift()],
231 ),
232 row!(Id::ZoomOut, "Zoom Out", &[cmd('-')], &[cmd('-').option()]),
233 row!(
234 Id::ActualSize,
235 "Actual Size",
236 &[cmd('0')],
237 &[cmd('0').option()]
238 ),
239 row!(Id::Sidebar, "Sidebar", &[cmd('s').control()], NONE),
240 row!(Id::PageList, "Page List", NONE, NONE),
241 row!(Id::DarkPages, "Dark Pages", NONE, NONE),
242 row!(Id::Table, "Table", NONE, NONE),
243 row!(Id::Picture, "Picture…", NONE, NONE),
244 row!(Id::Attachment, "Attach File…", NONE, NONE),
245 row!(Id::Link, "Link…", &[cmd('k')], &[cmd('k')]),
246 row!(
247 Id::Equation,
248 "Equation",
249 &[key('=').control()],
250 &[key('=').option()],
251 ),
252 row!(Id::Date, "Date", NONE, &[key('d').option().shift()]),
253 row!(Id::Time, "Time", NONE, &[key('t').option().shift()]),
254 row!(
255 Id::DateTime,
256 "Date & Time",
257 NONE,
258 &[key('f').option().shift()]
259 ),
260 row!(Id::Toggle(Toggle::Bold), "Bold", &[cmd('b')], &[cmd('b')]),
261 row!(
262 Id::Toggle(Toggle::Italic),
263 "Italic",
264 &[cmd('i')],
265 &[cmd('i')]
266 ),
267 row!(
268 Id::Toggle(Toggle::Underline),
269 "Underline",
270 &[cmd('u')],
271 &[cmd('u')]
272 ),
273 row!(
274 Id::Toggle(Toggle::Strikethrough),
275 "Strikethrough",
276 &[cmd('x').shift()],
277 &[cmd('-')],
278 ),
279 row!(
280 Id::Toggle(Toggle::Subscript),
281 "Subscript",
282 &[cmd('=').control().option()],
283 &[cmd('=')],
284 ),
285 row!(
286 Id::Toggle(Toggle::Superscript),
287 "Superscript",
288 &[cmd('=').option().shift()],
289 &[cmd('=').shift()],
290 ),
291 // AppKit's Hide Others takes Option-Command-H.
292 row!(
293 Id::Highlight,
294 "Highlight",
295 &[cmd('h').control()],
296 &[cmd('h').option()]
297 ),
298 row!(Id::FontColor, "Font Color", NONE, NONE),
299 row!(Id::Bullets, "Bullets", &[cmd('.')], &[cmd('.')]),
300 row!(Id::Numbering, "Numbering", &[cmd('/')], &[cmd('/')]),
301 row!(
302 Id::Align(Alignment::Left),
303 "Align Left",
304 &[cmd('l')],
305 &[cmd('l')]
306 ),
307 row!(Id::Align(Alignment::Center), "Center", NONE, NONE),
308 row!(
309 Id::Align(Alignment::Right),
310 "Align Right",
311 &[cmd('r')],
312 &[cmd('r')]
313 ),
314 row!(
315 Id::Indent,
316 "Increase Indent",
317 &[cmd(']')],
318 &[named(NamedKey::ArrowRight).option().shift()],
319 ),
320 row!(
321 Id::Outdent,
322 "Decrease Indent",
323 &[cmd('[')],
324 &[named(NamedKey::ArrowLeft).option().shift()],
325 ),
326 row!(
327 Id::ClearFormatting,
328 "Clear Formatting",
329 &[cmd('n').shift()],
330 &[cmd('n').shift()]
331 ),
332 tag!(NoteTag::ToDo, '1'),
333 tag!(NoteTag::Important, '2'),
334 tag!(NoteTag::Question, '3'),
335 tag!(NoteTag::RememberForLater, '4'),
336 tag!(NoteTag::Definition, '5'),
337 tag!(NoteTag::Highlight, '6'),
338 tag!(NoteTag::Contact, '7'),
339 tag!(NoteTag::Address, '8'),
340 tag!(NoteTag::PhoneNumber, '9'),
341 row!(
342 Id::RemoveTags,
343 "Remove Tag",
344 &[cmd('0').control()],
345 &[cmd('0')]
346 ),
347 row!(Id::Help, "Snowbound Help", NONE, NONE),
348];
349
350/// Where Snowbound Help leads.
351const HELP: &str = "https://shale.paperclover.net/snowbound";
352
353pub fn command(id: Id) -> &'static Command {
354 COMMANDS
355 .iter()
356 .find(|command| command.id == id)
357 .expect("Every command has a row")
358}
359
360impl Command {
361 pub fn chords(&self, platform: Platform) -> &'static [Chord] {
362 match platform {
363 Platform::MacOs => self.mac,
364 Platform::Gtk | Platform::Windows => self.pc,
365 }
366 }
367}
368
369/// Every choice the menu bar offers, in the order its `index` counts them.
370pub fn choices() -> impl Iterator<Item = Choice> {
371 COMMANDS
372 .iter()
373 .map(|command| Choice::Command(command.id))
374 .chain(FONTS.map(|name| Choice::Font(name.to_owned())))
375 .chain(SIZES.into_iter().map(Choice::Size))
376 .chain(std::iter::once(Choice::Highlight(None)))
377 .chain(
378 HIGHLIGHTS
379 .iter()
380 .map(|(color, _)| Choice::Highlight(Some(*color))),
381 )
382}
383
384/// How a menu names `choice`.
385#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
386pub fn title(choice: Choice) -> String {
387 match choice {
388 Choice::Command(id) => command(id).title.to_owned(),
389 Choice::Font(name) => name,
390 Choice::Size(size) => format!("{size}"),
391 Choice::Highlight(None) => "No Color".to_owned(),
392 Choice::Highlight(Some(color)) => HIGHLIGHTS
393 .iter()
394 .find(|(listed, _)| *listed == color)
395 .map_or_else(String::new, |(_, name)| (*name).to_owned()),
396 Choice::Color(_) | Choice::List(_) => unreachable!("Only the toolbar offers these"),
397 }
398}
399
400#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
401pub fn index(choice: &Choice) -> usize {
402 choices()
403 .position(|listed| listed == *choice)
404 .expect("Menus offer listed choices")
405}
406
407/// The command `key` with `modifiers` runs here.
408pub fn find(key: &Key, modifiers: Modifiers) -> Option<Id> {
409 let pressed = pressed(key, modifiers, Platform::CURRENT)?;
410 COMMANDS
411 .iter()
412 .find(|command| command.chords(Platform::CURRENT).contains(&pressed))
413 .map(|command| command.id)
414}
415
416fn pressed(key: &Key, modifiers: Modifiers, platform: Platform) -> Option<Chord> {
417 let (key, shifted) = match key {
418 Key::Named(named) => (Press::Named(*named), false),
419 Key::Character(text) => {
420 let mut chars = text.chars();
421 let character = chars.next().filter(|_| chars.next().is_none())?;
422 match SHIFTED.iter().find(|(shifted, _)| *shifted == character) {
423 Some((_, base)) => (Press::Char(*base), true),
424 None => (Press::Char(character.to_ascii_lowercase()), false),
425 }
426 }
427 };
428 Some(Chord {
429 key,
430 shift: modifiers.shift || shifted,
431 option: modifiers.option,
432 // Off macOS Control is the shortcut modifier.
433 control: modifiers.control && platform == Platform::MacOs,
434 command: modifiers.command,
435 })
436}
437
438/// The US keyboard's shifted symbols and the keys they share.
439const SHIFTED: [(char, char); 21] = [
440 ('+', '='),
441 ('_', '-'),
442 ('{', '['),
443 ('}', ']'),
444 ('<', ','),
445 ('>', '.'),
446 ('?', '/'),
447 (':', ';'),
448 ('"', '\''),
449 ('|', '\\'),
450 ('~', '`'),
451 (')', '0'),
452 ('!', '1'),
453 ('@', '2'),
454 ('#', '3'),
455 ('$', '4'),
456 ('%', '5'),
457 ('^', '6'),
458 ('&', '7'),
459 ('*', '8'),
460 ('(', '9'),
461];
462
463impl Chord {
464 /// The key AppKit matches and shows: a shifted symbol stands for Shift with its key.
465 pub fn equivalent(self) -> (String, bool) {
466 match self.key {
467 Press::Char(base) if self.shift && !base.is_ascii_alphabetic() => {
468 let shifted = SHIFTED.iter().find(|(_, key)| *key == base);
469 shifted.map_or((base.to_string(), true), |(symbol, _)| {
470 (symbol.to_string(), false)
471 })
472 }
473 Press::Char(base) => (base.to_string(), self.shift),
474 Press::Named(NamedKey::Space) => (" ".to_owned(), self.shift),
475 Press::Named(NamedKey::ArrowLeft) => ('\u{f702}'.to_string(), self.shift),
476 Press::Named(NamedKey::ArrowRight) => ('\u{f703}'.to_string(), self.shift),
477 Press::Named(named) => unreachable!("No command takes {named:?}"),
478 }
479 }
480
481 /// As a menu shows it on `platform`: ⌃⌥⇧⌘K on macOS, Ctrl+Alt+Shift+K elsewhere.
482 pub fn label(self, platform: Platform) -> String {
483 if platform == Platform::MacOs {
484 let (key, shift) = self.equivalent();
485 let key = match self.key {
486 Press::Named(NamedKey::Space) => "Space".to_owned(),
487 Press::Named(NamedKey::ArrowLeft) => "←".to_owned(),
488 Press::Named(NamedKey::ArrowRight) => "→".to_owned(),
489 _ => key.to_uppercase(),
490 };
491 [
492 (self.control, "⌃"),
493 (self.option, "⌥"),
494 (shift, "⇧"),
495 (self.command, "⌘"),
496 ]
497 .iter()
498 .filter(|(held, _)| *held)
499 .map(|(_, symbol)| *symbol)
500 .chain([key.as_str()])
501 .collect()
502 } else {
503 let key = match self.key {
504 Press::Char(key) => key.to_uppercase().to_string(),
505 Press::Named(NamedKey::ArrowLeft) => "Left".to_owned(),
506 Press::Named(NamedKey::ArrowRight) => "Right".to_owned(),
507 Press::Named(named) => format!("{named:?}"),
508 };
509 [
510 (self.command, "Ctrl+"),
511 (self.option, "Alt+"),
512 (self.shift, "Shift+"),
513 ]
514 .iter()
515 .filter(|(held, _)| *held)
516 .map(|(_, modifier)| *modifier)
517 .chain([key.as_str()])
518 .collect()
519 }
520 }
521}
522
523/// The chord a menu or hint shows for `id` here, empty where it has none.
524pub fn shortcut(id: Id) -> String {
525 command(id)
526 .chords(Platform::CURRENT)
527 .first()
528 .map_or_else(String::new, |chord| chord.label(Platform::CURRENT))
529}
530
531impl State {
532 pub(crate) fn format_state(&self) -> FormatState {
533 self.view.editor.format_state().unwrap_or_default()
534 }
535
536 /// Queues `choice` to run after this frame's input, as a key or a click on it does.
537 pub(crate) fn choose(&mut self, choice: Choice) {
538 self.commands.push(Work::Choose(choice));
539 self.window.request_redraw();
540 }
541
542 /// Every choice's status, in `choices` order, for the menu bar.
543 pub(crate) fn statuses(&self) -> Vec<Status> {
544 let format = self.format_state();
545 choices()
546 .map(|choice| self.status(&choice, &format))
547 .collect()
548 }
549
550 pub(crate) fn status(&self, choice: &Choice, format: &FormatState) -> Status {
551 let session = self.session.as_ref();
552 let welcome = session.is_none() && !self.temporary && self.sectionless.is_none();
553 let modal = self.options.is_some() || self.link.is_some();
554 let page = (session.is_some() || self.temporary) && !modal;
555 let writable = page && !session.is_some_and(|session| session.read_only());
556 let text = writable && self.view.accepts_text();
557 // Edit commands act on a focused field instead of the page.
558 let field = self
559 .ui
560 .focused()
561 .is_some_and(|focus| focus != crate::page());
562 let [anchor, focus] = self.view.editor.selection().positions;
563 let selected = page && !field && self.view.accepts_text() && anchor != focus;
564 let enabled = |enabled| Status {
565 enabled,
566 checked: false,
567 };
568 let checked = |checked| Status {
569 enabled: text,
570 checked,
571 };
572 let id = match choice {
573 Choice::Command(id) => *id,
574 Choice::Font(name) => return checked(format.font.as_ref() == Some(name)),
575 Choice::Size(size) => return checked(format.font_size == Some(*size)),
576 Choice::Highlight(_) | Choice::Color(_) | Choice::List(_) => return enabled(text),
577 };
578 match id {
579 Id::Settings | Id::NewNotebook | Id::OpenNotebook | Id::Help => enabled(!modal),
580 Id::CloseNotebook | Id::ShowNotebook => enabled(!modal && self.notebook().is_some()),
581 Id::NewSection | Id::NewSectionGroup => enabled(
582 !modal
583 && self
584 .notebook()
585 .is_some_and(|library| library.catalog().is_some()),
586 ),
587 Id::NewPage | Id::NewSubpage | Id::CopyPageLink | Id::Find | Id::Search => {
588 enabled(!modal && session.is_some())
589 }
590 Id::PageVersions => {
591 session
592 .filter(|_| !modal)
593 .map_or_else(Status::default, |session| Status {
594 enabled: !session.page_versions(session.space).is_empty(),
595 checked: session.shown_history == Some(session.space),
596 })
597 }
598 Id::Undo => enabled(writable && !field && self.view.editor.can_undo()),
599 Id::Redo => enabled(writable && !field && self.view.editor.can_redo()),
600 Id::Cut => enabled(writable && selected),
601 Id::Copy => enabled(selected),
602 Id::Paste => enabled(text && !field),
603 Id::SelectAll => enabled(field || page),
604 Id::Back => enabled(!modal && self.trail.open()[0]),
605 Id::Forward => enabled(!modal && self.trail.open()[1]),
606 Id::ZoomIn | Id::ZoomOut | Id::ActualSize => enabled(page),
607 Id::Sidebar => Status {
608 enabled: !welcome && !modal,
609 checked: self.sidebar,
610 },
611 Id::PageList => Status {
612 enabled: !modal && session.is_some(),
613 checked: self.pages_open,
614 },
615 Id::DarkPages => Status {
616 enabled: !modal,
617 checked: !self.light_pages,
618 },
619 Id::Table | Id::Picture | Id::Attachment => enabled(false),
620 Id::Link
621 | Id::Equation
622 | Id::Date
623 | Id::Time
624 | Id::DateTime
625 | Id::Highlight
626 | Id::FontColor
627 | Id::Indent
628 | Id::Outdent
629 | Id::ClearFormatting
630 | Id::RemoveTags => enabled(text),
631 Id::Toggle(toggle) => checked(format.toggles.contains(&toggle)),
632 Id::Bullets => checked(format.bullets),
633 Id::Numbering => checked(format.numbering),
634 Id::Align(alignment) => checked(format.alignment == Some(alignment)),
635 Id::Tag(tag) => checked(format.tags.contains(&tag)),
636 }
637 }
638
639 /// Applies `formatting`, remembering its font, colour or list style as the toolbar's
640 /// last pick.
641 fn format(&mut self, formatting: Formatting) -> Result<(), Box<dyn Error>> {
642 let kept = self.toolbar.clone();
643 let pens = &mut self.toolbar;
644 match &formatting {
645 Formatting::Font(name) => crate::remember(&mut self.recent_fonts, name.clone()),
646 Formatting::Highlight(color) => pens.highlight = *color,
647 Formatting::Color(color) => pens.font_color = *color,
648 Formatting::List(Some(ListStyle::Bullet(place))) => {
649 crate::remember(&mut pens.bullets, *place);
650 }
651 Formatting::List(Some(ListStyle::Number(place))) => {
652 crate::remember(&mut pens.numbering, *place);
653 }
654 _ => {}
655 }
656 if matches!(formatting, Formatting::Font(_)) || self.toolbar != kept {
657 self.save_settings();
658 }
659 let response = self.view.format(formatting)?;
660 self.respond(response);
661 Ok(())
662 }
663
664 /// The notebook the open section, or the notebook showing none, belongs to.
665 fn notebook(&self) -> Option<&Arc<crate::Library>> {
666 self.session
667 .as_ref()
668 .map(|session| &session.library)
669 .or(self.sectionless.as_ref())
670 }
671
672 /// Runs `choice` where it applies now.
673 pub(crate) fn run(&mut self, choice: Choice) -> Result<(), Box<dyn Error>> {
674 if !self.status(&choice, &self.format_state()).enabled {
675 return Ok(());
676 }
677 let format = Self::format;
678 let id = match choice {
679 Choice::Command(id) => id,
680 Choice::Font(name) => return format(self, Formatting::Font(name)),
681 Choice::Size(size) => return format(self, Formatting::FontSize(size)),
682 Choice::Highlight(color) => return format(self, Formatting::Highlight(color)),
683 Choice::Color(color) => return format(self, Formatting::Color(color)),
684 Choice::List(style) => return format(self, Formatting::List(style)),
685 };
686 let field = self.ui.focused().filter(|focus| *focus != page());
687 let response = match id {
688 Id::Settings => {
689 self.open_options();
690 return Ok(());
691 }
692 Id::NewNotebook => Work::NewNotebook,
693 Id::OpenNotebook => Work::OpenNotebook,
694 Id::CloseNotebook => {
695 Work::CloseNotebook(Arc::clone(self.notebook().ok_or("No notebook is open")?))
696 }
697 Id::NewSection | Id::NewSectionGroup => {
698 let library = Arc::clone(self.notebook().ok_or("No notebook is open")?);
699 let folder = self.session.as_ref().map_or_else(String::new, |session| {
700 crate::menus::folder(&session.tabs[session.tab].path)
701 });
702 Work::Structure(
703 library,
704 if id == Id::NewSection {
705 crate::manage::Structure::NewSection { folder }
706 } else {
707 crate::manage::Structure::NewGroup { folder }
708 },
709 )
710 }
711 Id::NewPage => Work::NewPage { under: None },
712 Id::NewSubpage => Work::NewPage {
713 under: self.session.as_ref().map(|session| session.space),
714 },
715 Id::PageVersions => {
716 let session = self.session.as_ref().ok_or("No notebook is open")?;
717 Work::History {
718 page: session.space,
719 show: session.shown_history != Some(session.space),
720 }
721 }
722 Id::CopyPageLink => {
723 let space = self.session.as_ref().ok_or("No notebook is open")?.space;
724 let link = self.page_link(space, None)?;
725 self.clipboard.set_text(link)?;
726 return Ok(());
727 }
728 Id::ShowNotebook => {
729 platform::reveal(&self.notebook().ok_or("No notebook is open")?.location);
730 return Ok(());
731 }
732 Id::Undo | Id::Redo => {
733 let response = self.view.undo(id == Id::Redo)?;
734 self.respond(response);
735 return Ok(());
736 }
737 Id::Cut | Id::Copy => {
738 let response = self.view.copy(id == Id::Cut)?;
739 self.respond(response);
740 return Ok(());
741 }
742 Id::Paste => Work::Page(Request::Paste),
743 Id::SelectAll => {
744 match field {
745 Some(field) => self.ui.focus_all(field),
746 None => {
747 let response = self.view.widen_selection()?;
748 self.respond(response);
749 }
750 }
751 return Ok(());
752 }
753 Id::Find => {
754 return self.start_find(field == Some(search::field()));
755 }
756 Id::Search => {
757 self.start_search();
758 return Ok(());
759 }
760 Id::Back | Id::Forward => {
761 self.travel(id == Id::Forward);
762 return Ok(());
763 }
764 Id::ZoomIn | Id::ZoomOut | Id::ActualSize => {
765 let zoom = match id {
766 Id::ZoomIn => self.view.zoom() * 1.1,
767 Id::ZoomOut => self.view.zoom() / 1.1,
768 _ => 1.0,
769 };
770 let response = self.view.set_zoom(zoom)?;
771 self.respond(response);
772 return Ok(());
773 }
774 Id::Sidebar => {
775 self.sidebar = !self.sidebar;
776 return Ok(());
777 }
778 Id::PageList => {
779 self.pages_open = !self.pages_open;
780 return Ok(());
781 }
782 Id::DarkPages => {
783 self.light_pages = !self.light_pages;
784 self.follow_color_scheme();
785 self.save_settings();
786 return Ok(());
787 }
788 Id::Table | Id::Picture | Id::Attachment => return Ok(()),
789 Id::Link => {
790 self.open_link_dialog();
791 return Ok(());
792 }
793 Id::Equation => {
794 let response = self.view.insert_equation()?;
795 self.respond(response);
796 return Ok(());
797 }
798 // OneNote inserts the system's short date or time and a space.
799 Id::Date | Id::Time | Id::DateTime => {
800 let now = crate::filetime();
801 let date = platform::short_date(now);
802 let [_, time] = platform::date_text(now);
803 let text = match id {
804 Id::Date => date,
805 Id::Time => time,
806 _ => format!("{date} {time}"),
807 };
808 let response = self.view.insert_text(format!("{text} "))?;
809 self.respond(response);
810 return Ok(());
811 }
812 Id::Help => {
813 platform::reveal(HELP);
814 return Ok(());
815 }
816 Id::Toggle(toggle) => return format(self, Formatting::Toggle(toggle)),
817 // The highlighter and font colour apply their last pick.
818 Id::Highlight => return format(self, Formatting::Highlight(self.toolbar.highlight)),
819 Id::FontColor => return format(self, Formatting::Color(self.toolbar.font_color)),
820 Id::Bullets => return format(self, Formatting::Bullets),
821 Id::Numbering => return format(self, Formatting::Numbering),
822 Id::Align(alignment) => return format(self, Formatting::Align(alignment)),
823 Id::Indent => return format(self, Formatting::Indent),
824 Id::Outdent => return format(self, Formatting::Outdent),
825 Id::ClearFormatting => return format(self, Formatting::Clear),
826 Id::Tag(tag) => return format(self, Formatting::Tag(tag)),
827 Id::RemoveTags => return format(self, Formatting::RemoveTags),
828 };
829 self.commands.push(response);
830 Ok(())
831 }
832}
833
834#[cfg(test)]
835mod tests {
836 use super::*;
837
838 const PLATFORMS: [Platform; 3] = [Platform::MacOs, Platform::Gtk, Platform::Windows];
839
840 #[test]
841 fn each_chord_runs_one_command_on_each_platform() {
842 for platform in PLATFORMS {
843 let chords: Vec<_> = COMMANDS
844 .iter()
845 .flat_map(|command| {
846 command
847 .chords(platform)
848 .iter()
849 .map(move |chord| (chord, command.title))
850 })
851 .collect();
852 for (at, (chord, title)) in chords.iter().enumerate() {
853 if let Some((_, other)) =
854 chords[at + 1..].iter().find(|(listed, _)| listed == chord)
855 {
856 panic!(
857 "{platform:?}: {} runs both {title} and {other}",
858 chord.label(platform)
859 );
860 }
861 }
862 }
863 }
864
865 #[test]
866 fn each_command_and_choice_is_listed_once() {
867 for (at, command) in COMMANDS.iter().enumerate() {
868 assert!(
869 COMMANDS[at + 1..]
870 .iter()
871 .all(|other| other.id != command.id),
872 "{:?}",
873 command.id
874 );
875 }
876 let choices: Vec<_> = choices().collect();
877 for (at, choice) in choices.iter().enumerate() {
878 assert_eq!(index(choice), at, "{choice:?}");
879 }
880 }
881
882 #[test]
883 fn keys_find_their_command_as_each_platform_types_them() {
884 let find = |platform, key: &str, shift, option, control, command| {
885 let modifiers = Modifiers {
886 shift,
887 option,
888 control,
889 command,
890 };
891 let pressed = pressed(&Key::Character(key.into()), modifiers, platform)?;
892 COMMANDS
893 .iter()
894 .find(|listed| listed.chords(platform).contains(&pressed))
895 .map(|listed| listed.id)
896 };
897 let mac = Platform::MacOs;
898 assert_eq!(
899 find(mac, "b", false, false, false, true),
900 Some(Id::Toggle(Toggle::Bold))
901 );
902 assert_eq!(
903 find(mac, "N", true, false, false, true),
904 Some(Id::ClearFormatting)
905 );
906 assert_eq!(find(mac, "+", true, false, false, true), Some(Id::ZoomIn));
907 assert_eq!(find(mac, "=", false, false, false, true), Some(Id::ZoomIn));
908 assert_eq!(
909 find(mac, "+", true, true, false, true),
910 Some(Id::Toggle(Toggle::Superscript))
911 );
912 assert_eq!(
913 find(mac, "=", false, false, true, false),
914 Some(Id::Equation)
915 );
916 assert_eq!(
917 find(mac, "0", false, false, true, true),
918 Some(Id::RemoveTags)
919 );
920 assert_eq!(
921 find(mac, "7", false, false, false, true),
922 Some(Id::Tag(NoteTag::Contact))
923 );
924 assert_eq!(find(mac, "q", false, false, false, true), None);
925 // The highlighter's chord runs the command that applies its last pick.
926 assert_eq!(
927 find(mac, "h", false, false, true, true),
928 Some(Id::Highlight)
929 );
930 // Control comes with the shortcut modifier off macOS.
931 let gtk = Platform::Gtk;
932 assert_eq!(
933 find(gtk, "b", false, false, true, true),
934 Some(Id::Toggle(Toggle::Bold))
935 );
936 assert_eq!(
937 find(gtk, "+", true, false, true, true),
938 Some(Id::Toggle(Toggle::Superscript))
939 );
940 assert_eq!(
941 find(gtk, "=", false, false, true, true),
942 Some(Id::Toggle(Toggle::Subscript))
943 );
944 assert_eq!(find(gtk, "+", true, true, true, true), Some(Id::ZoomIn));
945 assert_eq!(find(gtk, "D", true, true, false, false), Some(Id::Date));
946 assert_eq!(find(gtk, "h", false, true, true, true), Some(Id::Highlight));
947 assert_eq!(
948 find(gtk, "y", false, false, true, true),
949 cfg!(windows).then_some(Id::Redo)
950 );
951 let indent = pressed(
952 &Key::Named(NamedKey::ArrowRight),
953 Modifiers {
954 shift: true,
955 option: true,
956 ..Modifiers::default()
957 },
958 gtk,
959 );
960 assert!(
961 command(Id::Indent)
962 .pc
963 .iter()
964 .any(|chord| Some(*chord) == indent)
965 );
966 }
967
968 #[test]
969 fn chords_read_as_each_platform_labels_them() {
970 let label = |id, platform| command(id).chords(platform)[0].label(platform);
971 assert_eq!(
972 label(Id::Toggle(Toggle::Superscript), Platform::MacOs),
973 "⌥⌘+"
974 );
975 assert_eq!(
976 label(Id::Toggle(Toggle::Subscript), Platform::MacOs),
977 "⌃⌥⌘="
978 );
979 assert_eq!(label(Id::ClearFormatting, Platform::MacOs), "⇧⌘N");
980 assert_eq!(
981 label(Id::Toggle(Toggle::Superscript), Platform::Gtk),
982 "Ctrl+Shift+="
983 );
984 assert_eq!(label(Id::Indent, Platform::Gtk), "Alt+Shift+Right");
985 assert_eq!(label(Id::Date, Platform::Windows), "Alt+Shift+D");
986 }
987
988 /// Each toolbar button chooses a command or a list's entry, so it runs, enables and
989 /// checks as the menu bar and keyboard do.
990 #[test]
991 fn toolbar_buttons_run_commands() {
992 let source = include_str!("main.rs");
993 let start = source
994 .find(" fn toolbar(")
995 .expect("The toolbar is built in main.rs");
996 let body = &source[start..];
997 let body = &body[..body[1..]
998 .find("\n fn ")
999 .map_or(body.len(), |end| end + 1)];
1000 assert!(body.contains("self.choose("));
1001 for effect in [
1002 "self.respond(",
1003 "self.changed",
1004 "self.view.format(",
1005 "self.view.set_zoom(",
1006 "self.view.insert",
1007 "self.open_link_dialog(",
1008 ] {
1009 assert!(
1010 !body.contains(effect),
1011 "A toolbar button acts through `{effect}`; have it choose a command instead"
1012 );
1013 }
1014 }
1015}
crates/snowbound/src/conflict_render.rs+201-38
......@@ -1,6 +1,7 @@
1//! 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.
45
56use super::*;
67
......@@ -58,7 +59,16 @@ fn layout(ui: &mut Ui, session: &Session, menu: bool) -> Vec<Command> {
5859 ..Spec::default()
5960 },
6061 );
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 );
6272 commands.extend(rows.clicked.map(Command::OpenPage));
6373 ui.close();
6474 ui.close();
......@@ -67,7 +77,13 @@ fn layout(ui: &mut Ui, session: &Session, menu: bool) -> Vec<Command> {
6777}
6878
6979/// The view's pixels, RGBA: the interface with `page` drawn in its box.
70fn paint(ui: &Ui, page: Page, renderer: &mut draw::Renderer, device: &wgpu::Device, queue: &wgpu::Queue) -> Vec<u8> {
80fn paint(
81 ui: &Ui,
82 page: Page,
83 renderer: &mut draw::Renderer,
84 device: &wgpu::Device,
85 queue: &wgpu::Queue,
86) -> Vec<u8> {
7187 let size = SIZE.map(|side| (side * SCALE) as u32);
7288 let corner = ui.rect(super::page()).unwrap();
7389 let box_size = [
......@@ -108,13 +124,7 @@ fn paint(ui: &Ui, page: Page, renderer: &mut draw::Renderer, device: &wgpu::Devi
108124 let layers: Vec<_> = interface
109125 .iter()
110126 .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),
118128 ui::Layer::Custom { rect, .. } => draw::Layer {
119129 scale: viewport.scale,
120130 origin: [
......@@ -123,6 +133,7 @@ fn paint(ui: &Ui, page: Page, renderer: &mut draw::Renderer, device: &wgpu::Devi
123133 ],
124134 clip: Some(rect.map(|value| value * SCALE)),
125135 backdrop: Some(ui.theme.paper),
136 motion: None,
126137 primitives: &primitives,
127138 },
128139 })
......@@ -142,7 +153,12 @@ fn paint(ui: &Ui, page: Page, renderer: &mut draw::Renderer, device: &wgpu::Devi
142153 view_formats: &[],
143154 });
144155 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 )
146162 .unwrap();
147163 let row = (size[0] * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
148164 let buffer = device.create_buffer(&wgpu::BufferDescriptor {
......@@ -180,6 +196,33 @@ fn paint(ui: &Ui, page: Page, renderer: &mut draw::Renderer, device: &wgpu::Devi
180196 .collect()
181197}
182198
199/// A renderer drawing into `output`'s PNGs.
200fn gpu(output: &Path) -> (draw::Renderer, wgpu::Device, wgpu::Queue) {
201 std::fs::create_dir_all(output).unwrap();
202 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
203 let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
204 let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
205 let renderer = draw::Renderer::new(
206 device.clone(),
207 queue.clone(),
208 wgpu::TextureFormat::Rgba8UnormSrgb,
209 );
210 (renderer, device, queue)
211}
212
213/// Writes RGBA `pixels` of the view as a PNG.
214fn save(path: &Path, pixels: &[u8]) {
215 let size = SIZE.map(|side| (side * SCALE) as u32);
216 let mut encoder = png::Encoder::new(std::fs::File::create(path).unwrap(), size[0], size[1]);
217 encoder.set_color(png::ColorType::Rgba);
218 encoder.set_depth(png::BitDepth::Eight);
219 encoder
220 .write_header()
221 .unwrap()
222 .write_image_data(pixels)
223 .unwrap();
224}
225
183226/// The page shown, a page's versions listed while shown, and the bar's commands: showing
184227/// opens the newest version, hiding returns to the page.
185228#[test]
......@@ -189,7 +232,11 @@ fn conflict_views_offer_the_versions_and_render_offscreen() {
189232 let _ = std::fs::remove_dir_all(&directory);
190233 std::fs::create_dir_all(directory.join("cache")).unwrap();
191234 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();
193240 let library = Arc::new(crate::Library::section(&file, &directory.join("cache")));
194241 let section = library.open(&library.location, || {}).unwrap();
195242 let path = library.location.clone();
......@@ -197,17 +244,7 @@ fn conflict_views_offer_the_versions_and_render_offscreen() {
197244 let page = session.space;
198245 let version = session.versions(page)[0].space;
199246 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);
211248 for (name, space, shown, menu, appearance) in [
212249 ("page", page, None, false, Theme::light()),
213250 ("versions", page, Some(page), false, Theme::light()),
......@@ -215,7 +252,13 @@ fn conflict_views_offer_the_versions_and_render_offscreen() {
215252 ("version-menu", version, Some(page), true, Theme::light()),
216253 ("page-dark", page, None, false, Theme::dark()),
217254 ("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 ),
219262 ] {
220263 session.space = space;
221264 session.shown = shown;
......@@ -229,7 +272,10 @@ fn conflict_views_offer_the_versions_and_render_offscreen() {
229272 match (name, bar) {
230273 ("page" | "page-dark", Some(Bar::Page { shown: false, .. }))
231274 | ("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 ) => {}
233279 other => panic!("{other:?}", other = other.0),
234280 }
235281 let row = Id::ROOT.child("frame").child("panel").child(version);
......@@ -238,13 +284,7 @@ fn conflict_views_offer_the_versions_and_render_offscreen() {
238284 if let (Some(output), Some((renderer, device, queue))) = (&output, &mut gpu) {
239285 let shown = session.reader(space)().unwrap();
240286 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);
248288 }
249289 }
250290 // 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() {
252292 let marked = session.versions(page)[0].objects.clone();
253293 assert!(!marked.is_empty());
254294 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 }
259303 _ => false,
260304 });
261305 assert!(highlighted);
262306 session.section.close().unwrap();
263307 std::fs::remove_dir_all(&directory).unwrap();
264308}
309
310/// A page's versions listed under it while shown, each read-only under OneNote's bar with
311/// what it changed banded, from OneNote's own (`corpus/page-versions/native/step-04`).
312#[test]
313fn page_versions_list_open_read_only_and_render_offscreen() {
314 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
315 let directory = std::env::temp_dir().join(format!("snowbound-history-{}", std::process::id()));
316 let _ = std::fs::remove_dir_all(&directory);
317 std::fs::create_dir_all(directory.join("cache")).unwrap();
318 let file = directory.join("History.one");
319 std::fs::copy(
320 root.join("corpus/page-versions/native/step-04/notebook/History.one"),
321 &file,
322 )
323 .unwrap();
324 let library = Arc::new(crate::Library::section(&file, &directory.join("cache")));
325 let section = library.open(&library.location, || {}).unwrap();
326 let path = library.location.clone();
327 let (mut session, _) = read_session(section, library, path, None).unwrap();
328 let page = session.space;
329 let versions: Vec<_> = session
330 .page_versions(page)
331 .iter()
332 .map(|version| version.context)
333 .collect();
334 assert_eq!(versions.len(), 2);
335 assert_eq!(
336 crate::history::label(&session.page_versions(page)[1])
337 .rsplit_once(' ')
338 .map(|(_, who)| who),
339 Some("virtual")
340 );
341 let output = std::env::var_os("SNOWBOUND_CONFLICT_RENDER").map(PathBuf::from);
342 let mut gpu = output.as_deref().map(gpu);
343 for (name, shown, version, menu, appearance) in [
344 ("history-page", None, None, false, Theme::light()),
345 ("history-shown", Some(page), None, false, Theme::light()),
346 (
347 "history-version",
348 Some(page),
349 Some(versions[0]),
350 false,
351 Theme::light(),
352 ),
353 (
354 "history-menu",
355 Some(page),
356 Some(versions[0]),
357 true,
358 Theme::light(),
359 ),
360 (
361 "history-version-dark",
362 Some(page),
363 Some(versions[0]),
364 false,
365 Theme::dark(),
366 ),
367 (
368 "history-menu-dark",
369 Some(page),
370 Some(versions[0]),
371 true,
372 Theme::dark(),
373 ),
374 ] {
375 session.shown_history = shown;
376 session.version = version;
377 assert_eq!(session.read_only(), version.is_some(), "{name}");
378 match (session.bar(), version) {
379 (
380 Some(Bar::History {
381 version: open,
382 grouped: false,
383 ..
384 }),
385 Some(version),
386 ) => {
387 assert_eq!(open, version)
388 }
389 (None, None) => {}
390 _ => panic!("{name}"),
391 }
392 let mut ui = Ui::new(appearance, std::time::Duration::from_millis(500));
393 for _ in 0..8 {
394 layout(&mut ui, &session, menu);
395 std::thread::sleep(std::time::Duration::from_millis(30));
396 }
397 for context in &versions {
398 let row = Id::ROOT.child("frame").child("panel").child(context);
399 assert_eq!(ui.rect(row).is_some(), shown.is_some(), "{name}");
400 }
401 if let (Some(output), Some((renderer, device, queue))) = (&output, &mut gpu) {
402 let shown = match version {
403 Some(version) => session.version_reader(page, version)().unwrap(),
404 None => session.reader(page)().unwrap(),
405 };
406 save(
407 &output.join(format!("{name}.png")),
408 &paint(&ui, shown, renderer, device, queue),
409 );
410 }
411 }
412 // The newest version bands the line its author added since the version before it.
413 let newest = session.version_reader(page, versions[0])().unwrap();
414 let banded: Vec<_> = newest
415 .objects
416 .iter()
417 .flat_map(|object| match object {
418 onestore::page::PageObject::Outline(outline) => outline.paragraphs.clone(),
419 _ => Vec::new(),
420 })
421 .filter(|paragraph| paragraph.format.highlight == Some(canvas::conflict::CHANGED))
422 .filter_map(|paragraph| paragraph.text().map(|text| text.text.text().to_owned()))
423 .collect();
424 assert_eq!(banded, ["Second author line."]);
425 session.section.close().unwrap();
426 std::fs::remove_dir_all(&directory).unwrap();
427}
crates/snowbound/src/history.rs created+302
......@@ -0,0 +1,302 @@
1//! Page versions as OneNote 2010 shows them (Share, Page Versions): a page's earlier states
2//! listed under it by date and author, each read-only under a yellow bar whose menu restores,
3//! deletes or copies it. `corpus/page-versions` records OneNote's own.
4
5use super::*;
6use onestore::PageVersion;
7
8/// Which versions a Delete All Versions deletes.
9#[derive(Clone, Copy, PartialEq)]
10pub enum Scope {
11 Section,
12 /// The section group holding the open section, with the groups inside it.
13 Group,
14 Notebook,
15}
16
17impl Session {
18 /// A page's versions, newest first.
19 pub(crate) fn page_versions(&self, page: ExGuid) -> &[PageVersion] {
20 self.history
21 .iter()
22 .find(|(listed, _)| *listed == page)
23 .map_or(&[], |(_, versions)| versions)
24 }
25
26 /// Whether the open page takes no edits: a conflict page or a version.
27 pub(crate) fn read_only(&self) -> bool {
28 self.version.is_some() || self.conflict(self.space).is_some()
29 }
30
31 /// Version `version` of page `space` as the editor shows it: read-only, what it changed
32 /// since the version before it banded as OneNote bands it.
33 pub(crate) fn version_reader(
34 &self,
35 space: ExGuid,
36 version: ExGuid,
37 ) -> impl FnOnce() -> Result<Page, Box<dyn Error>> + Send + 'static {
38 let replica = Arc::clone(self.section.replica());
39 let versions = self.page_versions(space);
40 let older = versions
41 .iter()
42 .position(|listed| listed.context == version)
43 .and_then(|at| versions.get(at + 1))
44 .map(|older| older.context);
45 move || {
46 let page = replica.version(space, version)?;
47 let older = older
48 .map(|older| replica.version(space, older))
49 .transpose()?;
50 Ok(canvas::conflict::changes(page, older.as_ref()))
51 }
52 }
53
54 pub(crate) fn refresh_history(&mut self) -> Result<(), Box<dyn Error>> {
55 self.history = self.section.versions()?;
56 if self
57 .shown_history
58 .is_some_and(|page| self.page_versions(page).is_empty())
59 {
60 self.shown_history = None;
61 }
62 Ok(())
63 }
64
65 /// Whether the open section lies in a section group; a section opened on its own has a
66 /// file path, not a catalog one.
67 pub(crate) fn grouped(&self) -> bool {
68 self.library.catalog().is_some() && self.tabs[self.tab].path.contains('/')
69 }
70}
71
72/// A version's row in the page list: its date and who last changed it.
73pub fn label(version: &PageVersion) -> String {
74 match (version.modified, &version.author) {
75 (Some(modified), Some(author)) => format!("{} {author}", platform::short_date(modified)),
76 (Some(modified), None) => platform::short_date(modified),
77 (None, author) => author.clone().unwrap_or_default(),
78 }
79}
80
81/// The bar's menu over an open version, in OneNote 2010's words, and what its choice asks.
82/// Copy Page To opens `copy`, listing the folder's sections.
83pub fn menu(
84 ui: &mut Ui,
85 anchor: ui::Anchor,
86 [page, version]: [ExGuid; 2],
87 grouped: bool,
88 sections: &[&str],
89) -> Option<Command> {
90 let menu = Id::ROOT.child("conflict-menu");
91 let copy = Id::ROOT.child("conflict-copy");
92 let item = |text| ui::popup::Item {
93 text,
94 ..Default::default()
95 };
96 let items = [
97 item("Restore Version"),
98 item("Delete Version"),
99 ui::popup::Item {
100 disabled: sections.is_empty(),
101 ..item("Copy Page To…")
102 },
103 ui::popup::Item {
104 separated: true,
105 ..item("Delete All Versions in Section")
106 },
107 ui::popup::Item {
108 disabled: !grouped,
109 ..item("Delete All Versions in Section Group")
110 },
111 item("Delete All Versions in Notebook"),
112 ui::popup::Item {
113 separated: true,
114 ..item("Hide Page Versions")
115 },
116 ];
117 let chosen = match ui::popup::menu(ui, menu, anchor, &items, None) {
118 Some(0) => Some(Command::RestoreVersion { page, version }),
119 Some(1) => Some(Command::DeletePageVersion { page, version }),
120 Some(2) => {
121 ui.open_popup(copy);
122 None
123 }
124 Some(3) => Some(Command::DeleteAllVersions(Scope::Section)),
125 Some(4) => Some(Command::DeleteAllVersions(Scope::Group)),
126 Some(5) => Some(Command::DeleteAllVersions(Scope::Notebook)),
127 Some(_) => Some(Command::History { page, show: false }),
128 None => None,
129 };
130 let targets: Vec<_> = sections.iter().map(|name| item(name)).collect();
131 let tab = ui::popup::menu(ui, copy, anchor, &targets, None);
132 chosen.or(tab.map(|tab| Command::CopyVersion { version, tab }))
133}
134
135impl State {
136 /// Shows or hides a page's versions under it; hiding leaves an open version for its page.
137 pub(crate) fn show_history(&mut self, page: ExGuid, show: bool) -> Result<(), Box<dyn Error>> {
138 let session = self.session.as_mut().ok_or("No section is open")?;
139 session.shown_history = show.then_some(page);
140 if !show && session.version.is_some() {
141 self.commands.push(Command::OpenPage(page));
142 }
143 Ok(())
144 }
145
146 pub(crate) fn open_version(
147 &mut self,
148 page: ExGuid,
149 version: ExGuid,
150 ) -> Result<(), Box<dyn Error>> {
151 let session = self.session.as_ref().ok_or("No section is open")?;
152 let read = session.version_reader(page, version);
153 self.load(move || Ok(Loaded::Version(page, version, read()?)));
154 Ok(())
155 }
156
157 /// Restore Version: the version becomes the page, and the page as it stood the newest
158 /// version.
159 pub(crate) fn restore_version(
160 &mut self,
161 page: ExGuid,
162 version: ExGuid,
163 ) -> Result<(), Box<dyn Error>> {
164 let session = self.session.as_mut().ok_or("No section is open")?;
165 session
166 .section
167 .restore_version(page, version, &self.author)?;
168 session.status = "Saving";
169 session.refresh_history()?;
170 self.commands.push(Command::OpenPage(page));
171 Ok(())
172 }
173
174 pub(crate) fn delete_version(
175 &mut self,
176 page: ExGuid,
177 version: ExGuid,
178 ) -> Result<(), Box<dyn Error>> {
179 let session = self.session.as_mut().ok_or("No section is open")?;
180 session.section.delete_versions(&[(page, vec![version])])?;
181 session.status = "Saving";
182 session.refresh_history()?;
183 self.commands.push(Command::OpenPage(page));
184 Ok(())
185 }
186
187 /// Delete All Versions in the section, its group or the notebook, once confirmed.
188 pub(crate) fn delete_all_versions(&mut self, scope: Scope) -> Result<(), Box<dyn Error>> {
189 let session = self.session.as_mut().ok_or("No section is open")?;
190 let tab = &session.tabs[session.tab];
191 let (open, section) = (tab.path.clone(), tab.name.clone());
192 let folder = open
193 .rsplit_once('/')
194 .map_or("", |(folder, _)| folder)
195 .to_owned();
196 let (container, name) = match scope {
197 Scope::Section => ("section", section),
198 Scope::Group => (
199 "section group",
200 folder.rsplit('/').next().unwrap_or(&folder).to_owned(),
201 ),
202 Scope::Notebook => ("notebook", session.library.name.clone()),
203 };
204 if !platform::confirm(
205 &format!("Do you want to delete all page versions in the {container} \"{name}\"?"),
206 "You can't restore these versions afterward.",
207 "Cancel",
208 "Delete Versions",
209 ) {
210 return Ok(());
211 }
212 let all: Vec<(ExGuid, Vec<ExGuid>)> = session
213 .history
214 .iter()
215 .map(|(page, versions)| {
216 (
217 *page,
218 versions.iter().map(|version| version.context).collect(),
219 )
220 })
221 .collect();
222 if !all.is_empty() {
223 session.section.delete_versions(&all)?;
224 session.status = "Saving";
225 }
226 session.refresh_history()?;
227 if session.version.is_some() {
228 self.commands.push(Command::OpenPage(session.space));
229 }
230 if scope == Scope::Section {
231 return Ok(());
232 }
233 // The other sections, each opened, cleared and closed in the background.
234 let folder = match scope {
235 Scope::Group => folder,
236 _ => String::new(),
237 };
238 let library = Arc::clone(&session.library);
239 let proxy = self.proxy.clone();
240 std::thread::spawn(move || {
241 for path in sections(&library, &folder) {
242 if path == open {
243 continue;
244 }
245 let cleared = library
246 .open(&path, notify(proxy.clone()))
247 .and_then(|section| {
248 let all: Vec<(ExGuid, Vec<ExGuid>)> = section
249 .versions()?
250 .into_iter()
251 .map(|(page, versions)| {
252 (
253 page,
254 versions
255 .into_iter()
256 .map(|version| version.context)
257 .collect(),
258 )
259 })
260 .collect();
261 if !all.is_empty() {
262 section.delete_versions(&all)?;
263 }
264 Ok(section.close()?)
265 });
266 if let Err(error) = cleared {
267 eprintln!("Deleting the versions in {path} failed: {error}");
268 }
269 }
270 });
271 Ok(())
272 }
273}
274
275/// The readable sections at catalog path `folder` and in the groups inside it, leaving out
276/// the recycle bin.
277fn sections(library: &Library, folder: &str) -> Vec<String> {
278 let Some(catalog) = library.catalog() else {
279 return vec![library.location.clone()];
280 };
281 let mut pending = vec![catalog];
282 let mut root = None;
283 while let Some(candidate) = pending.pop() {
284 if candidate.path == folder {
285 root = Some(candidate);
286 break;
287 }
288 pending.extend(&candidate.groups);
289 }
290 let mut pending: Vec<_> = root.into_iter().collect();
291 let mut paths = Vec::new();
292 while let Some(folder) = pending.pop() {
293 paths.extend(library.tabs(&folder.path).into_iter().map(|tab| tab.path));
294 pending.extend(
295 folder
296 .groups
297 .iter()
298 .filter(|group| !library::recycle_bin(&group.path)),
299 );
300 }
301 paths
302}
crates/snowbound/src/library.rs+10-4
......@@ -83,6 +83,7 @@ pub struct Login {
8383
8484impl Login {
8585 /// The guest account a mount without one signed in as.
86 #[cfg(any(target_os = "macos", test))]
8687 pub fn guest(mount: &Mount) -> Self {
8788 Self {
8889 user: String::new(),
......@@ -327,7 +328,12 @@ impl Library {
327328 if let Some(tab) = tabs(folder).into_iter().next() {
328329 return Some(tab.path);
329330 }
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 );
331337 }
332338 None
333339 }
......@@ -356,9 +362,9 @@ impl Library {
356362 }
357363}
358364
359/// Whether `folder` is the notebook's recycle bin, which OneNote keeps out of its lists.
360pub 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.
366pub fn recycle_bin(path: &str) -> bool {
367 path.rsplit('/').next() == Some("OneNote_RecycleBin")
362368}
363369
364370pub fn file_name(path: &Path) -> String {
crates/snowbound/src/link.rs created+464
......@@ -0,0 +1,464 @@
1//! Links and equations on the page as OneNote 2010 offers them: the Link dialog,
2//! the page's context menu, and following a clicked link, to a page
3//! of the notebook or with the system's handler.
4
5use crate::{Command, State, page, platform};
6use onestore::ExGuid;
7use onestore::page::link::{LinkTarget, internal_link};
8use std::error::Error;
9use ui::{Anchor, Axis, Flags, Id, Spec, children, fill, popup::Item, px};
10use winit::keyboard::NamedKey;
11
12const WIDTH: f32 = 420.0;
13/// Pages the dialog lists at once; the search narrows the rest.
14const PAGES: usize = 8;
15
16/// The Link dialog's fields while it is open.
17pub struct LinkDialog {
18 text: String,
19 address: String,
20 search: String,
21}
22
23fn id() -> Id {
24 Id::ROOT.child("link")
25}
26
27fn text_field() -> Id {
28 id().child("text")
29}
30
31fn address_field() -> Id {
32 id().child("address")
33}
34
35fn search_field() -> Id {
36 id().child("search")
37}
38
39fn menu() -> Id {
40 Id::ROOT.child("text-menu")
41}
42
43/// What a link's address opens with the system's handler: a web address typed without its
44/// scheme is a web page, a share's `\\server\share` path its `smb:` URL.
45fn system_url(address: &str) -> String {
46 if let Some(share) = address.strip_prefix("\\\\") {
47 return format!("smb://{}", share.replace('\\', "/"));
48 }
49 let scheme = address.split_once(':').is_some_and(|(scheme, _)| {
50 scheme.len() > 1
51 && scheme
52 .chars()
53 .all(|c| c.is_ascii_alphanumeric() || "+-.".contains(c))
54 });
55 if scheme {
56 address.to_owned()
57 } else {
58 format!("http://{address}")
59 }
60}
61
62/// A page, or a paragraph on it, as Copy Link to Page gives it: the section file's path
63/// before the title, then the identities OneNote finds it by.
64fn clipboard_link(
65 path: &str,
66 section: [u8; 16],
67 title: &str,
68 page: [u8; 16],
69 object: Option<ExGuid>,
70) -> String {
71 let target = match object {
72 Some(object) => LinkTarget::Object {
73 identity: page,
74 title,
75 object,
76 },
77 None => LinkTarget::Page {
78 identity: page,
79 title,
80 },
81 };
82 let stored = internal_link(section, "", target);
83 let fragment = stored
84 .strip_prefix("onenote:#")
85 .and_then(|rest| rest.strip_suffix("&base-path="))
86 .unwrap_or_default();
87 format!("onenote:///{}#{fragment}", path.replace(' ', "%20"))
88}
89
90impl State {
91 /// Ctrl+K and the toolbar's Link: the dialog opens on what the selection links.
92 pub(crate) fn open_link_dialog(&mut self) {
93 let (text, address) = self.view.link_prefill();
94 self.link = Some(LinkDialog {
95 text,
96 address,
97 search: String::new(),
98 });
99 self.ui.open_popup(id());
100 self.ui.set_focus(Some(address_field()));
101 }
102
103 /// Builds the Link dialog while it is open. OK, or Enter in a field, links the address
104 /// shown as the text; a page picked from the list fills both.
105 pub(crate) fn link_dialog(&mut self) -> Result<(), Box<dyn Error>> {
106 let Some(dialog) = &mut self.link else {
107 return Ok(());
108 };
109 let ui = &mut self.ui;
110 if !ui.popup_open(id()) {
111 self.link = None;
112 return Ok(());
113 }
114 let theme = ui.theme.clone();
115 let row = theme.font_size * 2.0;
116 let window = ui.rect(Id::ROOT).unwrap_or_default();
117 let height = ui.rect(id()).map_or(360.0, |rect| rect[3] - rect[1]);
118 let [left, top] = [
119 ((window[2] - WIDTH) / 2.0).max(0.0),
120 ((window[3] - height) / 2.0).max(0.0),
121 ];
122 let fields = [text_field(), address_field(), search_field()];
123 let entered =
124 ui::popup::navigation(ui, &fields[..2], &[NamedKey::Enter]).contains(&NamedKey::Enter);
125 ui.open_as(
126 id(),
127 Spec {
128 axis: Axis::Y,
129 size: [px(WIDTH), children()],
130 fill: Some(theme.popup),
131 border: Some(theme.chip),
132 shadow: Some(theme.shadow),
133 radius: 8.0,
134 pad: [16.0, 12.0],
135 gap: 4.0,
136 anchor: Some(Anchor::Below([left, top, left, top])),
137 ..Spec::default()
138 },
139 );
140 let field = |ui: &mut ui::Ui, label: &str, id: Id, value: &mut String, hint: &str| {
141 if !label.is_empty() {
142 ui.leaf(
143 label,
144 Spec {
145 size: [fill(), px(row * 0.8)],
146 text: Some(label),
147 ..Spec::default()
148 },
149 );
150 }
151 ui::text_field(
152 ui,
153 id,
154 value,
155 hint,
156 Spec {
157 size: [fill(), px(row)],
158 fill: Some(theme.base),
159 border: Some(theme.accent),
160 radius: 4.0,
161 pad: [6.0, 0.0],
162 ..Spec::default()
163 },
164 );
165 };
166 field(ui, "Text to display:", text_field(), &mut dialog.text, "");
167 field(ui, "Address:", address_field(), &mut dialog.address, "");
168 field(
169 ui,
170 "Or pick a location in OneNote:",
171 search_field(),
172 &mut dialog.search,
173 "Search by text in title",
174 );
175 let search = dialog.search.to_lowercase();
176 let pages: Vec<(ExGuid, String)> = self
177 .session
178 .as_ref()
179 .map(|session| {
180 session
181 .pages
182 .iter()
183 .filter(|(_, title, _)| title.to_lowercase().contains(&search))
184 .take(PAGES)
185 .map(|(space, title, _)| (*space, title.clone()))
186 .collect()
187 })
188 .unwrap_or_default();
189 ui.open(
190 "pages",
191 Spec {
192 axis: Axis::Y,
193 size: [fill(), px(row * PAGES as f32 + 8.0)],
194 fill: Some(theme.base),
195 border: Some(theme.chip),
196 radius: 4.0,
197 pad: [4.0, 4.0],
198 flags: Flags::CLIP,
199 ..Spec::default()
200 },
201 );
202 let mut picked = None;
203 for (space, title) in &pages {
204 let shown = if title.is_empty() {
205 "Untitled page"
206 } else {
207 title
208 };
209 let spec = Spec {
210 flags: Flags::CLICKABLE,
211 size: [fill(), px(row)],
212 text: Some(shown),
213 hover_fill: Some(theme.hover()),
214 radius: 4.0,
215 pad: [8.0, 0.0],
216 ..Spec::default()
217 };
218 if ui.leaf(space, spec).clicked {
219 picked = Some((*space, title.clone()));
220 }
221 }
222 ui.close();
223 ui.open(
224 "buttons",
225 Spec {
226 size: [fill(), children()],
227 pad: [0.0, 8.0],
228 gap: 8.0,
229 ..Spec::default()
230 },
231 );
232 ui.leaf(
233 "space",
234 Spec {
235 size: [fill(), px(1.0)],
236 ..Spec::default()
237 },
238 );
239 let ok = ui::button(ui, "ok", "OK").clicked || entered;
240 let cancel = ui::button(ui, "cancel", "Cancel").clicked;
241 ui.close();
242 ui.close();
243 if let Some((space, title)) = picked
244 && let Some(session) = &self.session
245 {
246 let page = session.section.page(space)?;
247 let identity = page
248 .identity
249 .ok_or("This page has no identity to link to")?;
250 let address = internal_link(
251 session.section.identity()?,
252 &session.section.file().to_string_lossy(),
253 LinkTarget::Page {
254 identity,
255 title: &title,
256 },
257 );
258 let dialog = self.link.as_mut().unwrap();
259 dialog.address = address;
260 if dialog.text.is_empty() {
261 dialog.text = title;
262 }
263 return Ok(());
264 }
265 let dialog = self.link.as_ref().unwrap();
266 if ok && !dialog.address.trim().is_empty() {
267 let response = self.view.set_link(&dialog.text, &dialog.address)?;
268 self.respond(response);
269 } else if !cancel {
270 return Ok(());
271 }
272 self.ui.close_popup(id());
273 self.ui.set_focus(Some(page()));
274 self.link = None;
275 Ok(())
276 }
277
278 /// A secondary press on the page's text opens its context menu there.
279 pub(crate) fn open_text_menu(&mut self) -> Result<(), Box<dyn Error>> {
280 let Some((response, context)) = self.view.context()? else {
281 return Ok(());
282 };
283 self.respond(response);
284 let point = self.ui.pointer().unwrap_or_default();
285 self.text_menu = Some((context, point));
286 self.ui.open_popup(menu());
287 Ok(())
288 }
289
290 /// The page's context menu while it is open, with OneNote 2010's commands for text,
291 /// links and equations.
292 pub(crate) fn text_menu(&mut self) -> Result<(), Box<dyn Error>> {
293 let Some((context, point)) = &self.text_menu else {
294 return Ok(());
295 };
296 if !self.ui.popup_open(menu()) {
297 self.text_menu = None;
298 return Ok(());
299 }
300 let item = |text| Item {
301 text,
302 ..Item::default()
303 };
304 let mut items = vec![
305 Item {
306 disabled: !context.selected,
307 ..item("Cut")
308 },
309 Item {
310 disabled: !context.selected,
311 ..item("Copy")
312 },
313 item("Paste"),
314 ];
315 match &context.link {
316 Some(_) => items.extend([
317 Item {
318 separated: true,
319 ..item("Edit Link…")
320 },
321 item("Copy Link to Paragraph"),
322 Item {
323 separated: true,
324 ..item("Copy Link")
325 },
326 item("Select Link"),
327 item("Remove Link"),
328 ]),
329 None => items.extend([
330 Item {
331 separated: true,
332 ..item("Link…")
333 },
334 item("Copy Link to Paragraph"),
335 ]),
336 }
337 if context.equation {
338 items.extend([
339 Item {
340 separated: true,
341 ..item("Professional")
342 },
343 item("Linear"),
344 ]);
345 }
346 let Some(chosen) =
347 ui::popup::menu(&mut self.ui, menu(), Anchor::Point(*point), &items, None)
348 else {
349 return Ok(());
350 };
351 let Some((context, _)) = self.text_menu.take() else {
352 return Ok(());
353 };
354 let response = match items[chosen].text {
355 "Cut" => self.view.copy(true)?,
356 "Copy" => self.view.copy(false)?,
357 "Paste" => {
358 self.commands
359 .push(Command::Page(canvas::interaction::Request::Paste));
360 return Ok(());
361 }
362 "Edit Link…" | "Link…" => {
363 self.open_link_dialog();
364 return Ok(());
365 }
366 "Copy Link" => {
367 self.clipboard.set_text(context.link.unwrap_or_default())?;
368 return Ok(());
369 }
370 "Copy Link to Paragraph" => {
371 if let Some(session) = &self.session {
372 let link = self.page_link(session.space, context.paragraph)?;
373 self.clipboard.set_text(link)?;
374 }
375 return Ok(());
376 }
377 "Select Link" => self.view.unlink(true)?,
378 "Remove Link" => self.view.unlink(false)?,
379 text => self.view.switch_equation(text == "Linear")?,
380 };
381 self.respond(response);
382 Ok(())
383 }
384
385 /// Copy Link to Page, or to a paragraph `object` on it: the link OneNote puts on the
386 /// clipboard for page `space` of the open section.
387 pub(crate) fn page_link(
388 &self,
389 space: ExGuid,
390 object: Option<ExGuid>,
391 ) -> Result<String, Box<dyn Error>> {
392 let session = self.session.as_ref().ok_or("No section is open")?;
393 let page = session.section.page(space)?;
394 let identity = page
395 .identity
396 .ok_or("This page has no identity to link to")?;
397 let title = session
398 .pages
399 .iter()
400 .find(|(listed, ..)| *listed == space)
401 .map_or(page.title.as_str(), |(_, title, _)| title.as_str());
402 Ok(clipboard_link(
403 &session.section.file().to_string_lossy(),
404 session.section.identity()?,
405 title,
406 identity,
407 object,
408 ))
409 }
410
411 /// A clicked link: a page or section of an open notebook opens here; anything else opens
412 /// with the system's handler.
413 pub(crate) fn open_link(&mut self, address: &str) -> Result<(), Box<dyn Error>> {
414 if !address.to_ascii_lowercase().starts_with("onenote:") {
415 platform::reveal(system_url(address));
416 return Ok(());
417 }
418 let Some(session) = &self.session else {
419 return Ok(());
420 };
421 let library = std::sync::Arc::clone(&session.library);
422 let Ok(Some(notebook)) = &library.notebook else {
423 return Ok(());
424 };
425 let Some((path, space)) = notebook.find_page(address)? else {
426 return Ok(());
427 };
428 if path == session.tabs[session.tab].path {
429 self.commands.extend(space.map(Command::OpenPage));
430 return Ok(());
431 }
432 if let Some(space) = space {
433 self.last_pages.insert(library.key(&path), space);
434 }
435 self.commands.push(Command::OpenSection(library, path));
436 Ok(())
437 }
438}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443
444 #[test]
445 fn addresses_open_as_onenote_follows_them() {
446 assert_eq!(system_url("www.example.com"), "http://www.example.com");
447 assert_eq!(system_url("example.net/x"), "http://example.net/x");
448 assert_eq!(system_url("https://a.example/p"), "https://a.example/p");
449 assert_eq!(system_url("mailto:me@example.com"), "mailto:me@example.com");
450 assert_eq!(system_url("\\\\server\\share\\f"), "smb://server/share/f");
451 }
452
453 /// OneNote 2010's Copy Link to Page (`corpus/link-edit/native-typed`, README): the file's
454 /// path, the title, and the section and page identities.
455 #[test]
456 fn page_links_copy_as_onenote_copies_them() {
457 let section = *b"\x25\xfc\x1e\x0f\x7c\xdf\xcb\x45\x87\xa4\xbd\xd0\x3f\x42\x05\x7e";
458 let page = *b"\x18\xa3\xcc\xa9\x3e\xa4\x12\x49\xa1\xc7\xf1\x9f\x1e\xaf\x30\xca";
459 assert_eq!(
460 clipboard_link("C:\\one\\links.one", section, "Links", page, None),
461 "onenote:///C:\\one\\links.one#Links&section-id={0F1EFC25-DF7C-45CB-87A4-BDD03F42057E}&page-id={A9CCA318-A43E-4912-A1C7-F19F1EAF30CA}&end"
462 );
463 }
464}
crates/snowbound/src/linux.rs+315-18
......@@ -3,7 +3,16 @@
33//! the C library's locale.
44
55use canvas::date::DateField;
6use std::{ffi::CStr, path::PathBuf, process::Command, sync::OnceLock, time::Duration};
6use std::{
7 ffi::CStr,
8 path::PathBuf,
9 process::Command,
10 sync::{
11 OnceLock,
12 atomic::{AtomicU32, Ordering},
13 },
14 time::Duration,
15};
716use ui::{Flags, Spec, Ui, px};
817use winit::{
918 error::EventLoopError,
......@@ -16,8 +25,6 @@ use winit::{
1625const APP_ID: &str = "snowbound";
1726/// The title bar's leading margin; the window controls sit at its trailing end.
1827pub const LEADING: f32 = 8.0;
19/// Square, as compositors that round windows don't report by how much.
20pub const CORNER_RADIUS: f32 = 0.0;
2128/// How far inside the window's edges a press resizes it, and how far along them a corner
2229/// reaches, in logical pixels.
2330const EDGE: f32 = 5.0;
......@@ -27,6 +34,9 @@ const MAXIMIZE: &[&str] = &[include_str!("../assets/icons/window-maximize.svg")]
2734const RESTORE: &[&str] = &[include_str!("../assets/icons/window-restore.svg")];
2835
2936static QUIT: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new();
37/// Breeze's corner radius in units of the decoration's pixel grid, as `f32` bits; zero where
38/// KWin leaves windows square.
39static BREEZE_RADIUS: AtomicU32 = AtomicU32::new(0);
3040
3141pub fn event_loop(_headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLoopError> {
3242 // 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
3444 let event_loop = EventLoop::with_user_event().build()?;
3545 QUIT.set(event_loop.create_proxy())
3646 .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 }
3751 Ok(event_loop)
3852}
3953
54/// How far the window manager rounds the window's corners: KWin rounds the bottom corners
55/// of Breeze's borderless windows unless they are maximized or full screen.
56pub fn corner_radius(window: &Window) -> f32 {
57 let units = f32::from_bits(BREEZE_RADIUS.load(Ordering::Relaxed));
58 if units == 0.0
59 || !window.is_decorated()
60 || window.is_maximized()
61 || window.fullscreen().is_some()
62 {
63 return 0.0;
64 }
65 // Breeze snaps its radius to the device pixel grid.
66 let scale = window.scale_factor() as f32;
67 (units * scale).round() / scale
68}
69
70/// Reads Breeze's corner radius now and again whenever KWin is told to reload its
71/// configuration, as Breeze's and the border size's settings pages do, redrawing then.
72fn follow_breeze_radius(proxy: EventLoopProxy<crate::UserEvent>) {
73 let Ok(connection) = zbus::blocking::connection::Builder::session()
74 .and_then(|builder| builder.method_timeout(Duration::from_millis(500)).build())
75 else {
76 return;
77 };
78 let read = |connection: &zbus::blocking::Connection| {
79 let units = breeze_radius(connection).unwrap_or(0.0);
80 BREEZE_RADIUS.store(units.to_bits(), Ordering::Relaxed);
81 };
82 // Before the window's first frame, so its corners never change under the user.
83 read(&connection);
84 std::thread::spawn(move || {
85 let Ok(rule) = zbus::MatchRule::builder()
86 .msg_type(zbus::message::Type::Signal)
87 .interface("org.kde.KWin")
88 .and_then(|rule| rule.member("reloadConfig"))
89 .map(|rule| rule.build())
90 else {
91 return;
92 };
93 let Ok(signals) =
94 zbus::blocking::MessageIterator::for_match_rule(rule, &connection, Some(8))
95 else {
96 return;
97 };
98 for _ in signals {
99 read(&connection);
100 let _ = proxy.send_event(crate::UserEvent::Redraw);
101 }
102 });
103}
104
105/// Breeze's `Frame_FrameRadius` of 2.5 small spacings where KWin 6.5 or later decorates
106/// with Breeze, borderless and with rounded corners, as Breeze's own
107/// `Decoration::recalculateBorders` decides.
108fn breeze_radius(connection: &zbus::blocking::Connection) -> Option<f32> {
109 let proxy =
110 zbus::blocking::Proxy::new(connection, "org.kde.KWin", "/KWin", "org.kde.KWin").ok()?;
111 let info: String = proxy.call("supportInformation", &()).ok()?;
112 let field = |name: &str| {
113 info.lines()
114 .find_map(|line| line.strip_prefix(name)?.strip_prefix(": "))
115 };
116 let mut version = field("KWin version")?.split('.').map(str::parse::<u32>);
117 let (Some(Ok(major)), Some(Ok(minor))) = (version.next(), version.next()) else {
118 return None;
119 };
120 let breezerc = kconfig("breezerc");
121 let rounded = kconfig_entries(&breezerc)
122 .get(&("Common", "RoundedCorners"))
123 .is_none_or(|value| *value != "false");
124 // KDecoration's BorderSize::None is 0.
125 let applies = (major, minor) >= (6, 5)
126 && field("Plugin")? == "org.kde.breeze"
127 && field("borderSize")? == "0"
128 && rounded;
129 Some(if applies {
130 2.5 * field("smallSpacing")?.parse::<f32>().ok()?
131 } else {
132 0.0
133 })
134}
135
40136/// Decorated by the window manager, or on Wayland by the compositor where it offers
41137/// xdg-decoration and otherwise by winit's Adwaita frame; named so the desktop entry supplies
42138/// the icon on Wayland, while X11 takes it from the window.
......@@ -68,7 +164,10 @@ impl Clipboard {
68164
69165 pub fn set_text(&mut self, text: String) -> Result<(), arboard::Error> {
70166 match self {
71 Self::Wayland(clipboard) => Ok(clipboard.store(text)),
167 Self::Wayland(clipboard) => {
168 clipboard.store(text);
169 Ok(())
170 }
72171 Self::X11(clipboard) => clipboard.set_text(text),
73172 }
74173 }
......@@ -87,6 +186,109 @@ pub fn system_titlebar(window: &Window) -> bool {
87186 window.is_decorated()
88187}
89188
189/// Window managers show no document of a window's.
190pub fn represent(_: &Window, _: Option<&std::path::Path>) {}
191
192/// None: the title bars desktops draw are opaque.
193pub fn install_backdrop(_: &Window) -> bool {
194 false
195}
196
197/// The title bar's fills with the window focused and not, where the desktop's are known and
198/// suit `appearance`: KWin's from the KDE colour scheme, or on GNOME the Adwaita header bar
199/// that winit's frame and GNOME's both draw.
200pub fn titlebar(appearance: Theme) -> Option<[[f32; 4]; 2]> {
201 let desktops = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
202 let on = |name: &str| desktops.split(':').any(|desktop| desktop == name);
203 let fills = if on("KDE") {
204 kde_titlebar(&kconfig("kdeglobals"))
205 } else if on("GNOME") {
206 adwaita_titlebar(appearance)
207 } else {
208 return None;
209 }
210 .map(|[red, green, blue]| draw::srgb(red, green, blue));
211 // Past mid grey, as against a colour scheme chosen in Options, the theme's own strip.
212 let [red, green, blue, _] = fills[0];
213 let dark = 0.2126 * red + 0.7152 * green + 0.0722 * blue < 0.18;
214 (dark == (appearance == Theme::Dark)).then_some(fills)
215}
216
217/// KConfig file `name` from the system's configuration directories up to the user's, in
218/// rising precedence.
219fn kconfig(name: &str) -> Vec<String> {
220 let system = std::env::var("XDG_CONFIG_DIRS")
221 .ok()
222 .filter(|dirs| !dirs.is_empty())
223 .unwrap_or_else(|| "/etc/xdg".into());
224 let mut dirs: Vec<PathBuf> = std::env::split_paths(&system).collect();
225 dirs.reverse();
226 dirs.extend(xdg_base("XDG_CONFIG_HOME", ".config"));
227 dirs.iter()
228 .filter_map(|dir| std::fs::read_to_string(dir.join(name)).ok())
229 .collect()
230}
231
232/// The entries of KConfig `files` in rising precedence, by group and key.
233fn kconfig_entries(files: &[String]) -> std::collections::HashMap<(&str, &str), &str> {
234 let mut entries = std::collections::HashMap::new();
235 for file in files {
236 let mut group = "";
237 for line in file.lines().map(str::trim) {
238 if let Some(name) = line
239 .strip_prefix('[')
240 .and_then(|line| line.strip_suffix(']'))
241 {
242 group = name;
243 } else if let Some((key, value)) = line.split_once('=') {
244 entries.insert((group, key.trim()), value.trim());
245 }
246 }
247 }
248 entries
249}
250
251/// KWin's title bar fills, focused and not, from kdeglobals `files` in rising precedence: the
252/// colour scheme's header colours where it has them, as KWin prefers, else its window
253/// manager's, else Breeze Light's header.
254fn kde_titlebar(files: &[String]) -> [[u8; 3]; 2] {
255 let entries = kconfig_entries(files);
256 let color = |group, key| kde_color(entries.get(&(group, key))?);
257 let pair = |active: [u8; 3], inactive: Option<[u8; 3]>| [active, inactive.unwrap_or(active)];
258 if let Some(active) = color("Colors:Header", "BackgroundNormal") {
259 pair(active, color("Colors:Header][Inactive", "BackgroundNormal"))
260 } else if let Some(active) = color("WM", "activeBackground") {
261 pair(active, color("WM", "inactiveBackground"))
262 } else {
263 [[222, 224, 226], [239, 240, 241]]
264 }
265}
266
267/// A KConfig colour: `r,g,b` with an optional alpha, or `#rrggbb`.
268fn kde_color(value: &str) -> Option<[u8; 3]> {
269 if let Some(hex) = value.strip_prefix('#') {
270 let [_, red, green, blue] = u32::from_str_radix(hex, 16)
271 .ok()
272 .filter(|_| hex.len() == 6)?
273 .to_be_bytes();
274 return Some([red, green, blue]);
275 }
276 let mut channels = value.split(',').map(|channel| channel.trim().parse().ok());
277 Some([channels.next()??, channels.next()??, channels.next()??])
278}
279
280/// The header bar of winit's Adwaita frame, focused and not.
281fn adwaita_titlebar(appearance: Theme) -> [[u8; 3]; 2] {
282 let theme = match appearance {
283 Theme::Dark => sctk_adwaita::theme::ColorTheme::dark(),
284 Theme::Light => sctk_adwaita::theme::ColorTheme::light(),
285 };
286 [theme.active, theme.inactive].map(|colors| {
287 let color = colors.headerbar.to_color_u8();
288 [color.red(), color.green(), color.blue()]
289 })
290}
291
90292/// Minimize, maximize and close at the title bar's trailing end.
91293pub fn window_controls(ui: &mut Ui, window: &Window) {
92294 let theme = ui.theme.clone();
......@@ -166,15 +368,15 @@ pub fn install_text_input(_: &Window) {}
166368/// Options opens from the sidebar's footer and Ctrl+Comma, as Linux apps have no shared menu.
167369pub fn install_menu() {}
168370
371/// The toolbar and keyboard stand in for a menu bar.
372pub fn update_menu(_: impl FnOnce() -> Vec<crate::commands::Status>) {}
373
169374pub fn clear_marked_text(_: &Window) {}
170375
171376pub fn configure_presentation(_: &wgpu::Surface<'_>) {}
172377
173378pub fn commit_presentation(_: &Window) {}
174379
175/// GNOME has no character palette to open from an app.
176pub fn show_character_palette() {}
177
178380/// GTK's default double-click time.
179381pub fn double_click_interval() -> Duration {
180382 Duration::from_millis(400)
......@@ -188,6 +390,41 @@ pub fn appearance(_: &Window) -> Theme {
188390 if dark { Theme::Dark } else { Theme::Light }
189391}
190392
393/// Asks for the desktop's colours again whenever the settings portal reports a change to the
394/// colour scheme, or to KDE's colours.
395fn watch_settings(proxy: EventLoopProxy<crate::UserEvent>) {
396 std::thread::spawn(move || {
397 let Ok(connection) = zbus::blocking::Connection::session() else {
398 return;
399 };
400 let Ok(portal) = zbus::blocking::Proxy::new(
401 &connection,
402 "org.freedesktop.portal.Desktop",
403 "/org/freedesktop/portal/desktop",
404 "org.freedesktop.portal.Settings",
405 ) else {
406 return;
407 };
408 let Ok(changes) = portal.receive_signal("SettingChanged") else {
409 return;
410 };
411 for change in changes {
412 let Ok((namespace, _, _)) = change
413 .body()
414 .deserialize::<(String, String, zbus::zvariant::OwnedValue)>()
415 else {
416 continue;
417 };
418 if (namespace == "org.freedesktop.appearance"
419 || namespace.starts_with("org.kde.kdeglobals"))
420 && proxy.send_event(crate::UserEvent::Appearance).is_err()
421 {
422 return;
423 }
424 }
425 });
426}
427
191428fn portal_color_scheme() -> Option<bool> {
192429 use zbus::zvariant::{OwnedValue, Value};
193430 // 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> {
232469 .lines()
233470 .filter_map(|line| {
234471 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 );
237478 let point = point.replace("\\040", " ");
238479 (matches!(kind, "cifs" | "smb3") && path.starts_with(&point))
239480 .then(|| (source.replace("\\040", " "), point, options.to_owned()))
240481 })
241482 .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();
243488 crate::library::Mount::parse(&source, &within, &options)
244489}
245490
......@@ -258,7 +503,9 @@ pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login,
258503 {
259504 return Ok(crate::library::Login {
260505 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(),
262509 domain: mount.domain.clone(),
263510 });
264511 }
......@@ -268,7 +515,7 @@ pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login,
268515 ["--password", &title],
269516 )
270517 .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())?;
272519 // zenity answers "user|password"; kdialog only the password.
273520 let (typed, password) = asked
274521 .split_once('|')
......@@ -288,11 +535,14 @@ pub fn settings_dir() -> Option<PathBuf> {
288535
289536/// The app's folder in the XDG base directory `variable` names, or in `fallback` under home.
290537fn xdg_dir(variable: &str, fallback: &str) -> Option<PathBuf> {
538 Some(xdg_base(variable, fallback)?.join(APP_ID))
539}
540
541fn xdg_base(variable: &str, fallback: &str) -> Option<PathBuf> {
291542 std::env::var_os(variable)
292543 .map(PathBuf::from)
293544 .filter(|path| path.is_absolute())
294545 .or_else(|| Some(PathBuf::from(std::env::var_os("HOME")?).join(fallback)))
295 .map(|base| base.join(APP_ID))
296546}
297547
298548/// 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>(
543793 .then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned()))
544794}
545795
546/// Opens `folder` in the file manager.
547pub 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.
797pub 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());
550801 }
551802}
552803
......@@ -585,7 +836,13 @@ pub fn pick_notebook(title: &str) -> Option<PathBuf> {
585836 &format!("--title={title}"),
586837 "--file-filter=OneNote notebooks and sections | *.onetoc2 *.one",
587838 ],
588 ["--getopenfilename", ".", "*.onetoc2 *.one", "--title", title],
839 [
840 "--getopenfilename",
841 ".",
842 "*.onetoc2 *.one",
843 "--title",
844 title,
845 ],
589846 );
590847 asked
591848 .unwrap_or_else(|error| {
......@@ -675,4 +932,44 @@ mod tests {
675932 assert_eq!(locale_tag("de_DE"), "de-DE");
676933 assert_eq!(locale_tag("C.UTF-8"), "");
677934 }
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 }
678975}
crates/snowbound/src/macos.rs+157-86
......@@ -1,3 +1,4 @@
1use crate::commands;
12use canvas::date::DateField;
23use objc2::{
34 ClassType, DeclaredClass,
......@@ -14,9 +15,12 @@ use objc2_app_kit::{
1415};
1516use objc2_foundation::{
1617 MainThreadMarker, NSAttributedString, NSCalendar, NSCalendarUnit, NSDate, NSDateFormatter,
17 NSDateFormatterStyle, NSPoint, NSRange, NSRect, NSSize, NSString,
18 NSDateFormatterStyle, NSPoint, NSRange, NSRect, NSString,
19};
20use std::{
21 cell::{Cell, RefCell},
22 sync::OnceLock,
1823};
19use std::{cell::Cell, sync::OnceLock};
2024use winit::{
2125 error::EventLoopError,
2226 event_loop::{EventLoop, EventLoopProxy},
......@@ -27,7 +31,12 @@ use winit::{
2731
2832static QUIT: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new();
2933static INPUT_CLASS: OnceLock<&'static AnyClass> = OnceLock::new();
30thread_local! { static IN_KEY_DOWN: Cell<bool> = const { Cell::new(false) }; }
34static BACKDROP_CLASS: OnceLock<&'static AnyClass> = OnceLock::new();
35static DELEGATE_CLASS: OnceLock<&'static AnyClass> = OnceLock::new();
36thread_local! {
37 static IN_KEY_DOWN: Cell<bool> = const { Cell::new(false) };
38 static STATUSES: RefCell<Vec<commands::Status>> = const { RefCell::new(Vec::new()) };
39}
3140
3241unsafe extern "C" fn key_down(view: &AnyObject, _: Sel, event: &NSEvent) {
3342 let previous = IN_KEY_DOWN.replace(true);
......@@ -77,11 +86,14 @@ pub const LEADING: f32 = 78.0;
7786/// How far AppKit rounds a window's corners.
7887pub const CORNER_RADIUS: f32 = 10.0;
7988
89pub fn corner_radius(_: &Window) -> f32 {
90 CORNER_RADIUS
91}
92
8093/// A transparent title bar over the content, where the window draws its own.
8194pub fn window_attributes() -> WindowAttributes {
8295 Window::default_attributes()
8396 .with_titlebar_transparent(true)
84 .with_title_hidden(true)
8597 .with_fullsize_content_view(true)
8698}
8799
......@@ -109,6 +121,96 @@ pub fn system_titlebar(_: &Window) -> bool {
109121 false
110122}
111123
124unsafe extern "C" fn no_document_drag(
125 _: &AnyObject,
126 _: Sel,
127 _: &AnyObject,
128 _: &AnyObject,
129 _: NSPoint,
130 _: &AnyObject,
131) -> objc2::runtime::Bool {
132 objc2::runtime::Bool::NO
133}
134
135/// Names `file` as the window's document: the title shows its icon, and Command-clicking the
136/// title lists the folders it lies in. Dragging the icon, which could move the file out of its
137/// notebook, is off.
138pub fn represent(window: &Window, file: Option<&std::path::Path>) {
139 let window = ns_window(window);
140 unsafe {
141 let delegate: Option<Retained<AnyObject>> = msg_send_id![&window, delegate];
142 if let Some(delegate) = delegate {
143 let class = DELEGATE_CLASS.get_or_init(|| {
144 let mut class = ClassBuilder::new("SnowboundWindowDelegate", delegate.class())
145 .expect("Unique window delegate class");
146 class.add_method(
147 sel!(window:shouldDragDocumentWithEvent:from:withPasteboard:),
148 no_document_drag as unsafe extern "C" fn(_, _, _, _, _, _) -> _,
149 );
150 class.register()
151 });
152 if delegate.class() != *class {
153 assert_eq!(class.superclass(), Some(delegate.class()));
154 assert_eq!(class.instance_size(), delegate.class().instance_size());
155 // The subclass adds no ivars, so the existing allocation remains valid.
156 AnyObject::set_class(&delegate, class);
157 }
158 }
159 let url = file.map(|file| {
160 objc2_foundation::NSURL::fileURLWithPath(&NSString::from_str(&file.to_string_lossy()))
161 });
162 let _: () = msg_send![&window, setRepresentedURL: url.as_deref()];
163 }
164}
165
166/// Nothing the backdrop leaves to colour.
167pub fn titlebar(_: winit::window::Theme) -> Option<[[f32; 4]; 2]> {
168 None
169}
170
171unsafe extern "C" fn hit_nothing(_: &AnyObject, _: Sel, _: NSPoint) -> *mut AnyObject {
172 std::ptr::null_mut()
173}
174
175/// Lays the title bar's material under the window's content, where it shows through the
176/// app's transparent pixels as AppKit's title bars and toolbars show it: tinted by the
177/// desktop, and following the window's appearance and whether it is key. Presses pass
178/// through it to the view it lies in.
179pub fn install_backdrop(window: &Window) -> bool {
180 MainThreadMarker::new().expect("Views belong to the main thread");
181 let RawWindowHandle::AppKit(handle) =
182 window.window_handle().expect("Live AppKit window").as_raw()
183 else {
184 unreachable!()
185 };
186 unsafe {
187 let view = &*handle.ns_view.as_ptr().cast::<AnyObject>();
188 let class = BACKDROP_CLASS.get_or_init(|| {
189 let effect = AnyClass::get("NSVisualEffectView").expect("AppKit is linked");
190 let mut class =
191 ClassBuilder::new("SnowboundBackdrop", effect).expect("Unique backdrop class");
192 class.add_method(
193 sel!(hitTest:),
194 hit_nothing as unsafe extern "C" fn(_, _, _) -> _,
195 );
196 class.register()
197 });
198 let bounds: NSRect = msg_send![view, bounds];
199 let backdrop: Allocated<AnyObject> = msg_send_id![*class, alloc];
200 let backdrop: Retained<AnyObject> = msg_send_id![backdrop, initWithFrame: bounds];
201 // NSVisualEffectMaterialTitlebar, NSVisualEffectBlendingModeWithinWindow (which
202 // AppKit's own title bar measures as, over the window's desktop-tinted background)
203 // and NSVisualEffectStateFollowsWindowActiveState.
204 let _: () = msg_send![&backdrop, setMaterial: 3isize];
205 let _: () = msg_send![&backdrop, setBlendingMode: 1isize];
206 let _: () = msg_send![&backdrop, setState: 0isize];
207 // NSViewWidthSizable | NSViewHeightSizable
208 let _: () = msg_send![&backdrop, setAutoresizingMask: 18usize];
209 let _: () = msg_send![view, addSubview: &*backdrop];
210 }
211 true
212}
213
112214/// AppKit's window frame takes resizing presses.
113215pub fn resize_direction(_: &Window, _: [f32; 2]) -> Option<winit::window::ResizeDirection> {
114216 None
......@@ -157,7 +259,10 @@ unsafe extern "C" {
157259 password: *mut *mut std::ffi::c_void,
158260 item: *mut *const std::ffi::c_void,
159261 ) -> 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;
161266}
162267
163268/// 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 {
307412 std::time::Duration::from_secs_f64(unsafe { NSEvent::doubleClickInterval() })
308413}
309414
310pub fn show_character_palette() {
311 let mtm = MainThreadMarker::new().expect("Text input belongs to the main thread");
312 NSApplication::sharedApplication(mtm).orderFrontCharacterPalette(None);
313}
314
315415pub fn edit_date(
316416 timestamp: u64,
317417 field: DateField,
......@@ -502,10 +602,10 @@ pub fn configure_presentation(surface: &wgpu::Surface<'_>) {
502602 MainThreadMarker::new().expect("Layer presentation belongs to the main thread");
503603 // Retain wgpu's surface ownership while synchronizing presentation with AppKit resize transactions.
504604 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);
509609 }
510610}
511611
......@@ -520,54 +620,9 @@ pub fn commit_presentation(window: &Window) {
520620 }
521621}
522622
523/// The application's icon as the Dock shows it, `pixels` square.
524pub 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.
624pub fn app_icon(_: u32) -> Option<draw::RasterImage> {
625 None
571626}
572627
573628/// The insertion point's colour and selected text's fill with and without keyboard focus,
......@@ -635,9 +690,28 @@ declare_class!(
635690 if let Some(proxy) = QUIT.get() { let _ = proxy.send_event(crate::UserEvent::Quit); }
636691 }
637692
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)
641715 }
642716
643717 // NSAlert consumes Escape before it reaches cancelOperation:.
......@@ -665,6 +739,7 @@ pub fn event_loop(headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLo
665739 let _: Retained<CanvasApplication> =
666740 unsafe { msg_send_id![CanvasApplication::class(), sharedApplication] };
667741 let mut builder = EventLoop::with_user_event();
742 builder.with_default_menu(false);
668743 if headless {
669744 builder
670745 .with_activation_policy(ActivationPolicy::Prohibited)
......@@ -676,34 +751,30 @@ pub fn event_loop(headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLo
676751 Ok(event_loop)
677752}
678753
679/// Adds Settings… to the application menu Winit made, below About as macOS places it.
754/// Replaces Winit's application menu with the menu bar, whose items the table validates.
680755pub fn install_menu() {
681756 let mtm = MainThreadMarker::new().expect("Menus belong to the main thread");
682757 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.
684761 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));
700765 }
701766}
702767
703/// Opens `folder` in Finder.
704pub 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.
769pub 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.
774pub fn reveal(target: impl AsRef<std::ffi::OsStr>) {
775 let target = target.as_ref();
776 if let Err(error) = std::process::Command::new("open").arg(target).spawn() {
777 eprintln!("Cannot open {}: {error}", target.display());
707778 }
708779}
709780
crates/snowbound/src/main.rs+1123-435
......@@ -1,14 +1,21 @@
11mod art;
2mod commands;
23#[cfg(test)]
34mod conflict_render;
5mod history;
46mod library;
7mod link;
58mod manage;
69mod meeting;
10#[cfg(target_os = "macos")]
11mod menubar;
712mod menus;
13mod navigation;
814mod options;
915#[cfg_attr(target_os = "linux", path = "linux.rs")]
1016#[cfg_attr(target_os = "macos", path = "macos.rs")]
1117mod platform;
18mod rename;
1219mod screenshot;
1320mod search;
1421mod settings;
......@@ -28,11 +35,11 @@ use canvas::{
2835 outline::TagIcon,
2936};
3037use draw::Renderer;
38use library::Library;
3139use onestore::ExGuid;
3240use onestore::document::Format;
3341use onestore::page::Page;
3442use onestore::page::text::Paragraph;
35use library::Library;
3643use std::{
3744 collections::{HashMap, HashSet},
3845 error::Error,
......@@ -43,7 +50,7 @@ use std::{
4350use ui::{Axis, Flags, Id, Spec, Theme, Ui, children, fill, fit, px};
4451use winit::{
4552 application::ApplicationHandler,
46 dpi::{LogicalSize, PhysicalPosition, PhysicalSize},
53 dpi::{LogicalPosition, LogicalSize},
4754 event::{ElementState, Ime, MouseButton, MouseScrollDelta, WindowEvent},
4855 event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy},
4956 keyboard::{Key, ModifiersState, NamedKey},
......@@ -55,23 +62,37 @@ const TITLE: f32 = 28.0;
5562const TAB_ROW: f32 = 28.0;
5663/// Width of the section colour around the page.
5764const FRAME: f32 = 6.0;
58/// The page's corners share their centres with the window's.
59const ROUNDING: f32 = (platform::CORNER_RADIUS - FRAME).max(0.0);
6065const PAGE_LIST: f32 = 240.0;
6166/// Font sizes the size box offers, OneNote's list in points.
6267/// Fonts the font box offers first, OneNote's default leading.
6368const FONTS: [&str; 4] = ["Calibri", "Arial", "Times New Roman", "Courier New"];
64/// Fonts picked this session that the font box offers after `FONTS`, at most.
65const RECENT_FONTS: usize = 5;
69/// Picks the font box and the list galleries offer as recent, at most, as OneNote's
70/// galleries do.
71const RECENT: usize = 5;
72/// Height of a line in a numbering gallery's previews.
73const NUMBER_LINE: f32 = 16.0;
6674/// Font sizes the size box offers, OneNote's list in points.
6775const SIZES: [f32; 17] = [
6876 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,
6977 72.0,
7078];
7179/// OneNote's highlight colours, COLORREF.
72const HIGHLIGHTS: &[u32] = &[
73 0x00ffff, 0x00ff00, 0xffff00, 0xff00ff, 0xff0000, 0x0000ff, 0x800000, 0x808000, 0x008000,
74 0x800080, 0x000080, 0x008080, 0x808080, 0xc0c0c0, 0x000000,
80const 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"),
7596];
7697/// Office's theme and standard font colours, COLORREF.
7798const FONT_COLORS: [u32; 20] = [
......@@ -85,8 +106,6 @@ const ROW: f32 = 29.0;
85106const ROW_GAP: f32 = 3.0;
86107/// Space between the page and a page tab that isn't open.
87108const PILL_MARGIN: f32 = 3.0;
88/// Rounding where the open page tab meets the page, concentric with its neighbours'.
89const JOIN: f32 = PILL_MARGIN + ROUNDING;
90109/// Why the platform's date dialog could not change the page's date.
91110const DATE_UNCHOSEN: &str = "Choose another date or time.";
92111const DATE_OUT_OF_RANGE: &str = "This date is outside the notebook's supported range.";
......@@ -104,9 +123,12 @@ enum UserEvent {
104123 Replay(Replay),
105124 /// A page background finished rasterizing on its worker thread.
106125 Redraw,
107 /// The app menu's Settings item was chosen.
126 /// A menu bar item was chosen.
108127 #[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,
110132}
111133
112134/// Asks the event loop for a frame from any thread.
......@@ -166,8 +188,6 @@ struct App {
166188 proxy: EventLoopProxy<UserEvent>,
167189 input: Option<Input>,
168190 substitutes: Vec<PathBuf>,
169 /// Font files for the interface in place of the system's.
170 interface_font: Vec<Vec<u8>>,
171191 /// Where `--screenshot` writes its PNGs, drawn from a window never shown.
172192 screenshot: Option<PathBuf>,
173193 /// What the window starts from besides its input, until it opens.
......@@ -220,13 +240,31 @@ struct Session {
220240 shown: Option<ExGuid>,
221241 /// Which of the open conflict page's conflicting changes is selected, in page order.
222242 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>,
223249}
224250
225251/// The information bar above a page with conflict pages, or above one of them.
226252#[derive(Clone, Copy)]
227253enum 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 },
230268}
231269
232270impl Session {
......@@ -261,11 +299,21 @@ impl Session {
261299 /// The page conflict page `space` belongs to, with it.
262300 fn conflict(&self, space: ExGuid) -> Option<(ExGuid, &onestore::ConflictPage)> {
263301 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 ))
265306 })
266307 }
267308
268309 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 }
269317 match self.conflict(self.space) {
270318 Some((page, _)) => Some(Bar::Version {
271319 page,
......@@ -291,7 +339,10 @@ impl Session {
291339
292340 /// The page in `space` as the editor shows it: a conflict page read-only, its
293341 /// 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 {
295346 let replica = Arc::clone(self.section.replica());
296347 let objects = self
297348 .conflict(space)
......@@ -311,14 +362,21 @@ impl Session {
311362/// section has in it.
312363enum Loaded {
313364 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),
314367 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>),
316372}
317373
318374/// What an opening page replaces: the section, or the page of the open one.
319375enum Shown {
320376 Section(Box<Session>),
377 Created(Box<Session>),
321378 Page(ExGuid),
379 Version(ExGuid, ExGuid),
322380}
323381
324382/// A loader thread's request number and what it read, its page laid out.
......@@ -326,7 +384,8 @@ type Read = (u64, Result<Laid, String>);
326384
327385enum Laid {
328386 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>),
330389}
331390
332391/// The newest page read, laid out and waiting for the pictures it shows first, so it
......@@ -355,24 +414,60 @@ enum Command {
355414 Structure(Arc<Library>, manage::Structure),
356415 /// Adds a page at the end of the open section, or a subpage of a page, and edits its
357416 /// title.
358 NewPage { under: Option<ExGuid> },
417 NewPage {
418 under: Option<ExGuid>,
419 },
359420 /// Deletes pages of the open section to the notebook's recycle bin.
360421 DeletePages(Vec<ExGuid>),
361422 /// Moves or indents pages of the open section.
362423 Pages(Vec<onestore::PageEdit>),
363424 /// 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 },
365429 /// Gives the open page a template's background or colour.
366430 Template(templates::Choice),
367431 OpenPage(ExGuid),
368432 /// 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 },
371441 /// 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 },
373446 /// 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),
375468 Page(Request),
469 /// Runs a command, or one of a list's entries, after the frame's input.
470 Choose(commands::Choice),
376471}
377472
378473struct State {
......@@ -392,6 +487,8 @@ struct State {
392487 places: HashMap<(String, ExGuid), Place>,
393488 /// The page each section showed last this run, by `Library::key`.
394489 last_pages: HashMap<String, ExGuid>,
490 /// Pages visited, for Back and Forward.
491 trail: navigation::Trail,
395492 /// Asks for a frame when a worker thread finishes something the page shows.
396493 redraw: std::task::Waker,
397494 instance: wgpu::Instance,
......@@ -417,10 +514,21 @@ struct State {
417514 menu: Option<(menus::Target, [f32; 2])>,
418515 /// The Options dialog's choices while it is open.
419516 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])>,
420521 color_scheme: settings::ColorScheme,
421522 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],
422527 /// 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>>,
424532 /// What the pointer is dragging: a page's tab, a section tab or a sidebar row.
425533 drag: Option<menus::Drag>,
426534 /// Pages whose template strip was dismissed this run.
......@@ -437,6 +545,8 @@ struct State {
437545 fonts: Vec<String>,
438546 /// Fonts picked from the font box this session, latest first.
439547 recent_fonts: Vec<String>,
548 /// What the toolbar's buttons apply from their menus' last picks.
549 toolbar: settings::Toolbar,
440550 /// The application's icon at the display's scale, for the title bar.
441551 app_icon: Option<draw::RasterImage>,
442552 commands: Vec<Command>,
......@@ -486,7 +596,6 @@ impl State {
486596 proxy: EventLoopProxy<UserEvent>,
487597 input: Input,
488598 substitutes: &[PathBuf],
489 interface_font: Vec<Vec<u8>>,
490599 visible: bool,
491600 settings::Launch {
492601 file: settings,
......@@ -510,10 +619,14 @@ impl State {
510619 Input::Section { title, .. } => title.clone(),
511620 Input::Notebooks { .. } => "Snowbound".into(),
512621 })
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)),
514625 )?,
515626 );
516627 platform::install_text_input(&window);
628 // Beneath the surface's layer, which is added over it.
629 let backdrop = visible && platform::install_backdrop(&window);
517630 platform::install_menu();
518631 let access_adapter =
519632 accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy.clone());
......@@ -531,9 +644,17 @@ impl State {
531644 .await?;
532645 let (device, queue) = adapter.request_device(&Default::default()).await?;
533646 let size = window.inner_size();
534 let config = surface
647 let mut config = surface
535648 .get_default_config(&adapter, size.width, size.height)
536649 .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 });
537658 surface.configure(&device, &config);
538659 let renderer = Renderer::new(device, queue, config.format);
539660 let mut engine = TextEngine::default();
......@@ -546,6 +667,7 @@ impl State {
546667 let temporary = matches!(input, Input::Notes { .. } | Input::Page(_));
547668 let mut notebooks = Vec::new();
548669 let mut session = None;
670 let mut sectionless = None;
549671 let (editor, scene) = match input {
550672 Input::Notes {
551673 document,
......@@ -605,6 +727,11 @@ impl State {
605727 (editor, Some((scene, [0.0; 2])))
606728 }
607729 None => {
730 sectionless = notebooks
731 .iter()
732 .find(|library| Some(&library.location) == current.as_ref())
733 .or(notebooks.first())
734 .cloned();
608735 let blank = TextDocument::new(vec![Paragraph::new(
609736 String::new(),
610737 Format::default(),
......@@ -629,22 +756,17 @@ impl State {
629756 window.request_redraw();
630757 eprintln!("Canvas GPU: {:?}; scale factor {dpr}", adapter.get_info());
631758 window.set_theme(stored.color_scheme.theme());
759 let appearance = stored
760 .color_scheme
761 .theme()
762 .unwrap_or_else(|| platform::appearance(&window));
632763 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),
640765 platform::double_click_interval(),
641766 );
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();
648770 ui.set_focus(Some(page()));
649771 for family in FONTS {
650772 for (face, _) in engine.substitute(family).map_or(&[][..], |s| &s.faces) {
......@@ -692,10 +814,15 @@ impl State {
692814 dismissed: HashSet::new(),
693815 title_focus: None,
694816 menu: None,
817 link: None,
818 text_menu: None,
695819 options: None,
696820 color_scheme: stored.color_scheme,
697821 light_pages: stored.light_pages,
822 translucent,
823 titlebar,
698824 renaming: None,
825 sectionless,
699826 drag: None,
700827 templates: templates::View::Strip,
701828 thumbnails: templates::Thumbnails::default(),
......@@ -703,6 +830,7 @@ impl State {
703830 pages_open: true,
704831 fonts,
705832 recent_fonts: stored.recent_fonts,
833 toolbar: stored.toolbar,
706834 app_icon: platform::app_icon((16.0 * dpr).round() as u32),
707835 commands: Vec::new(),
708836 changed: false,
......@@ -725,6 +853,7 @@ impl State {
725853 opening: None,
726854 places: HashMap::new(),
727855 last_pages: HashMap::new(),
856 trail: navigation::Trail::default(),
728857 access_adapter,
729858 accessibility: accessibility::Accessibility::default(),
730859 };
......@@ -741,9 +870,14 @@ impl State {
741870 }
742871 lap("open", start);
743872 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 }
745879 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),
747881 scale,
748882 )?;
749883 lap("build", start);
......@@ -793,6 +927,8 @@ impl State {
793927 self.ui.begin(size, scale, Instant::now());
794928 let (section, open_tab, open_page) = self.build()?;
795929 self.options_dialog();
930 self.link_dialog()?;
931 self.text_menu()?;
796932 self.ui.end();
797933 self.edges(section, open_tab, open_page);
798934 if let Some(rect) = self.ui.rect(page()) {
......@@ -808,6 +944,22 @@ impl State {
808944 Ok(())
809945 }
810946
947 /// Takes `appearance`'s colours.
948 fn set_appearance(&mut self, appearance: winit::window::Theme) {
949 self.ui.theme = theme(appearance, self.light_pages, self.translucent.is_some());
950 self.titlebar = platform::titlebar(appearance).unwrap_or([self.ui.theme.strip; 2]);
951 }
952
953 /// Takes the colour scheme chosen, or the system's where it follows the system.
954 fn follow_color_scheme(&mut self) {
955 self.window.set_theme(self.color_scheme.theme());
956 let appearance = self
957 .color_scheme
958 .theme()
959 .unwrap_or_else(|| platform::appearance(&self.window));
960 self.set_appearance(appearance);
961 }
962
811963 /// Builds the frame's boxes and returns the section's colours as they ease, the open
812964 /// section tab and the open page's tab, for the edges drawn once they are laid out.
813965 fn build(&mut self) -> Result<(ui::Section, Id, Option<Id>), Box<dyn Error>> {
......@@ -816,6 +968,11 @@ impl State {
816968 .as_ref()
817969 .and_then(|session| session.tabs[session.tab].color),
818970 ));
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)];
819976 let mut ease = |part: &str, color: [f32; 4]| -> [f32; 4] {
820977 std::array::from_fn(|channel| {
821978 self.ui
......@@ -831,6 +988,7 @@ impl State {
831988 edge: ease("edge", target.edge),
832989 accent: ease("accent", target.accent),
833990 };
991 self.ui.theme.strip = ease("strip", strip);
834992 self.ui.theme.accent = section.accent;
835993 // Elsewhere the theme holds the platform's colours.
836994 #[cfg(target_os = "macos")]
......@@ -847,7 +1005,8 @@ impl State {
8471005 if !platform::system_titlebar(&self.window) {
8481006 self.title_bar(&theme);
8491007 }
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() {
8511010 self.welcome(&theme);
8521011 return Ok((section, Id::ROOT, None));
8531012 }
......@@ -859,7 +1018,7 @@ impl State {
8591018 ..Spec::default()
8601019 },
8611020 );
862 self.sidebar(&theme);
1021 let sidebar = self.sidebar(&theme);
8631022 self.ui.open(
8641023 "main",
8651024 Spec {
......@@ -877,26 +1036,40 @@ impl State {
8771036 ..Spec::default()
8781037 },
8791038 );
1039 self.sidebar_button(&theme, sidebar);
8801040 self.ui.leaf(
8811041 "corner",
8821042 Spec {
883 size: [px(ROUNDING), px(1.0)],
1043 size: [px(self.rounding()), px(1.0)],
8841044 ..Spec::default()
8851045 },
8861046 );
8871047 let row = self.ui.id("sections");
8881048 let (clicked, open_tab) = match &self.session {
8891049 Some(session) => {
1050 // A tab being renamed takes the name typed, which its field covers.
8901051 let tabs: Vec<_> = session
8911052 .tabs
8921053 .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 })
8941063 .collect();
8951064 let lit = self.page_drop(row);
1065 let dragged = self.dragged_tab(row);
8961066 let ui::shell::Tabs {
8971067 clicked,
8981068 context,
8991069 held,
1070 renamed,
1071 slot,
1072 settled,
9001073 open: open_tab,
9011074 } = ui::shell::section_tabs(
9021075 &mut self.ui,
......@@ -904,16 +1077,19 @@ impl State {
9041077 &tabs,
9051078 session.tab,
9061079 lit,
1080 dragged,
9071081 &section,
9081082 TAB_ROW,
9091083 );
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);
9121085 let Some(session) = &self.session else {
9131086 unreachable!("The tabs are the session's")
9141087 };
9151088 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 )
9171093 };
9181094 if let Some((tab, point)) = context {
9191095 self.menu = Some((
......@@ -925,7 +1101,18 @@ impl State {
9251101 ));
9261102 self.ui.open_popup(menus::id());
9271103 }
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)
9291116 }
9301117 None => {
9311118 let tabs = [("Temporary page", section_color(None))];
......@@ -936,6 +1123,7 @@ impl State {
9361123 shown,
9371124 0,
9381125 None,
1126 None,
9391127 &section,
9401128 TAB_ROW,
9411129 )
......@@ -971,11 +1159,7 @@ impl State {
9711159 && let Some(bar) = session.bar()
9721160 {
9731161 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();
9791163 let steps = [
9801164 session.change.is_some_and(|at| at > 0),
9811165 session.change.map_or(changes > 0, |at| at + 1 < changes),
......@@ -983,6 +1167,15 @@ impl State {
9831167 self.commands
9841168 .extend(conflict_bar(&mut self.ui, bar, &sections, steps));
9851169 }
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 }
9861179 self.ui.open_as(
9871180 page(),
9881181 Spec {
......@@ -1023,12 +1216,14 @@ impl State {
10231216 self.ui.close();
10241217 self.ui.close();
10251218 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 }
10291219 Ok((section, open_tab, open_page))
10301220 }
10311221
1222 /// The page's corner radius, sharing its centres with the window's corners.
1223 fn rounding(&self) -> f32 {
1224 (platform::corner_radius(&self.window) - FRAME).max(0.0)
1225 }
1226
10321227 /// Borders the open section tab and the frame's top, and rounds and borders the page
10331228 /// together with the open page's tab, joined where they meet.
10341229 fn edges(&mut self, section: ui::Section, open_tab: Id, open_page: Option<Id>) {
......@@ -1036,6 +1231,9 @@ impl State {
10361231 let (Some(frame), Some(page)) = (self.ui.rect(frame()), self.ui.rect(page())) else {
10371232 return;
10381233 };
1234 let rounding = self.rounding();
1235 // Where the open page tab meets the page, concentric with its neighbours.
1236 let join = PILL_MARGIN + rounding;
10391237 let [left, top, right, bottom] = page;
10401238 let tab = open_page
10411239 .and_then(|id| self.ui.rect(id))
......@@ -1047,14 +1245,14 @@ impl State {
10471245 row[3].min(panel[3]).min(bottom),
10481246 ];
10491247 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(|| {
10511249 // 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 {
10531251 top
10541252 } else {
10551253 start
10561254 };
1057 let end = if end > bottom - ROUNDING - JOIN {
1255 let end = if end > bottom - rounding - join {
10581256 bottom
10591257 } else {
10601258 end
......@@ -1062,59 +1260,75 @@ impl State {
10621260 [start, end, reach]
10631261 })
10641262 });
1065 let mut outline = vec![([left, top], ROUNDING)];
1263 let mut outline = vec![([left, top], rounding)];
10661264 match tab {
10671265 Some([start, end, reach]) => {
10681266 if start > top {
1069 outline.extend([([right, top], ROUNDING), ([right, start], JOIN)]);
1267 outline.extend([([right, top], rounding), ([right, start], join)]);
10701268 }
1071 outline.extend([([reach, start], ROUNDING), ([reach, end], ROUNDING)]);
1269 outline.extend([([reach, start], rounding), ([reach, end], rounding)]);
10721270 if end < bottom {
1073 outline.extend([([right, end], JOIN), ([right, bottom], ROUNDING)]);
1271 outline.extend([([right, end], join), ([right, bottom], rounding)]);
10741272 }
10751273 }
1076 None => outline.extend([([right, top], ROUNDING), ([right, bottom], ROUNDING)]),
1274 None => outline.extend([([right, top], rounding), ([right, bottom], rounding)]),
10771275 }
1078 outline.push(([left, bottom], ROUNDING));
1276 outline.push(([left, bottom], rounding));
10791277 let height = (frame[3] - frame[1]).max(1.0);
10801278 let paper = self.paper().color;
10811279 self.ui.round_corners(&outline, paper, |y| {
10821280 ui::mix(section.frame[0], section.frame[1], (y - frame[1]) / height)
10831281 });
10841282 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.
10871286 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;
10891289 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 }
11001307 let [foot, toe] = self
11011308 .ui
11021309 .rect(open_tab)
11031310 .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),
11091319 ],
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),
11141328 ],
1115 ] {
1116 self.ui.border(&edge, false, section.edge);
1117 }
1329 false,
1330 section.edge,
1331 );
11181332 }
11191333
11201334 /// The window's title bar beside the traffic lights: the application's icon, the
......@@ -1136,19 +1350,22 @@ impl State {
11361350 ..Spec::default()
11371351 },
11381352 );
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 }
11521369 self.ui.leaf(
11531370 "space",
11541371 Spec {
......@@ -1175,23 +1392,15 @@ impl State {
11751392 /// Two rows of formatting, tag, insert and zoom buttons, grouped as OneNote's Home
11761393 /// ribbon groups them.
11771394 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};
11791399 // 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();
11931401 let fonts = &self.fonts;
11941402 let recent_fonts = &self.recent_fonts;
1403 let pens = &self.toolbar;
11951404 let engine = &self.view.engine;
11961405 // Under the system's title bar the save status joins the toolbar.
11971406 let status = platform::system_titlebar(&self.window)
......@@ -1200,8 +1409,7 @@ impl State {
12001409 let ui = &mut self.ui;
12011410 let text = theme.text;
12021411 let on = |toggle| state.toggles.contains(&toggle);
1203 let mut command = None;
1204 let mut history = None;
1412 let mut choice = None;
12051413 ui.open(
12061414 "toolbar",
12071415 Spec {
......@@ -1214,10 +1422,10 @@ impl State {
12141422 );
12151423 group(ui, "history", |ui| {
12161424 if ui::shell::tool_button(ui, "undo", art::UNDO, text, false).clicked {
1217 history = Some(true);
1425 choice = Some(Choice::Command(Cmd::Undo));
12181426 }
12191427 if ui::shell::tool_button(ui, "redo", art::REDO, text, false).clicked {
1220 history = Some(false);
1428 choice = Some(Choice::Command(Cmd::Redo));
12211429 }
12221430 });
12231431 divider(ui, "history", theme);
......@@ -1272,7 +1480,7 @@ impl State {
12721480 ui::popup::menu(ui, popup("font"), anchor, &items, Some("Font"))
12731481 && let Some(name) = choices[index].1
12741482 {
1275 command = Some(Formatting::Font(name.to_owned()));
1483 choice = Some(Choice::Font(name.to_owned()));
12761484 }
12771485 let combo = ui.id("size");
12781486 if ui::shell::combo(ui, "size", &size, 44.0).pressed {
......@@ -1300,20 +1508,113 @@ impl State {
13001508 let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default());
13011509 if let Some(index) = ui::popup::menu(ui, popup("size"), anchor, &items, Some(&size))
13021510 {
1303 command = Some(Formatting::FontSize(sizes[index]));
1511 choice = Some(Choice::Size(sizes[index]));
13041512 }
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)));
13111569 }
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)));
13141615 }
13151616 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));
13171618 }
13181619 });
13191620 row(ui, 1, |ui| {
......@@ -1324,12 +1625,12 @@ impl State {
13241625 ("strikethrough", art::STRIKETHROUGH, Toggle::Strikethrough),
13251626 ] {
13261627 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)));
13281629 }
13291630 }
13301631 let script = ui.id("script");
13311632 let superscript = on(Toggle::Superscript);
1332 let [button, menu] = ui::shell::split_button(
1633 if ui::shell::split_button(
13331634 ui,
13341635 "script",
13351636 if superscript {
......@@ -1339,47 +1640,46 @@ impl State {
13391640 },
13401641 None,
13411642 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 {
13451648 Toggle::Superscript
13461649 } else {
13471650 Toggle::Subscript
1348 }));
1349 }
1350 if menu.pressed {
1351 ui.open_popup(popup("script"));
1651 })));
13521652 }
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),
13681656 ];
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();
13691669 let anchor = ui::Anchor::Below(ui.rect(script).unwrap_or_default());
13701670 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)));
13741672 }
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 [
13761676 (
13771677 "highlight",
13781678 art::HIGHLIGHTER,
1379 HIGHLIGHTS,
1679 &HIGHLIGHTS.map(|(color, _)| color)[..],
13801680 5,
1381 "No colour",
1382 0x00ffff,
1681 "No Color",
1682 None,
13831683 ),
13841684 (
13851685 "color",
......@@ -1387,68 +1687,69 @@ impl State {
13871687 &FONT_COLORS[..],
13881688 10,
13891689 "Automatic",
1390 0x3a3ae8,
1690 Some(pens.font_color.map_or(text, colorref)),
13911691 ),
13921692 ] {
13931693 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";
14031696 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 }));
14081702 }
14091703 let colors: Vec<_> = swatches.iter().map(|color| colorref(*color)).collect();
14101704 let anchor = ui::Anchor::Below(ui.rect(split).unwrap_or_default());
14111705 if let Some(chosen) =
14121706 ui::popup::colors(ui, popup(part), anchor, none, &colors, columns)
14131707 {
1414 command = Some(paint(chosen.and_then(|chosen| {
1708 let color = chosen.and_then(|chosen| {
14151709 swatches
14161710 .iter()
14171711 .zip(&colors)
14181712 .find(|(_, color)| **color == chosen)
14191713 .map(|(stored, _)| *stored)
1420 })));
1714 });
1715 choice = Some(if highlight {
1716 Choice::Highlight(color)
1717 } else {
1718 Choice::Color(color)
1719 });
14211720 }
14221721 }
14231722 if ui::shell::tool_button(ui, "outdent", art::OUTDENT, text, false).clicked {
1424 command = Some(Formatting::Outdent);
1723 choice = Some(Choice::Command(Cmd::Outdent));
14251724 }
14261725 if ui::shell::tool_button(ui, "indent", art::INDENT, text, false).clicked {
1427 command = Some(Formatting::Indent);
1726 choice = Some(Choice::Command(Cmd::Indent));
14281727 }
1429 let align = ui.id("align");
14301728 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),
14341732 ];
1733 let keys = alignments.map(|(alignment, _)| shortcut(Cmd::Align(alignment)));
14351734 let current = alignments
14361735 .iter()
1437 .find(|(alignment, ..)| Some(*alignment) == state.alignment)
1736 .find(|(alignment, _)| Some(*alignment) == state.alignment)
14381737 .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();
14501751 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)));
14521753 }
14531754 });
14541755 });
......@@ -1467,42 +1768,86 @@ impl State {
14671768 (tag_sources(TagIcon::Address), NoteTag::Address),
14681769 (tag_sources(TagIcon::Phone), NoteTag::PhoneNumber),
14691770 ];
1771 let all_tags = &tags;
14701772 group(ui, "tags", |ui| {
14711773 for (index, tags) in tags.chunks(5).enumerate() {
14721774 row(ui, index, |ui| {
14731775 for (column, (icon, tag)) in tags.iter().enumerate() {
14741776 let lit = state.tags.contains(tag);
14751777 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)));
14771779 }
14781780 }
14791781 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 }
14811821 }
14821822 });
14831823 }
14841824 });
14851825 divider(ui, "tags", theme);
14861826 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 }
15061851 });
15071852 ui.leaf(
15081853 "space",
......@@ -1525,11 +1870,10 @@ impl State {
15251870 }
15261871 let zoom = self.view.zoom();
15271872 let label = format!("{:.0}%", zoom * 100.0);
1528 let mut chosen = None;
15291873 group(ui, "zoom", |ui| {
15301874 row(ui, 0, |ui| {
15311875 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));
15331877 }
15341878 let level = ui.leaf(
15351879 "level",
......@@ -1544,36 +1888,16 @@ impl State {
15441888 },
15451889 );
15461890 if level.clicked {
1547 chosen = Some(1.0);
1891 choice = Some(Choice::Command(Cmd::ActualSize));
15481892 }
15491893 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));
15511895 }
15521896 });
15531897 });
15541898 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);
15771901 }
15781902 Ok(())
15791903 }
......@@ -1626,24 +1950,43 @@ impl State {
16261950 let found = self
16271951 .search
16281952 .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 );
16311969 if let Some((space, point)) = rows.context {
16321970 self.menu = Some((menus::Target::Page(space), point));
16331971 self.ui.open_popup(menus::id());
16341972 }
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 }
16371983 self.ui.close();
16381984 rows.open
16391985 }
16401986
16411987 /// Hands the page the events routed to its box, in its device pixels.
16421988 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);
16471990 let scale = self.ui.scale();
16481991 let corner = self.ui.rect(page()).unwrap_or_default();
16491992 let device = |point: [f32; 2]| {
......@@ -1667,6 +2010,14 @@ impl State {
16672010 self.view.pointer_released()?
16682011 }
16692012 }
2013 ui::Event::Button {
2014 button: MouseButton::Right,
2015 pressed: true,
2016 ..
2017 } if !read_only => {
2018 self.open_text_menu()?;
2019 continue;
2020 }
16702021 ui::Event::Button { .. } | ui::Event::Ime(Ime::Enabled) => continue,
16712022 // A conflict page takes no typing; commands and caret moves still apply.
16722023 ui::Event::Ime(Ime::Preedit(..) | Ime::Commit(_)) if read_only => continue,
......@@ -1691,28 +2042,6 @@ impl State {
16912042 }
16922043 ui::Event::Wheel(delta) => self.view.wheel([delta[0] * scale, delta[1] * scale])?,
16932044 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 }
17162045 self.view.key(&ui::edit_key(&key), text.as_deref())?
17172046 }
17182047 ui::Event::Ime(Ime::Preedit(text, cursor)) => self.view.compose(text, cursor)?,
......@@ -1774,7 +2103,12 @@ impl State {
17742103 }
17752104 Command::CopyVersion { version, tab } => {
17762105 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 };
17782112 if tab == session.tab {
17792113 session.section.import_page(&page, &self.author)?;
17802114 } else {
......@@ -1793,6 +2127,11 @@ impl State {
17932127 }
17942128 }
17952129 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)?,
17962135 Command::OpenNotebook => {
17972136 if let Some(path) = platform::pick_notebook("Open Notebook") {
17982137 self.open_path(&path);
......@@ -1824,17 +2163,15 @@ impl State {
18242163 let response = self.view.paste(&text, language)?;
18252164 self.respond(response);
18262165 }
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)?,
18282168 }
18292169 Ok(())
18302170 }
18312171
18322172 /// Runs `read` on a thread of its own, which lays out the page it read; `open_loaded`
18332173 /// 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) {
18382175 self.loading += 1;
18392176 let (id, sender, redraw) = (self.loading, self.loads.0.clone(), self.redraw.clone());
18402177 let layouts = Arc::clone(&self.layouts);
......@@ -1842,7 +2179,9 @@ impl State {
18422179 let laid = read().and_then(|loaded| {
18432180 let (shown, page) = match loaded {
18442181 Loaded::Section(session, page) => (Shown::Section(session), page),
2182 Loaded::Created(session, page) => (Shown::Created(session), page),
18452183 Loaded::Page(space, page) => (Shown::Page(space), page),
2184 Loaded::Version(space, version, page) => (Shown::Version(space, version), page),
18462185 Loaded::Library(library, path) => return Ok(Laid::Library(library, path)),
18472186 };
18482187 let mut engine = layouts.lock().map_err(|_| "Page layout failed")?;
......@@ -1864,12 +2203,11 @@ impl State {
18642203 fn open_loaded(&mut self) -> Result<(), Box<dyn Error>> {
18652204 let reads: Vec<Read> = self.loads.1.try_iter().collect();
18662205 for (id, loaded) in reads {
1867 if id != self.loading {
1868 continue;
1869 }
18702206 match loaded {
2207 // A later load supersedes what this one shows, but not its failure.
2208 Ok(_) if id != self.loading => {}
18712209 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())?,
18732211 Err(error) => {
18742212 eprintln!("{error}");
18752213 platform::alert("Couldn't open", &error);
......@@ -1896,11 +2234,13 @@ impl State {
18962234 self.persist()?;
18972235 if let Some(session) = &self.session {
18982236 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());
19002239 self.last_pages.insert(key, session.space);
19012240 }
2241 let created = matches!(opening.loaded, Shown::Created(_));
19022242 match opening.loaded {
1903 Shown::Section(session) => {
2243 Shown::Section(session) | Shown::Created(session) => {
19042244 // The settings keep the notebook shown, not the section.
19052245 let other_notebook = self
19062246 .session
......@@ -1916,13 +2256,18 @@ impl State {
19162256 None => self.notebooks.push(Arc::clone(&session.library)),
19172257 }
19182258 self.session = Some(*session);
2259 self.sectionless = None;
19192260 if other_notebook {
19202261 self.save_settings();
19212262 }
19222263 }
1923 Shown::Page(space) => {
2264 Shown::Page(space) | Shown::Version(space, _) => {
19242265 let session = self.session.as_mut().ok_or("No section is open")?;
19252266 session.space = space;
2267 session.version = match opening.loaded {
2268 Shown::Version(_, version) => Some(version),
2269 _ => None,
2270 };
19262271 session.change = None;
19272272 // Pages created, moved or deleted since the list was read.
19282273 session.pages = session.section.pages()?;
......@@ -1933,24 +2278,39 @@ impl State {
19332278 self.places.get(&(key, session.space)).copied()
19342279 });
19352280 self.view.open(opening.editor, Some(opening.scene), place);
2281 self.visited();
19362282 self.opened()?;
19372283 self.refind(true)?;
19382284 if self.title_focus.take().is_some_and(|space| {
19392285 self.session
19402286 .as_ref()
19412287 .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)
19432294 {
19442295 self.view.editor.focus_outline(title.id)?;
19452296 let response = self.view.focus_text()?;
19462297 self.respond(response);
19472298 }
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 }
19482307 Ok(())
19492308 }
19502309
19512310 /// Takes `library`, a notebook read again after a change, in place of the one it was;
19522311 /// 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>> {
19542314 match self
19552315 .notebooks
19562316 .iter_mut()
......@@ -1959,6 +2319,23 @@ impl State {
19592319 Some(open) => *open = Arc::clone(&library),
19602320 None => self.notebooks.push(Arc::clone(&library)),
19612321 }
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 };
19622339 let Some(session) = &mut self.session else {
19632340 return Ok(());
19642341 };
......@@ -2018,8 +2395,8 @@ impl State {
20182395 let corner = self.ui.rect(page()).unwrap_or_default();
20192396 let [x0, y0, x1, y1] = self.view.caret_area()?;
20202397 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),
20232400 );
20242401 let start = Instant::now();
20252402 self.update_accessibility()?;
......@@ -2066,6 +2443,10 @@ impl State {
20662443 };
20672444 if self.window.title() != title {
20682445 self.window.set_title(&title);
2446 platform::represent(
2447 &self.window,
2448 self.session.as_ref().map(|session| session.section.file()),
2449 );
20692450 }
20702451 }
20712452
......@@ -2153,8 +2534,8 @@ impl State {
21532534 if ops.is_empty() {
21542535 return Ok(());
21552536 }
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.
21582539 self.stale = true;
21592540 return Ok(());
21602541 }
......@@ -2267,7 +2648,19 @@ impl State {
22672648 let back = session.shown;
22682649 session.pages = session.section.pages()?;
22692650 session.refresh_conflicts()?;
2651 session.refresh_history()?;
22702652 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 }
22712664 if !listed(session.space) && session.conflict(session.space).is_none() {
22722665 // A page removed elsewhere, or a conflict page deleted elsewhere, which
22732666 // returns to its page.
......@@ -2291,9 +2684,14 @@ impl State {
22912684 let mut error = None;
22922685 let view = &self.view;
22932686 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 };
22972695 self.access_adapter.update_if_active(|| {
22982696 match self.accessibility.update(
22992697 &view.editor,
......@@ -2419,7 +2817,23 @@ impl State {
24192817 }
24202818 };
24212819 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 }
24232837 self.window.pre_present_notify();
24242838 let start = Instant::now();
24252839 self.renderer.queue.present(frame);
......@@ -2432,6 +2846,15 @@ impl State {
24322846 Ok(())
24332847 }
24342848
2849 /// The window's pixels per point, lowered where the window is larger than the GPU's
2850 /// largest texture, so the surface renders scaled rather than failing.
2851 fn scale(&self) -> f32 {
2852 let size = self.window.inner_size();
2853 let most = self.renderer.device.limits().max_texture_dimension_2d as f32;
2854 let longest = size.width.max(size.height) as f32;
2855 self.window.scale_factor() as f32 * (most / longest).min(1.0)
2856 }
2857
24352858 /// The paper the open page lies on: the theme's, in the page's colour.
24362859 fn paper(&self) -> canvas::gpu::Paper {
24372860 canvas::gpu::Paper {
......@@ -2464,13 +2887,7 @@ impl State {
24642887 let layers: Vec<_> = interface
24652888 .iter()
24662889 .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),
24742891 ui::Layer::Custom { rect, .. } => draw::Layer {
24752892 scale: viewport.scale,
24762893 origin: [
......@@ -2479,6 +2896,7 @@ impl State {
24792896 ],
24802897 clip: Some(rect.map(|value| value * scale)),
24812898 backdrop: Some(paper.color),
2899 motion: None,
24822900 primitives: &page_primitives,
24832901 },
24842902 })
......@@ -2489,7 +2907,7 @@ impl State {
24892907 .draw(
24902908 target,
24912909 [self.config.width, self.config.height],
2492 self.ui.theme.base,
2910 self.ui.theme.strip,
24932911 &layers,
24942912 )
24952913 .map_err(|error| format!("Canvas drawing failed: {error:?}"))?;
......@@ -2583,6 +3001,23 @@ impl State {
25833001 if let ui::Event::Button { pressed: false, .. } = event {
25843002 self.strip_held = false;
25853003 }
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 }
25863021 if let ui::Event::Button {
25873022 button: MouseButton::Left,
25883023 pressed: true,
......@@ -2613,15 +3048,12 @@ impl State {
26133048 }
26143049 return;
26153050 }
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()))
26233055 {
2624 self.open_options();
3056 self.choose(commands::Choice::Command(id));
26253057 } else {
26263058 self.ui.event(event);
26273059 }
......@@ -2629,8 +3061,9 @@ impl State {
26293061 }
26303062}
26313063
2632/// The interface's colours in `appearance`, on white pages when `light_pages`.
2633fn 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`.
3066fn theme(appearance: winit::window::Theme, light_pages: bool, backdrop: bool) -> Theme {
26343067 let mut theme = match appearance {
26353068 winit::window::Theme::Dark => Theme::dark(),
26363069 winit::window::Theme::Light => Theme::light(),
......@@ -2639,7 +3072,11 @@ fn theme(appearance: winit::window::Theme, light_pages: bool) -> Theme {
26393072 let light = Theme::light();
26403073 [theme.paper, theme.paper_ink] = [light.paper, light.paper_ink];
26413074 }
2642 theme
3075 if backdrop {
3076 theme.over_backdrop()
3077 } else {
3078 theme
3079 }
26433080}
26443081
26453082/// 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
26633100 ..Spec::default()
26643101 },
26653102 );
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).
26763116 let [said, action] = match bar {
26773117 Bar::Page { shown, .. } => [
26783118 "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
26863126 "Conflicting changes are highlighted in red. This page cannot be edited, but you can copy changes to the primary page.",
26873127 "Click here for more options.",
26883128 ],
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 ],
26893133 };
26903134 ui.open(
26913135 "message",
......@@ -2699,8 +3143,9 @@ fn conflict_bar(ui: &mut Ui, bar: Bar, sections: &[&str], steps: [bool; 2]) -> O
26993143 ui.leaf(
27003144 part,
27013145 Spec {
2702 size: [fit(), px(18.0)],
3146 size: [fill(), fit()],
27033147 text: Some(text),
3148 overflow: ui::Overflow::Wrap,
27043149 color: Some(draw::srgb(0x20, 0x20, 0x20)),
27053150 ..Spec::default()
27063151 },
......@@ -2717,6 +3162,16 @@ fn conflict_bar(ui: &mut Ui, bar: Bar, sections: &[&str], steps: [bool; 2]) -> O
27173162 let anchor = ui::Anchor::Below([left, top, left, bottom]);
27183163 match bar {
27193164 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 }
27203175 Bar::Version { page, version } => {
27213176 if clicked {
27223177 ui.open_popup(menu);
......@@ -2782,76 +3237,246 @@ struct Rows {
27823237 context: Option<(ExGuid, [f32; 2])>,
27833238 /// The page held down, which a drag moves.
27843239 held: Option<ExGuid>,
3240 /// A page version clicked: the page, and the version's context.
3241 version: Option<(ExGuid, ExGuid)>,
3242 /// The page whose tab was pressed twice in a row, which renames it.
3243 renamed: Option<ExGuid>,
3244 /// Whether the rename field kept or dropped the name typed, once it closes.
3245 kept: Option<bool>,
3246 /// Where each page's tab stands down the list with none dragged.
3247 places: Vec<f32>,
3248 /// The dragged tab, let go, stands in its place.
3249 settled: bool,
3250}
3251
3252/// A page's tab being dragged: the page, the top it follows the pointer to down the list
3253/// until let go, and where among the other pages it would land.
3254#[derive(Clone, Copy)]
3255struct PageDrag {
3256 space: ExGuid,
3257 top: Option<f32>,
3258 slot: Option<usize>,
3259}
3260
3261/// The page list's zero-height first box, where its rows are measured from.
3262fn page_list_top() -> &'static str {
3263 "top"
27853264}
27863265
27873266/// 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)]
27893270fn page_rows(
27903271 ui: &mut Ui,
27913272 theme: &Theme,
27923273 section: &ui::Section,
27933274 session: &Session,
27943275 found: &HashSet<ExGuid>,
3276 rounding: f32,
3277 mut renaming: Option<&mut rename::Renaming>,
3278 dragged: Option<PageDrag>,
27953279) -> 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;
28253301 continue;
28263302 }
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,
28323313 };
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)]
3363fn page_row(
3364 ui: &mut Ui,
3365 theme: &Theme,
3366 section: &ui::Section,
3367 session: &Session,
3368 found: &HashSet<ExGuid>,
3369 rounding: f32,
3370 renaming: Option<&mut rename::Renaming>,
3371 (space, title, level): (&ExGuid, &String, u32),
3372 shift: f32,
3373 lifted: bool,
3374 rows: &mut Rows,
3375) -> f32 {
3376 let mut height = ROW;
3377 let versions = session.versions(*space);
3378 let tab = PageTab {
3379 label: if title.is_empty() {
3380 "Untitled page"
3381 } else {
3382 title
3383 },
3384 dim: title.is_empty(),
3385 indent: level.saturating_sub(1),
3386 conflicted: !versions.is_empty(),
3387 found: found.contains(space),
3388 renaming: renaming
3389 .filter(|renaming| renaming.page(*space))
3390 .map(|renaming| &mut renaming.name),
3391 shift,
3392 lifted,
3393 };
3394 let selected = *space == session.space && session.version.is_none();
3395 if selected && !lifted {
3396 rows.open = Some(ui.id(space));
3397 }
3398 let (signal, kept) = page_tab(ui, theme, section, space, tab, selected, rounding);
3399 rows.kept = rows.kept.or(kept);
3400 if signal.clicked && !selected {
3401 rows.clicked = Some(*space);
3402 }
3403 if signal.pressed && signal.unit != draw::edit::SelectionUnit::Grapheme {
3404 rows.renamed = Some(*space);
3405 }
3406 if let Some(point) = signal.context {
3407 rows.context = Some((*space, point));
3408 }
3409 if signal.dragging {
3410 rows.held = Some(*space);
3411 }
3412 if lifted {
3413 return height;
3414 }
3415 // OneNote lists a page's conflict pages beneath it, each by its date and whose
3416 // version it is, and its page versions alike.
3417 let muted = ui::Section {
3418 tab: ui::mix(section.tab, theme.chip, 0.6),
3419 ..*section
3420 };
3421 if session.shown_history == Some(*space) {
3422 for version in session.page_versions(*space) {
3423 let label = history::label(version);
28383424 let tab = PageTab {
28393425 label: &label,
28403426 dim: false,
28413427 indent: level.saturating_sub(1),
28423428 conflicted: false,
28433429 found: false,
3430 renaming: None,
3431 shift,
3432 lifted: false,
28443433 };
2845 let selected = version.space == session.space;
3434 let selected = session.version == Some(version.context);
28463435 if selected {
2847 rows.open = Some(ui.id(version.space));
3436 rows.open = Some(ui.id(version.context));
28483437 }
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));
28513445 }
28523446 }
28533447 }
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
28553480}
28563481
28573482/// A row of the page list.
......@@ -2864,23 +3489,38 @@ struct PageTab<'a> {
28643489 conflicted: bool,
28653490 /// A search found the page, which OneNote marks yellow.
28663491 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,
28673498}
28683499
28693500/// 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.
28713502fn page_tab(
28723503 ui: &mut Ui,
28733504 theme: &Theme,
28743505 section: &ui::Section,
28753506 id: &ExGuid,
2876 tab: &PageTab,
3507 tab: PageTab,
28773508 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 };
28803518 let spec = Spec {
28813519 flags: Flags::CLICKABLE,
28823520 size: [px(PAGE_LIST), px(ROW)],
28833521 pad: [pad, 0.0],
3522 offset: [0.0, tab.shift],
3523 shadow: tab.lifted.then_some([0.0, 0.0, 0.0, 0.35]),
28843524 ..Spec::default()
28853525 };
28863526 let (spec, color) = if selected {
......@@ -2891,15 +3531,18 @@ fn page_tab(
28913531 };
28923532 (spec, theme.paper_ink)
28933533 } 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 }
28983541 };
28993542 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,
29033546 inset: [PILL_MARGIN, 0.0, 6.0, ROW_GAP],
29043547 // The label keeps its place as the tab opens and closes.
29053548 pad: [pad - PILL_MARGIN, 0.0],
......@@ -2913,15 +3556,22 @@ fn page_tab(
29133556 (spec, color)
29143557 };
29153558 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 };
29253575 if tab.conflicted {
29263576 ui.leaf(
29273577 "conflict",
......@@ -2935,7 +3585,60 @@ fn page_tab(
29353585 );
29363586 }
29373587 ui.close();
2938 ui.signal(row)
3588 (ui.signal(row), kept)
3589}
3590
3591/// Puts `item` first among `recent` picks.
3592fn remember<T: PartialEq>(recent: &mut Vec<T>, item: T) {
3593 recent.retain(|known| *known != item);
3594 recent.insert(0, item);
3595 recent.truncate(RECENT);
3596}
3597
3598/// Builds popup `id` as a gallery of a list library of `count` styles after None, under
3599/// `headings[1]`, and above it the library places `recent` under `headings[0]`, with library
3600/// place `current` outlined; `preview` builds a place's cell. Returns the place chosen, or
3601/// None for None.
3602#[allow(clippy::too_many_arguments)]
3603fn list_gallery(
3604 ui: &mut Ui,
3605 id: Id,
3606 anchor: ui::Anchor,
3607 headings: [&str; 2],
3608 (recent, count): (&[usize], usize),
3609 current: Option<usize>,
3610 size: [f32; 2],
3611 preview: impl Fn(&mut Ui, usize),
3612) -> Option<Option<usize>> {
3613 let groups = [(headings[0], recent.len()), (headings[1], count + 1)];
3614 let place = |index: usize| match index.checked_sub(recent.len()) {
3615 None => Some(recent[index]),
3616 Some(at) => at.checked_sub(1),
3617 };
3618 let chosen = ui::popup::gallery(
3619 ui,
3620 id,
3621 anchor,
3622 &groups[usize::from(recent.is_empty())..],
3623 5,
3624 size,
3625 current.map(|place| recent.len() + 1 + place),
3626 |ui, index| match place(index) {
3627 Some(place) => preview(ui, place),
3628 None => {
3629 ui.leaf(
3630 "none",
3631 Spec {
3632 size: [fill(), fill()],
3633 text: Some("None"),
3634 center: true,
3635 ..Spec::default()
3636 },
3637 );
3638 }
3639 },
3640 )?;
3641 Some(place(chosen))
29393642}
29403643
29413644/// A toolbar group's rows, stacked.
......@@ -2997,6 +3700,7 @@ fn read_session(
29973700 .ok_or("The section has no pages")?;
29983701 let page = section.page(space)?;
29993702 let conflicts = section.conflicts()?;
3703 let history = section.versions()?;
30003704 Ok((
30013705 Session {
30023706 section,
......@@ -3009,6 +3713,9 @@ fn read_session(
30093713 conflicts,
30103714 shown: None,
30113715 change: None,
3716 history,
3717 shown_history: None,
3718 version: None,
30123719 },
30133720 page,
30143721 ))
......@@ -3111,9 +3818,15 @@ impl ApplicationHandler<UserEvent> for App {
31113818 }
31123819 return;
31133820 }
3114 UserEvent::Options => {
3821 UserEvent::Choose(choice) => {
31153822 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();
31173830 state.window.request_redraw();
31183831 }
31193832 return;
......@@ -3132,7 +3845,7 @@ impl ApplicationHandler<UserEvent> for App {
31323845 Replay::Tick => {}
31333846 Replay::Appearance(appearance) => {
31343847 state.window.set_theme(Some(appearance));
3135 state.ui.theme = theme(appearance, state.light_pages);
3848 state.set_appearance(appearance);
31363849 }
31373850 Replay::Quit => {
31383851 self.close(event_loop);
......@@ -3183,7 +3896,6 @@ impl ApplicationHandler<UserEvent> for App {
31833896 self.proxy.clone(),
31843897 self.input.take().unwrap(),
31853898 &self.substitutes,
3186 std::mem::take(&mut self.interface_font),
31873899 self.screenshot.is_none(),
31883900 self.launch.take().expect("The window opens once"),
31893901 )) {
......@@ -3230,8 +3942,9 @@ impl ApplicationHandler<UserEvent> for App {
32303942 WindowEvent::RedrawRequested => return state.frame(),
32313943 WindowEvent::Resized(size) => {
32323944 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;
32353948 state
32363949 .surface
32373950 .configure(&state.renderer.device, &state.config);
......@@ -3241,15 +3954,12 @@ impl ApplicationHandler<UserEvent> for App {
32413954 return state.frame();
32423955 }
32433956 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));
32463958 state.window.request_redraw();
32473959 }
32483960 WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
32493961 state.renderer.clear_glyph_cache();
32503962 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);
32533963 state.window.request_redraw();
32543964 }
32553965 WindowEvent::Focused(focused) => {
......@@ -3423,7 +4133,6 @@ fn main() -> Result<(), Box<dyn Error>> {
34234133 let mut args = std::env::args_os().skip(1);
34244134 let mut positional = Vec::new();
34254135 let mut substitutes = Vec::new();
3426 let mut interface_font = Vec::new();
34274136 let mut reference = None;
34284137 let mut editable = false;
34294138 let mut section = None;
......@@ -3437,27 +4146,6 @@ fn main() -> Result<(), Box<dyn Error>> {
34374146 args.next()
34384147 .ok_or("Provide a font file after --substitute-font.")?,
34394148 ));
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 }
34614149 } else if arg == "--notebook" {
34624150 notebook = Some(PathBuf::from(
34634151 args.next()
......@@ -3484,7 +4172,8 @@ fn main() -> Result<(), Box<dyn Error>> {
34844172 ));
34854173 } else if arg == "--settings" {
34864174 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.")?,
34884177 ));
34894178 } else if arg == "--screenshot" {
34904179 screenshot = Some(PathBuf::from(
......@@ -3525,7 +4214,7 @@ fn main() -> Result<(), Box<dyn Error>> {
35254214 }
35264215 if positional.len() > 2 {
35274216 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]..."
35294218 .into(),
35304219 );
35314220 }
......@@ -3592,7 +4281,6 @@ fn main() -> Result<(), Box<dyn Error>> {
35924281 cache,
35934282 }),
35944283 substitutes,
3595 interface_font,
35964284 screenshot,
35974285 state: None,
35984286 startup_error: None,
crates/snowbound/src/manage.rs+58-11
......@@ -52,6 +52,13 @@ fn follow(current: &str, from: &str, to: &str) -> String {
5252 }
5353}
5454
55/// The section `library` shows after a change: `open` where it still lists it, else its
56/// first, or none once it has no sections.
57fn shown(library: &Library, open: Option<String>) -> Option<String> {
58 open.filter(|path| library.contains(path))
59 .or_else(|| library.first_section())
60}
61
5562/// The first of `base`, `base 2`, `base 3`… that `taken` does not hold, as OneNote names
5663/// new sections ("New Section 1", …) and groups.
5764fn unused(taken: &[String], base: &str, numbered: bool) -> String {
......@@ -162,13 +169,16 @@ impl State {
162169 /// Closes `library`: its files stay, and the sidebar and the next launch leave it out.
163170 pub(crate) fn close_notebook(&mut self, library: &Arc<Library>) {
164171 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))
169178 {
170179 self.persist().unwrap_or_else(|error| eprintln!("{error}"));
171180 self.session = None;
181 self.sectionless = None;
172182 self.templates = crate::templates::View::Strip;
173183 match self
174184 .notebooks
......@@ -176,7 +186,10 @@ impl State {
176186 .find_map(|open| Some((Arc::clone(open), open.first_section()?)))
177187 {
178188 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 }
180193 }
181194 }
182195 self.save_settings();
......@@ -257,20 +270,24 @@ impl State {
257270 (current, None)
258271 }
259272 };
273 let fresh = created.is_some();
260274 let open = created.or(followed.clone());
261275 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 };
266279 // The open section, perhaps renamed or moved, stays open: its replica is this
267280 // thread's to reopen only where no section holds it.
268281 if followed.as_deref() == Some(path.as_str()) {
269 return Ok(Loaded::Library(library, path));
282 return Ok(Loaded::Library(library, Some(path)));
270283 }
271284 let section = library.open(&path, notify)?;
272285 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 })
274291 });
275292 }
276293
......@@ -765,6 +782,36 @@ mod tests {
765782 std::fs::remove_dir_all(&temporary).unwrap();
766783 }
767784
785 /// Deleting a group's last section shows the notebook's other one; deleting that too
786 /// leaves the notebook showing no section, where it failed before.
787 #[test]
788 fn deleting_every_section_leaves_the_notebook_showing_none() {
789 let temporary =
790 std::env::temp_dir().join(format!("snowbound-empty-{}", std::process::id()));
791 let _ = std::fs::remove_dir_all(&temporary);
792 std::fs::create_dir_all(&temporary).unwrap();
793 let root = temporary.join("Emptied");
794 let location = root.to_str().unwrap();
795 let cache = temporary.join("cache");
796 let mut notebook = Notebook::create(location, &cache, NOTEBOOK_COLOR, &dated()).unwrap();
797 notebook.create_group("", "Group").unwrap();
798 notebook.create_section("Group", "Inner", &dated()).unwrap();
799
800 notebook.delete("Group/Inner.one").unwrap();
801 let library = Library::created(location, notebook, &cache);
802 assert_eq!(
803 shown(&library, Some("Group/Inner.one".into())).as_deref(),
804 Some("New Section 1.one")
805 );
806
807 let mut notebook = library.reopen().unwrap();
808 notebook.delete("New Section 1.one").unwrap();
809 let library = library.with(notebook);
810 assert_eq!(shown(&library, Some("New Section 1.one".into())), None);
811 assert!(library.tabs("").is_empty());
812 std::fs::remove_dir_all(&temporary).unwrap();
813 }
814
768815 #[test]
769816 fn moved_sections_are_followed_and_new_names_take_the_next_number() {
770817 assert_eq!(follow("Group/Inner.one", "Group", "Kept"), "Kept/Inner.one");
crates/snowbound/src/menubar.rs created+407
......@@ -0,0 +1,407 @@
1//! The macOS menu bar, laid out as OneNote for Mac's: the command table's commands, and
2//! AppKit's own items where the system provides them.
3
4use crate::commands::{self, Choice, Chord, Id, cmd, named};
5use canvas::editor::{Alignment, NoteTag, Toggle};
6use draw::edit::NamedKey;
7use objc2::{
8 rc::Retained,
9 runtime::{AnyObject, Sel},
10};
11use objc2_app_kit::{NSEventModifierFlags, NSMenu, NSMenuItem};
12use objc2_foundation::{MainThreadMarker, NSString};
13
14enum Item {
15 Command(Id),
16 /// AppKit's own: its title, its action's selector and its chord.
17 System(&'static str, &'static str, Option<Chord>),
18 Separator,
19 Menu(&'static str, &'static [Item]),
20 /// The system's Services menu.
21 Services,
22 /// Each of a list's choices.
23 Fonts,
24 Sizes,
25 Highlights,
26}
27
28use Item::{Command as C, Separator as S};
29
30const MENUS: &[Item] = &[
31 Item::Menu(
32 "Snowbound",
33 &[
34 Item::System("About Snowbound", "orderFrontStandardAboutPanel:", None),
35 S,
36 C(Id::Settings),
37 S,
38 Item::Services,
39 S,
40 Item::System("Hide Snowbound", "hide:", Some(cmd('h'))),
41 Item::System(
42 "Hide Others",
43 "hideOtherApplications:",
44 Some(cmd('h').option()),
45 ),
46 Item::System("Show All", "unhideAllApplications:", None),
47 S,
48 Item::System("Quit Snowbound", "terminate:", Some(cmd('q'))),
49 ],
50 ),
51 Item::Menu(
52 "File",
53 &[
54 C(Id::NewNotebook),
55 C(Id::OpenNotebook),
56 C(Id::CloseNotebook),
57 S,
58 C(Id::NewSection),
59 C(Id::NewSectionGroup),
60 C(Id::NewPage),
61 C(Id::NewSubpage),
62 S,
63 C(Id::PageVersions),
64 C(Id::CopyPageLink),
65 C(Id::ShowNotebook),
66 ],
67 ),
68 Item::Menu(
69 "Edit",
70 &[
71 C(Id::Undo),
72 C(Id::Redo),
73 S,
74 C(Id::Cut),
75 C(Id::Copy),
76 C(Id::Paste),
77 C(Id::SelectAll),
78 S,
79 C(Id::Find),
80 C(Id::Search),
81 S,
82 Item::System("Start Dictation…", "startDictation:", None),
83 Item::System(
84 "Emoji & Symbols",
85 "orderFrontCharacterPalette:",
86 Some(named(NamedKey::Space).command().control()),
87 ),
88 ],
89 ),
90 // AppKit appends Enter Full Screen itself, and would repeat one listed here.
91 Item::Menu(
92 "View",
93 &[
94 C(Id::Back),
95 C(Id::Forward),
96 S,
97 C(Id::ZoomIn),
98 C(Id::ZoomOut),
99 C(Id::ActualSize),
100 S,
101 C(Id::Sidebar),
102 C(Id::PageList),
103 C(Id::DarkPages),
104 ],
105 ),
106 Item::Menu(
107 "Insert",
108 &[
109 C(Id::Table),
110 C(Id::Picture),
111 C(Id::Link),
112 C(Id::Equation),
113 S,
114 C(Id::Date),
115 C(Id::Time),
116 C(Id::DateTime),
117 ],
118 ),
119 Item::Menu(
120 "Format",
121 &[
122 Item::Menu("Font", &[Item::Fonts]),
123 Item::Menu("Size", &[Item::Sizes]),
124 S,
125 C(Id::Toggle(Toggle::Bold)),
126 C(Id::Toggle(Toggle::Italic)),
127 C(Id::Toggle(Toggle::Underline)),
128 C(Id::Toggle(Toggle::Strikethrough)),
129 C(Id::Toggle(Toggle::Subscript)),
130 C(Id::Toggle(Toggle::Superscript)),
131 S,
132 C(Id::Highlight),
133 Item::Menu("Highlight Color", &[Item::Highlights]),
134 C(Id::FontColor),
135 S,
136 C(Id::Bullets),
137 C(Id::Numbering),
138 S,
139 C(Id::Align(Alignment::Left)),
140 C(Id::Align(Alignment::Center)),
141 C(Id::Align(Alignment::Right)),
142 S,
143 C(Id::Indent),
144 C(Id::Outdent),
145 S,
146 C(Id::ClearFormatting),
147 ],
148 ),
149 Item::Menu(
150 "Tags",
151 &[
152 C(Id::Tag(NoteTag::ToDo)),
153 C(Id::Tag(NoteTag::Important)),
154 C(Id::Tag(NoteTag::Question)),
155 C(Id::Tag(NoteTag::RememberForLater)),
156 C(Id::Tag(NoteTag::Definition)),
157 C(Id::Tag(NoteTag::Highlight)),
158 C(Id::Tag(NoteTag::Contact)),
159 C(Id::Tag(NoteTag::Address)),
160 C(Id::Tag(NoteTag::PhoneNumber)),
161 S,
162 C(Id::RemoveTags),
163 ],
164 ),
165 Item::Menu(
166 "Window",
167 &[
168 Item::System("Minimize", "performMiniaturize:", Some(cmd('m'))),
169 Item::System("Zoom", "performZoom:", None),
170 S,
171 Item::System("Bring All to Front", "arrangeInFront:", None),
172 ],
173 ),
174 Item::Menu("Help", &[C(Id::Help)]),
175];
176
177/// The bar, and the menus AppKit fills itself.
178pub struct Menus {
179 pub bar: Retained<NSMenu>,
180 pub services: Retained<NSMenu>,
181 pub window: Retained<NSMenu>,
182 pub help: Retained<NSMenu>,
183}
184
185/// Builds the bar; the table's items send `choose:` to `target`, which validates them.
186pub fn build(mtm: MainThreadMarker, target: &AnyObject) -> Menus {
187 let services = NSMenu::new(mtm);
188 let bar = NSMenu::new(mtm);
189 let mut submenus = Vec::new();
190 for item in MENUS {
191 let Item::Menu(title, items) = item else {
192 unreachable!("The bar holds menus")
193 };
194 let menu = menu(mtm, target, title, items, &services);
195 let holder = NSMenuItem::new(mtm);
196 holder.setSubmenu(Some(&menu));
197 bar.addItem(&holder);
198 submenus.push(menu);
199 }
200 let named = |name: &str| {
201 submenus
202 .iter()
203 .find(|menu| unsafe { menu.title() }.to_string() == name)
204 .cloned()
205 .expect("The bar has the menus AppKit fills")
206 };
207 Menus {
208 window: named("Window"),
209 help: named("Help"),
210 services,
211 bar,
212 }
213}
214
215fn menu(
216 mtm: MainThreadMarker,
217 target: &AnyObject,
218 title: &str,
219 items: &[Item],
220 services: &NSMenu,
221) -> Retained<NSMenu> {
222 let menu = unsafe { NSMenu::initWithTitle(mtm.alloc(), &NSString::from_str(title)) };
223 let choose = |choice: &Choice, title: &str, chord: Option<Chord>| {
224 let item = entry(mtm, title, Some(objc2::sel!(choose:)), chord);
225 // The application validates and runs the table's items whatever holds focus.
226 unsafe {
227 item.setTarget(Some(target));
228 item.setTag(commands::index(choice) as isize);
229 }
230 menu.addItem(&item);
231 };
232 let list = |pick: fn(&Choice) -> bool| {
233 for choice in commands::choices().filter(pick) {
234 choose(&choice, &commands::title(choice.clone()), None);
235 }
236 };
237 for item in items {
238 match item {
239 Item::Command(id) => {
240 let command = commands::command(*id);
241 choose(
242 &Choice::Command(*id),
243 command.title,
244 command.mac.first().copied(),
245 );
246 }
247 Item::System(title, action, chord) => {
248 menu.addItem(&entry(mtm, title, Some(Sel::register(action)), *chord));
249 }
250 Item::Separator => menu.addItem(&NSMenuItem::separatorItem(mtm)),
251 Item::Menu(title, items) => {
252 let holder = entry(mtm, title, None, None);
253 holder.setSubmenu(Some(&self::menu(mtm, target, title, items, services)));
254 menu.addItem(&holder);
255 }
256 Item::Services => {
257 let holder = entry(mtm, "Services", None, None);
258 holder.setSubmenu(Some(services));
259 menu.addItem(&holder);
260 }
261 Item::Fonts => list(|choice| matches!(choice, Choice::Font(_))),
262 Item::Sizes => list(|choice| matches!(choice, Choice::Size(_))),
263 Item::Highlights => list(|choice| matches!(choice, Choice::Highlight(_))),
264 }
265 }
266 menu
267}
268
269fn entry(
270 mtm: MainThreadMarker,
271 title: &str,
272 action: Option<Sel>,
273 chord: Option<Chord>,
274) -> Retained<NSMenuItem> {
275 let (key, shift) = chord.map_or((String::new(), false), Chord::equivalent);
276 let item = unsafe {
277 NSMenuItem::initWithTitle_action_keyEquivalent(
278 mtm.alloc(),
279 &NSString::from_str(title),
280 action,
281 &NSString::from_str(&key),
282 )
283 };
284 if let Some(chord) = chord {
285 let mut mask = NSEventModifierFlags(0);
286 for (held, flag) in [
287 (shift, NSEventModifierFlags::NSEventModifierFlagShift),
288 (
289 chord.option,
290 NSEventModifierFlags::NSEventModifierFlagOption,
291 ),
292 (
293 chord.control,
294 NSEventModifierFlags::NSEventModifierFlagControl,
295 ),
296 (
297 chord.command,
298 NSEventModifierFlags::NSEventModifierFlagCommand,
299 ),
300 ] {
301 if held {
302 mask.0 |= flag.0;
303 }
304 }
305 item.setKeyEquivalentModifierMask(mask);
306 }
307 item
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313 use draw::edit::Platform;
314
315 /// The menu tree as the bar shows it: titles, chords, and each table item's choice.
316 fn dump(menu: &NSMenu, depth: usize, out: &mut String, chords: &mut Vec<String>) {
317 for index in 0..unsafe { menu.numberOfItems() } {
318 let item = unsafe { menu.itemAtIndex(index) }.expect("Items within the count");
319 let indent = " ".repeat(depth);
320 if unsafe { item.isSeparatorItem() } {
321 out.push_str(&format!("{indent}---\n"));
322 continue;
323 }
324 let mask = unsafe { item.keyEquivalentModifierMask() }.0;
325 let key = unsafe { item.keyEquivalent() }.to_string();
326 let chord = if key.is_empty() {
327 String::new()
328 } else {
329 let symbols = [
330 (NSEventModifierFlags::NSEventModifierFlagControl, "⌃"),
331 (NSEventModifierFlags::NSEventModifierFlagOption, "⌥"),
332 (NSEventModifierFlags::NSEventModifierFlagShift, "⇧"),
333 (NSEventModifierFlags::NSEventModifierFlagCommand, "⌘"),
334 ];
335 let held: String = symbols
336 .iter()
337 .filter(|(flag, _)| mask & flag.0 != 0)
338 .map(|(_, symbol)| *symbol)
339 .collect();
340 let key = match key.as_str() {
341 " " => "Space".to_owned(),
342 "\u{f702}" => "←".to_owned(),
343 "\u{f703}" => "→".to_owned(),
344 key => key.to_uppercase(),
345 };
346 format!("{held}{key}")
347 };
348 if !chord.is_empty() {
349 chords.push(chord.clone());
350 }
351 let action = unsafe { item.action() }
352 .filter(|action| *action != objc2::sel!(submenuAction:))
353 .map_or(String::new(), |action| {
354 if action == objc2::sel!(choose:) {
355 let tag = unsafe { item.tag() } as usize;
356 format!(
357 "{:?}",
358 commands::choices().nth(tag).expect("Tags index choices")
359 )
360 } else {
361 action.name().to_owned()
362 }
363 });
364 let submenu = unsafe { item.submenu() };
365 // The bar's items show their menu's title.
366 let title = match &submenu {
367 Some(submenu) if depth == 0 => unsafe { submenu.title() }.to_string(),
368 _ => unsafe { item.title() }.to_string(),
369 };
370 out.push_str(format!("{indent}{title:<24} {chord:<8} {action}").trim_end());
371 out.push('\n');
372 if let Some(submenu) = submenu {
373 dump(&submenu, depth + 1, out, chords);
374 }
375 }
376 }
377
378 #[test]
379 fn menu_bar_offers_each_chord_once_as_the_keyboard_does() {
380 // Built off the main thread for the test only; nothing shows or runs.
381 let mtm = unsafe { MainThreadMarker::new_unchecked() };
382 let target: Retained<AnyObject> =
383 unsafe { objc2::msg_send_id![objc2::class!(NSObject), new] };
384 let menus = build(mtm, &target);
385 let (mut tree, mut chords) = (String::new(), Vec::new());
386 dump(&menus.bar, 0, &mut tree, &mut chords);
387 println!("{tree}");
388 for (at, chord) in chords.iter().enumerate() {
389 assert!(!chords[at + 1..].contains(chord), "{chord} is on two items");
390 }
391 // The keyboard's first chord for each command is on its menu item.
392 for command in commands::COMMANDS {
393 let Some(chord) = command.mac.first() else {
394 continue;
395 };
396 let line = tree
397 .lines()
398 .find(|line| line.ends_with(&format!("Command({:?})", command.id)))
399 .unwrap_or_else(|| panic!("{} has a chord and no menu item", command.title));
400 assert!(
401 line.contains(&chord.label(Platform::MacOs)),
402 "{line} shows {}",
403 chord.label(Platform::MacOs)
404 );
405 }
406 }
407}
crates/snowbound/src/menus.rs+267-128
......@@ -7,14 +7,45 @@ use std::sync::Arc;
77use ui::{Anchor, Id, popup::Item};
88
99/// What a drag holds.
10pub enum Drag {
10pub enum Dragged {
1111 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 },
1418 /// A section tab, by index.
1519 Tab(usize),
1620}
1721
22/// Something held down to drag: where the pointer took it, whether it has moved far enough
23/// to lift, and for rows, the index in their list where the gap it would land in opens.
24pub struct Drag {
25 pub what: Dragged,
26 /// The pointer when pressed, and its distance from the box's corner.
27 from: [f32; 2],
28 grab: [f32; 2],
29 pub lifted: bool,
30 /// Escape called it off; it ends when the button comes up.
31 pub cancelled: bool,
32 /// Let go: what it held eases into its place, then the drag ends.
33 pub released: bool,
34 pub slot: Option<usize>,
35}
36
37impl Drag {
38 /// Whether it follows the pointer: lifted, held, and not called off.
39 pub fn live(&self) -> bool {
40 self.lifted && !self.cancelled && !self.released
41 }
42
43 /// Where the dragged box's corner follows the pointer at `pointer`.
44 pub fn corner(&self, pointer: [f32; 2]) -> [f32; 2] {
45 [pointer[0] - self.grab[0], pointer[1] - self.grab[1]]
46 }
47}
48
1849/// What a context menu was opened on.
1950pub enum Target {
2051 Page(ExGuid),
......@@ -28,12 +59,72 @@ pub fn id() -> Id {
2859}
2960
3061/// The catalog folder holding `path`.
31fn folder(path: &str) -> String {
62pub(crate) fn folder(path: &str) -> String {
3263 path.rsplit_once('/')
3364 .map_or(String::new(), |(folder, _)| folder.to_owned())
3465}
3566
3667impl 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
37128 /// The open context menu, and what its choice does.
38129 pub(crate) fn context_menu(&mut self) {
39130 let Some((target, point)) = &self.menu else {
......@@ -58,8 +149,18 @@ impl State {
58149 let above = at
59150 .and_then(|at| at.checked_sub(1))
60151 .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 });
61158 vec![
62159 item("Delete"),
160 Item {
161 separated: true,
162 ..item("Copy Link to Page")
163 },
63164 Item {
64165 separated: true,
65166 ..item("New Page")
......@@ -75,6 +176,15 @@ impl State {
75176 disabled: level <= 1,
76177 ..item("Promote Subpage")
77178 },
179 Item {
180 separated: true,
181 disabled: !versions,
182 ..item(if shown {
183 "Hide Page Versions"
184 } else {
185 "Show Page Versions"
186 })
187 },
78188 ]
79189 }
80190 Target::Section { .. } | Target::Group { .. } => vec![
......@@ -104,8 +214,19 @@ impl State {
104214 };
105215 let command = match (target, items[chosen].text) {
106216 (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 }
107226 (Target::Page(_), "New Page") => Some(Command::NewPage { under: None }),
108227 (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 }),
109230 (Target::Page(space), text) => self.session.as_ref().and_then(|session| {
110231 let level = session
111232 .pages
......@@ -123,13 +244,11 @@ impl State {
123244 Target::Section { library, path } | Target::Group { library, path },
124245 "Rename",
125246 ) => {
126 let name = path.rsplit('/').next().unwrap_or_default();
127 self.renaming = Some(crate::sidebar::Renaming {
247 self.rename(crate::rename::Target::Entry {
128248 library,
129 name: name.strip_suffix(".one").unwrap_or(name).to_owned(),
130249 path,
250 in_tab: false,
131251 });
132 self.sidebar = true;
133252 None
134253 }
135254 (Target::Section { library, path }, "Delete") => platform::confirm(
......@@ -166,8 +285,18 @@ impl State {
166285 /// The section tab, of those built as `row`, under the pointer while a page is dragged
167286 /// there, which a drop moves the page into.
168287 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 )
171300 else {
172301 return None;
173302 };
......@@ -180,156 +309,166 @@ impl State {
180309 })
181310 }
182311
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.
187331 pub(crate) fn drag_pages(
188332 &mut self,
189333 held: Option<ExGuid>,
190 section: &ui::Section,
191 panel: [f32; 4],
334 places: &[f32],
335 settled: bool,
192336 row: Id,
193337 ) {
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 {
195355 return;
196356 };
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;
201359 };
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.
206366 self.drag = None;
207 }
208 let (Some(space), Some([_, y])) = (dropped, self.ui.pointer()) else {
209367 return;
210368 };
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();
211386 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 });
216389 return;
217390 }
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
220395 .pages
221396 .iter()
222397 .filter(|(listed, ..)| *listed != space)
223 .filter_map(|(listed, _, level)| {
224 Some((*listed, *level, self.ui.rect(self.ui.id(listed))?))
225 })
226398 .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
241401 .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) {
267404 self.commands.push(Command::Pages(vec![edit]));
268405 }
269406 }
270407
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.
274425 pub(crate) fn drag_tabs(
275426 &mut self,
276427 held: Option<usize>,
428 slot: Option<usize>,
429 settled: bool,
277430 row: Id,
278 bar: [f32; 4],
279 accent: [f32; 4],
280431 ) {
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 {
295451 return;
296452 };
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 {
308456 return;
309457 }
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 ));
333472 }
334473}
335474
crates/snowbound/src/navigation.rs created+175
......@@ -0,0 +1,175 @@
1//! Back and Forward through the pages visited, across sections and notebooks, as OneNote
2//! 2010's Quick Access Toolbar offers them.
3
4use super::*;
5
6/// A page shown: its notebook's location, its section's catalog path and the page.
7#[derive(Clone, Debug, PartialEq)]
8pub struct Place {
9 pub notebook: String,
10 pub section: String,
11 pub page: ExGuid,
12}
13
14/// Pages visited before and after the one shown.
15#[derive(Default)]
16pub struct Trail {
17 back: Vec<Place>,
18 here: Option<Place>,
19 forward: Vec<Place>,
20}
21
22/// Places Back keeps, at most.
23const KEPT: usize = 100;
24
25impl Trail {
26 /// Notes `place` shown. Arriving anywhere but where Back or Forward went drops the pages
27 /// ahead, as a browser does.
28 pub fn visit(&mut self, place: Place) {
29 if self.here.as_ref() == Some(&place) {
30 return;
31 }
32 if let Some(here) = self.here.replace(place) {
33 self.back.push(here);
34 if self.back.len() > KEPT {
35 self.back.remove(0);
36 }
37 }
38 self.forward.clear();
39 }
40
41 /// Steps back, or `forward`, to the nearest place `exists` accepts, dropping those it
42 /// refuses; the place to show.
43 pub fn step(&mut self, forward: bool, exists: impl Fn(&Place) -> bool) -> Option<Place> {
44 let (from, to) = if forward {
45 (&mut self.forward, &mut self.back)
46 } else {
47 (&mut self.back, &mut self.forward)
48 };
49 let place = std::iter::from_fn(|| from.pop()).find(|place| exists(place))?;
50 if let Some(here) = self.here.replace(place.clone()) {
51 to.push(here);
52 }
53 Some(place)
54 }
55
56 /// Whether Back and Forward have somewhere to go.
57 pub fn open(&self) -> [bool; 2] {
58 [!self.back.is_empty(), !self.forward.is_empty()]
59 }
60}
61
62impl State {
63 /// Notes the page just shown for Back and Forward.
64 pub(crate) fn visited(&mut self) {
65 if let Some(session) = &self.session {
66 self.trail.visit(Place {
67 notebook: session.library.location.clone(),
68 section: session.tabs[session.tab].path.clone(),
69 page: session.space,
70 });
71 }
72 }
73
74 /// Shows the page visited before the one shown, or after it going `forward`. A page
75 /// deleted from the open section since is passed over.
76 pub(crate) fn travel(&mut self, forward: bool) {
77 let session = self.session.as_ref();
78 let exists = |place: &Place| {
79 session.is_none_or(|session| {
80 session.library.location != place.notebook
81 || session.tabs[session.tab].path != place.section
82 || session.pages.iter().any(|(page, ..)| *page == place.page)
83 })
84 };
85 let Some(place) = self.trail.step(forward, exists) else {
86 return;
87 };
88 if let Some(session) = &self.session
89 && session.library.location == place.notebook
90 && session.tabs[session.tab].path == place.section
91 {
92 self.commands.push(Command::OpenPage(place.page));
93 return;
94 }
95 let Some(library) = self
96 .notebooks
97 .iter()
98 .find(|library| library.location == place.notebook)
99 else {
100 return;
101 };
102 let library = Arc::clone(library);
103 self.last_pages
104 .insert(library.key(&place.section), place.page);
105 self.commands
106 .push(Command::OpenSection(library, place.section));
107 }
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113
114 fn place(section: &str, page: u32) -> Place {
115 Place {
116 notebook: "/notebooks/Personal".into(),
117 section: section.into(),
118 page: ExGuid {
119 guid: [7; 16],
120 n: page,
121 },
122 }
123 }
124
125 #[test]
126 fn back_and_forward_retrace_visits_across_sections() {
127 let mut trail = Trail::default();
128 assert_eq!(trail.open(), [false, false]);
129 for (section, page) in [("A.one", 1), ("A.one", 2), ("B.one", 1)] {
130 trail.visit(place(section, page));
131 }
132 // Showing the same page again, as a reload does, is no visit.
133 trail.visit(place("B.one", 1));
134 let all = |_: &Place| true;
135 assert_eq!(trail.step(false, all), Some(place("A.one", 2)));
136 // Arriving where Back went is not a new visit.
137 trail.visit(place("A.one", 2));
138 assert_eq!(trail.open(), [true, true]);
139 assert_eq!(trail.step(false, all), Some(place("A.one", 1)));
140 assert_eq!(trail.step(false, all), None);
141 assert_eq!(trail.step(true, all), Some(place("A.one", 2)));
142 assert_eq!(trail.step(true, all), Some(place("B.one", 1)));
143 assert_eq!(trail.step(true, all), None);
144 // A page opened after going back drops the pages ahead.
145 trail.step(false, all);
146 trail.visit(place("C.one", 5));
147 assert_eq!(trail.open(), [true, false]);
148 assert_eq!(trail.step(false, all), Some(place("A.one", 2)));
149 }
150
151 #[test]
152 fn back_passes_over_pages_since_deleted() {
153 let mut trail = Trail::default();
154 for page in 1..=3 {
155 trail.visit(place("A.one", page));
156 }
157 let kept = |place: &Place| place.page.n != 2;
158 assert_eq!(trail.step(false, kept), Some(place("A.one", 1)));
159 assert_eq!(trail.step(true, kept), Some(place("A.one", 3)));
160 assert_eq!(trail.open(), [true, false]);
161 }
162
163 #[test]
164 fn back_keeps_its_last_hundred_places() {
165 let mut trail = Trail::default();
166 for page in 0..150 {
167 trail.visit(place("A.one", page));
168 }
169 let mut steps = 0;
170 while trail.step(false, |_| true).is_some() {
171 steps += 1;
172 }
173 assert_eq!(steps, KEPT);
174 }
175}
crates/snowbound/src/options.rs+10-16
......@@ -77,12 +77,6 @@ impl State {
7777 }
7878 let theme = ui.theme.clone();
7979 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 ];
8680 let entered = ui::popup::navigation(ui, &[user_name()], &[NamedKey::Enter])
8781 .contains(&NamedKey::Enter);
8882 ui.open_as(
......@@ -95,7 +89,7 @@ impl State {
9589 shadow: Some(theme.shadow),
9690 radius: 8.0,
9791 pad: [6.0, 6.0],
98 anchor: Some(Anchor::Below([left, top, left, top])),
92 anchor: Some(Anchor::Dialog),
9993 ..Spec::default()
10094 },
10195 );
......@@ -189,8 +183,13 @@ impl State {
189183 Page::Display => {
190184 heading(ui, &theme, "Display");
191185 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
194193 {
195194 options.light_pages = dark;
196195 }
......@@ -247,12 +246,7 @@ impl State {
247246 self.author = name.to_owned();
248247 self.color_scheme = options.color_scheme;
249248 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();
256250 self.save_settings();
257251 } else if !cancel {
258252 return;
......@@ -270,7 +264,7 @@ fn heading(ui: &mut Ui, theme: &Theme, text: &str) {
270264 size: [fill(), px(theme.font_size * 1.8)],
271265 text: Some(text),
272266 bold: true,
273 fill: Some(theme.strip),
267 fill: Some(ui::mix(theme.panel, theme.chip, 0.4)),
274268 radius: 4.0,
275269 pad: [8.0, 0.0],
276270 ..Spec::default()
crates/snowbound/src/rename.rs created+201
......@@ -0,0 +1,201 @@
1//! Renaming in place, as VS Code renames a file and OneNote 2010 a section's tab: a field
2//! over the name with it all selected, where Enter or a press elsewhere keeps the name typed
3//! and Escape the old one.
4
5use crate::{Command, Library, State, manage::Structure};
6use onestore::{
7 ExGuid,
8 op::{Edit, Op, PageOp},
9 page::PageObject,
10};
11use std::{error::Error, sync::Arc};
12use ui::{Event, Id, Spec, Theme, Ui, fill, px};
13use winit::keyboard::{Key, NamedKey};
14
15/// Room between the field's edge and its text.
16pub const PAD: f32 = 3.0;
17
18/// What is being renamed, and the name typed so far.
19pub struct Renaming {
20 pub target: Target,
21 pub name: String,
22}
23
24pub enum Target {
25 /// A section or group by catalog path, in its sidebar row or, `in_tab`, its tab.
26 Entry {
27 library: Arc<Library>,
28 path: String,
29 in_tab: bool,
30 },
31 /// A page of the open section, by its title in its tab.
32 Page(ExGuid),
33}
34
35impl Renaming {
36 /// Whether the sidebar row or tab of the section or group at `path` shows the field.
37 pub fn entry(&self, library: &Arc<Library>, path: &str, tab: bool) -> bool {
38 matches!(&self.target, Target::Entry { library: renamed, path: at, in_tab }
39 if Arc::ptr_eq(renamed, library) && at == path && *in_tab == tab)
40 }
41
42 pub fn page(&self, space: ExGuid) -> bool {
43 matches!(self.target, Target::Page(renamed) if renamed == space)
44 }
45}
46
47pub fn field() -> Id {
48 Id::ROOT.child("rename")
49}
50
51/// The rename field, `height` tall, editing `name`: whether Enter kept or Escape dropped
52/// the name typed.
53pub fn edit(ui: &mut Ui, theme: &Theme, name: &mut String, height: f32) -> Option<bool> {
54 let signal = ui::text_field(
55 ui,
56 field(),
57 name,
58 "",
59 Spec {
60 size: [fill(), px(height)],
61 fill: Some(theme.base),
62 border: Some(theme.accent),
63 radius: 2.0,
64 pad: [PAD, 0.0],
65 ..Spec::default()
66 },
67 );
68 signal.events.iter().find_map(|event| match event {
69 Event::Key {
70 key: Key::Named(key @ (NamedKey::Enter | NamedKey::Escape)),
71 ..
72 } => Some(*key == NamedKey::Enter),
73 _ => None,
74 })
75}
76
77impl State {
78 /// Opens the rename field on `target` with its name selected.
79 pub(crate) fn rename(&mut self, target: Target) {
80 let name = match &target {
81 Target::Entry { path, in_tab, .. } => {
82 self.sidebar |= !in_tab;
83 let name = path.rsplit('/').next().unwrap_or_default();
84 name.strip_suffix(".one").unwrap_or(name).to_owned()
85 }
86 Target::Page(space) => self
87 .session
88 .as_ref()
89 .and_then(|session| session.pages.iter().find(|(page, ..)| page == space))
90 .map(|(_, title, _)| title.clone())
91 .unwrap_or_default(),
92 };
93 self.renaming = Some(Renaming { target, name });
94 self.ui.focus_all(field());
95 }
96
97 /// Closes the rename field, renaming what it was on to the name typed where `keep`.
98 pub(crate) fn finish_renaming(&mut self, keep: bool) {
99 let Some(Renaming { target, name }) = self.renaming.take() else {
100 return;
101 };
102 self.ui.set_focus(Some(crate::page()));
103 let name = name.trim().to_owned();
104 if !keep || name.is_empty() {
105 return;
106 }
107 match target {
108 Target::Entry { library, path, .. } => {
109 let old = path.rsplit('/').next().unwrap_or_default();
110 if name != old.strip_suffix(".one").unwrap_or(old) {
111 self.commands.push(Command::Structure(
112 library,
113 Structure::Rename { path, name },
114 ));
115 }
116 }
117 Target::Page(space) => {
118 if let Err(error) = self.retitle(space, name) {
119 crate::platform::alert("Couldn't rename the page", &error.to_string());
120 }
121 }
122 }
123 }
124
125 /// The rename field over the section tab being renamed, of those built as `row` in the
126 /// tab row `bar`, where the tab's label stands.
127 pub(crate) fn tab_rename_field(&mut self, theme: &Theme, row: Id, bar: Id) {
128 let (Some(session), Some(renaming)) = (&self.session, &mut self.renaming) else {
129 return;
130 };
131 let Some(tab) = session
132 .tabs
133 .iter()
134 .position(|tab| renaming.entry(&session.library, &tab.path, true))
135 else {
136 return;
137 };
138 let (Some(rect), Some(bar)) =
139 (self.ui.rect(ui::shell::tab_id(row, tab)), self.ui.rect(bar))
140 else {
141 return;
142 };
143 let width = self.ui.measure(&renaming.name)[0] + 4.0 + 2.0 * PAD;
144 self.ui.open(
145 "rename",
146 Spec {
147 flags: ui::Flags::FLOAT,
148 size: [px(width.max(48.0)), px(rect[3] - rect[1] - 6.0)],
149 position: [
150 rect[0] + ui::shell::TAB_PAD - PAD - bar[0],
151 rect[1] + 3.0 - bar[1],
152 ],
153 ..Spec::default()
154 },
155 );
156 let kept = edit(
157 &mut self.ui,
158 theme,
159 &mut renaming.name,
160 rect[3] - rect[1] - 6.0,
161 );
162 self.ui.close();
163 if let Some(keep) = kept {
164 self.finish_renaming(keep);
165 }
166 }
167
168 /// Gives page `space` of the open section the title `name`.
169 fn retitle(&mut self, space: ExGuid, name: String) -> Result<(), Box<dyn Error>> {
170 self.persist()?;
171 let session = self.session.as_mut().ok_or("No section is open")?;
172 let page = session.section.page(space)?;
173 let title = page
174 .objects
175 .iter()
176 .find_map(|object| match object {
177 PageObject::Title(title) => title.outlines.first()?.paragraphs.first()?.text(),
178 _ => None,
179 })
180 .ok_or("The page has no title to rename")?;
181 let length = title.text.utf16_offset(title.text.text().len())?;
182 session.section.apply(
183 &self.author,
184 Edit {
185 at: crate::filetime(),
186 ops: vec![Op::Page {
187 space,
188 op: PageOp::Text {
189 text: title.id,
190 range: 0..length,
191 with: name,
192 },
193 }],
194 },
195 )?;
196 session.status = "Saving";
197 session.pages = session.section.pages()?;
198 self.edited(vec![space]);
199 self.refresh()
200 }
201}
crates/snowbound/src/screenshot.rs+23-13
......@@ -5,7 +5,8 @@
55use super::*;
66use winit::window::Theme as Appearance;
77
8/// The window in points, drawn at `SCALE` pixels per point.
8/// The window in points, unless `SNOWBOUND_SCREENSHOT_SIZE` gives it as `WIDTHxHEIGHT`,
9/// drawn at `SCALE` pixels per point or fewer where the GPU caps textures.
910const SIZE: [f32; 2] = [1440.0, 900.0];
1011const SCALE: f32 = 2.0;
1112/// 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] = [
2021impl State {
2122 /// Writes `PREFIX-light.png` and `PREFIX-dark.png`, the page unfocused so no caret shows.
2223 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);
2434 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)?;
2737 self.respond(response);
2838 self.ui.set_focus(None);
2939 if let Some(title) = self
......@@ -37,9 +47,9 @@ impl State {
3747 }
3848 for (name, appearance) in [("light", Appearance::Light), ("dark", Appearance::Dark)] {
3949 self.window.set_theme(Some(appearance));
40 self.ui.theme = theme(appearance, self.light_pages);
50 self.set_appearance(appearance);
4151 // The page's box sizes the view whose pictures settle.
42 self.layout(SIZE, SCALE)?;
52 self.layout(size, scale)?;
4353 let paper = self.paper();
4454 if let Some((scene, _)) = &mut self.view.scene {
4555 scene.settle(Some(&self.view.editor), self.view.viewport.scale, paper);
......@@ -47,7 +57,7 @@ impl State {
4757 // Later frames lay out with earlier frames' measurements while colours ease.
4858 let deadline = Instant::now() + std::time::Duration::from_secs(2);
4959 loop {
50 self.layout(SIZE, SCALE)?;
60 self.layout(size, scale)?;
5161 if !self.ui.wants_frame() || Instant::now() > deadline {
5262 break;
5363 }
......@@ -56,7 +66,7 @@ impl State {
5666 let path = PathBuf::from(format!("{}-{name}.png", prefix.display()));
5767 self.snapshot(&path)?;
5868 #[cfg(target_os = "macos")]
59 window_frame(&path, appearance)?;
69 window_frame(&path, appearance, scale)?;
6070 }
6171 Ok(())
6272 }
......@@ -65,7 +75,7 @@ impl State {
6575/// Paints the traffic lights into the PNG at `path`, rounds its corners to transparency
6676/// and edges them with the window's hairline.
6777#[cfg(target_os = "macos")]
68fn window_frame(path: &Path, appearance: Appearance) -> Result<(), Box<dyn Error>> {
78fn window_frame(path: &Path, appearance: Appearance, scale: f32) -> Result<(), Box<dyn Error>> {
6979 let mut reader =
7080 png::Decoder::new(std::io::BufReader::new(std::fs::File::open(path)?)).read_info()?;
7181 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
8494 }
8595 };
8696 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;
8898 for (index, pixel) in pixels.chunks_exact_mut(4).enumerate() {
8999 let x = (index as u32 % info.width) as f32 + 0.5;
90100 let y = (index as u32 / info.width) as f32 + 0.5;
91101 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);
93103 blend(
94104 pixel,
95105 [rim[0], rim[1], rim[2], 255],
96 inside(distance - 6.0 * SCALE),
106 inside(distance - 6.0 * scale),
97107 );
98108 blend(
99109 pixel,
100110 [fill[0], fill[1], fill[2], 255],
101 inside(distance - 5.5 * SCALE),
111 inside(distance - 5.5 * scale),
102112 );
103113 }
104114 // Signed distance to the window's rounded rectangle.
crates/snowbound/src/search.rs+35-46
......@@ -2,7 +2,7 @@
22//! results dropping down from it, and Find on This Page. The index is kept on a thread of
33//! its own, from the section files and the open section's replica.
44
5use crate::{Command, Library, State, Theme, art, lap, page};
5use crate::{Command, Library, State, Theme, art, commands, lap, page};
66use canvas::search::{Entry, Found, Index, Query, page_matches};
77use notebook::Replica;
88use onestore::ExGuid;
......@@ -43,16 +43,6 @@ impl Scope {
4343 }
4444}
4545
46const SEARCH_KEY: &str = if cfg!(target_os = "macos") {
47 "⌘E"
48} else {
49 "Ctrl+E"
50};
51const FIND_KEY: &str = if cfg!(target_os = "macos") {
52 "⌘F"
53} else {
54 "Ctrl+F"
55};
5646/// The results list's rows, the headings above their groups, and its width.
5747const RESULT: f32 = 42.0;
5848const HEADING: f32 = 24.0;
......@@ -71,7 +61,7 @@ fn scope_menu() -> Id {
7161}
7262
7363/// The field of the box, and of the results over it.
74fn field() -> Id {
64pub(crate) fn field() -> Id {
7565 Id::ROOT.child("search-field")
7666}
7767
......@@ -274,7 +264,7 @@ fn sections(library: &Library) -> Vec<String> {
274264 folder
275265 .groups
276266 .iter()
277 .filter(|group| !crate::library::recycle_bin(group)),
267 .filter(|group| !crate::library::recycle_bin(&group.path)),
278268 );
279269 }
280270 paths
......@@ -678,7 +668,11 @@ impl State {
678668 },
679669 );
680670 } 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 );
682676 let signal = ui::text_field(
683677 &mut self.ui,
684678 field(),
......@@ -705,15 +699,6 @@ impl State {
705699 Ok(())
706700 }
707701
708 /// Whether `signal`, a field's, holds the command key with `letter`.
709 fn shortcut(&self, signal: &ui::Signal, letter: &str) -> bool {
710 self.view.modifiers().command
711 && signal.events.iter().any(|event| {
712 matches!(event, ui::Event::Key { key: winit::keyboard::Key::Character(key), .. }
713 if key.eq_ignore_ascii_case(letter))
714 })
715 }
716
717702 fn scope_button(&mut self, theme: &Theme) {
718703 let button = self.ui.id("scope");
719704 if ui::shell::tool_button(&mut self.ui, "scope", art::SEARCH, theme.text_dim, false).pressed
......@@ -727,6 +712,7 @@ impl State {
727712 /// OneNote's "Search In:" menu.
728713 fn scope_popup(&mut self) {
729714 let current = self.search.scope;
715 let find = commands::shortcut(commands::Id::Find);
730716 let mut items = vec![
731717 ui::popup::Item {
732718 text: "Search In:",
......@@ -734,8 +720,8 @@ impl State {
734720 ..Default::default()
735721 },
736722 ui::popup::Item {
737 text: "Find on This Page",
738 shortcut: FIND_KEY,
723 text: commands::command(commands::Id::Find).title,
724 shortcut: &find,
739725 ..Default::default()
740726 },
741727 ];
......@@ -800,30 +786,31 @@ impl State {
800786 self.ui.set_focus(Some(field()));
801787 }
802788 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;
803794 self.ui.open_as(
804795 results(),
805796 Spec {
806797 axis: Axis::Y,
807 size: [px(RESULTS.max(right - left)), children()],
798 size: [px(width), children()],
808799 fill: Some(theme.popup),
809800 border: Some(theme.chip),
810801 shadow: Some(theme.shadow),
811802 radius: 6.0,
812803 pad: [4.0, 4.0],
813804 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),
820806 ..Spec::default()
821807 },
822808 );
823809 self.ui.open(
824810 "box",
825811 Spec {
826 size: [fill(), px(ui::shell::TOOL)],
812 flags: Flags::STILL,
813 size: [px(field_width), px(ui::shell::TOOL)],
827814 fill: Some(theme.base),
828815 border: Some(theme.accent),
829816 radius: 4.0,
......@@ -832,8 +819,12 @@ impl State {
832819 ..Spec::default()
833820 },
834821 );
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(
837828 &mut self.ui,
838829 field(),
839830 &mut self.search.query,
......@@ -846,10 +837,6 @@ impl State {
846837 );
847838 self.scope_button(theme);
848839 self.ui.close();
849 if self.shortcut(&signal, "f") {
850 self.ui.close();
851 return self.start_find(true);
852 }
853840 let query = self.search.query.trim().to_owned();
854841 let found = &self.search.found;
855842 let titled = found.iter().take_while(|found| found.in_title).count();
......@@ -916,7 +903,10 @@ impl State {
916903 "find",
917904 Spec {
918905 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 )),
920910 color: Some(theme.text_dim),
921911 ..Spec::default()
922912 },
......@@ -1018,7 +1008,10 @@ impl State {
10181008 let label = if self.search.query.trim().is_empty() {
10191009 "Find on page".to_owned()
10201010 } else if count == 0 {
1021 format!("No matches. Try {SEARCH_KEY}")
1011 format!(
1012 "No matches. Try {}",
1013 commands::shortcut(commands::Id::Search)
1014 )
10221015 } else {
10231016 format!(
10241017 "Match {} of {count}",
......@@ -1051,7 +1044,7 @@ impl State {
10511044 step = Some(true);
10521045 }
10531046 }
1054 let signal = ui::text_field(
1047 ui::text_field(
10551048 &mut self.ui,
10561049 find_field(),
10571050 &mut self.search.query,
......@@ -1065,10 +1058,6 @@ impl State {
10651058 if std::mem::take(&mut self.search.claim) {
10661059 self.ui.set_focus(Some(find_field()));
10671060 }
1068 if self.shortcut(&signal, "e") {
1069 self.start_search();
1070 return Ok(());
1071 }
10721061 if ui::shell::tool_button(&mut self.ui, "close", art::CLOSE, theme.text_dim, false).clicked
10731062 {
10741063 self.end_search();
crates/snowbound/src/settings.rs+38
......@@ -15,6 +15,7 @@ pub struct Settings {
1515 pub sidebar: bool,
1616 /// Fonts picked from the font box, latest first.
1717 pub recent_fonts: Vec<String>,
18 pub toolbar: Toolbar,
1819 /// The name edits are stored under, OneNote's User name; the account's full name until
1920 /// first saved.
2021 pub user_name: Option<String>,
......@@ -25,6 +26,31 @@ pub struct Settings {
2526 pub search_scope: crate::search::Scope,
2627}
2728
29/// What the toolbar's buttons apply from their menus' last picks.
30#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
31#[serde(default)]
32pub struct Toolbar {
33 /// COLORREFs the highlighter and the font colour button apply; `None` removes the
34 /// highlight, or makes the colour automatic.
35 pub highlight: Option<u32>,
36 pub font_color: Option<u32>,
37 /// Places in OneNote's bullet and numbering libraries picked lately, latest first.
38 pub bullets: Vec<usize>,
39 pub numbering: Vec<usize>,
40}
41
42impl Default for Toolbar {
43 /// Office's yellow highlighter and red font colour.
44 fn default() -> Self {
45 Self {
46 highlight: Some(0x00ffff),
47 font_color: Some(0x0000ff),
48 bullets: Vec::new(),
49 numbering: Vec::new(),
50 }
51 }
52}
53
2854/// The interface's colours: the system's appearance, or one chosen in Options.
2955#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
3056#[serde(rename_all = "lowercase")]
......@@ -97,6 +123,7 @@ impl crate::State {
97123 .map(|library| library.location.clone()),
98124 sidebar: self.sidebar,
99125 recent_fonts: self.recent_fonts.clone(),
126 toolbar: self.toolbar.clone(),
100127 user_name: Some(self.author.clone()),
101128 color_scheme: self.color_scheme,
102129 light_pages: self.light_pages,
......@@ -140,6 +167,12 @@ mod tests {
140167 current: Some("/notebooks/Personal".into()),
141168 sidebar: true,
142169 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 },
143176 user_name: Some("Snowbound Test".into()),
144177 color_scheme: ColorScheme::Dark,
145178 light_pages: true,
......@@ -155,6 +188,11 @@ mod tests {
155188 ..Settings::default()
156189 }
157190 );
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);
158196 std::fs::remove_dir_all(&directory).unwrap();
159197 }
160198}
crates/snowbound/src/sidebar.rs+617-292
......@@ -1,26 +1,25 @@
11//! OneNote 2010's navigation bar: the open notebooks with their sections and section
22//! groups, down the window's left, beside the section tabs.
33
4use crate::{art, library::Library, menus::Target, section_color};
4use crate::{
5 art,
6 library::Library,
7 menus::{Dragged, Target},
8 rename, section_color,
9};
510use notebook::discover::{Folder, SectionState};
611use std::{collections::HashSet, sync::Arc};
7use ui::{Axis, Event, Flags, Id, Signal, Spec, Theme, Ui, fill, fit, px};
8use winit::keyboard::{Key, NamedKey};
12use ui::{Axis, Flags, Id, Signal, Spec, Theme, Ui, fill, fit, px};
913
1014pub 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.
13pub 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.
16const RAIL: f32 = crate::TAB_ROW;
1417const ROW: f32 = 24.0;
1518/// How far each level of the tree sits inside its parent.
1619const INDENT: f32 = 16.0;
1720
1821/// What the sidebar was asked to do.
1922pub enum Action {
20 /// Expands or collapses the sidebar.
21 Toggle,
22 /// Shows a notebook where it was left.
23 Notebook(usize),
2423 /// Opens a notebook's section by catalog path.
2524 Open {
2625 notebook: usize,
......@@ -36,18 +35,8 @@ pub enum Action {
3635 Menu(Target, [f32; 2]),
3736 /// Ends renaming, with the name typed or without.
3837 Renamed(bool),
39}
40
41/// A section or group being renamed in place, and the name typed so far.
42pub struct Renaming {
43 pub library: Arc<Library>,
44 pub path: String,
45 pub name: String,
46}
47
48/// The field a row being renamed shows.
49pub fn rename_field() -> Id {
50 Id::ROOT.child("rename")
38 /// Explains why a section or group can't be read: its name and the reason.
39 Unavailable(String, String),
5140}
5241
5342/// Names a notebook's or group's rows for folding them.
......@@ -61,12 +50,35 @@ pub struct Tree<'a> {
6150 /// The open section's notebook index and catalog path.
6251 open: Option<(usize, &'a str)>,
6352 folded: &'a HashSet<String>,
64 renaming: Option<&'a mut Renaming>,
53 renaming: Option<&'a mut rename::Renaming>,
6554 action: Option<Action>,
6655 /// Each section and group row: where it lies, and what it is.
6756 rows: Vec<Entry>,
6857 /// The section or group held down, which a drag moves.
6958 held: Option<Entry>,
59 /// The row dragged, which the tree leaves out for a gap where it would land.
60 lifted: Option<(&'a Entry, &'a Landing)>,
61 /// The dragged row as it was built, for drawing it over the others.
62 ghost: Option<Ghost>,
63 /// Where the gap the dragged row would land in stands.
64 gap: Option<[f32; 4]>,
65}
66
67/// Where a dragged row would land: before a row of a folder or at its end, or into a group.
68#[derive(Clone, PartialEq)]
69pub struct Landing {
70 pub folder: String,
71 pub before: Option<String>,
72 pub into: bool,
73}
74
75/// The dragged row's look, kept to draw it over the others.
76struct Ghost {
77 label: String,
78 icon: Leading,
79 depth: u32,
80 dim: bool,
81 selected: Option<[f32; 4]>,
7082}
7183
7284impl<'a> Tree<'a> {
......@@ -74,7 +86,7 @@ impl<'a> Tree<'a> {
7486 theme: &'a Theme,
7587 open: Option<(usize, &'a str)>,
7688 folded: &'a HashSet<String>,
77 renaming: Option<&'a mut Renaming>,
89 renaming: Option<&'a mut rename::Renaming>,
7890 ) -> Self {
7991 Self {
8092 theme,
......@@ -84,6 +96,9 @@ impl<'a> Tree<'a> {
8496 action: None,
8597 rows: Vec::new(),
8698 held: None,
99 lifted: None,
100 ghost: None,
101 gap: None,
87102 }
88103 }
89104}
......@@ -95,18 +110,26 @@ pub struct Entry {
95110 pub path: String,
96111 pub group: bool,
97112 pub rect: [f32; 4],
113 /// The row's box, which a drag draws over the others.
114 pub id: Id,
98115}
99116
100117impl Entry {
101118 fn folder(&self) -> &str {
102119 self.path.rsplit_once('/').map_or("", |(folder, _)| folder)
103120 }
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 }
104127}
105128
106129/// The sidebar's header row, `header` tall, then with `rows` the tree of `notebooks` with
107130/// the open section marked.
108131fn 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);
110133 ui.open(
111134 "header",
112135 Spec {
......@@ -116,9 +139,14 @@ fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc<Library>], header: f32
116139 ..Spec::default()
117140 },
118141 );
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 );
122150 ui.leaf(
123151 "title",
124152 Spec {
......@@ -148,7 +176,7 @@ fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc<Library>], header: f32
148176 let (row, fold) = tree_row(
149177 ui,
150178 tree,
151 ("notebook", index),
179 ui.id(("notebook", index)),
152180 Row {
153181 label: &library.name,
154182 icon: Leading::Icon(art::NOTEBOOK),
......@@ -159,16 +187,18 @@ fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc<Library>], header: f32
159187 renamed: false,
160188 },
161189 );
162 if fold {
190 if row.clicked || fold {
163191 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));
166192 }
167193 if let Some(point) = row.context {
168194 tree.action = Some(Action::Menu(Target::Notebook(Arc::clone(library)), point));
169195 }
170 if unfolded && let Some(catalog) = library.catalog() {
196 if let Some(catalog) = library.catalog()
197 && unfolding(ui, ("notebook rows", index), unfolded)
198 {
171199 folder(ui, tree, library, index, catalog, 1);
200 ui.close();
201 ui.close();
172202 }
173203 }
174204 ui.close();
......@@ -192,7 +222,7 @@ fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc<Library>], header: f32
192222 let (row, _) = tree_row(
193223 ui,
194224 tree,
195 part,
225 ui.id(part),
196226 Row {
197227 label,
198228 icon: Leading::Icon(icon),
......@@ -224,9 +254,9 @@ fn folder(
224254 .filter(|(index, _)| *index == notebook)
225255 .map(|(_, path)| path);
226256 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))
230260 };
231261 for section in &folder.sections {
232262 let (name, color, readable) = match &section.state {
......@@ -241,28 +271,29 @@ fn folder(
241271 false,
242272 ),
243273 };
274 gap(ui, tree, &folder.path, Some(&section.path));
244275 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);
259290 if row.clicked && readable && open != Some(section.path.as_str()) {
260291 tree.action = Some(Action::Open {
261292 notebook,
262293 path: section.path.clone(),
263294 });
264295 }
265 entry(ui, tree, notebook, &section.path, false, &row);
296 entry(ui, tree, notebook, &section.path, id, false, &row);
266297 if let Some(point) = row.context {
267298 let target = Target::Section {
268299 library: Arc::clone(library),
......@@ -273,50 +304,200 @@ fn folder(
273304 }
274305 for group in &folder.groups {
275306 // 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) {
277308 continue;
278309 }
279310 let key = fold_key(library, &group.path);
280311 let unfolded = !tree.folded.contains(&key);
281312 let name = group.path.rsplit('/').next().unwrap_or_default();
313 gap(ui, tree, &folder.path, Some(&group.path));
282314 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(
284363 ui,
285364 tree,
286 ("group", notebook, &group.path),
365 ui.id(("unavailable", notebook, &entry.path)),
287366 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 },
290373 depth,
291 dim: false,
292 fold: Some(unfolded),
374 dim: true,
375 fold: None,
293376 selected: None,
294 renamed: renaming,
377 renamed: false,
295378 },
296379 );
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()));
310382 }
311383 }
312384}
313385
314/// Records a section or group row for dragging.
315fn 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.
389fn 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.
428fn lifts(tree: &mut Tree, notebook: usize, path: &str, row: &Row) -> bool {
429 let lifted = tree
430 .lifted
431 .is_some_and(|(lifted, _)| lifted.notebook == notebook && lifted.path == path);
432 if lifted {
433 tree.ghost = Some(Ghost {
434 label: row.label.to_owned(),
435 icon: row.icon,
436 depth: row.depth,
437 dim: row.dim,
438 selected: row.selected,
439 });
440 }
441 lifted
442}
443
444/// The gap before the row of `before` in `folder`, or at its end, which opens where a
445/// dragged row would land and closes where it no longer would.
446fn gap(ui: &mut Ui, tree: &mut Tree, folder: &str, before: Option<&str>) {
447 let Some((_, landing)) = tree.lifted else {
448 return;
449 };
450 let here = !landing.into && landing.folder == folder && landing.before.as_deref() == before;
451 let id = ui.id(("gap", before));
452 let height = ui.animate(id, if here { ROW } else { 0.0 });
453 if here {
454 tree.gap = ui.rect(id);
455 }
456 if height > 0.0 {
457 ui.leaf(
458 ("gap", before),
459 Spec {
460 size: [fill(), px(height)],
461 ..Spec::default()
462 },
463 );
464 }
465}
466
467/// Opens the boxes a notebook's or group's rows ease open and shut in, where they show:
468/// the caller builds the rows and closes both.
469fn unfolding(ui: &mut Ui, part: impl std::hash::Hash, unfolded: bool) -> bool {
470 let outer = ui.id(part);
471 let rows = ui
472 .rect(outer.child("rows"))
473 .map_or(0.0, |rect| rect[3] - rect[1]);
474 let height = ui.animate(outer, if unfolded { rows } else { 0.0 });
475 if !unfolded && height == 0.0 {
476 return false;
477 }
478 ui.open_as(
479 outer,
480 Spec {
481 flags: Flags::CLIP,
482 axis: Axis::Y,
483 size: [fill(), px(height)],
484 ..Spec::default()
485 },
486 );
487 ui.open(
488 "rows",
489 Spec {
490 axis: Axis::Y,
491 size: [fill(), ui::children()],
492 ..Spec::default()
493 },
494 );
495 true
496}
497
498/// Records a section or group row, built as `id`, for dragging.
499fn entry(ui: &Ui, tree: &mut Tree, notebook: usize, path: &str, id: Id, group: bool, row: &Signal) {
500 let Some(rect) = ui.rect(id) else {
320501 return;
321502 };
322503 let entry = Entry {
......@@ -324,6 +505,7 @@ fn entry(ui: &Ui, tree: &mut Tree, notebook: usize, path: &str, group: bool, row
324505 path: path.to_owned(),
325506 group,
326507 rect,
508 id,
327509 };
328510 if row.dragging {
329511 tree.held = Some(entry.clone());
......@@ -331,10 +513,11 @@ fn entry(ui: &Ui, tree: &mut Tree, notebook: usize, path: &str, group: bool, row
331513 tree.rows.push(entry);
332514}
333515
516#[derive(Clone, Copy)]
334517enum Leading {
335518 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]),
338521}
339522
340523struct Row<'a> {
......@@ -351,13 +534,13 @@ struct Row<'a> {
351534}
352535
353536/// One row of the tree: its signal, and whether its fold arrow was clicked.
354fn tree_row(ui: &mut Ui, tree: &mut Tree, part: impl std::hash::Hash, row: Row) -> (Signal, bool) {
537fn tree_row(ui: &mut Ui, tree: &mut Tree, id: Id, row: Row) -> (Signal, bool) {
355538 let theme = tree.theme;
356539 let lit = row
357540 .selected
358541 .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,
361544 Spec {
362545 flags: Flags::CLICKABLE,
363546 size: [fill(), px(ROW)],
......@@ -365,69 +548,31 @@ fn tree_row(ui: &mut Ui, tree: &mut Tree, part: impl std::hash::Hash, row: Row)
365548 hover_fill: Some(ui::mix(lit.unwrap_or(theme.strip), theme.hover(), 0.6)),
366549 radius: 4.0,
367550 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 },
369553 ..Spec::default()
370554 },
371555 );
372556 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 );
407572 match (row.renamed, tree.renaming.as_deref_mut()) {
408573 (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));
431576 }
432577 }
433578 _ => {
......@@ -464,14 +609,14 @@ fn tree_row(ui: &mut Ui, tree: &mut Tree, part: impl std::hash::Hash, row: Row)
464609}
465610
466611impl 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 {
469614 if self.temporary {
470 return;
615 return 0.0;
471616 }
472617 let width = self.ui.animate(
473618 self.ui.id("sidebar"),
474 if self.sidebar { WIDTH } else { RAIL },
619 if self.sidebar { WIDTH } else { 0.0 },
475620 );
476621 self.ui.open(
477622 "sidebar",
......@@ -500,39 +645,43 @@ impl crate::State {
500645 Some((index, session.tabs[session.tab].path.as_str()))
501646 });
502647 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 });
504656 sidebar(
505657 &mut self.ui,
506658 &mut tree,
507659 &self.notebooks,
508660 crate::TAB_ROW,
509 width > RAIL + 0.5,
661 width > 0.5,
510662 );
511663 let Tree {
512 action, rows, held, ..
664 action,
665 rows,
666 mut held,
667 ghost,
668 gap,
669 ..
513670 } = tree;
514671 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);
516676 self.ui.close();
517677 self.ui.close();
518678 match action {
519 Some(Action::Toggle) => {
520 self.sidebar = !self.sidebar;
521 self.save_settings();
522 }
523679 Some(Action::Fold(key)) => {
524680 if !self.folded.remove(&key) {
525681 self.folded.insert(key);
526682 }
527683 }
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() => {
536685 let library = Arc::clone(&self.notebooks[notebook]);
537686 self.commands
538687 .push(crate::Command::OpenSection(library, path));
......@@ -541,120 +690,208 @@ impl crate::State {
541690 self.menu = Some((target, point));
542691 self.ui.open_popup(crate::menus::id());
543692 }
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),
562694 Some(Action::NewNotebook) => self.commands.push(crate::Command::NewNotebook),
563695 Some(Action::OpenNotebook) => self.commands.push(crate::Command::OpenNotebook),
564696 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;
566712 }
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)
567810 }
568811
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`.
573817 fn drag_entries(
574818 &mut self,
575819 held: Option<Entry>,
576820 rows: &[Entry],
821 gap: Option<[f32; 4]>,
822 settled: bool,
577823 corner: [f32; 4],
578824 theme: &Theme,
579825 ) {
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 {
591857 return;
592858 };
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
604866 else {
605867 return;
606868 };
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
616875 .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 })
640886 .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;
652889 }
653 };
654 if held.is_some() {
890 }
891 if live && landing.into {
655892 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;
658895 self.ui.mark(
659896 [
660897 left - corner[0],
......@@ -665,28 +902,38 @@ impl crate::State {
665902 [accent[0], accent[1], accent[2], 0.3],
666903 4.0,
667904 );
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 );
684905 }
685 } else {
686 let library = std::sync::Arc::clone(&self.notebooks[dragged.notebook]);
687 self.commands
688 .push(crate::Command::Structure(library, change));
689906 }
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));
690937 }
691938
692939 /// Opens what the open panel chose: a notebook folder, a notebook's table of contents,
......@@ -745,13 +992,64 @@ impl crate::State {
745992 /// The window below the title bar on the first run, or once every notebook is closed:
746993 /// what is missing, and the two ways to start.
747994 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 ) {
7481046 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,
7521050 Spec {
7531051 size: [fill(), fill()],
754 fill: Some(theme.base),
1052 fill: Some(background),
7551053 ..Spec::default()
7561054 },
7571055 );
......@@ -773,26 +1071,13 @@ impl crate::State {
7731071 "title",
7741072 Spec {
7751073 size: [fill(), px(28.0)],
776 text: Some("No notebooks open"),
1074 text: Some(title),
7771075 center: true,
7781076 ..Spec::default()
7791077 },
7801078 );
7811079 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 {
7961081 let button = self.ui.leaf(
7971082 part,
7981083 Spec {
......@@ -817,3 +1102,43 @@ impl crate::State {
8171102 self.ui.close();
8181103 }
8191104}
1105
1106#[cfg(test)]
1107mod tests {
1108 use super::*;
1109
1110 fn row(path: &str, group: bool, top: f32) -> Entry {
1111 Entry {
1112 notebook: 0,
1113 path: path.to_owned(),
1114 group,
1115 rect: [0.0, top, 200.0, top + ROW],
1116 id: Id::ROOT.child(path),
1117 }
1118 }
1119
1120 /// A dragged row lands before or after a row by its middle, into a group by the group's
1121 /// middle half, and at the end of another folder.
1122 #[test]
1123 fn a_dragged_row_lands_between_rows_into_groups_and_last_in_other_folders() {
1124 let rows = [
1125 row("A.one", false, 0.0),
1126 row("B.one", false, 24.0),
1127 row("G", true, 48.0),
1128 row("G/C.one", false, 72.0),
1129 ];
1130 let lifted = row("Z.one", false, 200.0);
1131 let landing = |y| landing_at(&rows, &lifted, y).unwrap();
1132 let at = |folder: &str, before: Option<&str>, into| Landing {
1133 folder: folder.to_owned(),
1134 before: before.map(str::to_owned),
1135 into,
1136 };
1137 assert!(landing(4.0) == at("", Some("A.one"), false));
1138 assert!(landing(20.0) == at("", Some("B.one"), false));
1139 assert!(landing(60.0) == at("G", None, true));
1140 assert!(landing(50.0) == at("", Some("G"), false));
1141 assert!(landing(80.0) == at("G", None, false));
1142 assert!(landing(500.0) == at("G", None, false));
1143 }
1144}
crates/snowbound/src/templates.rs+1-1
......@@ -418,7 +418,7 @@ impl crate::State {
418418 .chain(editor.caret_outline())
419419 .filter(|outline| !outline.title)
420420 .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) {
422422 self.templates = View::Strip;
423423 return;
424424 }
crates/ui/examples/popups.rs+194-13
......@@ -65,10 +65,176 @@ fn main() {
6565 render(panels, 5, &path);
6666 println!("{path}");
6767 }
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 }
6892 }
6993 measure();
7094}
7195
96/// Builds a frame's popup, given the ids of the combo and the split button's arrow.
97type Build<'a> = dyn Fn(&mut Ui, [Id; 2]) + 'a;
98
99/// Popup `id` as `build` builds it, every 16 ms from the frame it opens until it has
100/// closed again; no frame is asked for once each settles.
101fn opening_and_closing(
102 theme: &Theme,
103 device: &wgpu::Device,
104 queue: &wgpu::Queue,
105 renderer: &mut draw::Renderer,
106 id: Id,
107 build: &Build<'_>,
108) -> Vec<Vec<u8>> {
109 let mut scene = Scene {
110 ui: Ui::new(theme.clone(), Duration::from_millis(500)),
111 start: Instant::now(),
112 frames: 0,
113 };
114 scene.frame(|_, _| {});
115 scene.frame(|ui, ids| {
116 ui.open_popup(id);
117 build(ui, ids);
118 });
119 let mut panels = vec![paint(device, queue, renderer, &scene.ui)];
120 for _ in 0..17 {
121 scene.frame(build);
122 panels.push(paint(device, queue, renderer, &scene.ui));
123 }
124 assert!(!scene.ui.wants_frame(), "settled open");
125 scene.ui.close_popup(id);
126 for _ in 0..12 {
127 scene.frame(build);
128 panels.push(paint(device, queue, renderer, &scene.ui));
129 }
130 assert!(!scene.ui.wants_frame(), "settled closed");
131 panels
132}
133
134/// A search's dropdown over the box at `combo`, as the app's search box opens: its field
135/// holds the box's place and widens while the panel comes in around it.
136fn results(ui: &mut Ui, id: Id, combo: Id) {
137 if !ui.popup_open(id) {
138 return;
139 }
140 let theme = ui.theme.clone();
141 let [left, top, right, bottom] = ui.rect(combo).unwrap_or_default();
142 let anchor = Anchor::Over([left - 4.0, top - 4.0, right + 4.0, bottom + 4.0]);
143 let width = 300.0;
144 let open = ui.opening(id, anchor).unwrap_or(1.0);
145 ui.open_as(
146 id,
147 Spec {
148 axis: ui::Axis::Y,
149 size: [px(width), ui::children()],
150 fill: Some(theme.popup),
151 border: Some(theme.chip),
152 shadow: Some(theme.shadow),
153 radius: 6.0,
154 pad: [4.0, 4.0],
155 gap: 4.0,
156 anchor: Some(anchor),
157 ..Spec::default()
158 },
159 );
160 ui.leaf(
161 "box",
162 Spec {
163 flags: ui::Flags::STILL,
164 size: [
165 px(right - left + (width - 8.0 - (right - left)) * open),
166 px(bottom - top),
167 ],
168 text: Some("Search All Notebooks (⌘E)"),
169 color: Some(theme.text_dim),
170 fill: Some(theme.base),
171 border: Some(theme.accent),
172 radius: 4.0,
173 pad: [6.0, 0.0],
174 ..Spec::default()
175 },
176 );
177 for (index, page) in ["Meeting notes", "Quarterly planning", "Reading list"]
178 .iter()
179 .enumerate()
180 {
181 ui.leaf(
182 index,
183 Spec {
184 size: [fill(), px(22.0)],
185 text: Some(page),
186 pad: [8.0, 0.0],
187 ..Spec::default()
188 },
189 );
190 }
191 ui.close();
192}
193
194/// A small dialog, as Options opens over the window.
195fn dialog(ui: &mut Ui, id: Id) {
196 if !ui.popup_open(id) {
197 return;
198 }
199 let theme = ui.theme.clone();
200 ui.open_as(
201 id,
202 Spec {
203 axis: ui::Axis::Y,
204 size: [px(260.0), ui::children()],
205 fill: Some(theme.popup),
206 border: Some(theme.chip),
207 shadow: Some(theme.shadow),
208 radius: 8.0,
209 pad: [14.0, 12.0],
210 gap: 8.0,
211 anchor: Some(Anchor::Dialog),
212 ..Spec::default()
213 },
214 );
215 ui.leaf(
216 "title",
217 Spec {
218 size: [fill(), ui::fit()],
219 text: Some("Options"),
220 bold: true,
221 ..Spec::default()
222 },
223 );
224 ui.leaf(
225 "body",
226 Spec {
227 size: [fill(), ui::fit()],
228 text: Some("These choices apply to every notebook, and are kept when OK is chosen."),
229 overflow: ui::Overflow::Wrap,
230 color: Some(theme.text_dim),
231 ..Spec::default()
232 },
233 );
234 ui::button(ui, "ok", "OK");
235 ui.close();
236}
237
72238/// Frames a change shows at: before it, then each of the 150 ms it takes.
73239const FRAMES: [u32; 9] = [1, 2, 3, 4, 5, 6, 7, 8, 10];
74240
......@@ -344,6 +510,7 @@ impl Scene {
344510 art!("icons/font-color"),
345511 Some(draw::srgb(0xe8, 0x3a, 0x30)),
346512 false,
513 Id::ROOT.child("font color"),
347514 );
348515 ui.close();
349516 ui.close();
......@@ -568,7 +735,7 @@ fn highlight(scene: &mut Scene) {
568735 let id = Id::ROOT.child("highlight");
569736 scene.open(id, vec![key(NamedKey::ArrowDown)], |ui, [_, split]| {
570737 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);
572739 });
573740}
574741
......@@ -601,14 +768,34 @@ fn palette(scene: &mut Scene) {
601768fn tags(scene: &mut Scene) {
602769 let tags = [
603770 ("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 ),
605776 ("Question", None, "⌘3"),
606777 ("Remember for later", Some(art!("tags/remember")), "⌘4"),
607778 ("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 ),
612799 ("Web site to visit", None, ""),
613800 ("Idea", None, ""),
614801 ("Password", None, ""),
......@@ -686,13 +873,7 @@ fn paint(
686873 let layers: Vec<_> = interface
687874 .iter()
688875 .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)),
696877 ui::Layer::Custom { .. } => None,
697878 })
698879 .collect();
crates/ui/src/layout.rs+40-6
......@@ -1,12 +1,22 @@
1use crate::{Axis, Built, Flags, Id, Size, State};
1use crate::{Axis, Built, Flags, ICON, ICON_GAP, Id, Overflow, Size, State, text::Texts};
22use std::collections::HashMap;
33
44/// Sizes each box on both axes, then places it: standalone sizes, sizes taken from
55/// 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
78/// index order is pre-order.
8pub(crate) fn solve(nodes: &mut [Built], states: &HashMap<Id, State>, scale: f32) {
9pub(crate) fn solve(
10 nodes: &mut [Built],
11 states: &HashMap<Id, State>,
12 scale: f32,
13 texts: &mut Texts,
14 frame: u64,
15) {
916 for axis in 0..2 {
17 if axis == 1 {
18 fit_labels(nodes, texts, frame);
19 }
1020 for node in nodes.iter_mut() {
1121 node.computed[axis] = match node.size[axis].size {
1222 Size::Pixels(pixels) => pixels,
......@@ -47,7 +57,7 @@ pub(crate) fn solve(nodes: &mut [Built], states: &HashMap<Id, State>, scale: f32
4757 if node.children.is_empty() || (axis == 1 && node.flags.contains(Flags::SCROLL)) {
4858 continue;
4959 }
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);
5161 let children: Vec<_> = in_flow(nodes, index).collect();
5262 if along(node, axis) {
5363 let excess = flow(nodes, index, axis) - room;
......@@ -110,8 +120,9 @@ pub(crate) fn solve(nodes: &mut [Built], states: &HashMap<Id, State>, scale: f32
110120 } else {
111121 0.0
112122 };
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;
115126 nodes[index].rect = [
116127 snap(x),
117128 snap(y),
......@@ -145,3 +156,26 @@ fn flow(nodes: &[Built], index: usize, axis: usize) -> f32 {
145156 sizes.fold(0.0, f32::max)
146157 }
147158}
159
160fn fit_labels(nodes: &mut [Built], texts: &mut Texts, frame: u64) {
161 for node in nodes.iter_mut() {
162 let Some(label) = node.label.clone() else {
163 continue;
164 };
165 let icon = if node.icon.is_some() || node.image.is_some() {
166 ICON + ICON_GAP
167 } else {
168 0.0
169 };
170 let [left, _, right, _] = node.inset;
171 let width = (node.computed[0] - left - right - 2.0 * node.pad[0] - icon).max(0.0);
172 if label.size[0] <= width {
173 continue;
174 }
175 node.label = match node.overflow {
176 Overflow::Clip => continue,
177 Overflow::Wrap => Some(label.wrapped(width, node.center)),
178 Overflow::Ellipsis => Some(texts.ellipsis(&label, width, frame)),
179 };
180 }
181}
crates/ui/src/lib.rs+351-51
......@@ -43,6 +43,9 @@ const POPUP_SHADOW: [f32; 2] = [12.0, 4.0];
4343/// Logical size of a box's icon, and its distance from the label.
4444const ICON: f32 = 16.0;
4545const ICON_GAP: f32 = 6.0;
46/// Seconds a dialog and other popups take to open and to close.
47const DIALOG: [f32; 2] = [0.24, 0.16];
48const POPUP: [f32; 2] = [0.16, 0.12];
4649
4750/// A box's identity across frames: its parent's id combined with a builder-chosen part.
4851#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
......@@ -136,6 +139,9 @@ impl Flags {
136139 pub const FLOAT: Flags = Flags(16);
137140 /// The host paints it and receives the events routed to it.
138141 pub const CUSTOM: Flags = Flags(32);
142 /// Paints in place while the popup around it opens, as a field standing in for the one
143 /// it opened from.
144 pub const STILL: Flags = Flags(64);
139145
140146 pub(crate) fn contains(self, other: Flags) -> bool {
141147 self.0 & other.0 == other.0
......@@ -165,6 +171,18 @@ pub enum Shape {
165171 Tab { lean: f32 },
166172}
167173
174/// What a label does where it is wider than its box.
175#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
176pub enum Overflow {
177 /// Runs on as one line, cut at the box's edge.
178 #[default]
179 Clip,
180 /// Breaks into lines across the box, which grows to their height when sized by its text.
181 Wrap,
182 /// Stays one line, ending in an ellipsis where it is cut.
183 Ellipsis,
184}
185
168186/// Where a popup opens, flipping to the far side of its anchor where the window ends first.
169187#[derive(Clone, Copy, Debug, PartialEq)]
170188pub enum Anchor {
......@@ -174,8 +192,10 @@ pub enum Anchor {
174192 Right([f32; 4]),
175193 /// Over the rectangle from its corner, as a combo box opens into its own list.
176194 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.
178196 Point([f32; 2]),
197 /// Centred in the window over the interface, which dims, as a dialog opens.
198 Dialog,
179199}
180200
181201impl Anchor {
......@@ -187,6 +207,7 @@ impl Anchor {
187207 Anchor::Right(rect) => (rect, Some(0)),
188208 Anchor::Over(rect) => (rect, None),
189209 Anchor::Point([x, y]) => ([x, y, x, y], Some(1)),
210 Anchor::Dialog => return ((room - size) / 2.0).max(0.0),
190211 };
191212 let [low, high] = [rect[axis], rect[axis + 2]];
192213 let (first, second) = if along == Some(axis) {
......@@ -202,6 +223,51 @@ impl Anchor {
202223 first.min(room - size).max(0.0)
203224 }
204225 }
226
227 /// How a popup laid out at `rect` beside the anchor shows `open` of the way open: it
228 /// grows out of the anchor as it fades in, and a dialog swings up into place.
229 fn motion(self, rect: [f32; 4], open: f32) -> Motion {
230 let pivot = match self {
231 Anchor::Below(anchor) => [anchor[0], anchor[3]],
232 Anchor::Right(anchor) => [anchor[2], anchor[1]],
233 Anchor::Over(anchor) => [anchor[0], anchor[1]],
234 Anchor::Point(point) => point,
235 Anchor::Dialog => [(rect[0] + rect[2]) / 2.0, rect[1]],
236 };
237 let (from, tilt) = match self {
238 Anchor::Dialog => (0.95, 0.4),
239 Anchor::Over(_) => (0.94, 0.25),
240 _ => (0.94, 0.0),
241 };
242 Motion {
243 zoom: from + (1.0 - from) * open,
244 pivot: [
245 pivot[0].clamp(rect[0], rect[2]),
246 pivot[1].clamp(rect[1], rect[3]),
247 ],
248 tilt: tilt * (1.0 - open),
249 opacity: open,
250 }
251 }
252
253 /// Seconds the popup takes to open and to close.
254 fn durations(self) -> [f32; 2] {
255 if self == Anchor::Dialog {
256 DIALOG
257 } else {
258 POPUP
259 }
260 }
261}
262
263/// A box and what it holds scaled by `zoom` about `pivot`, leaned back `tilt` radians about
264/// the line through it, and faded; logical pixels.
265#[derive(Clone, Copy, Debug, PartialEq)]
266struct Motion {
267 zoom: f32,
268 pivot: [f32; 2],
269 tilt: f32,
270 opacity: f32,
205271}
206272
207273/// What a box is this frame. Colours are linear RGBA.
......@@ -218,6 +284,7 @@ pub struct Spec<'a> {
218284 /// Shapes the label semibold.
219285 pub bold: bool,
220286 pub font: Option<&'a str>,
287 pub overflow: Overflow,
221288 /// The label's colour; the theme's text colour otherwise.
222289 pub color: Option<[f32; 4]>,
223290 pub fill: Option<[f32; 4]>,
......@@ -246,6 +313,9 @@ pub struct Spec<'a> {
246313 pub center: bool,
247314 /// For floating boxes, the offset from the parent's corner.
248315 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],
249319 pub cursor: Option<CursorIcon>,
250320 /// Floats the box over all others beside a rectangle in the window, as a popup
251321 /// `Ui::open_popup` opened; boxes beneath take no input while one is open.
......@@ -291,13 +361,9 @@ pub struct Signal {
291361 pub events: Vec<Event>,
292362}
293363
294/// A frame's painting, in order: interface primitives under a clip, or a host-painted box.
364/// A frame's painting, in order: interface primitives, or a host-painted box.
295365pub enum Layer<'a> {
296 Primitives {
297 /// Logical pixels.
298 clip: Option<[f32; 4]>,
299 primitives: Vec<Primitive<'a>>,
300 },
366 Primitives(Primitives<'a>),
301367 Custom {
302368 id: Id,
303369 /// The box's visible logical rectangle.
......@@ -305,6 +371,52 @@ pub enum Layer<'a> {
305371 },
306372}
307373
374/// Interface primitives under a clip, in logical pixels, perhaps part of the way through
375/// opening or closing.
376pub struct Primitives<'a> {
377 pub clip: Option<[f32; 4]>,
378 motion: Option<Motion>,
379 pub primitives: Vec<Primitive<'a>>,
380}
381
382impl Primitives<'_> {
383 /// The layer the renderer paints at `scale` device pixels per logical pixel.
384 pub fn layer(&self, scale: f32) -> draw::Layer<'_> {
385 let Motion {
386 zoom,
387 pivot,
388 tilt,
389 opacity,
390 } = self.motion.unwrap_or(Motion {
391 zoom: 1.0,
392 pivot: [0.0; 2],
393 tilt: 0.0,
394 opacity: 1.0,
395 });
396 let device = |point: [f32; 2]| point.map(|value| value * scale);
397 let moved = |point: [f32; 2]| {
398 device(std::array::from_fn(|axis| {
399 pivot[axis] + (point[axis] - pivot[axis]) * zoom
400 }))
401 };
402 draw::Layer {
403 scale: scale * zoom,
404 origin: moved([0.0; 2]),
405 clip: self.clip.map(|[left, top, right, bottom]| {
406 let [[left, top], [right, bottom]] = [moved([left, top]), moved([right, bottom])];
407 [left, top, right, bottom]
408 }),
409 backdrop: None,
410 motion: self.motion.map(|_| draw::Motion {
411 opacity,
412 tilt,
413 pivot: device(pivot),
414 }),
415 primitives: &self.primitives,
416 }
417 }
418}
419
308420struct Built {
309421 id: Id,
310422 parent: usize,
......@@ -313,6 +425,7 @@ struct Built {
313425 size: [Extent; 2],
314426 axis: Axis,
315427 label: Option<Rc<Label>>,
428 overflow: Overflow,
316429 color: [f32; 4],
317430 fill: Option<[f32; 4]>,
318431 gradient: Option<[f32; 4]>,
......@@ -329,8 +442,10 @@ struct Built {
329442 gap: f32,
330443 center: bool,
331444 position: [f32; 2],
445 offset: [f32; 2],
332446 cursor: Option<CursorIcon>,
333447 anchor: Option<Anchor>,
448 motion: Option<Motion>,
334449 /// Rectangles relative to the box, painted over its fill.
335450 marks: Vec<([f32; 4], [f32; 4], f32)>,
336451 computed: [f32; 2],
......@@ -353,6 +468,8 @@ struct State {
353468 /// A text field's selection, and the selection and unit of the press a drag extends.
354469 selection: Selection,
355470 press: (Selection, SelectionUnit),
471 /// The field selects all its text when next built.
472 select_all: bool,
356473 /// Where a dragged scrollbar thumb was taken, from its start.
357474 grab: f32,
358475 /// An animated value and its target, for `Ui::animate`.
......@@ -369,6 +486,16 @@ struct Popup {
369486 highlight: Option<u64>,
370487 /// The height its results ease from and to, and the seconds since they set out.
371488 height: Option<[f32; 3]>,
489 opened: Instant,
490}
491
492/// A popup's painting as it last showed, fading out since it closed.
493struct Closing {
494 id: Id,
495 display: Vec<Display>,
496 anchor: Anchor,
497 rect: [f32; 4],
498 closed: Instant,
372499}
373500
374501struct Hit {
......@@ -378,8 +505,10 @@ struct Hit {
378505 cursor: Option<CursorIcon>,
379506}
380507
508#[derive(Clone)]
381509enum Display {
382510 Clip(Option<[f32; 4]>),
511 Motion(Option<Motion>),
383512 Rect {
384513 rect: [f32; 4],
385514 fill: [f32; 4],
......@@ -425,6 +554,8 @@ enum Display {
425554
426555pub struct Ui {
427556 pub theme: Theme,
557 /// The colours icons' slots take.
558 pub icon_palette: draw::Palette,
428559 /// Whether the window has keyboard focus; without it a field hides its caret and dims
429560 /// its selection.
430561 pub window_focused: bool,
......@@ -456,6 +587,10 @@ pub struct Ui {
456587 display: Vec<Display>,
457588 /// Where the popups' painting starts in `display`, for edges drawn beneath them.
458589 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>,
459594 animating: bool,
460595 /// This frame routed input, whose effects the builder may only have seen after boxes
461596 /// built before it read their state; one more frame shows them.
......@@ -471,6 +606,7 @@ impl Ui {
471606 pub fn new(theme: Theme, double_click: Duration) -> Self {
472607 Self {
473608 theme,
609 icon_palette: draw::Palette::default(),
474610 window_focused: true,
475611 frame: 0,
476612 now: Instant::now(),
......@@ -495,6 +631,8 @@ impl Ui {
495631 clicks: Clicks::new(double_click),
496632 display: Vec::new(),
497633 popups_painted: 0,
634 painted: Vec::new(),
635 closing: Vec::new(),
498636 animating: false,
499637 routed: false,
500638 caret: None,
......@@ -538,6 +676,15 @@ impl Ui {
538676 self.focus = id;
539677 }
540678
679 /// Focuses text field `id` with all its text selected once it is built, as a field
680 /// opening to rename something does.
681 pub fn focus_all(&mut self, id: Id) {
682 let state = self.states.entry(id).or_default();
683 state.touched = self.frame;
684 state.select_all = true;
685 self.focus = Some(id);
686 }
687
541688 /// The clickable or custom box at `point` in the latest layout.
542689 pub fn box_at(&self, point: [f32; 2]) -> Option<Id> {
543690 self.hit(point, Flags::CLICKABLE | Flags::CUSTOM)
......@@ -767,6 +914,7 @@ impl Ui {
767914 query: String::new(),
768915 highlight: None,
769916 height: None,
917 opened: self.now,
770918 });
771919 self.focus = Some(id);
772920 self.states.entry(id).or_default().touched = self.frame;
......@@ -838,11 +986,10 @@ impl Ui {
838986 /// Opens a box under an id chosen outside the hierarchy, so the host can name it
839987 /// wherever it is built.
840988 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| {
844990 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)
846993 });
847994 // Popups hang from the root, outside the clips and flow of where they are built.
848995 let parent = if spec.anchor.is_some() {
......@@ -872,6 +1019,15 @@ impl Ui {
8721019 }
8731020
8741021 /// 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
8751031 pub fn animate(&mut self, id: Id, target: f32) -> f32 {
8761032 let state = self.states.entry(id).or_default();
8771033 state.touched = self.frame;
......@@ -881,12 +1037,6 @@ impl Ui {
8811037 *value
8821038 }
8831039
884 /// Shapes labels in the family the font `files` define, in place of the system's
885 /// interface font. Returns the family's name, or None when the files hold no font.
886 pub fn use_fonts(&mut self, files: impl IntoIterator<Item = Vec<u8>>) -> Option<String> {
887 self.texts.use_fonts(files)
888 }
889
8901040 /// Previews `family` in the font `data` holds, as a font menu shows the substitute a
8911041 /// missing family lays out in.
8921042 pub fn preview_font(&mut self, data: parley::fontique::Blob<u8>, family: &str) {
......@@ -937,7 +1087,13 @@ impl Ui {
9371087 /// Solves this frame's layout and prepares its painting.
9381088 pub fn end(&mut self) {
9391089 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 );
9411097 for node in &self.nodes {
9421098 let state = self.states.entry(node.id).or_default();
9431099 state.rect = node.rect;
......@@ -960,16 +1116,74 @@ impl Ui {
9601116 {
9611117 self.close_from(gone);
9621118 }
963 self.display.clear();
1119 let last = std::mem::take(&mut self.display);
1120 let last_popups = self.popups_painted;
9641121 self.hits.clear();
965 self.paint(0, None);
1122 self.paint(0, None, None);
9661123 self.popups_painted = self.display.len();
9671124 let beneath = self.hits.len();
1125 let mut painted = Vec::new();
9681126 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));
9711135 }
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 ));
9721144 }
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();
9731187 self.modal = if self.popups.is_empty() { 0 } else { beneath };
9741188 for id in [&mut self.hover, &mut self.active, &mut self.focus] {
9751189 if id.is_some_and(|id| !self.states.contains_key(&id)) {
......@@ -978,19 +1192,50 @@ impl Ui {
9781192 }
9791193 }
9801194
981 fn paint(&mut self, index: usize, clip: Option<[f32; 4]>) {
1195 /// How far open popup `id` beside `anchor` shows this frame, from 0 to 1, while open.
1196 pub fn opening(&mut self, id: Id, anchor: Anchor) -> Option<f32> {
1197 let opened = self.popups.iter().find(|popup| popup.id == id)?.opened;
1198 Some(1.0 - (1.0 - self.progress(opened, anchor.durations()[0])).powi(3))
1199 }
1200
1201 /// How far through an animation `duration` seconds long that began at `start` this
1202 /// frame is, asking for frames until it ends.
1203 fn progress(&mut self, start: Instant, duration: f32) -> f32 {
1204 let progress =
1205 (self.now.saturating_duration_since(start).as_secs_f32() / duration).min(1.0);
1206 self.animating |= progress < 1.0;
1207 progress
1208 }
1209
1210 /// Dims the window beneath a dialog `open` of the way open.
1211 fn scrim(&mut self, anchor: Anchor, open: f32) {
1212 if anchor == Anchor::Dialog {
1213 self.display.push(Display::Rect {
1214 rect: self.nodes[0].rect,
1215 fill: [0.0, 0.0, 0.0, self.theme.shadow[3] * 0.5 * open],
1216 shade: None,
1217 border: None,
1218 radius: 0.0,
1219 });
1220 }
1221 }
1222
1223 fn paint(&mut self, index: usize, clip: Option<[f32; 4]>, motion: Option<Motion>) {
9821224 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 }
9831234 let state = &self.states[&node.id];
9841235 let rect = node.rect;
9851236 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.
9881237 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)),
9941239 (base, None) => base,
9951240 };
9961241 let fill = blend(node.fill, node.hover_fill);
......@@ -1085,7 +1330,12 @@ impl Ui {
10851330 let rect = if node.label.is_some() {
10861331 [x, top, x + ICON, top + ICON]
10871332 } 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 ]
10891339 };
10901340 self.display.push(Display::Image {
10911341 image: image.clone(),
......@@ -1130,30 +1380,47 @@ impl Ui {
11301380 }
11311381 for child in node.children.clone() {
11321382 if self.nodes[child].anchor.is_none() {
1133 self.paint(child, inner_clip);
1383 self.paint(child, inner_clip, own.unwrap_or(motion));
11341384 }
11351385 }
11361386 if inner_clip != clip {
11371387 self.display.push(Display::Clip(clip));
11381388 }
1389 if own.is_some() {
1390 self.display.push(Display::Motion(motion));
1391 }
11391392 }
11401393
11411394 /// This frame's painting in order, in logical pixels.
11421395 pub fn layers(&self) -> Vec<Layer<'_>> {
11431396 let mut layers = Vec::new();
11441397 let mut clip = None;
1398 let mut motion = None;
11451399 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 }
11461414 for item in &self.display {
11471415 match item {
11481416 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);
11551418 clip = *next;
11561419 }
1420 Display::Motion(next) => {
1421 flush(&mut layers, clip, motion, &mut primitives);
1422 motion = *next;
1423 }
11571424 Display::Rect {
11581425 rect,
11591426 fill,
......@@ -1221,6 +1488,7 @@ impl Ui {
12211488 origin: *origin,
12221489 size: ICON,
12231490 tint: *tint,
1491 palette: self.icon_palette,
12241492 }),
12251493 Display::Text {
12261494 label,
......@@ -1234,12 +1502,7 @@ impl Ui {
12341502 ink: *color,
12351503 }),
12361504 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);
12431506 layers.push(Layer::Custom {
12441507 id: *id,
12451508 rect: *rect,
......@@ -1247,9 +1510,7 @@ impl Ui {
12471510 }
12481511 }
12491512 }
1250 if !primitives.is_empty() {
1251 layers.push(Layer::Primitives { clip, primitives });
1252 }
1513 flush(&mut layers, clip, motion, &mut primitives);
12531514 layers
12541515 }
12551516
......@@ -1257,9 +1518,16 @@ impl Ui {
12571518 (&mut self.texts, self.frame)
12581519 }
12591520
1260 pub(crate) fn field(&mut self, id: Id) -> (&mut Selection, &mut (Selection, SelectionUnit)) {
1521 pub(crate) fn field(
1522 &mut self,
1523 id: Id,
1524 ) -> (&mut Selection, &mut (Selection, SelectionUnit), &mut bool) {
12611525 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 )
12631531 }
12641532
12651533 pub(crate) fn grab(&mut self, id: Id) -> &mut f32 {
......@@ -1293,6 +1561,7 @@ impl Built {
12931561 size: spec.size,
12941562 axis: spec.axis,
12951563 label,
1564 overflow: spec.overflow,
12961565 color: spec.color.unwrap_or(text),
12971566 fill: spec.fill,
12981567 gradient: spec.gradient,
......@@ -1309,8 +1578,10 @@ impl Built {
13091578 gap: spec.gap,
13101579 center: spec.center,
13111580 position: spec.position,
1581 offset: spec.offset,
13121582 cursor: spec.cursor,
13131583 anchor: spec.anchor,
1584 motion: None,
13141585 marks: Vec::new(),
13151586 computed: [0.0; 2],
13161587 relative: [0.0; 2],
......@@ -1407,9 +1678,38 @@ impl Corner {
14071678 }
14081679}
14091680
1410/// `a` moved `t` of the way to `b`.
1681/// Where a box dragged from index `from` of a list laid out at `spans`, each a start and a
1682/// length along it, lands with its middle at `middle`: after every other box whose middle it
1683/// has passed, as browser tabs reorder. The index is in the list's new order.
1684pub fn drop_slot(spans: &[[f32; 2]], from: usize, middle: f32) -> usize {
1685 spans
1686 .iter()
1687 .enumerate()
1688 .filter(|(index, [start, length])| *index != from && start + length / 2.0 < middle)
1689 .count()
1690}
1691
1692/// How far box `index` of a list slides aside while the box at `from`, `length` long, is
1693/// dragged to land at `to`.
1694pub fn slide(index: usize, from: usize, to: usize, length: f32) -> f32 {
1695 if from < index && index <= to {
1696 -length
1697 } else if to <= index && index < from {
1698 length
1699 } else {
1700 0.0
1701 }
1702}
1703
1704/// `a` moved `t` of the way to `b`, mixed as premultiplied colours, so from transparent
1705/// `b` fades in with its own hue.
14111706pub 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]
14131713}
14141714
14151715fn contains(rect: [f32; 4], point: [f32; 2]) -> bool {
crates/ui/src/list.rs+1-26
......@@ -4,8 +4,6 @@
44use crate::{Axis, Event, Flags, HALF_LIFE, Id, Size, Spec, Ui, fill, mix, px, scrollbar};
55use winit::keyboard::NamedKey;
66
7/// How far rows fade out under a scrolled list's edges.
8const EDGE: f64 = 10.0;
97/// Room beside a scrolling list's rows for its scrollbar.
108pub(crate) const GUTTER: f32 = 12.0;
119
......@@ -191,8 +189,6 @@ pub fn list<R: Rows>(
191189 let scroll = state.scroll;
192190 ui.animating |= scroll != state.target;
193191
194 let theme = ui.theme.clone();
195 let background = spec.fill.unwrap_or(theme.base);
196192 ui.open_as(
197193 id,
198194 Spec {
......@@ -235,28 +231,7 @@ pub fn list<R: Rows>(
235231 ui.close();
236232 shown.push((key, place));
237233 }
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);
260235 if let Some(offset) = scrollbar(
261236 ui,
262237 "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
33//! frame it is, closing the popup.
44
55use crate::{
......@@ -16,7 +16,13 @@ use winit::keyboard::{Key, NamedKey};
1616pub(crate) const CHECK: &[&str] = &[include_str!("../assets/check.svg")];
1717/// Inset of a popup's contents, and its distance from what it drops down from.
1818const PAD: f32 = 4.0;
19/// A palette's rows and filter field, and a menu's more compact rows.
1920const ROW: f32 = 26.0;
21pub(crate) const MENU_ROW: f32 = 22.0;
22/// Inset of a menu row's icon and text.
23const ROW_PAD: f32 = 8.0;
24/// How far a menu's icons sit inside its leading edge.
25pub(crate) const ICON_INSET: f32 = PAD + ROW_PAD;
2026/// Rows a list shows before it scrolls.
2127const ROWS: f32 = 12.0;
2228/// Height of the rule between groups.
......@@ -108,6 +114,7 @@ pub fn menu(
108114 items,
109115 filter,
110116 width.max(least).max(NARROWEST),
117 MENU_ROW,
111118 )
112119}
113120
......@@ -128,6 +135,7 @@ pub fn palette(ui: &mut Ui, id: Id, commands: &[Item]) -> Option<usize> {
128135 commands,
129136 Some("Search commands"),
130137 width,
138 ROW,
131139 )
132140}
133141
......@@ -138,6 +146,7 @@ fn choose(
138146 items: &[Item],
139147 filter: Option<&str>,
140148 width: f32,
149 row: f32,
141150) -> Option<usize> {
142151 let field = id.child("filter");
143152 if filter.is_some() && ui.focus == Some(id) {
......@@ -148,21 +157,31 @@ fn choose(
148157 let mut query = std::mem::take(&mut popup.query);
149158 let mut highlight = popup.highlight;
150159 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 };
151174 surface(ui, id, anchor, width);
152175 if let Some(placeholder) = filter {
153176 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 };
159177 text_field(
160178 ui,
161179 field,
162180 &mut query,
163181 placeholder,
164182 Spec {
165 size: [fill(), px(height)],
183 flags,
184 size,
166185 fill: Some(theme.base),
167186 border: Some(theme.accent),
168187 radius: 4.0,
......@@ -178,7 +197,10 @@ fn choose(
178197 // Unfiltered, the current or checked item starts highlighted; filtered, the best match.
179198 highlight = highlight.or_else(|| {
180199 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)
182204 } else {
183205 (0..matches.count()).find(|row| matches.selectable(*row))
184206 };
......@@ -186,10 +208,10 @@ fn choose(
186208 });
187209 let window = ui.rect(Id::ROOT).map_or(0.0, |window| window[3]);
188210 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());
190212 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));
193215 // The popup eases to its new height as the results change; the rows do not move.
194216 let dt = ui.dt;
195217 let [from, to, elapsed] = state(ui, id).height.get_or_insert([view, view, RESIZE]);
......@@ -212,7 +234,7 @@ fn choose(
212234 ui.leaf(
213235 "empty",
214236 Spec {
215 size: [fill(), px(ROW)],
237 size: [fill(), px(row)],
216238 text: Some("No matches"),
217239 color: Some(theme.text_dim),
218240 pad: [8.0, 0.0],
......@@ -223,7 +245,7 @@ fn choose(
223245 } else {
224246 let list = List {
225247 rows: &matches,
226 row: ROW,
248 row,
227249 keys: &keys,
228250 hover_selects: true,
229251 };
......@@ -285,7 +307,7 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) {
285307 size: [fill(), fill()],
286308 fill: row.selected.then(|| theme.hover()),
287309 radius: 4.0,
288 pad: [8.0, 0.0],
310 pad: [ROW_PAD, 0.0],
289311 gap: ICON_GAP,
290312 ..Spec::default()
291313 },
......@@ -299,7 +321,7 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) {
299321 ui.leaf(
300322 "icon",
301323 Spec {
302 size: [px(ICON), px(ROW)],
324 size: [px(ICON), fill()],
303325 icon: if item.checked { Some(CHECK) } else { item.icon },
304326 color: Some(if item.disabled {
305327 mix(tint, theme.popup, 0.5)
......@@ -313,7 +335,7 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) {
313335 ui.leaf(
314336 "text",
315337 Spec {
316 size: [fill(), px(ROW)],
338 size: [fill(), fill()],
317339 text: Some(item.text),
318340 font: item.font,
319341 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) {
326348 ui.leaf(
327349 "shortcut",
328350 Spec {
329 size: [fit(), px(ROW)],
351 size: [fit(), fill()],
330352 text: Some(item.shortcut),
331353 color: Some(theme.text_dim),
332354 ..Spec::default()
......@@ -451,6 +473,115 @@ pub fn colors(
451473 None
452474}
453475
476/// Builds popup `id` as a gallery beside `anchor` while it is open: `groups` of cells, each
477/// a heading and a count, in rows of `columns` cells `size` large, with cell `current`
478/// outlined. `cell` builds a cell's contents from its index, counted through the groups.
479/// Returns the index of the cell chosen.
480#[allow(clippy::too_many_arguments)]
481pub fn gallery(
482 ui: &mut Ui,
483 id: Id,
484 anchor: Anchor,
485 groups: &[(&str, usize)],
486 columns: usize,
487 size: [f32; 2],
488 current: Option<usize>,
489 mut cell: impl FnMut(&mut Ui, usize),
490) -> Option<usize> {
491 if !ui.popup_open(id) {
492 return None;
493 }
494 let cell_id = |index: usize| id.child(("cell", index));
495 let count: usize = groups.iter().map(|(_, count)| count).sum();
496 let keys = navigation(
497 ui,
498 &[id],
499 &[
500 NamedKey::ArrowLeft,
501 NamedKey::ArrowRight,
502 NamedKey::ArrowUp,
503 NamedKey::ArrowDown,
504 NamedKey::Enter,
505 ],
506 );
507 let mut highlight = state(ui, id).highlight.map(|cell| cell as usize);
508 let mut chosen = None;
509 for index in 0..count {
510 let signal = ui.signal(cell_id(index));
511 if signal.hovered && ui.moved {
512 highlight = Some(index);
513 }
514 if signal.clicked {
515 chosen = Some(index);
516 }
517 }
518 for key in keys {
519 let at = highlight.or(current).unwrap_or(0);
520 highlight = Some(match key {
521 NamedKey::Enter => {
522 chosen = chosen.or(highlight);
523 continue;
524 }
525 NamedKey::ArrowLeft => at.saturating_sub(1),
526 NamedKey::ArrowRight => (at + 1).min(count - 1),
527 NamedKey::ArrowUp => at.saturating_sub(columns),
528 _ => (at + columns).min(count - 1),
529 });
530 }
531 if let Some(index) = chosen {
532 ui.close_popup(id);
533 return Some(index);
534 }
535 let theme = ui.theme.clone();
536 surface(ui, id, anchor, columns as f32 * size[0] + 2.0 * PAD);
537 let mut index = 0;
538 for (group, (heading, cells)) in groups.iter().enumerate() {
539 ui.leaf(
540 ("heading", group),
541 Spec {
542 size: [fill(), px(MENU_ROW)],
543 text: Some(heading),
544 font_size: Some(theme.font_size - 2.0),
545 bold: true,
546 color: Some(theme.text_dim),
547 pad: [PAD, 0.0],
548 ..Spec::default()
549 },
550 );
551 for row in 0..cells.div_ceil(columns) {
552 ui.open(
553 ("row", group, row),
554 Spec {
555 size: [fill(), px(size[1])],
556 ..Spec::default()
557 },
558 );
559 for _ in 0..columns.min(cells - row * columns) {
560 ui.open_as(
561 cell_id(index),
562 Spec {
563 flags: Flags::CLICKABLE,
564 axis: Axis::Y,
565 size: [px(size[0]), px(size[1])],
566 fill: (highlight == Some(index)).then(|| theme.hover()),
567 border: (current == Some(index)).then_some(theme.accent),
568 radius: 4.0,
569 pad: [PAD, PAD],
570 ..Spec::default()
571 },
572 );
573 cell(ui, index);
574 ui.close();
575 index += 1;
576 }
577 ui.close();
578 }
579 }
580 ui.close();
581 state(ui, id).highlight = highlight.map(|cell| cell as u64);
582 None
583}
584
454585/// Opens popup `id`'s panel `width` wide beside `anchor`; the caller closes it.
455586fn surface(ui: &mut Ui, id: Id, anchor: Anchor, width: f32) {
456587 let anchor = match anchor {
crates/ui/src/shell.rs+198-41
......@@ -1,10 +1,10 @@
11//! Controls a notebook window's chrome is built from: OneNote's section tabs and the
22//! toolbar's compact buttons.
33
4use draw::PathStyle;
4use draw::{PathStyle, edit::SelectionUnit};
55
66use 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,
88};
99use std::hash::Hash;
1010
......@@ -14,17 +14,25 @@ pub const CHEVRON: &[&str] = &[include_str!("../assets/chevron-down.svg")];
1414const TAB_ROUNDING: f32 = 4.0;
1515/// Side of a toolbar button.
1616pub const TOOL: f32 = 22.0;
17/// Width of a menu arrow beside a toolbar button.
18const ARROW: f32 = 10.0;
19/// Where a section tab's label starts inside it.
20pub const TAB_PAD: f32 = 10.0;
1721
1822/// Section tabs in a row `height` tall, as OneNote draws them: each leans over the next
1923/// at 45°, and the open one lies on top in `section`'s colours, rising to meet the frame
2024/// 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)]
2229pub fn section_tabs(
2330 ui: &mut Ui,
2431 part: impl Hash,
2532 tabs: &[(&str, [f32; 4])],
2633 active: usize,
2734 lit: Option<usize>,
35 dragged: Option<Dragged>,
2836 section: &Section,
2937 height: f32,
3038) -> Tabs {
......@@ -38,25 +46,60 @@ pub fn section_tabs(
3846 },
3947 );
4048 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];
4251 let placed: Vec<_> = tabs
4352 .iter()
4453 .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);
4655 left += width;
4756 (left - width, width)
4857 })
4958 .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.
5272 let order = (0..tabs.len())
5373 .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);
5677 for index in order {
5778 let (name, color) = tabs[index];
5879 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);
60103 // Whole device pixels, so a rising tab reuses its rasterized outlines.
61104 let tall = ((low + (tallest - low) * open) * ui.scale()).round() / ui.scale();
62105 let colors = theme.section(color);
......@@ -77,11 +120,12 @@ pub fn section_tabs(
77120 }),
78121 gradient: Some(fill),
79122 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)),
82126 radius: TAB_ROUNDING,
83127 shape: Shape::Tab { lean: lean(height) },
84 pad: [10.0, 0.0],
128 pad: [TAB_PAD, 0.0],
85129 ..Spec::default()
86130 },
87131 );
......@@ -94,6 +138,9 @@ pub fn section_tabs(
94138 if signal.dragging {
95139 held = Some(index);
96140 }
141 if signal.pressed && signal.unit != SelectionUnit::Grapheme {
142 renamed = Some(index);
143 }
97144 }
98145 let open_tab = ui.id(("tab", active));
99146 ui.close();
......@@ -101,10 +148,21 @@ pub fn section_tabs(
101148 clicked,
102149 context,
103150 held,
151 renamed,
152 slot,
153 settled,
104154 open: open_tab,
105155 }
106156}
107157
158/// A section tab being dragged: its index, and its leading edge along the row while it
159/// follows the pointer; none once let go, as it eases into its place over the others.
160#[derive(Clone, Copy, Debug)]
161pub struct Dragged {
162 pub index: usize,
163 pub start: Option<f32>,
164}
165
108166/// The id of tab `index` of the section tabs built as `row`.
109167pub fn tab_id(row: Id, index: usize) -> Id {
110168 row.child(("tab", index))
......@@ -117,6 +175,12 @@ pub struct Tabs {
117175 pub context: Option<(usize, [f32; 2])>,
118176 /// The tab held down, which a drag moves.
119177 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,
120184 /// The open tab's id, for `tab_base`.
121185 pub open: Id,
122186}
......@@ -158,34 +222,62 @@ pub fn tool_button(
158222 )
159223}
160224
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.
164229pub fn split_button(
165230 ui: &mut Ui,
166231 part: impl Hash,
167232 icon: &'static [&'static str],
168233 bar: Option<[f32; 4]>,
169234 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,
174251 Spec {
175252 size: [children(), px(TOOL)],
176253 ..Spec::default()
177254 },
178255 );
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",
181277 Spec {
182 flags: Flags::CLICKABLE,
183278 size: [px(TOOL), px(TOOL)],
184279 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),
189281 center: true,
190282 ..Spec::default()
191283 },
......@@ -196,21 +288,79 @@ pub fn split_button(
196288 ui.mark([top, top + 13.0, top + 16.0, top + 16.0], color, 0.0);
197289 }
198290 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.
324pub fn menu_button(
325 ui: &mut Ui,
326 part: impl Hash,
327 icon: &'static [&'static str],
328 menu: Id,
329) -> Anchor {
330 let theme = ui.theme.clone();
331 let id = ui.open(
332 part,
201333 Spec {
202334 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()),
206337 hover_fill: Some(theme.hover()),
207338 radius: 4.0,
208 center: true,
209339 ..Spec::default()
210340 },
211341 );
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 }
212357 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])
214364}
215365
216366/// A drop-down box `width` wide showing `text`.
......@@ -295,7 +445,8 @@ impl Ui {
295445
296446 /// Rounds the corners of the clockwise outline `points`, as `border` does: paints
297447 /// `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`.
299450 pub fn round_corners(
300451 &mut self,
301452 points: &[([f32; 2], f32)],
......@@ -311,6 +462,16 @@ impl Ui {
311462 corner.start[0] - corner.point[0],
312463 corner.start[1] - corner.point[1],
313464 ];
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 };
314475 self.beneath_popups(Display::Path {
315476 data: format!(
316477 "M{} {}{}L0 0Z",
......@@ -319,12 +480,8 @@ impl Ui {
319480 corner.curve(corner.point)
320481 ),
321482 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],
328485 });
329486 }
330487 }
crates/ui/src/tests.rs+605-10
......@@ -61,6 +61,36 @@ fn fill_yields_to_strict_siblings_along_the_flow() {
6161 assert_eq!(ui.rect(ids[1]), Some([280.0, 0.0, 400.0, 100.0]));
6262}
6363
64#[test]
65fn padding_wider_than_its_box_leaves_loose_children_empty_not_negative() {
66 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
67 let mut child = None;
68 frame(&mut ui, |ui| {
69 ui.open(
70 "cramped",
71 Spec {
72 size: [px(10.0), px(10.0)],
73 pad: [8.0, 8.0],
74 ..Spec::default()
75 },
76 );
77 let loose = Extent {
78 size: Size::Pixels(20.0),
79 strictness: 0.0,
80 };
81 child = Some(ui.open(
82 "child",
83 Spec {
84 size: [loose; 2],
85 ..Spec::default()
86 },
87 ));
88 ui.close();
89 ui.close();
90 });
91 assert_eq!(ui.rect(child.unwrap()), Some([8.0, 8.0, 8.0, 8.0]));
92}
93
6494#[test]
6595fn children_sum_with_gaps_and_padding() {
6696 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
......@@ -327,10 +357,10 @@ fn wheel_scrolls_within_the_content_and_clips_children() {
327357 let layers = ui.layers();
328358 assert!(matches!(
329359 layers[..],
330 [Layer::Primitives {
360 [Layer::Primitives(Primitives {
331361 clip: Some([0.0, 0.0, 100.0, 100.0]),
332362 ..
333 }] | []
363 })] | []
334364 ));
335365}
336366
......@@ -424,6 +454,20 @@ fn text_fields_edit_with_keys_and_selection() {
424454 assert_eq!(text, "");
425455}
426456
457#[test]
458fn a_field_focused_whole_selects_its_text_once_for_typing_over() {
459 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
460 let mut text = "New Section 1".to_owned();
461 ui.focus_all(field());
462 field_frame(&mut ui, &mut text);
463 ui.event(typed("K"));
464 field_frame(&mut ui, &mut text);
465 ui.event(typed("i"));
466 let signal = field_frame(&mut ui, &mut text);
467 assert!(signal.focused);
468 assert_eq!(text, "Ki");
469}
470
427471#[test]
428472#[cfg_attr(not(target_vendor = "apple"), ignore = "macOS editing conventions")]
429473fn 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() {
448492 .layers()
449493 .iter()
450494 .flat_map(|layer| match layer {
451 Layer::Primitives { primitives, .. } => primitives.as_slice(),
495 Layer::Primitives(layer) => layer.primitives.as_slice(),
452496 Layer::Custom { .. } => &[],
453497 })
454498 .find_map(|primitive| match primitive {
......@@ -467,9 +511,16 @@ fn text_field_carets_fade_on_appkit_s_blink_and_restart_when_moved() {
467511 assert_eq!(at(&mut ui, 700).0, faded);
468512 assert_eq!(at(&mut ui, 900).0, 0.0);
469513 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 );
471518 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 );
473524}
474525
475526#[test]
......@@ -1397,8 +1448,9 @@ fn a_filtered_menu_eases_to_its_new_height_while_its_rows_appear_in_place() {
13971448 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
13981449 build(&mut ui);
13991450 ui.open_popup(menu_id());
1400 build(&mut ui);
1401 build(&mut ui);
1451 for _ in 0..12 {
1452 build(&mut ui);
1453 }
14021454 let results = menu_id().child("results");
14031455 let height = |ui: &Ui| {
14041456 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() {
14061458 };
14071459 let tall = height(&ui);
14081460 assert!(
1409 !ui.wants_frame() && tall > 5.0 * 26.0,
1461 !ui.wants_frame() && tall > 5.0 * popup::MENU_ROW,
14101462 "open at its full height"
14111463 );
14121464 ui.event(typed("tnr"));
......@@ -1419,13 +1471,556 @@ fn a_filtered_menu_eases_to_its_new_height_while_its_rows_appear_in_place() {
14191471 "Times New Roman at the top"
14201472 );
14211473 let easing = height(&ui);
1422 assert!(easing > 26.0 && easing < tall, "{easing}");
1474 assert!(easing > popup::MENU_ROW && easing < tall, "{easing}");
14231475 let mut frames = 0;
14241476 while ui.wants_frame() {
14251477 build(&mut ui);
14261478 frames += 1;
14271479 assert_eq!(ui.rect(menu_id().child("rows").child(42_u64)), Some(row));
14281480 }
1429 assert_eq!(height(&ui), 26.0);
1481 assert_eq!(height(&ui), popup::MENU_ROW);
14301482 assert!(frames <= 10, "settles in {frames} frames");
14311483}
1484
1485/// A column `width` wide holding one label: its box, and the label as laid out.
1486fn fitted(
1487 ui: &mut Ui,
1488 text: &str,
1489 width: f32,
1490 overflow: Overflow,
1491 center: bool,
1492) -> ([f32; 4], Rc<Label>) {
1493 let mut id = None;
1494 frame(ui, |ui| {
1495 ui.open(
1496 "column",
1497 Spec {
1498 axis: Axis::Y,
1499 size: [px(width), children()],
1500 ..Spec::default()
1501 },
1502 );
1503 id = Some(ui.open(
1504 "label",
1505 Spec {
1506 size: [fill(), fit()],
1507 text: Some(text),
1508 overflow,
1509 center,
1510 pad: [4.0, 2.0],
1511 ..Spec::default()
1512 },
1513 ));
1514 ui.close();
1515 ui.close();
1516 });
1517 let node = ui.nodes.iter().find(|node| Some(node.id) == id).unwrap();
1518 (node.rect, node.label.clone().unwrap())
1519}
1520
1521fn advances(label: &Label) -> Vec<f32> {
1522 label
1523 .layout
1524 .lines()
1525 .map(|line| line.metrics().advance)
1526 .collect()
1527}
1528
1529#[test]
1530fn wrapped_labels_break_to_their_width_and_the_box_grows_to_their_height() {
1531 let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK);
1532 let text = "Conflicting changes are highlighted in red. This page cannot be edited.";
1533 let (_, label) = fitted(&mut ui, text, 1000.0, Overflow::Wrap, false);
1534 assert_eq!(
1535 advances(&label).len(),
1536 1,
1537 "a label that fits stays one line"
1538 );
1539 let line = label.size[1];
1540 let mut previous = 2;
1541 for width in [320.0, 200.0, 120.0, 60.0] {
1542 let (rect, label) = fitted(&mut ui, text, width, Overflow::Wrap, false);
1543 let lines = advances(&label);
1544 assert!(lines.len() >= previous, "{width}: {lines:?}");
1545 assert!(
1546 lines.iter().all(|line| *line <= width - 8.0 + 0.01),
1547 "{width}: {lines:?}"
1548 );
1549 assert!((rect[3] - rect[1] - 4.0 - label.size[1]).abs() <= 0.5);
1550 assert!((label.size[1] / line - lines.len() as f32).abs() < 0.1);
1551 assert_eq!(rect[2] - rect[0], width);
1552 previous = lines.len();
1553 }
1554}
1555
1556#[test]
1557fn wrapping_breaks_cjk_emoji_and_words_longer_than_the_line() {
1558 let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK);
1559 for text in [
1560 "東京都の天気は晴れのち曇り、明日は雨が降るでしょう",
1561 "🎉🎈🎂🎁🎊🎉🎈🎂🎁🎊🎉🎈🎂🎁🎊",
1562 "Supercalifragilisticexpialidocious-antidisestablishmentarianism",
1563 ] {
1564 let lines = advances(&fitted(&mut ui, text, 90.0, Overflow::Wrap, false).1);
1565 assert!(lines.len() > 2, "{text}: {lines:?}");
1566 assert!(lines.iter().all(|line| *line <= 82.01), "{text}: {lines:?}");
1567 }
1568}
1569
1570#[test]
1571fn centred_wrapped_lines_centre_across_the_box() {
1572 let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK);
1573 let (_, label) = fitted(
1574 &mut ui,
1575 "A long line that wraps, then short",
1576 150.0,
1577 Overflow::Wrap,
1578 true,
1579 );
1580 let last = label.layout.lines().last().unwrap();
1581 let Some(parley::PositionedLayoutItem::GlyphRun(run)) = last.items().next() else {
1582 panic!("no glyphs");
1583 };
1584 let room = 150.0 - 8.0 - last.metrics().advance;
1585 assert!(
1586 room > 20.0 && (run.offset() - room / 2.0).abs() < 1.0,
1587 "{}",
1588 run.offset()
1589 );
1590}
1591
1592#[test]
1593fn ellipsis_shortens_a_label_to_its_box_and_leaves_one_that_fits() {
1594 let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK);
1595 let text = "Meeting notes from the quarterly planning review";
1596 let (whole, label) = fitted(&mut ui, text, 1000.0, Overflow::Ellipsis, false);
1597 assert_eq!(label.key.0, text);
1598 for width in [200.0, 90.0, 30.0] {
1599 let (rect, label) = fitted(&mut ui, text, width, Overflow::Ellipsis, false);
1600 let (shown, lines) = (&label.key.0, advances(&label));
1601 assert!(shown.ends_with('…') && !shown.ends_with(" …"), "{shown}");
1602 assert!(text.starts_with(shown.trim_end_matches('…')));
1603 assert_eq!(lines.len(), 1);
1604 assert!(lines[0] <= width - 8.0 + 0.01, "{width}: {shown} {lines:?}");
1605 assert_eq!(rect[3] - rect[1], whole[3] - whole[1]);
1606 }
1607 let (_, label) = fitted(
1608 &mut ui,
1609 "東京都の天気は晴れのち曇り",
1610 80.0,
1611 Overflow::Ellipsis,
1612 false,
1613 );
1614 assert!(
1615 label.key.0.ends_with('…') && label.key.0.chars().count() > 1,
1616 "{}",
1617 label.key.0
1618 );
1619}
1620
1621#[test]
1622fn popups_ease_open_and_closed_then_stop_asking_for_frames() {
1623 let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK);
1624 let id = Id::ROOT.child("dialog");
1625 let build = |ui: &mut Ui| {
1626 if ui.popup_open(id) {
1627 ui.leaf(
1628 "dialog",
1629 Spec {
1630 size: [px(100.0), px(50.0)],
1631 fill: Some([1.0; 4]),
1632 anchor: Some(Anchor::Dialog),
1633 ..Spec::default()
1634 },
1635 );
1636 }
1637 };
1638 let motion = |ui: &Ui| {
1639 ui.layers().iter().find_map(|layer| match layer {
1640 Layer::Primitives(primitives) => primitives.motion,
1641 Layer::Custom { .. } => None,
1642 })
1643 };
1644 frame(&mut ui, |ui| {
1645 ui.open_popup(id);
1646 build(ui);
1647 });
1648 let first = motion(&ui).unwrap();
1649 assert!(first.opacity < 0.1 && first.zoom < 0.96 && first.tilt > 0.3);
1650 assert_eq!(
1651 first.pivot,
1652 [200.0, 125.0],
1653 "the dialog swings from its top centre"
1654 );
1655 for _ in 0..16 {
1656 frame(&mut ui, build);
1657 }
1658 assert_eq!(motion(&ui).map(|motion| motion.opacity), Some(1.0));
1659 assert!(!ui.wants_frame());
1660 ui.close_popup(id);
1661 frame(&mut ui, build);
1662 frame(&mut ui, build);
1663 let closing = motion(&ui).unwrap();
1664 assert!(closing.opacity < 1.0 && closing.opacity > 0.0);
1665 assert!(ui.wants_frame());
1666 for _ in 0..10 {
1667 frame(&mut ui, build);
1668 }
1669 assert_eq!(motion(&ui), None);
1670 assert!(!ui.wants_frame());
1671}
1672
1673#[test]
1674fn a_dragged_box_lands_past_the_middles_it_crossed_and_the_rest_slide_aside() {
1675 let spans = [[0.0, 40.0], [40.0, 60.0], [100.0, 20.0], [120.0, 50.0]];
1676 // Still over its own place, box 1 stays; past box 2's middle, it lands after it.
1677 assert_eq!(drop_slot(&spans, 1, 70.0), 1);
1678 assert_eq!(drop_slot(&spans, 1, 111.0), 2);
1679 assert_eq!(drop_slot(&spans, 1, 500.0), 3);
1680 assert_eq!(drop_slot(&spans, 3, -10.0), 0);
1681 assert_eq!(
1682 (0..4)
1683 .map(|index| slide(index, 1, 3, 60.0))
1684 .collect::<Vec<_>>(),
1685 [0.0, 0.0, -60.0, -60.0]
1686 );
1687 assert_eq!(
1688 (0..4)
1689 .map(|index| slide(index, 3, 1, 50.0))
1690 .collect::<Vec<_>>(),
1691 [0.0, 50.0, 50.0, 0.0]
1692 );
1693}
1694
1695/// Clicks the middle of `id`'s box as laid out now, where it must be hit.
1696fn click_box(ui: &mut Ui, id: Id) {
1697 let rect = ui.rect(id).unwrap();
1698 let middle = [(rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0];
1699 assert_eq!(
1700 ui.box_at(middle),
1701 Some(id),
1702 "the box is hit where it is laid out"
1703 );
1704 click(ui, middle);
1705}
1706
1707/// What a dialog's controls reported in a frame.
1708#[derive(Default)]
1709struct Reported {
1710 checked: bool,
1711 ok: bool,
1712 scheme: Option<usize>,
1713}
1714
1715/// A dialog holding a check box, a button, a field and a combo opening a menu of schemes.
1716fn dialog_frame(ui: &mut Ui, text: &mut String) -> Reported {
1717 let mut reported = Reported::default();
1718 frame(ui, |ui| {
1719 let dialog = Id::ROOT.child("dialog");
1720 if !ui.popup_open(dialog) {
1721 return;
1722 }
1723 ui.open_as(
1724 dialog,
1725 Spec {
1726 axis: Axis::Y,
1727 size: [px(240.0), children()],
1728 fill: Some([1.0; 4]),
1729 pad: [8.0, 8.0],
1730 anchor: Some(Anchor::Dialog),
1731 ..Spec::default()
1732 },
1733 );
1734 reported.checked = check_box(ui, "check", "Dark pages", false).clicked;
1735 reported.ok = button(ui, "ok", "OK").clicked;
1736 text_field(
1737 ui,
1738 Id::ROOT.child("name"),
1739 text,
1740 "",
1741 Spec {
1742 size: [fill(), px(26.0)],
1743 ..Spec::default()
1744 },
1745 );
1746 let combo = ui.id("combo");
1747 if shell::combo(ui, "combo", "Light", 120.0).pressed {
1748 ui.open_popup(Id::ROOT.child("schemes"));
1749 }
1750 let items = ["System", "Light", "Dark"].map(|text| popup::Item {
1751 text,
1752 ..popup::Item::default()
1753 });
1754 let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default());
1755 reported.scheme = popup::menu(ui, Id::ROOT.child("schemes"), anchor, &items, None);
1756 ui.close();
1757 });
1758 reported
1759}
1760
1761#[test]
1762fn every_control_in_an_open_dialog_takes_clicks_where_it_rests() {
1763 let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK);
1764 let mut text = String::new();
1765 let dialog = Id::ROOT.child("dialog");
1766 dialog_frame(&mut ui, &mut text);
1767 ui.open_popup(dialog);
1768 let settle = |ui: &mut Ui, text: &mut String| {
1769 for _ in 0..20 {
1770 dialog_frame(ui, text);
1771 }
1772 assert!(!ui.wants_frame());
1773 };
1774 settle(&mut ui, &mut text);
1775 click_box(&mut ui, dialog.child("check"));
1776 assert!(dialog_frame(&mut ui, &mut text).checked);
1777 click_box(&mut ui, dialog.child("ok"));
1778 assert!(dialog_frame(&mut ui, &mut text).ok);
1779 click_box(&mut ui, Id::ROOT.child("name"));
1780 dialog_frame(&mut ui, &mut text);
1781 assert_eq!(ui.focused(), Some(Id::ROOT.child("name")));
1782 ui.event(typed("a"));
1783 dialog_frame(&mut ui, &mut text);
1784 assert_eq!(text, "a");
1785 click_box(&mut ui, dialog.child("combo"));
1786 dialog_frame(&mut ui, &mut text);
1787 let schemes = Id::ROOT.child("schemes");
1788 assert!(ui.popup_open(schemes) && ui.popup_open(dialog));
1789 settle(&mut ui, &mut text);
1790 click_box(&mut ui, schemes.child("rows").child(2_u64));
1791 assert_eq!(dialog_frame(&mut ui, &mut text).scheme, Some(2));
1792 assert!(!ui.popup_open(schemes) && ui.popup_open(dialog));
1793}
1794
1795#[test]
1796fn a_combo_s_field_holds_its_place_as_its_list_opens_and_its_rows_take_clicks() {
1797 let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK);
1798 let fonts = fonts();
1799 let combo = Id::ROOT.child("combo");
1800 let mut chosen = None;
1801 let mut build = |ui: &mut Ui| {
1802 frame(ui, |ui| {
1803 shell::combo(ui, "combo", "Calibri", 120.0);
1804 let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default());
1805 chosen = popup::menu(ui, menu_id(), anchor, &fonts, Some("Font"));
1806 });
1807 chosen
1808 };
1809 build(&mut ui);
1810 ui.open_popup(menu_id());
1811 build(&mut ui);
1812 let field = menu_id().child("filter");
1813 let [box_rect, first] = [ui.rect(combo).unwrap(), ui.rect(field).unwrap()];
1814 assert_eq!(first[..2], box_rect[..2], "the field starts on the box");
1815 assert!(first[2] - first[0] < box_rect[2] - box_rect[0] + 20.0);
1816 let moving: Vec<_> = ui
1817 .layers()
1818 .iter()
1819 .filter_map(|layer| match layer {
1820 Layer::Primitives(layer) => Some(layer.motion.is_some()),
1821 Layer::Custom { .. } => None,
1822 })
1823 .collect();
1824 let panel = moving.iter().position(|moving| *moving).unwrap();
1825 assert!(
1826 moving[panel..].contains(&false),
1827 "the field paints without the popup's motion"
1828 );
1829 for _ in 0..20 {
1830 build(&mut ui);
1831 }
1832 assert!(!ui.wants_frame());
1833 let [panel, last] = [ui.rect(menu_id()).unwrap(), ui.rect(field).unwrap()];
1834 assert_eq!(last[2], panel[2] - 4.0, "the field widens across the popup");
1835 click_box(&mut ui, menu_id().child("rows").child(3_u64));
1836 assert_eq!(build(&mut ui), Some(3));
1837}
1838
1839fn built(ui: &Ui, id: Id) -> &Built {
1840 ui.nodes.iter().find(|node| node.id == id).unwrap()
1841}
1842
1843/// A split button at (100, 100) opening `split_menu()`; its signal.
1844fn split_frame(ui: &mut Ui) -> Signal {
1845 let mut signal = Signal::default();
1846 frame(ui, |ui| {
1847 ui.open(
1848 "bar",
1849 Spec {
1850 flags: Flags::FLOAT,
1851 size: [px(200.0), px(shell::TOOL)],
1852 position: [100.0, 100.0],
1853 ..Spec::default()
1854 },
1855 );
1856 signal = shell::split_button(ui, "split", CHECK_ICON, None, false, split_menu());
1857 let item = popup::Item {
1858 text: "Item",
1859 ..Default::default()
1860 };
1861 popup::menu(ui, split_menu(), BELOW, &[item], None);
1862 ui.close();
1863 });
1864 signal
1865}
1866
1867const CHECK_ICON: &[&str] = popup::CHECK;
1868
1869fn split_menu() -> Id {
1870 Id::ROOT.child("split menu")
1871}
1872
1873fn split_part(part: &str) -> Id {
1874 Id::ROOT.child("bar").child("split").child(part)
1875}
1876
1877fn center(rect: [f32; 4]) -> [f32; 2] {
1878 [(rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0]
1879}
1880
1881#[test]
1882fn a_split_button_fills_its_button_alone_and_outlines_both_halves_from_its_arrow() {
1883 let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK);
1884 split_frame(&mut ui);
1885 let [button, arrow] = [split_part("button"), split_part("menu")];
1886 let face = button.child("face");
1887 let ring = split_part("ring");
1888 let settle = |ui: &mut Ui| {
1889 for _ in 0..60 {
1890 split_frame(ui);
1891 }
1892 };
1893 ui.event(Event::PointerMoved(center(ui.rect(button).unwrap())));
1894 settle(&mut ui);
1895 // The fill rounds past the button's clip, so it meets the arrow square.
1896 let [left, top, right, bottom] = ui.rect(button).unwrap();
1897 assert!(built(&ui, face).fill.is_some());
1898 assert_eq!(ui.rect(face), Some([left, top, right + 4.0, bottom]));
1899 assert!(built(&ui, button).flags.contains(Flags::CLIP));
1900 assert!(built(&ui, ring).border.is_none());
1901 ui.event(Event::PointerMoved(center(ui.rect(arrow).unwrap())));
1902 settle(&mut ui);
1903 assert!(built(&ui, face).fill.is_none());
1904 assert!(built(&ui, ring).border.is_some());
1905 assert_eq!(
1906 ui.rect(ring),
1907 Some([left, top, ui.rect(arrow).unwrap()[2], bottom])
1908 );
1909 // The arrow opens the menu, and the outline stays while it is open.
1910 let point = center(ui.rect(arrow).unwrap());
1911 click(&mut ui, point);
1912 assert!(!split_frame(&mut ui).clicked);
1913 assert!(ui.popup_open(split_menu()));
1914 ui.event(Event::PointerMoved([390.0, 290.0]));
1915 settle(&mut ui);
1916 assert!(built(&ui, ring).border.is_some());
1917 ui.close_popup(split_menu());
1918 settle(&mut ui);
1919 let point = center(ui.rect(button).unwrap());
1920 click(&mut ui, point);
1921 assert!(split_frame(&mut ui).clicked);
1922 assert!(!ui.popup_open(split_menu()));
1923}
1924
1925#[test]
1926fn a_menu_button_opens_its_menu_with_the_icons_under_its_own() {
1927 let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK);
1928 let items = [
1929 popup::Item {
1930 text: "Align left",
1931 icon: Some(CHECK_ICON),
1932 ..Default::default()
1933 },
1934 popup::Item {
1935 text: "Center",
1936 icon: Some(CHECK_ICON),
1937 current: true,
1938 ..Default::default()
1939 },
1940 ];
1941 let build = |ui: &mut Ui| {
1942 frame(ui, |ui| {
1943 ui.open(
1944 "bar",
1945 Spec {
1946 flags: Flags::FLOAT,
1947 size: [px(200.0), px(shell::TOOL)],
1948 position: [100.0, 100.0],
1949 ..Spec::default()
1950 },
1951 );
1952 let anchor = shell::menu_button(ui, "align", CHECK_ICON, menu_id());
1953 popup::menu(ui, menu_id(), anchor, &items, None);
1954 ui.close();
1955 });
1956 };
1957 build(&mut ui);
1958 let button = ui.rect(Id::ROOT.child("bar").child("align")).unwrap();
1959 // One target: the arrow opens the menu as the icon does.
1960 click(&mut ui, [button[2] - 3.0, center(button)[1]]);
1961 for _ in 0..40 {
1962 build(&mut ui);
1963 }
1964 assert!(ui.popup_open(menu_id()));
1965 let icon = ui
1966 .rect(
1967 menu_id()
1968 .child("rows")
1969 .child(0_usize)
1970 .child("item")
1971 .child("icon"),
1972 )
1973 .unwrap();
1974 assert_eq!(icon[0], button[0] + (shell::TOOL - ICON) / 2.0);
1975}
1976
1977#[test]
1978fn galleries_choose_across_their_groups_by_keys_and_clicks() {
1979 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
1980 let gallery = Id::ROOT.child("gallery");
1981 let groups = [("Recent", 2), ("Library", 5)];
1982 let build = |ui: &mut Ui| {
1983 let mut chosen = None;
1984 frame(ui, |ui| {
1985 chosen = popup::gallery(
1986 ui,
1987 gallery,
1988 BELOW,
1989 &groups,
1990 3,
1991 [30.0, 30.0],
1992 Some(3),
1993 |ui, index| {
1994 ui.leaf(
1995 "label",
1996 Spec {
1997 size: [fill(), fill()],
1998 text: Some(["a", "b", "c", "d", "e", "f", "g"][index]),
1999 ..Spec::default()
2000 },
2001 );
2002 },
2003 );
2004 });
2005 chosen
2006 };
2007 build(&mut ui);
2008 ui.open_popup(gallery);
2009 build(&mut ui);
2010 // Keys start from the current cell, the library's second.
2011 for named in [NamedKey::ArrowDown, NamedKey::ArrowLeft, NamedKey::Enter] {
2012 ui.event(key(named));
2013 }
2014 assert_eq!(build(&mut ui), Some(5));
2015 ui.open_popup(gallery);
2016 for _ in 0..40 {
2017 build(&mut ui);
2018 }
2019 let cell = |index: usize| ui.rect(gallery.child(("cell", index))).unwrap();
2020 // The library starts a row of its own under its heading.
2021 assert_eq!(cell(2)[0], cell(0)[0]);
2022 assert!(cell(2)[1] > cell(0)[3]);
2023 let point = center(cell(1));
2024 click(&mut ui, point);
2025 assert_eq!(build(&mut ui), Some(1));
2026}
crates/ui/src/text.rs+54-21
......@@ -1,14 +1,15 @@
11use draw::{GlyphRun, Glyphs, RenderError};
22use 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,
55};
6use std::{borrow::Cow, collections::HashMap, rc::Rc, sync::Arc};
6use std::{borrow::Cow, collections::HashMap, rc::Rc};
77
8/// A shaped single line of interface text, in logical pixels.
8/// Shaped interface text, in logical pixels: a single line unless fitted to a width.
99pub(crate) struct Label {
1010 pub layout: Layout<()>,
1111 pub size: [f32; 2],
12 pub key: LabelKey,
1213}
1314
1415type LabelKey = (String, u32, bool, Option<String>);
......@@ -18,8 +19,6 @@ type LabelKey = (String, u32, bool, Option<String>);
1819pub(crate) struct Texts {
1920 fonts: FontContext,
2021 context: LayoutContext<()>,
21 /// A family registered in place of the system's interface font.
22 family: Option<String>,
2322 /// By text, size, weight and preferred family, with the frame each was last used.
2423 cache: HashMap<LabelKey, (Rc<Label>, u64)>,
2524}
......@@ -34,7 +33,12 @@ impl Texts {
3433 font: Option<&str>,
3534 frame: u64,
3635 ) -> 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 );
3842 if let Some((label, touched)) = self.cache.get_mut(&key) {
3943 *touched = frame;
4044 return label.clone();
......@@ -47,7 +51,6 @@ impl Texts {
4751 [GenericFamily::SystemUi, GenericFamily::SansSerif].map(FontFamilyName::Generic);
4852 let families: Vec<_> = font
4953 .into_iter()
50 .chain(self.family.as_deref())
5154 .map(FontFamilyName::named)
5255 .chain(system)
5356 .collect();
......@@ -55,29 +58,40 @@ impl Texts {
5558 families,
5659 ))));
5760 builder.push_default(StyleProperty::FontSize(size));
61 builder.push_default(StyleProperty::OverflowWrap(OverflowWrap::Anywhere));
5862 if bold {
5963 builder.push_default(StyleProperty::FontWeight(parley::FontWeight::SEMI_BOLD));
6064 }
6165 let mut layout = builder.build(text);
6266 layout.break_all_lines(None);
6367 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 });
6573 self.cache.insert(key, (label.clone(), frame));
6674 label
6775 }
6876
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)
8195 }
8296
8397 pub fn preview_font(&mut self, data: Blob<u8>, family: &str) {
......@@ -96,6 +110,25 @@ impl Texts {
96110 }
97111}
98112
113impl Label {
114 /// The text broken into lines no wider than `width` and aligned across it.
115 pub fn wrapped(&self, width: f32, center: bool) -> Rc<Label> {
116 let mut layout = self.layout.clone();
117 layout.break_all_lines(Some(width));
118 let alignment = if center {
119 Alignment::Center
120 } else {
121 Alignment::Start
122 };
123 layout.align(alignment, AlignmentOptions::default());
124 Rc::new(Label {
125 size: [width, layout.height()],
126 layout,
127 key: self.key.clone(),
128 })
129 }
130}
131
99132impl Glyphs for Label {
100133 fn runs(
101134 &self,
crates/ui/src/theme.rs+119-1
......@@ -8,7 +8,8 @@ pub struct Theme {
88 pub base: [f32; 4],
99 /// Side panels.
1010 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.
1213 pub strip: [f32; 4],
1314 pub accent: [f32; 4],
1415 /// The text caret, and selected text's fill with and without keyboard focus; the
......@@ -65,6 +66,14 @@ pub struct Section {
6566 pub accent: [f32; 4],
6667}
6768
69impl Section {
70 /// A tab under the pointer: most of the way to the accent, which lies past the frame's
71 /// lightness from the tabs in both themes, so it stands out from both.
72 pub fn hover(&self) -> [f32; 4] {
73 crate::mix(self.tab, self.accent, 0.7)
74 }
75}
76
6877impl Theme {
6978 /// After File Pilot's default dark scheme.
7079 pub fn dark() -> Self {
......@@ -123,11 +132,61 @@ impl Theme {
123132 }
124133 }
125134
135 /// The theme over a system material that shows through the strip: dim text, controls
136 /// and fields take the text colour at part opacity, as vibrancy draws them.
137 pub fn over_backdrop(self) -> Self {
138 let text = |alpha| [self.text[0], self.text[1], self.text[2], alpha];
139 Self {
140 strip: [0.0; 4],
141 text_dim: text(0.5),
142 chip: text(0.1),
143 base: text(0.05),
144 ..self
145 }
146 }
147
126148 /// Hovered controls: the accent over the base.
127149 pub fn hover(&self) -> [f32; 4] {
128150 crate::mix(self.base, self.accent, 0.35)
129151 }
130152
153 /// Colours for icons' slots under a section of `accent`, with `highlight` the colour
154 /// the highlighter applies. The accent keeps its hue but stands as clear of the toolbar's
155 /// lightness as text must; the badge takes the first of green, blue and orange whose hue
156 /// stands clear of the accent's.
157 pub fn icon_palette(&self, accent: [f32; 4], highlight: [f32; 4]) -> draw::Palette {
158 // Over a system material the strip is clear, and the panel has its lightness.
159 let behind = if self.strip[3] == 1.0 {
160 self.strip
161 } else {
162 self.panel
163 };
164 let [under, ..] = draw::oklab(behind);
165 let [lightness, a, b] = draw::oklab(accent);
166 let lightness = if under < 0.5 {
167 lightness.max(under + ICON_CONTRAST)
168 } else {
169 lightness.min(under - ICON_CONTRAST)
170 };
171 let [r, g, b] = draw::from_oklab([lightness, a, b]);
172 let accent = [r, g, b, 1.0];
173 let apart = |a: f32, b: f32| {
174 let turn = (a - b).rem_euclid(360.0);
175 turn.min(360.0 - turn)
176 };
177 let accent_hue = hue(accent);
178 let badge = [135.0, 215.0, 30.0]
179 .into_iter()
180 .find(|badge| apart(*badge, accent_hue) >= 60.0)
181 .expect("Three hues this far apart leave one clear of any other");
182 let rgb = |[r, g, b, _]: [f32; 4]| [r, g, b];
183 draw::Palette {
184 accent: Some(rgb(accent)),
185 highlight: Some(rgb(highlight)),
186 badge: Some(rgb(hsl(badge, 0.65, 0.45))),
187 }
188 }
189
131190 /// The colours a section takes from the hue of `color`, linear RGBA.
132191 pub fn section(&self, color: [f32; 4]) -> Section {
133192 let hue = hue(color);
......@@ -147,6 +206,9 @@ impl Theme {
147206 }
148207}
149208
209/// Least OKLab lightness difference between icons' accent marks and the toolbar.
210const ICON_CONTRAST: f32 = 0.4;
211
150212/// The hue in degrees of a linear colour, as it looks in sRGB.
151213fn hue(color: [f32; 4]) -> f32 {
152214 let [r, g, b] = [color[0], color[1], color[2]].map(|value| {
......@@ -195,4 +257,60 @@ mod tests {
195257 assert_eq!(hsl(0.0, 1.0, 0.5), srgb(0xff, 0, 0));
196258 assert_eq!(hsl(240.0, 0.0, 1.0), srgb(0xff, 0xff, 0xff));
197259 }
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 }
198316}
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
4444 ..Spec::default()
4545 },
4646 );
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);
4849 ui.leaf(
4950 "box",
5051 Spec {
5152 size: [px(side), px(height)],
5253 inset: [0.0, margin, 0.0, margin],
5354 fill: Some(if checked { theme.accent } else { theme.base }),
54 border: Some(if checked || hovered {
55 border: Some(if checked || signal.hovered {
5556 theme.accent
5657 } else {
5758 theme.chip
......@@ -71,7 +72,7 @@ pub fn check_box(ui: &mut Ui, part: impl Hash, label: &str, checked: bool) -> Si
7172 },
7273 );
7374 ui.close();
74 ui.signal(id)
75 signal
7576}
7677
7778/// An overlay scrollbar along the far edge of the current box, for content whose scroll
......@@ -151,9 +152,9 @@ pub fn text_field(
151152 .zip(ui.rect(id))
152153 .map(|(pointer, rect)| pointer[0] - rect[0] - pad)
153154 .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))
157158 };
158159 let before = [selection.anchor(), selection.focus()];
159160 let (texts, frame) = ui.texts();
......@@ -181,7 +182,14 @@ pub fn text_field(
181182 Selection::new(anchor, focus)
182183 };
183184 }
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 };
185193 for event in &signal.events {
186194 let layout = &label.layout;
187195 let (range, inserted) = match event {
......@@ -192,7 +200,14 @@ pub fn text_field(
192200 // page keys to scroll.
193201 let command = match (Command::from_key(&key, modifiers), &key) {
194202 (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,
196211 (None, edit::Key::Named(edit::NamedKey::Home)) => {
197212 Some(Command::Move(Movement::DocumentStart))
198213 }
......@@ -254,7 +269,7 @@ pub fn text_field(
254269 }
255270 let moved = signal.pressed || before != [selection.anchor(), selection.focus()];
256271 {
257 let (stored, stored_press) = ui.field(id);
272 let (stored, stored_press, _) = ui.field(id);
258273 *stored = selection;
259274 *stored_press = press;
260275 }
vendor/sctk-adwaita/CHANGELOG.md created+67
......@@ -0,0 +1,67 @@
1## [Unreleased]
2
3## 0.10.1
4- Panic hardening of ab-glyph (#64)
5
6## 0.10.0
7- Bump sctk to 0.19
8
9## 0.9.0
10- Bump crossfont to 0.8.0 (#60)
11
12## 0.8.1
13- Fix `ab_glyph` renderer panicking with integer scale factor 3 (#50)
14- Improved roundness of headerbar (#51)
15
16## 0.8.0
17- **Breaking:** `AdwaitaFrame::new` now takes `Arc<CompositorState>` as an argument
18- Fix leftmost title pixel sometimes being cut off (#45)
19- Fix transparency in ab_glyph renderer (#44)
20- Extended resize corners (#47)
21- Center maximize icon (#46)
22- Window shadows (#43)
23
24### Dependencies updates
25- Bump crossfont to 0.6.0 (#52)
26
27## 0.7.0
28- **Breaking:** `wayland-csd-frame` is now used as a part of the public interface.
29
30## 0.6.1
31- Bump tiny-skia to v0.11 (#32)
32- cleanup: Remove debug println (#29)
33- Support custom header buttons layouts (#30)
34- The double click threshold value was raised to `400ms`
35
36## 0.6.0
37- Update the `smithay-client-toolkit` to `0.17.0`
38- Don't use tiny-skia's default features
39
40## 0.5.4
41- Timeout dbus call to settings portal (100ms)
42
43## 0.5.3
44- `ab_glyph` titles will read the system title font using memory mapped buffers instead of reading to heap.
45 Lowers RAM usage.
46- Improve titlebar-font config parsing to correctly handle more font names.
47
48## 0.5.2
49- `ab_glyph` & `crossfont` titles will use gnome "titlebar-font" config if available.
50- `ab_glyph` titles are now more consistent with `crossfont` titles both using system sans
51 if no better font config is available.
52- Rounded corners are now disabled on maximized and tiled windows.
53- Double click interval is now 400ms (as previous 1s interval was caused by bug fixed in 0.5.1)
54
55## 0.5.1
56- Use dbus org.freedesktop.portal.Settings to automatically choose light or dark theming.
57- Double click detection fix.
58- Apply button click on release instead of press.
59
60## 0.5.0
61- `title` feature got removed
62- `ab_glyph` default feature got added (for `ab_glyph` based title rendering)
63- `crossfont` feature got added (for `crossfont` based title rendering)
64 - Can be enable like this:
65 ```toml
66 sctk-adwaita = { default-features = false, features = ["crossfont"] }
67 ```
vendor/sctk-adwaita/Cargo.toml created+61
......@@ -0,0 +1,61 @@
1# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
2#
3# When uploading crates to the registry Cargo will automatically
4# "normalize" Cargo.toml files for maximal compatibility
5# with all versions of Cargo and also rewrite `path` dependencies
6# to registry (e.g., crates.io) dependencies.
7#
8# If you are reading this file be aware that the original Cargo.toml
9# will likely look very different (and much more reasonable).
10# See Cargo.toml.orig for the original contents.
11
12[package]
13edition = "2021"
14name = "sctk-adwaita"
15version = "0.10.1"
16authors = ["Poly <marynczak.bartlomiej@gmail.com>"]
17description = "Adwaita-like SCTK Frame"
18documentation = "https://docs.rs/sctk-adwaita"
19readme = "README.md"
20keywords = ["sctk"]
21license = "MIT"
22repository = "https://github.com/PolyMeilex/sctk-adwaita"
23
24[dependencies.ab_glyph]
25version = "0.2.17"
26optional = true
27
28[dependencies.crossfont]
29version = "0.8.0"
30optional = true
31
32[dependencies.log]
33version = "0.4"
34
35[dependencies.memmap2]
36version = "0.9.0"
37optional = true
38
39[dependencies.smithay-client-toolkit]
40version = "0.19.0"
41default_features = false
42
43[dependencies.tiny-skia]
44version = "0.11"
45features = [
46 "std",
47 "simd",
48]
49default-features = false
50
51[features]
52ab_glyph = [
53 "dep:ab_glyph",
54 "memmap2",
55]
56crossfont = ["dep:crossfont"]
57default = ["ab_glyph"]
58
59# Upstream predates this lint; a path dependency's warnings are not capped.
60[lints.rust]
61mismatched_lifetime_syntaxes = "allow"
vendor/sctk-adwaita/LICENSE created+21
......@@ -0,0 +1,21 @@
1MIT License
2
3Copyright (c) 2022 Bartłomiej Maryńczak
4
5Permission is hereby granted, free of charge, to any person obtaining a copy
6of this software and associated documentation files (the "Software"), to deal
7in the Software without restriction, including without limitation the rights
8to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9copies of the Software, and to permit persons to whom the Software is
10furnished to do so, subject to the following conditions:
11
12The above copyright notice and this permission notice shall be included in all
13copies or substantial portions of the Software.
14
15THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21SOFTWARE.
vendor/sctk-adwaita/README.md created+19
......@@ -0,0 +1,19 @@
1# Adwaita-like SCTK Frame
2
3| | |
4|---|---|
5|![active](https://i.imgur.com/WdO8e0i.png)|![hover](https://i.imgur.com/TkUq2WF.png)|
6![inactive](https://i.imgur.com/MTFdSjK.png)|
7
8### Dark mode:
9![image](https://user-images.githubusercontent.com/20758186/169424673-3b9fa022-f112-4928-8360-305a714ba979.png)
10
11## Title text: ab_glyph
12By default title text is drawn with _ab_glyph_ crate. This can be disabled by disabling default features.
13
14## Title text: crossfont
15Alternatively title text may be drawn with _crossfont_ crate. This adds a requirement on _freetype_.
16
17```toml
18sctk-adwaita = { default-features = false, features = ["crossfont"] }
19```
vendor/sctk-adwaita/SNOWBOUND.md created+34
......@@ -0,0 +1,34 @@
1# sctk-adwaita, vendored
2
3Upstream: [sctk-adwaita](https://github.com/PolyMeilex/sctk-adwaita) 0.10.1 from
4crates.io (the version winit resolves to in `Cargo.lock`), copied without its
5`examples/`, `Cargo.lock` and CI files. The workspace `Cargo.toml` points
6`[patch.crates-io]` here.
7
8`Cargo.toml` gains a `[lints.rust]` table silencing a lint newer than the
9release, since Cargo does not cap a path dependency's warnings. The one source
10change drops the 1 px border line under the header bar, so the title
11bar flows into Snowbound's toolbar as it does in a GTK 4 app:
12
13```diff
14--- a/src/lib.rs
15+++ b/src/lib.rs
16@@ fn draw_headerbar_bg(
17 None,
18 );
19
20- pixmap.fill_rect(
21- Rect::from_xywh(0., h - 1., w, h)?,
22- &colors.border_paint(),
23- Transform::identity(),
24- None,
25- );
26-
27 Some(())
28 }
29```
30
31To bump: copy the new release's `src/`, `Cargo.toml`, `LICENSE`, `README.md`
32and `CHANGELOG.md` over this folder, reapply the diff, and update the version
33above. The patch applies only while the version here satisfies winit's
34requirement; `cargo tree -i sctk-adwaita` shows the path source when it does.
vendor/sctk-adwaita/src/buttons.rs created+382
......@@ -0,0 +1,382 @@
1use log::{debug, warn};
2use smithay_client_toolkit::reexports::csd_frame::{WindowManagerCapabilities, WindowState};
3use tiny_skia::{FillRule, PathBuilder, PixmapMut, Rect, Stroke, Transform};
4
5use crate::{theme::ColorMap, Location, SkiaResult};
6
7/// The size of the button on the header bar in logical points.
8const BUTTON_SIZE: f32 = 24.;
9const BUTTON_MARGIN: f32 = 5.;
10const BUTTON_SPACING: f32 = 13.;
11
12#[derive(Debug)]
13pub(crate) struct Buttons {
14 // Sorted by order vec of buttons for the left and right sides
15 buttons_left: Vec<Button>,
16 buttons_right: Vec<Button>,
17 layout_config: Option<(String, String)>,
18}
19
20type ButtonLayout = (Vec<Button>, Vec<Button>);
21
22impl Default for Buttons {
23 fn default() -> Self {
24 let (buttons_left, buttons_right) = Buttons::get_default_buttons_layout();
25
26 Self {
27 buttons_left,
28 buttons_right,
29 layout_config: None,
30 }
31 }
32}
33
34impl Buttons {
35 pub fn new(layout_config: Option<(String, String)>) -> Self {
36 match Buttons::parse_button_layout(layout_config.clone()) {
37 Some((buttons_left, buttons_right)) => Self {
38 buttons_left,
39 buttons_right,
40 layout_config,
41 },
42 _ => Self::default(),
43 }
44 }
45
46 /// Rearrange the buttons with the new width.
47 pub fn arrange(&mut self, width: u32, margin_h: f32) {
48 let mut left_x = BUTTON_MARGIN + margin_h;
49 let mut right_x = width as f32 - BUTTON_MARGIN;
50
51 for button in &mut self.buttons_left {
52 button.offset = left_x;
53
54 // Add the button size plus spacing
55 left_x += BUTTON_SIZE + BUTTON_SPACING;
56 }
57
58 for button in &mut self.buttons_right {
59 // Subtract the button size.
60 right_x -= BUTTON_SIZE;
61
62 // Update it
63 button.offset = right_x;
64
65 // Subtract spacing for the next button.
66 right_x -= BUTTON_SPACING;
67 }
68 }
69
70 /// Find the coordinate of the button.
71 pub fn find_button(&self, x: f64, y: f64) -> Location {
72 let x = x as f32;
73 let y = y as f32;
74 let buttons = self.buttons_left.iter().chain(self.buttons_right.iter());
75
76 for button in buttons {
77 if button.contains(x, y) {
78 return Location::Button(button.kind);
79 }
80 }
81
82 Location::Head
83 }
84
85 pub fn update_wm_capabilities(&mut self, wm_capabilites: WindowManagerCapabilities) {
86 let supports_maximize = wm_capabilites.contains(WindowManagerCapabilities::MAXIMIZE);
87 let supports_minimize = wm_capabilites.contains(WindowManagerCapabilities::MINIMIZE);
88
89 self.update_buttons(supports_maximize, supports_minimize);
90 }
91
92 pub fn update_buttons(&mut self, supports_maximize: bool, supports_minimize: bool) {
93 let is_supported = |button: &Button| match button.kind {
94 ButtonKind::Close => true,
95 ButtonKind::Maximize => supports_maximize,
96 ButtonKind::Minimize => supports_minimize,
97 };
98
99 let (buttons_left, buttons_right) =
100 Buttons::parse_button_layout(self.layout_config.clone())
101 .unwrap_or_else(Buttons::get_default_buttons_layout);
102
103 self.buttons_left = buttons_left.into_iter().filter(is_supported).collect();
104 self.buttons_right = buttons_right.into_iter().filter(is_supported).collect();
105 }
106
107 pub fn right_buttons_start_x(&self) -> Option<f32> {
108 self.buttons_right.last().map(|button| button.x())
109 }
110
111 pub fn left_buttons_end_x(&self) -> Option<f32> {
112 self.buttons_left.last().map(|button| button.end_x())
113 }
114
115 #[allow(clippy::too_many_arguments)]
116 pub fn draw(
117 &self,
118 start_x: f32,
119 end_x: f32,
120 scale: f32,
121 colors: &ColorMap,
122 mouse_location: Location,
123 pixmap: &mut PixmapMut,
124 resizable: bool,
125 state: &WindowState,
126 ) {
127 let left_buttons_right_limit =
128 self.right_buttons_start_x().unwrap_or(end_x).min(end_x) - BUTTON_SPACING;
129 let buttons_left = self.buttons_left.iter().map(|x| (x, Side::Left));
130 let buttons_right = self.buttons_right.iter().map(|x| (x, Side::Right));
131
132 for (button, side) in buttons_left.chain(buttons_right) {
133 let is_visible = button.x() > start_x && button.end_x() < end_x
134 // If we have buttons from both sides and they overlap, prefer the right side
135 && (side == Side::Right || button.end_x() < left_buttons_right_limit);
136
137 if is_visible {
138 button.draw(scale, colors, mouse_location, pixmap, resizable, state);
139 }
140 }
141 }
142
143 fn parse_button_layout(sides: Option<(String, String)>) -> Option<ButtonLayout> {
144 let Some((left_side, right_side)) = sides else {
145 return None;
146 };
147
148 let buttons_left = Buttons::parse_button_layout_side(left_side, Side::Left);
149 let buttons_right = Buttons::parse_button_layout_side(right_side, Side::Right);
150
151 if buttons_left.is_empty() && buttons_right.is_empty() {
152 warn!("No valid buttons found in configuration");
153 return None;
154 }
155
156 Some((buttons_left, buttons_right))
157 }
158
159 fn parse_button_layout_side(config: String, side: Side) -> Vec<Button> {
160 let mut buttons: Vec<Button> = vec![];
161
162 for button in config.split(',').take(3) {
163 let button_kind = match button {
164 "close" => ButtonKind::Close,
165 "maximize" => ButtonKind::Maximize,
166 "minimize" => ButtonKind::Minimize,
167 "appmenu" => {
168 debug!("Ignoring \"appmenu\" button");
169 continue;
170 }
171 _ => {
172 warn!("Ignoring unknown button type: {button}");
173 continue;
174 }
175 };
176
177 buttons.push(Button::new(button_kind));
178 }
179
180 // For the right side, we need to revert the order
181 if side == Side::Right {
182 buttons.into_iter().rev().collect()
183 } else {
184 buttons
185 }
186 }
187
188 fn get_default_buttons_layout() -> ButtonLayout {
189 (
190 vec![],
191 vec![
192 Button::new(ButtonKind::Close),
193 Button::new(ButtonKind::Maximize),
194 Button::new(ButtonKind::Minimize),
195 ],
196 )
197 }
198}
199
200#[derive(Debug, Clone)]
201pub(crate) struct Button {
202 /// The button offset into the header bar canvas.
203 offset: f32,
204 /// The kind of the button.
205 kind: ButtonKind,
206}
207
208impl Button {
209 pub fn new(kind: ButtonKind) -> Self {
210 Self { offset: 0., kind }
211 }
212
213 pub fn radius(&self) -> f32 {
214 BUTTON_SIZE / 2.0
215 }
216
217 pub fn x(&self) -> f32 {
218 self.offset
219 }
220
221 pub fn center_x(&self) -> f32 {
222 self.offset + self.radius()
223 }
224
225 pub fn center_y(&self) -> f32 {
226 BUTTON_MARGIN + self.radius()
227 }
228
229 pub fn end_x(&self) -> f32 {
230 self.offset + BUTTON_SIZE
231 }
232
233 fn contains(&self, x: f32, y: f32) -> bool {
234 x > self.offset
235 && x < self.offset + BUTTON_SIZE
236 && y > BUTTON_MARGIN
237 && y < BUTTON_MARGIN + BUTTON_SIZE
238 }
239
240 pub fn draw(
241 &self,
242 scale: f32,
243 colors: &ColorMap,
244 mouse_location: Location,
245 pixmap: &mut PixmapMut,
246 resizable: bool,
247 state: &WindowState,
248 ) -> SkiaResult {
249 let button_bg = if mouse_location == Location::Button(self.kind)
250 && (resizable || self.kind != ButtonKind::Maximize)
251 {
252 colors.button_hover_paint()
253 } else {
254 colors.button_idle_paint()
255 };
256
257 // Convert to pixels.
258 let x = self.center_x() * scale;
259 let y = self.center_y() * scale;
260 let radius = self.radius() * scale;
261
262 // Draw the button background.
263 let circle = PathBuilder::from_circle(x, y, radius)?;
264 pixmap.fill_path(
265 &circle,
266 &button_bg,
267 FillRule::Winding,
268 Transform::identity(),
269 None,
270 );
271
272 let mut button_icon_paint = colors.button_icon_paint();
273 // Do AA only for diagonal lines.
274 button_icon_paint.anti_alias = self.kind == ButtonKind::Close;
275
276 // Draw the icon.
277 match self.kind {
278 ButtonKind::Close => {
279 let x_icon = {
280 let size = 3.5 * scale;
281 let mut pb = PathBuilder::new();
282
283 {
284 let sx = x - size;
285 let sy = y - size;
286 let ex = x + size;
287 let ey = y + size;
288
289 pb.move_to(sx, sy);
290 pb.line_to(ex, ey);
291 pb.close();
292 }
293
294 {
295 let sx = x - size;
296 let sy = y + size;
297 let ex = x + size;
298 let ey = y - size;
299
300 pb.move_to(sx, sy);
301 pb.line_to(ex, ey);
302 pb.close();
303 }
304
305 pb.finish()?
306 };
307
308 pixmap.stroke_path(
309 &x_icon,
310 &button_icon_paint,
311 &Stroke {
312 width: 1.1 * scale,
313 ..Default::default()
314 },
315 Transform::identity(),
316 None,
317 );
318 }
319 ButtonKind::Maximize => {
320 let path2 = {
321 let size = 8.0 * scale;
322 let hsize = size / 2.0;
323 let mut pb = PathBuilder::new();
324
325 let x = x - hsize;
326 let y = y - hsize;
327 if state.contains(WindowState::MAXIMIZED) {
328 let offset = 2.0 * scale;
329 if let Some(rect) =
330 Rect::from_xywh(x, y + offset, size - offset, size - offset)
331 {
332 pb.push_rect(rect);
333 pb.move_to(rect.left() + offset, rect.top() - offset);
334 pb.line_to(rect.right() + offset, rect.top() - offset);
335 pb.line_to(rect.right() + offset, rect.bottom() - offset + 0.5);
336 }
337 } else if let Some(rect) = Rect::from_xywh(x, y, size, size) {
338 pb.push_rect(rect);
339 }
340
341 pb.finish()?
342 };
343
344 pixmap.stroke_path(
345 &path2,
346 &button_icon_paint,
347 &Stroke {
348 width: 1.0 * scale,
349 ..Default::default()
350 },
351 Transform::identity(),
352 None,
353 );
354 }
355 ButtonKind::Minimize => {
356 let len = 8.0 * scale;
357 let hlen = len / 2.0;
358 pixmap.fill_rect(
359 Rect::from_xywh(x - hlen, y + hlen, len, scale)?,
360 &button_icon_paint,
361 Transform::identity(),
362 None,
363 );
364 }
365 }
366
367 Some(())
368 }
369}
370
371#[derive(Debug, Copy, Clone, PartialEq, Eq)]
372pub enum ButtonKind {
373 Close,
374 Maximize,
375 Minimize,
376}
377
378#[derive(Debug, Copy, Clone, PartialEq, Eq)]
379pub enum Side {
380 Left,
381 Right,
382}
vendor/sctk-adwaita/src/config.rs created+56
......@@ -0,0 +1,56 @@
1//! System configuration.
2use std::process::Command;
3
4/// Query system to see if dark theming should be preferred.
5pub(crate) fn prefer_dark() -> bool {
6 // outputs something like: `variant variant uint32 1`
7 let stdout = Command::new("dbus-send")
8 .arg("--reply-timeout=100")
9 .arg("--print-reply=literal")
10 .arg("--dest=org.freedesktop.portal.Desktop")
11 .arg("/org/freedesktop/portal/desktop")
12 .arg("org.freedesktop.portal.Settings.Read")
13 .arg("string:org.freedesktop.appearance")
14 .arg("string:color-scheme")
15 .output()
16 .ok()
17 .and_then(|out| String::from_utf8(out.stdout).ok());
18
19 if matches!(stdout, Some(ref s) if s.is_empty()) {
20 log::error!("XDG Settings Portal did not return response in time: timeout: 100ms, key: color-scheme");
21 }
22
23 matches!(stdout, Some(s) if s.trim().ends_with("uint32 1"))
24}
25
26/// Query system configuration for buttons layout.
27/// Should be updated to use standard xdg-desktop-portal specs once available
28/// https://github.com/flatpak/xdg-desktop-portal/pull/996
29pub(crate) fn get_button_layout_config() -> Option<(String, String)> {
30 let config_string = Command::new("dbus-send")
31 .arg("--reply-timeout=100")
32 .arg("--print-reply=literal")
33 .arg("--dest=org.freedesktop.portal.Desktop")
34 .arg("/org/freedesktop/portal/desktop")
35 .arg("org.freedesktop.portal.Settings.Read")
36 .arg("string:org.gnome.desktop.wm.preferences")
37 .arg("string:button-layout")
38 .output()
39 .ok()
40 .and_then(|out| String::from_utf8(out.stdout).ok())?;
41
42 let sides_split: Vec<_> = config_string
43 // Taking last word
44 .rsplit(' ')
45 .next()?
46 // Split by left/right side
47 .split(':')
48 // Only two sides
49 .take(2)
50 .collect();
51
52 match sides_split.as_slice() {
53 [left, right] => Some((left.to_string(), right.to_string())),
54 _ => None,
55 }
56}
vendor/sctk-adwaita/src/lib.rs created+773
......@@ -0,0 +1,773 @@
1use std::error::Error;
2use std::mem;
3use std::num::NonZeroU32;
4use std::sync::Arc;
5use std::time::Duration;
6
7use tiny_skia::{
8 Color, FillRule, Mask, Path, PathBuilder, Pixmap, PixmapMut, PixmapPaint, Point, Rect,
9 Transform,
10};
11
12use smithay_client_toolkit::reexports::client::backend::ObjectId;
13use smithay_client_toolkit::reexports::client::protocol::wl_shm;
14use smithay_client_toolkit::reexports::client::protocol::wl_subsurface::WlSubsurface;
15use smithay_client_toolkit::reexports::client::protocol::wl_surface::WlSurface;
16use smithay_client_toolkit::reexports::client::{Dispatch, Proxy, QueueHandle};
17use smithay_client_toolkit::reexports::csd_frame::{
18 CursorIcon, DecorationsFrame, FrameAction, FrameClick, WindowManagerCapabilities, WindowState,
19};
20
21use smithay_client_toolkit::compositor::{CompositorState, Region, SurfaceData};
22use smithay_client_toolkit::shell::WaylandSurface;
23use smithay_client_toolkit::shm::{slot::SlotPool, Shm};
24use smithay_client_toolkit::subcompositor::SubcompositorState;
25use smithay_client_toolkit::subcompositor::SubsurfaceData;
26
27mod buttons;
28mod config;
29mod parts;
30mod pointer;
31mod shadow;
32pub mod theme;
33mod title;
34mod wl_typed;
35
36use crate::theme::{
37 ColorMap, ColorTheme, BORDER_SIZE, CORNER_RADIUS, HEADER_SIZE, RESIZE_HANDLE_CORNER_SIZE,
38 VISIBLE_BORDER_SIZE,
39};
40
41use buttons::Buttons;
42use config::get_button_layout_config;
43use parts::DecorationParts;
44use pointer::{Location, MouseState};
45use shadow::Shadow;
46use title::TitleText;
47use wl_typed::WlTyped;
48
49/// XXX this is not result, so `must_use` when needed.
50type SkiaResult = Option<()>;
51
52/// A simple set of decorations
53#[derive(Debug)]
54pub struct AdwaitaFrame<State> {
55 /// The base surface used to create the window.
56 base_surface: WlTyped<WlSurface, SurfaceData>,
57
58 compositor: Arc<CompositorState>,
59
60 /// Subcompositor to create/drop subsurfaces ondemand.
61 subcompositor: Arc<SubcompositorState>,
62
63 /// Queue handle to perform object creation.
64 queue_handle: QueueHandle<State>,
65
66 /// The drawable decorations, `None` when hidden.
67 decorations: Option<DecorationParts>,
68
69 /// Memory pool to allocate the buffers for the decorations.
70 pool: SlotPool,
71
72 /// Whether the frame should be redrawn.
73 dirty: bool,
74
75 /// Whether the drawing should be synced with the main surface.
76 should_sync: bool,
77
78 /// Scale factor used for the surface.
79 scale_factor: u32,
80
81 /// Wether the frame is resizable.
82 resizable: bool,
83
84 buttons: Buttons,
85 state: WindowState,
86 wm_capabilities: WindowManagerCapabilities,
87 mouse: MouseState,
88 theme: ColorTheme,
89 title: Option<String>,
90 title_text: Option<TitleText>,
91 shadow: Shadow,
92}
93
94impl<State> AdwaitaFrame<State>
95where
96 State: Dispatch<WlSurface, SurfaceData> + Dispatch<WlSubsurface, SubsurfaceData> + 'static,
97{
98 pub fn new(
99 base_surface: &impl WaylandSurface,
100 shm: &Shm,
101 compositor: Arc<CompositorState>,
102 subcompositor: Arc<SubcompositorState>,
103 queue_handle: QueueHandle<State>,
104 frame_config: FrameConfig,
105 ) -> Result<Self, Box<dyn Error>> {
106 let base_surface = WlTyped::wrap::<State>(base_surface.wl_surface().clone());
107
108 let pool = SlotPool::new(1, shm)?;
109
110 let decorations = Some(DecorationParts::new(
111 &base_surface,
112 &subcompositor,
113 &queue_handle,
114 ));
115
116 let theme = frame_config.theme;
117
118 Ok(AdwaitaFrame {
119 base_surface,
120 decorations,
121 pool,
122 compositor,
123 subcompositor,
124 queue_handle,
125 dirty: true,
126 scale_factor: 1,
127 should_sync: true,
128 title: None,
129 title_text: TitleText::new(theme.active.font_color),
130 theme,
131 buttons: Buttons::new(get_button_layout_config()),
132 mouse: Default::default(),
133 state: WindowState::empty(),
134 wm_capabilities: WindowManagerCapabilities::all(),
135 resizable: true,
136 shadow: Shadow::default(),
137 })
138 }
139
140 /// Update the current frame config.
141 pub fn set_config(&mut self, config: FrameConfig) {
142 self.theme = config.theme;
143 self.dirty = true;
144 }
145
146 fn precise_location(
147 &self,
148 location: Location,
149 decoration: &DecorationParts,
150 x: f64,
151 y: f64,
152 ) -> Location {
153 let header_width = decoration.header().surface_rect.width;
154 let side_height = decoration.side_height();
155
156 let left_corner_x = BORDER_SIZE + RESIZE_HANDLE_CORNER_SIZE;
157 let right_corner_x = (header_width + BORDER_SIZE).saturating_sub(RESIZE_HANDLE_CORNER_SIZE);
158 let top_corner_y = RESIZE_HANDLE_CORNER_SIZE;
159 let bottom_corner_y = side_height.saturating_sub(RESIZE_HANDLE_CORNER_SIZE);
160 match location {
161 Location::Head | Location::Button(_) => self.buttons.find_button(x, y),
162 Location::Top | Location::TopLeft | Location::TopRight => {
163 if x <= f64::from(left_corner_x) {
164 Location::TopLeft
165 } else if x >= f64::from(right_corner_x) {
166 Location::TopRight
167 } else {
168 Location::Top
169 }
170 }
171 Location::Bottom | Location::BottomLeft | Location::BottomRight => {
172 if x <= f64::from(left_corner_x) {
173 Location::BottomLeft
174 } else if x >= f64::from(right_corner_x) {
175 Location::BottomRight
176 } else {
177 Location::Bottom
178 }
179 }
180 Location::Left => {
181 if y <= f64::from(top_corner_y) {
182 Location::TopLeft
183 } else if y >= f64::from(bottom_corner_y) {
184 Location::BottomLeft
185 } else {
186 Location::Left
187 }
188 }
189 Location::Right => {
190 if y <= f64::from(top_corner_y) {
191 Location::TopRight
192 } else if y >= f64::from(bottom_corner_y) {
193 Location::BottomRight
194 } else {
195 Location::Right
196 }
197 }
198 other => other,
199 }
200 }
201
202 fn redraw_inner(&mut self) -> Option<bool> {
203 let decorations = self.decorations.as_mut()?;
204
205 // Reset the dirty bit.
206 self.dirty = false;
207 let should_sync = mem::take(&mut self.should_sync);
208
209 // Don't draw borders if the frame explicitly hidden or fullscreened.
210 if self.state.contains(WindowState::FULLSCREEN) {
211 decorations.hide();
212 return Some(true);
213 }
214
215 let colors = if self.state.contains(WindowState::ACTIVATED) {
216 &self.theme.active
217 } else {
218 &self.theme.inactive
219 };
220
221 let draw_borders = if self.state.contains(WindowState::MAXIMIZED) {
222 // Don't draw the borders.
223 decorations.hide_borders();
224 false
225 } else {
226 true
227 };
228 let border_paint = colors.border_paint();
229
230 // Draw the borders.
231 for (idx, part) in decorations
232 .parts()
233 .filter(|(idx, _)| *idx == DecorationParts::HEADER || draw_borders)
234 {
235 let scale = self.scale_factor;
236
237 let mut rect = part.surface_rect;
238 // XXX to perfectly align the visible borders we draw them with
239 // the header, otherwise rounded corners won't look 'smooth' at the
240 // start. To achieve that, we enlargen the width of the header by
241 // 2 * `VISIBLE_BORDER_SIZE`, and move `x` by `VISIBLE_BORDER_SIZE`
242 // to the left.
243 if idx == DecorationParts::HEADER && draw_borders {
244 rect.width += 2 * VISIBLE_BORDER_SIZE;
245 rect.x -= VISIBLE_BORDER_SIZE as i32;
246 }
247
248 rect.width *= scale;
249 rect.height *= scale;
250
251 let (buffer, canvas) = match self.pool.create_buffer(
252 rect.width as i32,
253 rect.height as i32,
254 rect.width as i32 * 4,
255 wl_shm::Format::Argb8888,
256 ) {
257 Ok((buffer, canvas)) => (buffer, canvas),
258 Err(_) => continue,
259 };
260
261 // Create the pixmap and fill with transparent color.
262 let mut pixmap = PixmapMut::from_bytes(canvas, rect.width, rect.height)?;
263
264 // Fill everything with transparent background, since we draw rounded corners and
265 // do invisible borders to enlarge the input zone.
266 pixmap.fill(Color::TRANSPARENT);
267
268 if !self.state.intersects(WindowState::TILED) {
269 self.shadow.draw(
270 &mut pixmap,
271 scale,
272 self.state.contains(WindowState::ACTIVATED),
273 idx,
274 );
275 }
276
277 match idx {
278 DecorationParts::HEADER => {
279 if let Some(title_text) = self.title_text.as_mut() {
280 title_text.update_scale(scale);
281 title_text.update_color(colors.font_color);
282 }
283
284 draw_headerbar(
285 &mut pixmap,
286 self.title_text.as_ref().map(|t| t.pixmap()).unwrap_or(None),
287 scale as f32,
288 self.resizable,
289 &self.state,
290 &self.theme,
291 &self.buttons,
292 self.mouse.location,
293 );
294 }
295 border => {
296 // The visible border is one pt.
297 let visible_border_size = VISIBLE_BORDER_SIZE * scale;
298
299 // XXX we do all the match using integral types and then convert to f32 in the
300 // end to ensure that result is finite.
301 let border_rect = match border {
302 DecorationParts::LEFT => {
303 let x = (rect.x.unsigned_abs() * scale) - visible_border_size;
304 let y = rect.y.unsigned_abs() * scale;
305 Rect::from_xywh(
306 x as f32,
307 y as f32,
308 visible_border_size as f32,
309 (rect.height - y) as f32,
310 )
311 }
312 DecorationParts::RIGHT => {
313 let y = rect.y.unsigned_abs() * scale;
314 Rect::from_xywh(
315 0.,
316 y as f32,
317 visible_border_size as f32,
318 (rect.height - y) as f32,
319 )
320 }
321 // We draw small visible border only bellow the window surface, no need to
322 // handle `TOP`.
323 DecorationParts::BOTTOM => {
324 let x = (rect.x.unsigned_abs() * scale) - visible_border_size;
325 Rect::from_xywh(
326 x as f32,
327 0.,
328 (rect.width - 2 * x) as f32,
329 visible_border_size as f32,
330 )
331 }
332 _ => None,
333 };
334
335 // Fill the visible border, if present.
336 if let Some(border_rect) = border_rect {
337 pixmap.fill_rect(border_rect, &border_paint, Transform::identity(), None);
338 }
339 }
340 };
341
342 if should_sync {
343 part.subsurface.set_sync();
344 } else {
345 part.subsurface.set_desync();
346 }
347
348 part.surface.set_buffer_scale(scale as i32);
349
350 part.subsurface.set_position(rect.x, rect.y);
351 buffer.attach_to(&part.surface).ok()?;
352
353 if part.surface.version() >= 4 {
354 part.surface.damage_buffer(0, 0, i32::MAX, i32::MAX);
355 } else {
356 part.surface.damage(0, 0, i32::MAX, i32::MAX);
357 }
358
359 if let Some(input_rect) = part.input_rect {
360 let input_region = Region::new(&*self.compositor).ok()?;
361 input_region.add(
362 input_rect.x,
363 input_rect.y,
364 input_rect.width as i32,
365 input_rect.height as i32,
366 );
367
368 part.surface
369 .set_input_region(Some(input_region.wl_region()));
370 }
371
372 part.surface.commit();
373 }
374
375 Some(should_sync)
376 }
377}
378
379impl<State> DecorationsFrame for AdwaitaFrame<State>
380where
381 State: Dispatch<WlSurface, SurfaceData> + Dispatch<WlSubsurface, SubsurfaceData> + 'static,
382{
383 fn update_state(&mut self, state: WindowState) {
384 let difference = self.state.symmetric_difference(state);
385 self.state = state;
386 self.dirty |= difference.intersects(
387 WindowState::ACTIVATED
388 | WindowState::FULLSCREEN
389 | WindowState::MAXIMIZED
390 | WindowState::TILED,
391 );
392 }
393
394 fn update_wm_capabilities(&mut self, wm_capabilities: WindowManagerCapabilities) {
395 self.dirty |= self.wm_capabilities != wm_capabilities;
396 self.wm_capabilities = wm_capabilities;
397 self.buttons.update_wm_capabilities(wm_capabilities);
398 }
399
400 fn set_hidden(&mut self, hidden: bool) {
401 if hidden {
402 self.dirty = false;
403 let _ = self.pool.resize(1);
404 self.decorations = None;
405 } else if self.decorations.is_none() {
406 self.decorations = Some(DecorationParts::new(
407 &self.base_surface,
408 &self.subcompositor,
409 &self.queue_handle,
410 ));
411 self.dirty = true;
412 self.should_sync = true;
413 }
414 }
415
416 fn set_resizable(&mut self, resizable: bool) {
417 self.dirty |= self.resizable != resizable;
418 self.resizable = resizable;
419 }
420
421 fn resize(&mut self, width: NonZeroU32, height: NonZeroU32) {
422 let Some(decorations) = self.decorations.as_mut() else {
423 log::error!("trying to resize the hidden frame.");
424 return;
425 };
426
427 decorations.resize(width.get(), height.get());
428 self.buttons
429 .arrange(width.get(), get_margin_h_lp(&self.state));
430 self.dirty = true;
431 self.should_sync = true;
432 }
433
434 fn draw(&mut self) -> bool {
435 self.redraw_inner().unwrap_or(true)
436 }
437
438 fn subtract_borders(
439 &self,
440 width: NonZeroU32,
441 height: NonZeroU32,
442 ) -> (Option<NonZeroU32>, Option<NonZeroU32>) {
443 if self.decorations.is_none() || self.state.contains(WindowState::FULLSCREEN) {
444 (Some(width), Some(height))
445 } else {
446 (
447 Some(width),
448 NonZeroU32::new(height.get().saturating_sub(HEADER_SIZE)),
449 )
450 }
451 }
452
453 fn add_borders(&self, width: u32, height: u32) -> (u32, u32) {
454 if self.decorations.is_none() || self.state.contains(WindowState::FULLSCREEN) {
455 (width, height)
456 } else {
457 (width, height + HEADER_SIZE)
458 }
459 }
460
461 fn location(&self) -> (i32, i32) {
462 if self.decorations.is_none() || self.state.contains(WindowState::FULLSCREEN) {
463 (0, 0)
464 } else {
465 (0, -(HEADER_SIZE as i32))
466 }
467 }
468
469 fn set_title(&mut self, title: impl Into<String>) {
470 let new_title = title.into();
471 if let Some(title_text) = self.title_text.as_mut() {
472 title_text.update_title(new_title.clone());
473 }
474
475 self.title = Some(new_title);
476 self.dirty = true;
477 }
478
479 fn on_click(
480 &mut self,
481 timestamp: Duration,
482 click: FrameClick,
483 pressed: bool,
484 ) -> Option<FrameAction> {
485 match click {
486 FrameClick::Normal => self.mouse.click(
487 timestamp,
488 pressed,
489 self.resizable,
490 &self.state,
491 &self.wm_capabilities,
492 ),
493 FrameClick::Alternate => self.mouse.alternate_click(pressed, &self.wm_capabilities),
494 _ => None,
495 }
496 }
497
498 fn set_scaling_factor(&mut self, scale_factor: f64) {
499 // NOTE: Clamp it just in case to some ok-ish range.
500 self.scale_factor = scale_factor.clamp(0.1, 64.).ceil() as u32;
501 self.dirty = true;
502 self.should_sync = true;
503 }
504
505 fn click_point_moved(
506 &mut self,
507 _timestamp: Duration,
508 surface: &ObjectId,
509 x: f64,
510 y: f64,
511 ) -> Option<CursorIcon> {
512 let decorations = self.decorations.as_ref()?;
513 let location = decorations.find_surface(surface);
514 if location == Location::None {
515 return None;
516 }
517
518 let old_location = self.mouse.location;
519
520 let location = self.precise_location(location, decorations, x, y);
521 let new_cursor = self.mouse.moved(location, x, y, self.resizable);
522
523 // Set dirty if we moved the cursor between the buttons.
524 self.dirty |= (matches!(old_location, Location::Button(_))
525 || matches!(self.mouse.location, Location::Button(_)))
526 && old_location != self.mouse.location;
527
528 Some(new_cursor)
529 }
530
531 fn click_point_left(&mut self) {
532 self.mouse.left()
533 }
534
535 fn is_dirty(&self) -> bool {
536 self.dirty
537 }
538
539 fn is_hidden(&self) -> bool {
540 self.decorations.is_none()
541 }
542}
543
544/// The configuration for the [`AdwaitaFrame`] frame.
545#[derive(Debug, Clone)]
546pub struct FrameConfig {
547 pub theme: ColorTheme,
548}
549
550impl FrameConfig {
551 /// Create the new configuration with the given `theme`.
552 pub fn new(theme: ColorTheme) -> Self {
553 Self { theme }
554 }
555
556 /// This is equivalent of calling `FrameConfig::new(ColorTheme::auto())`.
557 ///
558 /// For details see [`ColorTheme::auto`].
559 pub fn auto() -> Self {
560 Self {
561 theme: ColorTheme::auto(),
562 }
563 }
564
565 /// This is equivalent of calling `FrameConfig::new(ColorTheme::light())`.
566 ///
567 /// For details see [`ColorTheme::light`].
568 pub fn light() -> Self {
569 Self {
570 theme: ColorTheme::light(),
571 }
572 }
573
574 /// This is equivalent of calling `FrameConfig::new(ColorTheme::dark())`.
575 ///
576 /// For details see [`ColorTheme::dark`].
577 pub fn dark() -> Self {
578 Self {
579 theme: ColorTheme::dark(),
580 }
581 }
582}
583
584#[allow(clippy::too_many_arguments)]
585fn draw_headerbar(
586 pixmap: &mut PixmapMut,
587 text_pixmap: Option<&Pixmap>,
588 scale: f32,
589 resizable: bool,
590 state: &WindowState,
591 theme: &ColorTheme,
592 buttons: &Buttons,
593 mouse: Location,
594) {
595 let colors = theme.for_state(state.contains(WindowState::ACTIVATED));
596
597 let _ = draw_headerbar_bg(pixmap, scale, colors, state);
598
599 // Horizontal margin.
600 let margin_h = get_margin_h_lp(state) * 2.0;
601
602 let canvas_w = pixmap.width() as f32;
603 let canvas_h = pixmap.height() as f32;
604
605 let header_w = canvas_w - margin_h * 2.0;
606 let header_h = canvas_h;
607
608 if let Some(text_pixmap) = text_pixmap {
609 const TEXT_OFFSET: f32 = 10.;
610 let offset_x = TEXT_OFFSET * scale;
611
612 let text_w = text_pixmap.width() as f32;
613 let text_h = text_pixmap.height() as f32;
614
615 let x = margin_h + header_w / 2. - text_w / 2.;
616 let y = header_h / 2. - text_h / 2.;
617
618 let left_buttons_end_x = buttons.left_buttons_end_x().unwrap_or(0.0) * scale;
619 let right_buttons_start_x =
620 buttons.right_buttons_start_x().unwrap_or(header_w / scale) * scale;
621
622 {
623 // We have enough space to center text
624 let (x, y, text_canvas_start_x) = if (x + text_w < right_buttons_start_x - offset_x)
625 && (x > left_buttons_end_x + offset_x)
626 {
627 let text_canvas_start_x = x;
628
629 (x, y, text_canvas_start_x)
630 } else {
631 let x = left_buttons_end_x + offset_x;
632 let text_canvas_start_x = left_buttons_end_x + offset_x;
633
634 (x, y, text_canvas_start_x)
635 };
636
637 let text_canvas_end_x = right_buttons_start_x - x - offset_x;
638 // Ensure that text start within the bounds.
639 let x = x.max(margin_h + offset_x);
640
641 if let Some(clip) =
642 Rect::from_xywh(text_canvas_start_x, 0., text_canvas_end_x, canvas_h)
643 {
644 if let Some(mut mask) = Mask::new(canvas_w as u32, canvas_h as u32) {
645 mask.fill_path(
646 &PathBuilder::from_rect(clip),
647 FillRule::Winding,
648 false,
649 Transform::identity(),
650 );
651 pixmap.draw_pixmap(
652 x.round() as i32,
653 y as i32,
654 text_pixmap.as_ref(),
655 &PixmapPaint::default(),
656 Transform::identity(),
657 Some(&mask),
658 );
659 } else {
660 log::error!(
661 "Invalid mask width and height: w: {}, h: {}",
662 canvas_w as u32,
663 canvas_h as u32
664 );
665 }
666 }
667 }
668 }
669
670 // Draw the buttons.
671 buttons.draw(
672 margin_h, header_w, scale, colors, mouse, pixmap, resizable, state,
673 );
674}
675
676#[must_use]
677fn draw_headerbar_bg(
678 pixmap: &mut PixmapMut,
679 scale: f32,
680 colors: &ColorMap,
681 state: &WindowState,
682) -> SkiaResult {
683 let w = pixmap.width() as f32;
684 let h = pixmap.height() as f32;
685
686 let radius = if state.intersects(WindowState::MAXIMIZED | WindowState::TILED) {
687 0.
688 } else {
689 CORNER_RADIUS as f32 * scale
690 };
691
692 let bg = rounded_headerbar_shape(0., 0., w, h, radius)?;
693
694 pixmap.fill_path(
695 &bg,
696 &colors.headerbar_paint(),
697 FillRule::Winding,
698 Transform::identity(),
699 None,
700 );
701
702 Some(())
703}
704
705fn rounded_headerbar_shape(x: f32, y: f32, width: f32, height: f32, radius: f32) -> Option<Path> {
706 // https://stackoverflow.com/a/27863181
707 let cubic_bezier_circle = 0.552_284_8 * radius;
708
709 let mut pb = PathBuilder::new();
710 let mut cursor = Point::from_xy(x, y);
711
712 // !!!
713 // This code is heavily "inspired" by https://gitlab.com/snakedye/snui/
714 // So technically it should be licensed under MPL-2.0, sorry about that 🥺 👉👈
715 // !!!
716
717 // Positioning the cursor
718 cursor.y += radius;
719 pb.move_to(cursor.x, cursor.y);
720
721 // Drawing the outline
722 let next = Point::from_xy(cursor.x + radius, cursor.y - radius);
723 pb.cubic_to(
724 cursor.x,
725 cursor.y - cubic_bezier_circle,
726 next.x - cubic_bezier_circle,
727 next.y,
728 next.x,
729 next.y,
730 );
731 cursor = next;
732 pb.line_to(
733 {
734 cursor.x = x + width - radius;
735 cursor.x
736 },
737 cursor.y,
738 );
739 let next = Point::from_xy(cursor.x + radius, cursor.y + radius);
740 pb.cubic_to(
741 cursor.x + cubic_bezier_circle,
742 cursor.y,
743 next.x,
744 next.y - cubic_bezier_circle,
745 next.x,
746 next.y,
747 );
748 cursor = next;
749 pb.line_to(cursor.x, {
750 cursor.y = y + height;
751 cursor.y
752 });
753 pb.line_to(
754 {
755 cursor.x = x;
756 cursor.x
757 },
758 cursor.y,
759 );
760
761 pb.close();
762
763 pb.finish()
764}
765
766// returns horizontal margin, logical points
767fn get_margin_h_lp(state: &WindowState) -> f32 {
768 if state.intersects(WindowState::MAXIMIZED | WindowState::TILED) {
769 0.
770 } else {
771 VISIBLE_BORDER_SIZE as f32
772 }
773}
vendor/sctk-adwaita/src/parts.rs created+260
......@@ -0,0 +1,260 @@
1use smithay_client_toolkit::reexports::client::{
2 backend::ObjectId,
3 protocol::{wl_subsurface::WlSubsurface, wl_surface::WlSurface},
4 Dispatch, Proxy, QueueHandle,
5};
6
7use smithay_client_toolkit::{
8 compositor::SurfaceData,
9 subcompositor::{SubcompositorState, SubsurfaceData},
10};
11
12use crate::theme::{BORDER_SIZE, HEADER_SIZE, RESIZE_HANDLE_SIZE};
13use crate::{pointer::Location, wl_typed::WlTyped};
14
15/// The decoration's 'parts'.
16#[derive(Debug)]
17pub struct DecorationParts {
18 parts: [Part; 5],
19}
20
21impl DecorationParts {
22 // XXX keep in sync with `Self;:new`.
23 // Order is important. The lower the number, the earlier the part gets drawn.
24 // Because the header can overlap other parts, we draw it last.
25 pub const TOP: usize = 0;
26 pub const LEFT: usize = 1;
27 pub const RIGHT: usize = 2;
28 pub const BOTTOM: usize = 3;
29 pub const HEADER: usize = 4;
30
31 pub fn new<State>(
32 base_surface: &WlTyped<WlSurface, SurfaceData>,
33 subcompositor: &SubcompositorState,
34 queue_handle: &QueueHandle<State>,
35 ) -> Self
36 where
37 State: Dispatch<WlSurface, SurfaceData> + Dispatch<WlSubsurface, SubsurfaceData> + 'static,
38 {
39 // XXX the order must be in sync with associated constants.
40 let parts = [
41 // Top.
42 Part::new(
43 base_surface,
44 subcompositor,
45 queue_handle,
46 Rect {
47 x: -(BORDER_SIZE as i32),
48 y: -(HEADER_SIZE as i32 + BORDER_SIZE as i32),
49 width: 0, // Defined by `Self::resize`.
50 height: BORDER_SIZE,
51 },
52 Some(Rect {
53 x: BORDER_SIZE as i32 - RESIZE_HANDLE_SIZE as i32,
54 y: BORDER_SIZE as i32 - RESIZE_HANDLE_SIZE as i32,
55 width: 0, // Defined by `Self::resize`.
56 height: RESIZE_HANDLE_SIZE,
57 }),
58 ),
59 // Left.
60 Part::new(
61 base_surface,
62 subcompositor,
63 queue_handle,
64 Rect {
65 x: -(BORDER_SIZE as i32),
66 y: -(HEADER_SIZE as i32),
67 width: BORDER_SIZE,
68 height: 0, // Defined by `Self::resize`.
69 },
70 Some(Rect {
71 x: BORDER_SIZE as i32 - RESIZE_HANDLE_SIZE as i32,
72 y: 0,
73 width: RESIZE_HANDLE_SIZE,
74 height: 0, // Defined by `Self::resize`.
75 }),
76 ),
77 // Right.
78 Part::new(
79 base_surface,
80 subcompositor,
81 queue_handle,
82 Rect {
83 x: 0, // Defined by `Self::resize`.
84 y: -(HEADER_SIZE as i32),
85 width: BORDER_SIZE,
86 height: 0, // Defined by `Self::resize`.
87 },
88 Some(Rect {
89 x: 0,
90 y: 0,
91 width: RESIZE_HANDLE_SIZE,
92 height: 0, // Defined by `Self::resize`.
93 }),
94 ),
95 // Bottom.
96 Part::new(
97 base_surface,
98 subcompositor,
99 queue_handle,
100 Rect {
101 x: -(BORDER_SIZE as i32),
102 y: 0, // Defined by `Self::resize`.
103 width: 0, // Defined by `Self::resize`.
104 height: BORDER_SIZE,
105 },
106 Some(Rect {
107 x: BORDER_SIZE as i32 - RESIZE_HANDLE_SIZE as i32,
108 y: 0,
109 width: 0, // Defined by `Self::resize`,
110 height: RESIZE_HANDLE_SIZE,
111 }),
112 ),
113 // Header.
114 Part::new(
115 base_surface,
116 subcompositor,
117 queue_handle,
118 Rect {
119 x: 0,
120 y: -(HEADER_SIZE as i32),
121 width: 0, // Defined by `Self::resize`.
122 height: HEADER_SIZE,
123 },
124 None,
125 ),
126 ];
127
128 Self { parts }
129 }
130
131 pub fn parts(&self) -> std::iter::Enumerate<std::slice::Iter<Part>> {
132 self.parts.iter().enumerate()
133 }
134
135 pub fn hide(&self) {
136 for part in self.parts.iter() {
137 part.subsurface.set_sync();
138 part.surface.attach(None, 0, 0);
139 part.surface.commit();
140 }
141 }
142
143 pub fn hide_borders(&self) {
144 for (_, part) in self.parts().filter(|(idx, _)| *idx != Self::HEADER) {
145 part.surface.attach(None, 0, 0);
146 part.surface.commit();
147 }
148 }
149
150 // These unwraps are guaranteed to succeed because the affected options are filled above
151 // and then never emptied afterwards.
152 #[allow(clippy::unwrap_used)]
153 pub fn resize(&mut self, width: u32, height: u32) {
154 self.parts[Self::HEADER].surface_rect.width = width;
155
156 self.parts[Self::BOTTOM].surface_rect.width = width + 2 * BORDER_SIZE;
157 self.parts[Self::BOTTOM].surface_rect.y = height as i32;
158 self.parts[Self::BOTTOM].input_rect.as_mut().unwrap().width =
159 self.parts[Self::BOTTOM].surface_rect.width - (BORDER_SIZE * 2)
160 + (RESIZE_HANDLE_SIZE * 2);
161
162 self.parts[Self::TOP].surface_rect.width = self.parts[Self::BOTTOM].surface_rect.width;
163 self.parts[Self::TOP].input_rect.as_mut().unwrap().width =
164 self.parts[Self::TOP].surface_rect.width - (BORDER_SIZE * 2) + (RESIZE_HANDLE_SIZE * 2);
165
166 self.parts[Self::LEFT].surface_rect.height = height + HEADER_SIZE;
167 self.parts[Self::LEFT].input_rect.as_mut().unwrap().height =
168 self.parts[Self::LEFT].surface_rect.height;
169
170 self.parts[Self::RIGHT].surface_rect.height = self.parts[Self::LEFT].surface_rect.height;
171 self.parts[Self::RIGHT].surface_rect.x = width as i32;
172 self.parts[Self::RIGHT].input_rect.as_mut().unwrap().height =
173 self.parts[Self::RIGHT].surface_rect.height;
174 }
175
176 pub fn header(&self) -> &Part {
177 &self.parts[Self::HEADER]
178 }
179
180 pub fn side_height(&self) -> u32 {
181 self.parts[Self::LEFT].surface_rect.height
182 }
183
184 pub fn find_surface(&self, surface: &ObjectId) -> Location {
185 let pos = match self
186 .parts
187 .iter()
188 .position(|part| &part.surface.id() == surface)
189 {
190 Some(pos) => pos,
191 None => return Location::None,
192 };
193
194 match pos {
195 Self::HEADER => Location::Head,
196 Self::TOP => Location::Top,
197 Self::BOTTOM => Location::Bottom,
198 Self::LEFT => Location::Left,
199 Self::RIGHT => Location::Right,
200 _ => unreachable!(),
201 }
202 }
203}
204
205#[derive(Debug, Clone, Copy)]
206pub struct Rect {
207 pub x: i32,
208 pub y: i32,
209 pub width: u32,
210 pub height: u32,
211}
212
213#[derive(Debug)]
214pub struct Part {
215 pub surface: WlTyped<WlSurface, SurfaceData>,
216 pub subsurface: WlTyped<WlSubsurface, SubsurfaceData>,
217
218 /// Positioned relative to the main surface.
219 pub surface_rect: Rect,
220 /// Positioned relative to the local surface, aka. `surface_rect`.
221 ///
222 /// `None` if it fully covers `surface_rect`.
223 pub input_rect: Option<Rect>,
224}
225
226impl Part {
227 fn new<State>(
228 parent: &WlTyped<WlSurface, SurfaceData>,
229 subcompositor: &SubcompositorState,
230 queue_handle: &QueueHandle<State>,
231 surface_rect: Rect,
232 input_rect: Option<Rect>,
233 ) -> Part
234 where
235 State: Dispatch<WlSurface, SurfaceData> + Dispatch<WlSubsurface, SubsurfaceData> + 'static,
236 {
237 let (subsurface, surface) =
238 subcompositor.create_subsurface(parent.inner().clone(), queue_handle);
239
240 let subsurface = WlTyped::wrap::<State>(subsurface);
241 let surface = WlTyped::wrap::<State>(surface);
242
243 // Sync with the parent surface.
244 subsurface.set_sync();
245
246 Part {
247 surface,
248 subsurface,
249 surface_rect,
250 input_rect,
251 }
252 }
253}
254
255impl Drop for Part {
256 fn drop(&mut self) {
257 self.subsurface.destroy();
258 self.surface.destroy();
259 }
260}
vendor/sctk-adwaita/src/pointer.rs created+142
......@@ -0,0 +1,142 @@
1use std::time::Duration;
2
3use smithay_client_toolkit::reexports::csd_frame::{
4 CursorIcon, FrameAction, ResizeEdge, WindowManagerCapabilities, WindowState,
5};
6
7use crate::{
8 buttons::ButtonKind,
9 theme::{BORDER_SIZE, HEADER_SIZE},
10};
11
12/// Time to register the next click as a double click.
13///
14/// The value is the same as the default in gtk4.
15const DOUBLE_CLICK_DURATION: Duration = Duration::from_millis(400);
16
17/// The state of the mouse input inside the decorations frame.
18#[derive(Debug, Default)]
19pub(crate) struct MouseState {
20 pub location: Location,
21
22 /// The surface local location inside the surface.
23 position: (f64, f64),
24
25 /// The instant of the last click.
26 last_normal_click: Option<Duration>,
27}
28
29impl MouseState {
30 /// The normal click on decorations frame was made.
31 pub fn click(
32 &mut self,
33 timestamp: Duration,
34 pressed: bool,
35 resizable: bool,
36 state: &WindowState,
37 wm_capabilities: &WindowManagerCapabilities,
38 ) -> Option<FrameAction> {
39 let maximized = state.contains(WindowState::MAXIMIZED);
40 let action = match self.location {
41 Location::Top if resizable => FrameAction::Resize(ResizeEdge::Top),
42 Location::TopLeft if resizable => FrameAction::Resize(ResizeEdge::TopLeft),
43 Location::Left if resizable => FrameAction::Resize(ResizeEdge::Left),
44 Location::BottomLeft if resizable => FrameAction::Resize(ResizeEdge::BottomLeft),
45 Location::Bottom if resizable => FrameAction::Resize(ResizeEdge::Bottom),
46 Location::BottomRight if resizable => FrameAction::Resize(ResizeEdge::BottomRight),
47 Location::Right if resizable => FrameAction::Resize(ResizeEdge::Right),
48 Location::TopRight if resizable => FrameAction::Resize(ResizeEdge::TopRight),
49 Location::Button(ButtonKind::Close) if !pressed => FrameAction::Close,
50 Location::Button(ButtonKind::Maximize) if !pressed && !maximized => {
51 FrameAction::Maximize
52 }
53 Location::Button(ButtonKind::Maximize) if !pressed && maximized => {
54 FrameAction::UnMaximize
55 }
56 Location::Button(ButtonKind::Minimize) if !pressed => FrameAction::Minimize,
57 Location::Head
58 if pressed && wm_capabilities.contains(WindowManagerCapabilities::MAXIMIZE) =>
59 {
60 match self.last_normal_click.replace(timestamp) {
61 Some(last) if timestamp.saturating_sub(last) < DOUBLE_CLICK_DURATION => {
62 if maximized {
63 FrameAction::UnMaximize
64 } else {
65 FrameAction::Maximize
66 }
67 }
68 _ => FrameAction::Move,
69 }
70 }
71 Location::Head if pressed => FrameAction::Move,
72 _ => return None,
73 };
74
75 Some(action)
76 }
77
78 /// Alternative click on decorations frame was made.
79 pub fn alternate_click(
80 &mut self,
81 pressed: bool,
82 wm_capabilities: &WindowManagerCapabilities,
83 ) -> Option<FrameAction> {
84 // Invalidate the normal click.
85 self.last_normal_click = None;
86
87 match self.location {
88 Location::Head | Location::Button(_)
89 if pressed && wm_capabilities.contains(WindowManagerCapabilities::WINDOW_MENU) =>
90 {
91 Some(FrameAction::ShowMenu(
92 // XXX this could be one 1pt off when the frame is not maximized, but it's not
93 // like it really matters in the end.
94 self.position.0 as i32 - BORDER_SIZE as i32,
95 // We must offset it by header size for precise position.
96 self.position.1 as i32 - HEADER_SIZE as i32,
97 ))
98 }
99 _ => None,
100 }
101 }
102
103 /// The mouse moved inside the decorations frame.
104 pub fn moved(&mut self, location: Location, x: f64, y: f64, resizable: bool) -> CursorIcon {
105 self.location = location;
106 self.position = (x, y);
107 match self.location {
108 _ if !resizable => CursorIcon::Default,
109 Location::Top => CursorIcon::NResize,
110 Location::TopRight => CursorIcon::NeResize,
111 Location::Right => CursorIcon::EResize,
112 Location::BottomRight => CursorIcon::SeResize,
113 Location::Bottom => CursorIcon::SResize,
114 Location::BottomLeft => CursorIcon::SwResize,
115 Location::Left => CursorIcon::WResize,
116 Location::TopLeft => CursorIcon::NwResize,
117 _ => CursorIcon::Default,
118 }
119 }
120
121 /// The mouse left the decorations frame.
122 pub fn left(&mut self) {
123 // Reset only the location.
124 self.location = Location::None;
125 }
126}
127
128#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
129pub enum Location {
130 #[default]
131 None,
132 Head,
133 Top,
134 TopRight,
135 Right,
136 BottomRight,
137 Bottom,
138 BottomLeft,
139 Left,
140 TopLeft,
141 Button(ButtonKind),
142}
vendor/sctk-adwaita/src/shadow.rs created+397
......@@ -0,0 +1,397 @@
1use crate::{parts::DecorationParts, theme};
2use std::collections::BTreeMap;
3use tiny_skia::{Pixmap, PixmapMut, PixmapRef, Point, PremultipliedColorU8};
4
5// These values were generated from a screenshot of an libadwaita window using a script.
6// For more details see: https://github.com/PolyMeilex/sctk-adwaita/pull/43
7pub const SHADOW_SIZE: u32 = 43;
8const SHADOW_PARAMS_ACTIVE: (f32, f32, f32) = (0.206_505_5, 0.104_617_53, -0.000_542_446_2);
9const SHADOW_PARAMS_INACTIVE: (f32, f32, f32) = (0.168_297_29, 0.204_299_8, 0.001_769_798_6);
10
11fn shadow(pixel_dist: f32, scale: u32, active: bool) -> f32 {
12 let (a, b, c) = if active {
13 SHADOW_PARAMS_ACTIVE
14 } else {
15 SHADOW_PARAMS_INACTIVE
16 };
17
18 a * (-b * (pixel_dist / scale as f32)).exp() + c
19}
20
21#[derive(Debug)]
22struct RenderedShadow {
23 side: Pixmap,
24 edges: Pixmap,
25}
26
27impl RenderedShadow {
28 fn new(scale: u32, active: bool) -> RenderedShadow {
29 let shadow_size = SHADOW_SIZE * scale;
30 let corner_radius = theme::CORNER_RADIUS * scale;
31
32 #[allow(clippy::unwrap_used)]
33 let mut side = Pixmap::new(shadow_size, 1).unwrap();
34 for x in 0..side.width() as usize {
35 let alpha = (shadow(x as f32 + 0.5, scale, active) * u8::MAX as f32).round() as u8;
36
37 #[allow(clippy::unwrap_used)]
38 let color = PremultipliedColorU8::from_rgba(0, 0, 0, alpha).unwrap();
39 side.pixels_mut()[x] = color;
40 }
41
42 let edges_size = (corner_radius + shadow_size) * 2;
43 #[allow(clippy::unwrap_used)]
44 let mut edges = Pixmap::new(edges_size, edges_size).unwrap();
45 let edges_middle = Point::from_xy(edges_size as f32 / 2.0, edges_size as f32 / 2.0);
46 for y in 0..edges_size as usize {
47 let y_pos = y as f32 + 0.5;
48 for x in 0..edges_size as usize {
49 let dist = edges_middle.distance(Point::from_xy(x as f32 + 0.5, y_pos))
50 - corner_radius as f32;
51 let alpha = (shadow(dist, scale, active) * u8::MAX as f32).round() as u8;
52
53 #[allow(clippy::unwrap_used)]
54 let color = PremultipliedColorU8::from_rgba(0, 0, 0, alpha).unwrap();
55 edges.pixels_mut()[y * edges_size as usize + x] = color;
56 }
57 }
58
59 RenderedShadow { side, edges }
60 }
61
62 fn side_draw(
63 &self,
64 flipped: bool,
65 rotated: bool,
66 stack: usize,
67 dst_pixmap: &mut PixmapMut,
68 dst_left: usize,
69 dst_top: usize,
70 ) {
71 fn iter_copy<'a>(
72 src: impl Iterator<Item = &'a PremultipliedColorU8>,
73 dst: impl Iterator<Item = &'a mut PremultipliedColorU8>,
74 ) {
75 src.zip(dst).for_each(|(src, dst)| *dst = *src)
76 }
77
78 let dst_width = dst_pixmap.width() as usize;
79 let dst_pixels = dst_pixmap.pixels_mut();
80 match (flipped, rotated) {
81 (false, false) => (0..stack).for_each(|i| {
82 let dst = dst_pixels
83 .iter_mut()
84 .skip((dst_top + i) * dst_width + dst_left);
85 iter_copy(self.side.pixels().iter(), dst);
86 }),
87 (false, true) => (0..stack).for_each(|i| {
88 let dst = dst_pixels
89 .iter_mut()
90 .skip(dst_top * dst_width + dst_left + i)
91 .step_by(dst_width);
92 iter_copy(self.side.pixels().iter(), dst);
93 }),
94 (true, false) => (0..stack).for_each(|i| {
95 let dst = dst_pixels
96 .iter_mut()
97 .skip((dst_top + i) * dst_width + dst_left);
98 iter_copy(self.side.pixels().iter().rev(), dst);
99 }),
100 (true, true) => (0..stack).for_each(|i| {
101 let dst = dst_pixels
102 .iter_mut()
103 .skip(dst_top * dst_width + dst_left + i)
104 .step_by(dst_width);
105 iter_copy(self.side.pixels().iter().rev(), dst);
106 }),
107 }
108 }
109
110 #[allow(clippy::too_many_arguments)]
111 fn edges_draw(
112 &self,
113 src_x_offset: isize,
114 src_y_offset: isize,
115 dst_pixmap: &mut PixmapMut,
116 dst_rect_left: usize,
117 dst_rect_top: usize,
118 dst_rect_width: usize,
119 dst_rect_height: usize,
120 ) {
121 let src_width = self.edges.width() as usize;
122 let src_pixels = self.edges.pixels();
123 let dst_width = dst_pixmap.width() as usize;
124 let dst_pixels = dst_pixmap.pixels_mut();
125 for y in 0..dst_rect_height {
126 let dst_y = dst_rect_top + y;
127 let src_y = y as isize + src_y_offset;
128 if src_y < 0 {
129 continue;
130 }
131
132 let src_y = src_y as usize;
133 for x in 0..dst_rect_width {
134 let dst_x = dst_rect_left + x;
135 let src_x = x as isize + src_x_offset;
136 if src_x < 0 {
137 continue;
138 }
139
140 let src = src_pixels.get(src_y * src_width + src_x as usize);
141 let dst = dst_pixels.get_mut(dst_y * dst_width + dst_x);
142 if let (Some(src), Some(dst)) = (src, dst) {
143 *dst = *src;
144 }
145 }
146 }
147 }
148
149 fn draw(&self, dst_pixmap: &mut PixmapMut, scale: u32, part_idx: usize) {
150 let shadow_size = (SHADOW_SIZE * scale) as usize;
151 let visible_border_size = (theme::VISIBLE_BORDER_SIZE * scale) as usize;
152 let corner_radius = (theme::CORNER_RADIUS * scale) as usize;
153 assert!(corner_radius > visible_border_size);
154
155 let dst_width = dst_pixmap.width() as usize;
156 let dst_height = dst_pixmap.height() as usize;
157 let edges_half = self.edges.width() as usize / 2;
158 match part_idx {
159 DecorationParts::TOP => {
160 let left_edge_width = edges_half;
161 let right_edge_width = edges_half;
162 let side_width = dst_width
163 .saturating_sub(left_edge_width)
164 .saturating_sub(right_edge_width);
165
166 self.edges_draw(
167 0,
168 -(visible_border_size as isize),
169 dst_pixmap,
170 0,
171 0,
172 left_edge_width,
173 dst_height,
174 );
175
176 self.side_draw(
177 true,
178 true,
179 side_width,
180 dst_pixmap,
181 left_edge_width,
182 visible_border_size,
183 );
184
185 self.edges_draw(
186 edges_half as isize,
187 -(visible_border_size as isize),
188 dst_pixmap,
189 left_edge_width + side_width,
190 0,
191 right_edge_width,
192 dst_height,
193 );
194 }
195 DecorationParts::LEFT => {
196 let top_edge_height = corner_radius;
197 let bottom_edge_height = corner_radius - visible_border_size;
198 let side_height = dst_height
199 .saturating_sub(top_edge_height)
200 .saturating_sub(bottom_edge_height);
201
202 self.edges_draw(
203 0,
204 shadow_size as isize,
205 dst_pixmap,
206 0,
207 0,
208 dst_width.saturating_sub(visible_border_size),
209 top_edge_height,
210 );
211
212 self.side_draw(true, false, side_height, dst_pixmap, 0, top_edge_height);
213
214 self.edges_draw(
215 0,
216 edges_half as isize,
217 dst_pixmap,
218 0,
219 top_edge_height + side_height,
220 dst_width.saturating_sub(visible_border_size),
221 bottom_edge_height,
222 );
223 }
224 DecorationParts::RIGHT => {
225 let top_edge_height = corner_radius;
226 let bottom_edge_height = corner_radius - visible_border_size;
227 let side_height = dst_height
228 .saturating_sub(top_edge_height)
229 .saturating_sub(bottom_edge_height);
230
231 self.edges_draw(
232 edges_half as isize + corner_radius as isize,
233 shadow_size as isize,
234 dst_pixmap,
235 visible_border_size,
236 0,
237 dst_width.saturating_sub(visible_border_size),
238 top_edge_height,
239 );
240
241 self.side_draw(
242 false,
243 false,
244 side_height,
245 dst_pixmap,
246 visible_border_size,
247 top_edge_height,
248 );
249
250 self.edges_draw(
251 edges_half as isize + corner_radius as isize,
252 edges_half as isize,
253 dst_pixmap,
254 visible_border_size,
255 top_edge_height + side_height,
256 dst_width.saturating_sub(visible_border_size),
257 bottom_edge_height,
258 );
259 }
260 DecorationParts::BOTTOM => {
261 let left_edge_width = edges_half;
262 let right_edge_width = edges_half;
263 let side_width = dst_width
264 .saturating_sub(left_edge_width)
265 .saturating_sub(right_edge_width);
266
267 self.edges_draw(
268 0,
269 edges_half as isize + (corner_radius - visible_border_size) as isize,
270 dst_pixmap,
271 0,
272 0,
273 left_edge_width,
274 dst_height,
275 );
276
277 self.side_draw(
278 false,
279 true,
280 side_width,
281 dst_pixmap,
282 left_edge_width,
283 visible_border_size,
284 );
285
286 self.edges_draw(
287 edges_half as isize,
288 edges_half as isize + (corner_radius - visible_border_size) as isize,
289 dst_pixmap,
290 left_edge_width + side_width,
291 0,
292 right_edge_width,
293 dst_height,
294 );
295 }
296 DecorationParts::HEADER => {
297 self.edges_draw(
298 shadow_size as isize,
299 shadow_size as isize,
300 dst_pixmap,
301 0,
302 0,
303 corner_radius,
304 corner_radius,
305 );
306
307 self.edges_draw(
308 edges_half as isize,
309 shadow_size as isize,
310 dst_pixmap,
311 dst_width.saturating_sub(corner_radius),
312 0,
313 corner_radius,
314 corner_radius,
315 );
316 }
317 _ => unreachable!(),
318 }
319 }
320}
321
322#[derive(Debug)]
323struct CachedPart {
324 pixmap: Pixmap,
325 scale: u32,
326 active: bool,
327}
328
329impl CachedPart {
330 fn new(
331 dst_pixmap: &PixmapRef,
332 rendered: &RenderedShadow,
333 scale: u32,
334 active: bool,
335 part_idx: usize,
336 ) -> CachedPart {
337 #[allow(clippy::unwrap_used)]
338 let mut pixmap = Pixmap::new(dst_pixmap.width(), dst_pixmap.height()).unwrap();
339 rendered.draw(&mut pixmap.as_mut(), scale, part_idx);
340
341 CachedPart {
342 pixmap,
343 scale,
344 active,
345 }
346 }
347
348 fn matches(&self, dst_pixmap: &PixmapRef, dst_scale: u32, dst_active: bool) -> bool {
349 self.pixmap.width() == dst_pixmap.width()
350 && self.pixmap.height() == dst_pixmap.height()
351 && self.scale == dst_scale
352 && self.active == dst_active
353 }
354
355 fn draw(&self, dst_pixmap: &mut PixmapMut) {
356 let src_data = self.pixmap.data();
357 dst_pixmap.data_mut()[..src_data.len()].copy_from_slice(src_data);
358 }
359}
360
361#[derive(Default, Debug)]
362pub struct Shadow {
363 part_cache: [Option<CachedPart>; 5],
364 // (scale, active) -> RenderedShadow
365 rendered: BTreeMap<(u32, bool), RenderedShadow>,
366}
367
368impl Shadow {
369 pub fn draw(&mut self, pixmap: &mut PixmapMut, scale: u32, active: bool, part_idx: usize) {
370 let cache = &mut self.part_cache[part_idx];
371
372 if let Some(cache_value) = cache {
373 if !cache_value.matches(&pixmap.as_ref(), scale, active) {
374 *cache = None;
375 }
376 }
377
378 if cache.is_none() {
379 let rendered = self
380 .rendered
381 .entry((scale, active))
382 .or_insert_with(|| RenderedShadow::new(scale, active));
383
384 *cache = Some(CachedPart::new(
385 &pixmap.as_ref(),
386 rendered,
387 scale,
388 active,
389 part_idx,
390 ));
391 }
392
393 // We filled the cache above.
394 #[allow(clippy::unwrap_used)]
395 cache.as_ref().unwrap().draw(pixmap);
396 }
397}
vendor/sctk-adwaita/src/theme.rs created+139
......@@ -0,0 +1,139 @@
1pub use tiny_skia::Color;
2use tiny_skia::{Paint, Shader};
3
4// https://gitlab.gnome.org/GNOME/gtk/-/blob/1bf88f1d81043fd99740e2f91e56ade7ede7303b/gtk/gtkwindow.c#L165
5pub(crate) const RESIZE_HANDLE_SIZE: u32 = 12;
6// https://gitlab.gnome.org/GNOME/gtk/-/blob/1bf88f1d81043fd99740e2f91e56ade7ede7303b/gtk/gtkwindow.c#L166
7pub(crate) const RESIZE_HANDLE_CORNER_SIZE: u32 = 24;
8pub(crate) const BORDER_SIZE: u32 = crate::shadow::SHADOW_SIZE + VISIBLE_BORDER_SIZE;
9pub(crate) const HEADER_SIZE: u32 = 35;
10pub(crate) const CORNER_RADIUS: u32 = 10;
11pub(crate) const VISIBLE_BORDER_SIZE: u32 = 1;
12
13/// The color theme to use with the decorations frame.
14#[derive(Debug, Clone)]
15pub struct ColorTheme {
16 pub active: ColorMap,
17 pub inactive: ColorMap,
18}
19
20impl ColorTheme {
21 /// Automatically choose between light & dark themes based on:
22 /// * dbus org.freedesktop.portal.Settings
23 /// <https://flatpak.github.io/xdg-desktop-portal/#gdbus-interface-org-freedesktop-portal-Settings>
24 pub fn auto() -> Self {
25 match crate::config::prefer_dark() {
26 true => Self::dark(),
27 false => Self::light(),
28 }
29 }
30
31 /// Predefined light variant, which aims to replecate Adwaita theme.
32 pub fn light() -> Self {
33 Self {
34 active: ColorMap {
35 headerbar: Color::from_rgba8(235, 235, 235, 255),
36 button_idle: Color::from_rgba8(216, 216, 216, 255),
37 button_hover: Color::from_rgba8(207, 207, 207, 255),
38 button_icon: Color::from_rgba8(42, 42, 42, 255),
39 border_color: Color::from_rgba8(220, 220, 220, 255),
40 font_color: Color::from_rgba8(47, 47, 47, 255),
41 },
42 inactive: ColorMap {
43 headerbar: Color::from_rgba8(250, 250, 250, 255),
44 button_idle: Color::from_rgba8(240, 240, 240, 255),
45 button_hover: Color::from_rgba8(216, 216, 216, 255),
46 button_icon: Color::from_rgba8(148, 148, 148, 255),
47 border_color: Color::from_rgba8(220, 220, 220, 255),
48 font_color: Color::from_rgba8(150, 150, 150, 255),
49 },
50 }
51 }
52
53 /// Predefined dark variant, which aims to replecate Adwaita-dark theme.
54 pub fn dark() -> Self {
55 Self {
56 active: ColorMap {
57 headerbar: Color::from_rgba8(48, 48, 48, 255),
58 button_idle: Color::from_rgba8(69, 69, 69, 255),
59 button_hover: Color::from_rgba8(79, 79, 79, 255),
60 button_icon: Color::from_rgba8(255, 255, 255, 255),
61 border_color: Color::from_rgba8(58, 58, 58, 255),
62 font_color: Color::from_rgba8(255, 255, 255, 255),
63 },
64 inactive: ColorMap {
65 headerbar: Color::from_rgba8(36, 36, 36, 255),
66 button_idle: Color::from_rgba8(47, 47, 47, 255),
67 button_hover: Color::from_rgba8(57, 57, 57, 255),
68 button_icon: Color::from_rgba8(144, 144, 144, 255),
69 border_color: Color::from_rgba8(58, 58, 58, 255),
70 font_color: Color::from_rgba8(144, 144, 144, 255),
71 },
72 }
73 }
74
75 pub(crate) fn for_state(&self, active: bool) -> &ColorMap {
76 if active {
77 &self.active
78 } else {
79 &self.inactive
80 }
81 }
82}
83
84impl Default for ColorTheme {
85 fn default() -> Self {
86 Self::auto()
87 }
88}
89
90/// The color map for various decorcation parts.
91#[derive(Debug, Clone)]
92pub struct ColorMap {
93 pub headerbar: Color,
94 pub button_idle: Color,
95 pub button_hover: Color,
96 pub button_icon: Color,
97 pub border_color: Color,
98 pub font_color: Color,
99}
100
101impl ColorMap {
102 pub(crate) fn headerbar_paint(&self) -> Paint {
103 Paint {
104 shader: Shader::SolidColor(self.headerbar),
105 anti_alias: true,
106 ..Default::default()
107 }
108 }
109
110 pub(crate) fn button_idle_paint(&self) -> Paint {
111 Paint {
112 shader: Shader::SolidColor(self.button_idle),
113 anti_alias: true,
114 ..Default::default()
115 }
116 }
117
118 pub(crate) fn button_hover_paint(&self) -> Paint {
119 Paint {
120 shader: Shader::SolidColor(self.button_hover),
121 anti_alias: true,
122 ..Default::default()
123 }
124 }
125
126 pub(crate) fn button_icon_paint(&self) -> Paint {
127 Paint {
128 shader: Shader::SolidColor(self.button_icon),
129 ..Default::default()
130 }
131 }
132
133 pub(crate) fn border_paint(&self) -> Paint {
134 Paint {
135 shader: Shader::SolidColor(self.border_color),
136 ..Default::default()
137 }
138 }
139}
vendor/sctk-adwaita/src/title.rs created+61
......@@ -0,0 +1,61 @@
1use tiny_skia::{Color, Pixmap};
2
3#[cfg(any(feature = "crossfont", feature = "ab_glyph"))]
4mod config;
5#[cfg(any(feature = "crossfont", feature = "ab_glyph"))]
6mod font_preference;
7
8#[cfg(feature = "crossfont")]
9mod crossfont_renderer;
10
11#[cfg(all(not(feature = "crossfont"), feature = "ab_glyph"))]
12mod ab_glyph_renderer;
13
14#[cfg(all(not(feature = "crossfont"), not(feature = "ab_glyph")))]
15mod dumb;
16
17#[derive(Debug)]
18pub struct TitleText {
19 #[cfg(feature = "crossfont")]
20 imp: crossfont_renderer::CrossfontTitleText,
21 #[cfg(all(not(feature = "crossfont"), feature = "ab_glyph"))]
22 imp: ab_glyph_renderer::AbGlyphTitleText,
23 #[cfg(all(not(feature = "crossfont"), not(feature = "ab_glyph")))]
24 imp: dumb::DumbTitleText,
25}
26
27impl TitleText {
28 pub fn new(color: Color) -> Option<Self> {
29 #[cfg(feature = "crossfont")]
30 return crossfont_renderer::CrossfontTitleText::new(color)
31 .ok()
32 .map(|imp| Self { imp });
33
34 #[cfg(all(not(feature = "crossfont"), feature = "ab_glyph"))]
35 return Some(Self {
36 imp: ab_glyph_renderer::AbGlyphTitleText::new(color),
37 });
38
39 #[cfg(all(not(feature = "crossfont"), not(feature = "ab_glyph")))]
40 {
41 let _ = color;
42 return None;
43 }
44 }
45
46 pub fn update_scale(&mut self, scale: u32) {
47 self.imp.update_scale(scale)
48 }
49
50 pub fn update_title(&mut self, title: impl Into<String>) {
51 self.imp.update_title(title)
52 }
53
54 pub fn update_color(&mut self, color: Color) {
55 self.imp.update_color(color)
56 }
57
58 pub fn pixmap(&self) -> Option<&Pixmap> {
59 self.imp.pixmap()
60 }
61}
vendor/sctk-adwaita/src/title/Cantarell-Regular.ttf created
Binary files /dev/null and b/vendor/sctk-adwaita/src/title/Cantarell-Regular.ttf differ
vendor/sctk-adwaita/src/title/ab_glyph_renderer.rs created+199
......@@ -0,0 +1,199 @@
1//! Title renderer using ab_glyph.
2//!
3//! Requires no dynamically linked dependencies.
4//!
5//! Can fallback to a embedded Cantarell-Regular.ttf font (SIL Open Font Licence v1.1)
6//! if the system font doesn't work.
7use crate::title::{config, font_preference::FontPreference};
8use ab_glyph::{point, Font, FontRef, Glyph, PxScale, PxScaleFont, ScaleFont, VariableFont};
9use std::{fs::File, process::Command};
10use tiny_skia::{Color, Pixmap, PremultipliedColorU8};
11
12const CANTARELL: &[u8] = include_bytes!("Cantarell-Regular.ttf");
13
14#[derive(Debug)]
15pub struct AbGlyphTitleText {
16 title: String,
17 font: Option<(memmap2::Mmap, FontPreference)>,
18 original_px_size: f32,
19 size: PxScale,
20 color: Color,
21 pixmap: Option<Pixmap>,
22}
23
24impl AbGlyphTitleText {
25 pub fn new(color: Color) -> Self {
26 let font_pref = config::titlebar_font().unwrap_or_default();
27 let font_pref_pt_size = font_pref.pt_size;
28 let font = font_file_matching(&font_pref)
29 .and_then(|f| mmap(&f))
30 .map(|mmap| (mmap, font_pref));
31
32 let size = parse_font(&font)
33 .pt_to_px_scale(font_pref_pt_size)
34 .unwrap_or_else(|| {
35 log::error!("invalid font units_per_em");
36 PxScale { x: 17.6, y: 17.6 }
37 });
38
39 Self {
40 title: <_>::default(),
41 font,
42 original_px_size: size.x,
43 size,
44 color,
45 pixmap: None,
46 }
47 }
48
49 pub fn update_scale(&mut self, scale: u32) {
50 let new_scale = PxScale::from(self.original_px_size * scale as f32);
51 if (self.size.x - new_scale.x).abs() > f32::EPSILON {
52 self.size = new_scale;
53 self.pixmap = self.render();
54 }
55 }
56
57 pub fn update_title(&mut self, title: impl Into<String>) {
58 let new_title = title.into();
59 if new_title != self.title {
60 self.title = new_title;
61 self.pixmap = self.render();
62 }
63 }
64
65 pub fn update_color(&mut self, color: Color) {
66 if color != self.color {
67 self.color = color;
68 self.pixmap = self.render();
69 }
70 }
71
72 pub fn pixmap(&self) -> Option<&Pixmap> {
73 self.pixmap.as_ref()
74 }
75
76 /// Render returning the new `Pixmap`.
77 fn render(&self) -> Option<Pixmap> {
78 let font = parse_font(&self.font);
79 let font = font.as_scaled(self.size);
80
81 let glyphs = self.layout(&font);
82 let last_glyph = glyphs.last()?;
83 // + 2 because ab_glyph likes to draw outside of its area,
84 // so we add 1px border around the pixmap
85 let width = (last_glyph.position.x + font.h_advance(last_glyph.id)).ceil() as u32 + 2;
86 let height = font.height().ceil() as u32 + 2;
87
88 let mut pixmap = Pixmap::new(width, height)?;
89
90 let pixels = pixmap.pixels_mut();
91
92 for glyph in glyphs {
93 if let Some(outline) = font.outline_glyph(glyph) {
94 let bounds = outline.px_bounds();
95 let left = bounds.min.x as u32;
96 let top = bounds.min.y as u32;
97 outline.draw(|x, y, c| {
98 // `ab_glyph` may return values greater than 1.0, but they are defined to be
99 // same as 1.0. For our purposes, we need to constrain this value.
100 let c = c.min(1.0);
101
102 // offset the index by 1, so it is in the center of the pixmap
103 let p_idx = (top + y + 1) * width + (left + x + 1);
104 let Some(pixel) = pixels.get_mut(p_idx as usize) else {
105 log::warn!("Ignoring out of range pixel (pixel id: {p_idx}");
106 return;
107 };
108
109 let old_alpha_u8 = pixel.alpha();
110
111 let new_alpha = c + (old_alpha_u8 as f32 / 255.0);
112 if let Some(px) = PremultipliedColorU8::from_rgba(
113 (self.color.red() * new_alpha * 255.0) as _,
114 (self.color.green() * new_alpha * 255.0) as _,
115 (self.color.blue() * new_alpha * 255.0) as _,
116 (new_alpha * 255.0) as _,
117 ) {
118 *pixel = px;
119 }
120 })
121 }
122 }
123
124 Some(pixmap)
125 }
126
127 /// Simple single-line glyph layout.
128 fn layout(&self, font: &PxScaleFont<impl Font>) -> Vec<Glyph> {
129 let mut caret = point(0.0, font.ascent());
130 let mut last_glyph: Option<Glyph> = None;
131 let mut target = Vec::new();
132 for c in self.title.chars() {
133 if c.is_control() {
134 continue;
135 }
136 let mut glyph = font.scaled_glyph(c);
137 if let Some(previous) = last_glyph.take() {
138 caret.x += font.kern(previous.id, glyph.id);
139 }
140 glyph.position = caret;
141
142 last_glyph = Some(glyph.clone());
143 caret.x += font.h_advance(glyph.id);
144
145 target.push(glyph);
146 }
147 target
148 }
149}
150
151/// Parse the memmapped system font or fallback to built-in cantarell.
152fn parse_font(sys_font: &Option<(memmap2::Mmap, FontPreference)>) -> FontRef<'_> {
153 match sys_font {
154 Some((mmap, font_pref)) => {
155 FontRef::try_from_slice(mmap)
156 .map(|mut f| {
157 // basic "bold" handling for variable fonts
158 if font_pref
159 .style
160 .as_deref()
161 .map_or(false, |s| s.eq_ignore_ascii_case("bold"))
162 {
163 f.set_variation(b"wght", 700.0);
164 }
165 f
166 })
167 .unwrap_or_else(|_| {
168 // We control the default font, so I guess it's fine to unwrap it
169 #[allow(clippy::unwrap_used)]
170 FontRef::try_from_slice(CANTARELL).unwrap()
171 })
172 }
173 // We control the default font, so I guess it's fine to unwrap it
174 #[allow(clippy::unwrap_used)]
175 _ => FontRef::try_from_slice(CANTARELL).unwrap(),
176 }
177}
178
179/// Font-config without dynamically linked dependencies
180fn font_file_matching(pref: &FontPreference) -> Option<File> {
181 let mut pattern = pref.name.clone();
182 if let Some(style) = &pref.style {
183 pattern.push(':');
184 pattern.push_str(style);
185 }
186 Command::new("fc-match")
187 .arg("-f")
188 .arg("%{file}")
189 .arg(&pattern)
190 .output()
191 .ok()
192 .and_then(|out| String::from_utf8(out.stdout).ok())
193 .and_then(|path| File::open(path.trim()).ok())
194}
195
196fn mmap(file: &File) -> Option<memmap2::Mmap> {
197 // Safety: System font files are not expected to be mutated during use
198 unsafe { memmap2::Mmap::map(file).ok() }
199}
vendor/sctk-adwaita/src/title/config.rs created+20
......@@ -0,0 +1,20 @@
1//! System font configuration.
2use crate::title::font_preference::FontPreference;
3use std::process::Command;
4
5/// Query system for which font to use for window titles.
6pub(crate) fn titlebar_font() -> Option<FontPreference> {
7 // outputs something like: `'Cantarell Bold 12'`
8 let stdout = Command::new("gsettings")
9 .args(["get", "org.gnome.desktop.wm.preferences", "titlebar-font"])
10 .output()
11 .ok()
12 .and_then(|out| String::from_utf8(out.stdout).ok())?;
13
14 FontPreference::from_name_style_size(
15 stdout
16 .trim()
17 .trim_end_matches('\'')
18 .trim_start_matches('\''),
19 )
20}
vendor/sctk-adwaita/src/title/crossfont_renderer.rs created+227
......@@ -0,0 +1,227 @@
1use std::mem;
2
3use crossfont::{GlyphKey, Rasterize, RasterizedGlyph};
4use tiny_skia::{Color, Pixmap, PixmapPaint, PixmapRef, Transform};
5
6use crate::title::config;
7
8pub struct CrossfontTitleText {
9 title: String,
10
11 font_desc: crossfont::FontDesc,
12 font_key: crossfont::FontKey,
13 size: crossfont::Size,
14 scale: u32,
15 metrics: crossfont::Metrics,
16 rasterizer: crossfont::Rasterizer,
17 color: Color,
18
19 pixmap: Option<Pixmap>,
20}
21
22impl std::fmt::Debug for CrossfontTitleText {
23 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24 f.debug_struct("TitleText")
25 .field("title", &self.title)
26 .field("font_desc", &self.font_desc)
27 .field("font_key", &self.font_key)
28 .field("size", &self.size)
29 .field("scale", &self.scale)
30 .field("pixmap", &self.pixmap)
31 .finish()
32 }
33}
34
35impl CrossfontTitleText {
36 pub fn new(color: Color) -> Result<Self, crossfont::Error> {
37 let title = "".into();
38
39 let font_pref = config::titlebar_font().unwrap_or_default();
40 let font_style = font_pref
41 .style
42 .map(crossfont::Style::Specific)
43 .unwrap_or_else(|| crossfont::Style::Description {
44 slant: crossfont::Slant::Normal,
45 weight: crossfont::Weight::Normal,
46 });
47 let font_desc = crossfont::FontDesc::new(&font_pref.name, font_style);
48
49 let mut rasterizer = crossfont::Rasterizer::new()?;
50 let size = crossfont::Size::new(font_pref.pt_size);
51 let font_key = rasterizer.load_font(&font_desc, size)?;
52
53 // Need to load at least one glyph for the face before calling metrics.
54 // The glyph requested here ('m' at the time of writing) has no special
55 // meaning.
56 rasterizer.get_glyph(GlyphKey {
57 font_key,
58 character: 'm',
59 size,
60 })?;
61
62 let metrics = rasterizer.metrics(font_key, size)?;
63
64 let mut this = Self {
65 pixmap: None,
66 rasterizer,
67 font_desc,
68 font_key,
69 scale: 1,
70 metrics,
71 title,
72 color,
73 size,
74 };
75
76 this.rerender();
77
78 Ok(this)
79 }
80
81 fn update_metrics(&mut self) -> Result<(), crossfont::Error> {
82 self.rasterizer.get_glyph(GlyphKey {
83 font_key: self.font_key,
84 character: 'm',
85 size: self.size,
86 })?;
87 self.metrics = self.rasterizer.metrics(self.font_key, self.size)?;
88 Ok(())
89 }
90
91 pub fn update_scale(&mut self, scale: u32) {
92 let old_scale = mem::replace(&mut self.scale, scale);
93 if old_scale != self.scale {
94 self.size = self.size.scale(self.scale as f32 / old_scale as f32);
95 self.update_metrics().ok();
96 self.rerender();
97 }
98 }
99
100 pub fn update_title<S: Into<String>>(&mut self, title: S) {
101 let title = title.into();
102 if self.title != title {
103 self.title = title;
104 self.rerender();
105 }
106 }
107
108 pub fn update_color(&mut self, color: Color) {
109 if self.color != color {
110 self.color = color;
111 self.rerender();
112 }
113 }
114
115 fn rerender(&mut self) {
116 let glyphs: Vec<_> = self
117 .title
118 .chars()
119 .filter_map(|character| {
120 let key = GlyphKey {
121 character,
122 font_key: self.font_key,
123 size: self.size,
124 };
125
126 self.rasterizer
127 .get_glyph(key)
128 .map(|glyph| (key, glyph))
129 .ok()
130 })
131 .collect();
132
133 if glyphs.is_empty() {
134 self.pixmap = None;
135 return;
136 }
137
138 let width = self.calc_width(&glyphs);
139 let height = self.metrics.line_height.round() as i32;
140
141 let mut pixmap = if let Some(p) = Pixmap::new(width as u32, height as u32) {
142 p
143 } else {
144 self.pixmap = None;
145 return;
146 };
147 // pixmap.fill(Color::from_rgba8(255, 0, 0, 55));
148
149 let mut caret = 0;
150 let mut last_glyph = None;
151
152 for (key, glyph) in glyphs {
153 let mut buffer = Vec::with_capacity(glyph.width as usize * 4);
154
155 let glyph_buffer = match &glyph.buffer {
156 crossfont::BitmapBuffer::Rgb(v) => v.chunks(3),
157 crossfont::BitmapBuffer::Rgba(v) => v.chunks(4),
158 };
159
160 for px in glyph_buffer {
161 let alpha = if let Some(alpha) = px.get(3) {
162 *alpha as f32 / 255.0
163 } else {
164 let r = px[0] as f32 / 255.0;
165 let g = px[1] as f32 / 255.0;
166 let b = px[2] as f32 / 255.0;
167 (r + g + b) / 3.0
168 };
169
170 let mut color = self.color;
171 color.set_alpha(alpha);
172 let color = color.premultiply().to_color_u8();
173
174 buffer.push(color.red());
175 buffer.push(color.green());
176 buffer.push(color.blue());
177 buffer.push(color.alpha());
178 }
179
180 if let Some(last) = last_glyph {
181 let (x, _) = self.rasterizer.kerning(last, key);
182 caret += x as i32;
183 }
184
185 if let Some(pixmap_glyph) =
186 PixmapRef::from_bytes(&buffer, glyph.width as _, glyph.height as _)
187 {
188 pixmap.draw_pixmap(
189 glyph.left + caret,
190 height - glyph.top + self.metrics.descent.round() as i32,
191 pixmap_glyph,
192 &PixmapPaint::default(),
193 Transform::identity(),
194 None,
195 );
196 }
197
198 caret += glyph.advance.0;
199
200 last_glyph = Some(key);
201 }
202
203 self.pixmap = Some(pixmap);
204 }
205
206 pub fn pixmap(&self) -> Option<&Pixmap> {
207 self.pixmap.as_ref()
208 }
209
210 fn calc_width(&mut self, glyphs: &[(GlyphKey, RasterizedGlyph)]) -> i32 {
211 let mut caret = 0;
212 let mut last_glyph: Option<&GlyphKey> = None;
213
214 for (key, glyph) in glyphs.iter() {
215 if let Some(last) = last_glyph {
216 let (x, _) = self.rasterizer.kerning(*last, *key);
217 caret += x as i32;
218 }
219
220 caret += glyph.advance.0;
221
222 last_glyph = Some(key);
223 }
224
225 caret
226 }
227}
vendor/sctk-adwaita/src/title/dumb.rs created+16
......@@ -0,0 +1,16 @@
1use tiny_skia::{Color, Pixmap};
2
3#[derive(Debug)]
4pub struct DumbTitleText {}
5
6impl DumbTitleText {
7 pub fn update_scale(&mut self, _scale: u32) {}
8
9 pub fn update_title<S: Into<String>>(&mut self, _title: S) {}
10
11 pub fn update_color(&mut self, _color: Color) {}
12
13 pub fn pixmap(&self) -> Option<&Pixmap> {
14 None
15 }
16}
vendor/sctk-adwaita/src/title/font_preference.rs created+91
......@@ -0,0 +1,91 @@
1#[derive(Debug)]
2pub(crate) struct FontPreference {
3 pub name: String,
4 pub style: Option<String>,
5 pub pt_size: f32,
6}
7
8impl Default for FontPreference {
9 fn default() -> Self {
10 Self {
11 name: "sans-serif".into(),
12 style: None,
13 pt_size: 10.0,
14 }
15 }
16}
17
18impl FontPreference {
19 /// Parse config string like `Cantarell 12`, `Cantarell Bold 11`, `Noto Serif CJK HK Bold 12`.
20 pub fn from_name_style_size(conf: &str) -> Option<Self> {
21 // assume last is size, 2nd last is style and the rest is name.
22 match conf.rsplit_once(' ') {
23 Some((head, tail)) if tail.chars().all(|c| c.is_numeric()) => {
24 let pt_size: f32 = tail.parse().unwrap_or(10.0);
25 match head.rsplit_once(' ') {
26 Some((name, style)) if !name.is_empty() => Some(Self {
27 name: name.into(),
28 style: Some(style.into()),
29 pt_size,
30 }),
31 None if !head.is_empty() => Some(Self {
32 name: head.into(),
33 style: None,
34 pt_size,
35 }),
36 _ => None,
37 }
38 }
39 Some((head, tail)) if !head.is_empty() => Some(Self {
40 name: head.into(),
41 style: Some(tail.into()),
42 pt_size: 10.0,
43 }),
44 None if !conf.is_empty() => Some(Self {
45 name: conf.into(),
46 style: None,
47 pt_size: 10.0,
48 }),
49 _ => None,
50 }
51 }
52}
53
54#[test]
55fn pref_from_multi_name_variant_size() {
56 let pref = FontPreference::from_name_style_size("Noto Serif CJK HK Bold 12").unwrap();
57 assert_eq!(pref.name, "Noto Serif CJK HK");
58 assert_eq!(pref.style, Some("Bold".into()));
59 assert!((pref.pt_size - 12.0).abs() < f32::EPSILON);
60}
61
62#[test]
63fn pref_from_name_variant_size() {
64 let pref = FontPreference::from_name_style_size("Cantarell Bold 12").unwrap();
65 assert_eq!(pref.name, "Cantarell");
66 assert_eq!(pref.style, Some("Bold".into()));
67 assert!((pref.pt_size - 12.0).abs() < f32::EPSILON);
68}
69
70#[test]
71fn pref_from_name_size() {
72 let pref = FontPreference::from_name_style_size("Cantarell 12").unwrap();
73 assert_eq!(pref.name, "Cantarell");
74 assert_eq!(pref.style, None);
75 assert!((pref.pt_size - 12.0).abs() < f32::EPSILON);
76}
77
78#[test]
79fn pref_from_name() {
80 let pref = FontPreference::from_name_style_size("Cantarell").unwrap();
81 assert_eq!(pref.name, "Cantarell");
82 assert_eq!(pref.style, None);
83 assert!((pref.pt_size - 10.0).abs() < f32::EPSILON);
84}
85#[test]
86fn pref_from_multi_name_style() {
87 let pref = FontPreference::from_name_style_size("Foo Bar Baz Bold").unwrap();
88 assert_eq!(pref.name, "Foo Bar Baz");
89 assert_eq!(pref.style, Some("Bold".into()));
90 assert!((pref.pt_size - 10.0).abs() < f32::EPSILON);
91}
vendor/sctk-adwaita/src/wl_typed.rs created+52
......@@ -0,0 +1,52 @@
1use std::{marker::PhantomData, ops::Deref};
2
3use smithay_client_toolkit::reexports::client::{Dispatch, Proxy};
4
5#[derive(Debug)]
6pub struct WlTyped<I, DATA>(I, PhantomData<DATA>);
7
8impl<I, DATA> WlTyped<I, DATA>
9where
10 I: Proxy,
11 DATA: Send + Sync + 'static,
12{
13 #[allow(clippy::extra_unused_type_parameters)]
14 pub fn wrap<STATE>(i: I) -> Self
15 where
16 STATE: Dispatch<I, DATA>,
17 {
18 Self(i, PhantomData)
19 }
20
21 pub fn inner(&self) -> &I {
22 &self.0
23 }
24
25 #[allow(dead_code)]
26 pub fn data(&self) -> &DATA {
27 // Generic on Self::wrap makes sure that this will never panic
28 #[allow(clippy::unwrap_used)]
29 self.0.data().unwrap()
30 }
31}
32
33impl<I: Clone, D> Clone for WlTyped<I, D> {
34 fn clone(&self) -> Self {
35 Self(self.0.clone(), PhantomData)
36 }
37}
38
39impl<I, D> Deref for WlTyped<I, D> {
40 type Target = I;
41
42 fn deref(&self) -> &Self::Target {
43 &self.0
44 }
45}
46
47impl<I: PartialEq, D> PartialEq for WlTyped<I, D> {
48 fn eq(&self, other: &Self) -> bool {
49 self.0 == other.0
50 }
51}
52impl<I: Eq, D> Eq for WlTyped<I, D> {}