authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-27 11:01:00-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-27 11:01:00-07:00
log1c5ad4e548ef0556a3810f749c2cee0e5e51f289
treede334424b672c0b5a715ebe96eae1fd6868b860a
parent130e7b1bd5ad7e9aac3a3b4e1dadb6ab3452cb27
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: notebooks, sections and pages in the app, native Linux titlebars

Open and close notebooks as remembered state, a first-run screen, the notebook sidebar behind a notebook-icon rail, New Notebook/Section/Page with OneNote's menus, the template strip (Ivy, Purple Clouds, Notebook, Solid Color, Meeting) drawn with our own art, page colours, drag to reorder and to move pages across sections, and mounted SMB shares opened through the embedded client. Linux uses the desktop's titlebar (KWin server-side, Adwaita frame on GNOME). Clicks that change state always get another frame. Conflict highlighting lives in canvas. Assisted-by: claude-opus-5.5 Assisted-by: claude-opus-5

27 files changed, 2485 insertions(+), 345 deletions(-)

Cargo.lock+17
...@@ -625,6 +625,7 @@ dependencies = [...@@ -625,6 +625,7 @@ dependencies = [
625 "accesskit_consumer",625 "accesskit_consumer",
626 "color_quant",626 "color_quant",
627 "draw",627 "draw",
628 "icu_normalizer",
628 "image",629 "image",
629 "onestore",630 "onestore",
630 "parley",631 "parley",
...@@ -1468,6 +1469,9 @@ dependencies = [...@@ -1468,6 +1469,9 @@ dependencies = [
1468 "icu_properties",1469 "icu_properties",
1469 "icu_provider",1470 "icu_provider",
1470 "smallvec",1471 "smallvec",
1472 "utf16_iter",
1473 "utf8_iter",
1474 "write16",
1471 "zerovec",1475 "zerovec",
1472]1476]
14731477
...@@ -1886,6 +1890,7 @@ dependencies = [...@@ -1886,6 +1890,7 @@ dependencies = [
1886 "pollster",1890 "pollster",
1887 "serde",1891 "serde",
1888 "serde_json",1892 "serde_json",
1893 "tempfile",
1889 "wgpu",1894 "wgpu",
1890]1895]
18911896
...@@ -3666,6 +3671,12 @@ dependencies = [...@@ -3666,6 +3671,12 @@ dependencies = [
3666 "tiny-skia-path 0.12.0",3671 "tiny-skia-path 0.12.0",
3667]3672]
36683673
3674[[package]]
3675name = "utf16_iter"
3676version = "1.0.5"
3677source = "registry+https://github.com/rust-lang/crates.io-index"
3678checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
3679
3669[[package]]3680[[package]]
3670name = "utf8_iter"3681name = "utf8_iter"
3671version = "1.0.4"3682version = "1.0.4"
...@@ -4460,6 +4471,12 @@ version = "0.57.1"...@@ -4460,6 +4471,12 @@ version = "0.57.1"
4460source = "registry+https://github.com/rust-lang/crates.io-index"4471source = "registry+https://github.com/rust-lang/crates.io-index"
4461checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"4472checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
44624473
4474[[package]]
4475name = "write16"
4476version = "1.0.0"
4477source = "registry+https://github.com/rust-lang/crates.io-index"
4478checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"
4479
4463[[package]]4480[[package]]
4464name = "writeable"4481name = "writeable"
4465version = "0.6.4"4482version = "0.6.4"
crates/canvas/Cargo.toml+1
...@@ -15,6 +15,7 @@ onestore = { path = "../onestore" }...@@ -15,6 +15,7 @@ onestore = { path = "../onestore" }
15parley.workspace = true15parley.workspace = true
16serde = { version = "1.0.229", features = ["derive"] }16serde = { version = "1.0.229", features = ["derive"] }
17serde_json = "1.0.151"17serde_json = "1.0.151"
18icu_normalizer = "2.3"
18skrifa = "0.44.0"19skrifa = "0.44.0"
1920
20accesskit = { version = "0.25.0", optional = true }21accesskit = { version = "0.25.0", optional = true }
crates/canvas/src/conflict.rs created+43
...@@ -0,0 +1,43 @@
1//! Conflict pages as OneNote shows them.
2
3use onestore::{
4 ExGuid,
5 page::{Page, PageObject, PageParagraph, ParagraphContent},
6};
7
8/// OneNote's highlight for conflicting changes on a conflict page, COLORREF.
9pub const CONFLICTING: u32 = 0xd6d6ff;
10
11/// `page` with its conflict `objects` marked as OneNote shows a conflict page's: a band
12/// across each conflicting paragraph.
13pub fn highlighted(mut page: Page, objects: &[ExGuid]) -> Page {
14 fn mark(paragraphs: &mut [PageParagraph], objects: &[ExGuid]) {
15 for paragraph in paragraphs {
16 match &mut paragraph.content {
17 ParagraphContent::Table(table) => {
18 for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) {
19 mark(&mut cell.paragraphs, objects);
20 }
21 }
22 ParagraphContent::Text(text)
23 if objects.contains(&paragraph.id) || objects.contains(&text.id) =>
24 {
25 paragraph.format.highlight = Some(CONFLICTING);
26 }
27 _ => {}
28 }
29 }
30 }
31 for object in &mut page.objects {
32 match object {
33 PageObject::Outline(outline) => mark(&mut outline.paragraphs, objects),
34 PageObject::Title(title) => {
35 for outline in &mut title.outlines {
36 mark(&mut outline.paragraphs, objects);
37 }
38 }
39 _ => {}
40 }
41 }
42 page
43}
crates/canvas/src/editor.rs+40
...@@ -1095,6 +1095,11 @@ impl CanvasEditor {...@@ -1095,6 +1095,11 @@ impl CanvasEditor {
1095 ]1095 ]
1096 }1096 }
10971097
1098 /// The page's colour (View, Page Color), COLORREF.
1099 pub fn page_color(&self) -> Option<u32> {
1100 self.header.color
1101 }
1102
1098 /// Where the page's first body line starts, in points, under its title.1103 /// Where the page's first body line starts, in points, under its title.
1099 pub fn body_start(&self) -> Option<[f32; 2]> {1104 pub fn body_start(&self) -> Option<[f32; 2]> {
1100 let title = self1105 let title = self
...@@ -1759,6 +1764,41 @@ impl CanvasEditor {...@@ -1759,6 +1764,41 @@ impl CanvasEditor {
1759 Ok(true)1764 Ok(true)
1760 }1765 }
17611766
1767 /// Puts `image` in the active outline's flow after the caret's paragraph, as OneNote
1768 /// inserts a picture into an outline; the caret stays.
1769 pub fn insert_picture(
1770 &mut self,
1771 engine: &mut TextEngine,
1772 image: onestore::page::Image,
1773 ) -> Result<(), EditorError> {
1774 let selection = self.selection();
1775 let caret = selection.positions[0].max(selection.positions[1]);
1776 let (container, index, node) = self
1777 .active_outline()
1778 .document
1779 .leaf(caret.paragraph)
1780 .ok_or(EditError::InvalidRange)?;
1781 let picture = PageParagraph {
1782 id: onestore::page::text::new_id()?,
1783 content: ParagraphContent::Image(image),
1784 style: None,
1785 lists: Vec::new(),
1786 tags: Vec::new(),
1787 collapsed: false,
1788 ..node.clone()
1789 };
1790 self.commit(
1791 engine,
1792 DocumentEdit {
1793 columns: BTreeMap::new(),
1794 container,
1795 range: index + 1..index + 1,
1796 replacement: vec![picture],
1797 },
1798 selection,
1799 )
1800 }
1801
1762 pub fn remove_image(&mut self, engine: &mut TextEngine, id: ExGuid) -> Result<(), EditorError> {1802 pub fn remove_image(&mut self, engine: &mut TextEngine, id: ExGuid) -> Result<(), EditorError> {
1763 if self.outline_picture(id).is_some() {1803 if self.outline_picture(id).is_some() {
1764 return self.edit_outline_picture(engine, id, None);1804 return self.edit_outline_picture(engine, id, None);
crates/canvas/src/gpu/mod.rs+34
...@@ -52,6 +52,20 @@ impl Paper {...@@ -52,6 +52,20 @@ impl Paper {
52 ink: [0.0, 0.0, 0.0, 1.0],52 ink: [0.0, 0.0, 0.0, 1.0],
53 };53 };
5454
55 /// This paper under a page coloured `color` (COLORREF, OneNote's View, Page Color): the
56 /// colour as OneNote paints it on white paper, moved onto this paper as a fill is, so
57 /// dark paper takes the colour's hue at its own depth. Text keeps its ink; template art
58 /// lies over the colour.
59 pub fn colored(self, color: Option<u32>) -> Self {
60 match color {
61 Some(color) => Self {
62 color: self.tint(colorref(color)),
63 ..self
64 },
65 None => self,
66 }
67 }
68
55 /// A fill OneNote draws on white paper, moved onto this paper: as far from it in69 /// A fill OneNote draws on white paper, moved onto this paper: as far from it in
56 /// OKLab lightness as from white, in the same hue and chroma, so text keeps its70 /// OKLab lightness as from white, in the same hue and chroma, so text keeps its
57 /// contrast on it.71 /// contrast on it.
...@@ -171,6 +185,26 @@ mod tests {...@@ -171,6 +185,26 @@ mod tests {
171 use onestore::page::text::Paragraph;185 use onestore::page::text::Paragraph;
172 use std::time::Duration;186 use std::time::Duration;
173187
188 /// A page colour paints as OneNote paints it on white paper, and on dark paper as a
189 /// dark paper of its hue; no colour leaves the paper as it was.
190 #[test]
191 fn page_colours_colour_the_paper() {
192 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()));
194 let dark = Paper {
195 color: draw::srgb(0x1f, 0x20, 0x22),
196 ink: draw::srgb(0xe6, 0xe6, 0xe6),
197 };
198 let colored = dark.colored(Some(teal));
199 let [lightness, a, b] = draw::oklab(colored.color);
200 let [paper, ..] = draw::oklab(dark.color);
201 let [_, ta, tb] = draw::oklab(colorref(teal));
202 assert!(lightness > paper && lightness < 0.5);
203 assert!((a - ta).abs() < 1e-3 && (b - tb).abs() < 1e-3);
204 assert_eq!(colored.ink, dark.ink);
205 assert_eq!(dark.colored(None).color, dark.color);
206 }
207
174 #[test]208 #[test]
175 fn runs_carry_their_highlight_as_what_lies_behind_them() {209 fn runs_carry_their_highlight_as_what_lies_behind_them() {
176 let run = |highlight, color| {210 let run = |highlight, color| {
crates/canvas/src/interaction/mod.rs+25-6
...@@ -199,6 +199,9 @@ pub struct PageView {...@@ -199,6 +199,9 @@ pub struct PageView {
199 /// Input is by finger: the focused outline's grips reach further, and outline chrome199 /// Input is by finger: the focused outline's grips reach further, and outline chrome
200 /// shows only while the view is focused, as there is no hover to reveal it.200 /// shows only while the view is focused, as there is no hover to reveal it.
201 pub touch: bool,201 pub touch: bool,
202 /// A platform scroll view owns the viewport: changes neither clamp it nor reveal the
203 /// caret, which the host does knowing its bars and keyboard.
204 pub host_viewport: bool,
202 /// The caret's opacity in its blink.205 /// The caret's opacity in its blink.
203 caret: f32,206 caret: f32,
204 /// When the caret last moved, which restarts its blink.207 /// When the caret last moved, which restarts its blink.
...@@ -256,6 +259,7 @@ impl PageView {...@@ -256,6 +259,7 @@ impl PageView {
256 modifiers: Modifiers::default(),259 modifiers: Modifiers::default(),
257 focused: true,260 focused: true,
258 touch: false,261 touch: false,
262 host_viewport: false,
259 caret: 1.0,263 caret: 1.0,
260 blink_from: Instant::now(),264 blink_from: Instant::now(),
261 };265 };
...@@ -403,7 +407,12 @@ impl PageView {...@@ -403,7 +407,12 @@ impl PageView {
403 fn hit_test(&self, point: [f32; 2]) -> Option<Hit> {407 fn hit_test(&self, point: [f32; 2]) -> Option<Hit> {
404 if let Some(ObjectFocus::Image(id)) = self.object_focus408 if let Some(ObjectFocus::Image(id)) = self.object_focus
405 && let Some((origin, size)) = self.editor.image_placement(id)409 && let Some((origin, size)) = self.editor.image_placement(id)
406 && let Some(handle) = image_handle_at(image_rect(origin, size), self.pixel(), point)410 && let Some(handle) = image_handle_at(
411 image_rect(origin, size),
412 self.pixel(),
413 point,
414 if self.touch { TOUCH_REACH } else { 0.0 },
415 )
407 {416 {
408 return Some(Hit::Image { id, handle });417 return Some(Hit::Image { id, handle });
409 }418 }
...@@ -456,14 +465,18 @@ impl PageView {...@@ -456,14 +465,18 @@ impl PageView {
456465
457 /// Keeps the view in bounds and restarts the caret blink after a change.466 /// Keeps the view in bounds and restarts the caret blink after a change.
458 fn changed(&mut self) -> Result<Response> {467 fn changed(&mut self) -> Result<Response> {
459 self.scroll().clamp(&mut self.viewport);468 if !self.host_viewport {
469 self.scroll().clamp(&mut self.viewport);
470 }
460 self.caret = 1.0;471 self.caret = 1.0;
461 self.blink_from = Instant::now();472 self.blink_from = Instant::now();
462 Ok(Response::changed())473 Ok(Response::changed())
463 }474 }
464475
465 fn moved(&mut self) -> Result<Response> {476 fn moved(&mut self) -> Result<Response> {
466 self.scroll().clamp(&mut self.viewport);477 if !self.host_viewport {
478 self.scroll().clamp(&mut self.viewport);
479 }
467 Ok(Response {480 Ok(Response {
468 moved: true,481 moved: true,
469 redraw: true,482 redraw: true,
...@@ -563,7 +576,9 @@ impl PageView {...@@ -563,7 +576,9 @@ impl PageView {
563576
564 /// Reveals the caret or focused object after an edit, then reports the change.577 /// Reveals the caret or focused object after an edit, then reports the change.
565 fn edited(&mut self) -> Result<Response> {578 fn edited(&mut self) -> Result<Response> {
566 self.reveal_focus()?;579 if !self.host_viewport {
580 self.reveal_focus()?;
581 }
567 self.changed()582 self.changed()
568 }583 }
569584
...@@ -1641,9 +1656,13 @@ fn image_handles(rect: [f32; 4], pixel: f32) -> impl Iterator<Item = ([i8; 2], [...@@ -1641,9 +1656,13 @@ fn image_handles(rect: [f32; 4], pixel: f32) -> impl Iterator<Item = ([i8; 2], [
1641 })1656 })
1642}1657}
16431658
1644fn image_handle_at(rect: [f32; 4], pixel: f32, point: [f32; 2]) -> Option<[i8; 2]> {1659/// The handle of a picture at `rect` under `point`, each reaching `reach` pixels beyond its
1660/// drawn square.
1661fn image_handle_at(rect: [f32; 4], pixel: f32, point: [f32; 2], reach: f32) -> Option<[i8; 2]> {
1645 image_handles(rect, pixel)1662 image_handles(rect, pixel)
1646 .find(|(_, center)| (0..2).all(|axis| (point[axis] - center[axis]).abs() <= 5.0 * pixel))1663 .find(|(_, center)| {
1664 (0..2).all(|axis| (point[axis] - center[axis]).abs() <= (5.0 + reach) * pixel)
1665 })
1647 .map(|(handle, _)| handle)1666 .map(|(handle, _)| handle)
1648}1667}
16491668
crates/canvas/src/interaction/tests.rs+3-3
...@@ -691,14 +691,14 @@ fn picture_handles_resize_as_onenote_does() {...@@ -691,14 +691,14 @@ fn picture_handles_resize_as_onenote_does() {
691 let pixel = 0.75;691 let pixel = 0.75;
692 let rect = image_rect(origin, size);692 let rect = image_rect(origin, size);
693 assert_eq!(693 assert_eq!(
694 image_handle_at(rect, pixel, [468.0 - 3.75, 86.4 - 3.75]),694 image_handle_at(rect, pixel, [468.0 - 3.75, 86.4 - 3.75], 0.0),
695 Some([-1, -1])695 Some([-1, -1])
696 );696 );
697 assert_eq!(697 assert_eq!(
698 image_handle_at(rect, pixel, [468.0 + 166.5, 286.5 + 3.75]),698 image_handle_at(rect, pixel, [468.0 + 166.5, 286.5 + 3.75], 0.0),
699 Some([0, 1])699 Some([0, 1])
700 );700 );
701 assert_eq!(image_handle_at(rect, pixel, [600.0, 150.0]), None);701 assert_eq!(image_handle_at(rect, pixel, [600.0, 150.0], 0.0), None);
702}702}
703703
704const DEFAULT_MARGIN: [f32; 2] = [36.0, 14.4];704const DEFAULT_MARGIN: [f32; 2] = [36.0, 14.4];
crates/canvas/src/lib.rs+2
...@@ -1,9 +1,11 @@...@@ -1,9 +1,11 @@
1pub mod conflict;
1pub mod document;2pub mod document;
2pub mod editor;3pub mod editor;
3pub mod language;4pub mod language;
4pub mod layout;5pub mod layout;
5pub mod math;6pub mod math;
6pub mod outline;7pub mod outline;
8pub mod search;
7pub mod template;9pub mod template;
810
9pub mod date;11pub mod date;
crates/canvas/src/search.rs created+417
...@@ -0,0 +1,417 @@
1//! Search as OneNote 2010 searches: each query word matches the start of a word, ignoring
2//! case and diacritics; a page matches when all its words appear in its title or text, and
3//! pages whose titles hold every word come first, then the most recently modified.
4
5use crate::document::TextPosition;
6use crate::editor::{CanvasEditor, Selection};
7use icu_normalizer::DecomposingNormalizerBorrowed;
8use onestore::ExGuid;
9use onestore::page::{Page, PageObject, PageParagraph, ParagraphContent, text::Paragraph};
10use std::ops::Range;
11
12/// Characters around a hit a snippet shows before it, and in all.
13const BEFORE: usize = 24;
14const SNIPPET: usize = 90;
15
16/// A query's words, lowered and stripped of diacritics; a quoted phrase is one word.
17#[derive(Clone, Debug, Default, PartialEq)]
18pub struct Query {
19 terms: Vec<String>,
20}
21
22impl Query {
23 pub fn new(text: &str) -> Self {
24 let mut terms = Vec::new();
25 for (index, part) in text.split('"').enumerate() {
26 if index % 2 == 1 {
27 terms.push(fold(part).split_whitespace().collect::<Vec<_>>().join(" "));
28 } else {
29 terms.extend(fold(part).split_whitespace().map(str::to_owned));
30 }
31 }
32 terms.retain(|term| !term.is_empty());
33 Self { terms }
34 }
35
36 pub fn is_empty(&self) -> bool {
37 self.terms.is_empty()
38 }
39
40 /// Where the words start words of `folded`, as `fold` leaves text, in order and merged
41 /// where they overlap.
42 fn hits(&self, folded: &str) -> Vec<Range<usize>> {
43 let mut hits: Vec<Range<usize>> = self
44 .terms
45 .iter()
46 .flat_map(|term| {
47 folded
48 .match_indices(term.as_str())
49 .filter(|(at, _)| word_start(folded, *at))
50 .map(|(at, term)| at..at + term.len())
51 })
52 .collect();
53 hits.sort_by_key(|hit| (hit.start, std::cmp::Reverse(hit.end)));
54 let mut merged: Vec<Range<usize>> = Vec::with_capacity(hits.len());
55 for hit in hits {
56 match merged.last_mut() {
57 Some(last) if hit.start <= last.end => last.end = last.end.max(hit.end),
58 _ => merged.push(hit),
59 }
60 }
61 merged
62 }
63
64 fn all_in(&self, folded: &str) -> bool {
65 self.terms.iter().all(|term| starts_word(term, folded))
66 }
67
68 /// Where the query's words start words of `text`, as byte ranges of it.
69 pub fn find(&self, text: &str) -> Vec<Range<usize>> {
70 let (folded, source) = fold_mapped(text);
71 self.hits(&folded)
72 .into_iter()
73 .map(|hit| source_range(text, &source, hit))
74 .collect()
75 }
76}
77
78/// Ideographs and kana are words of their own, as their text has no spaces between words.
79fn ideographic(character: char) -> bool {
80 matches!(u32::from(character),
81 0x3040..=0x30ff | 0x3400..=0x4dbf | 0x4e00..=0x9fff | 0xf900..=0xfaff | 0x20000..=0x3ffff)
82}
83
84fn starts_word(term: &str, folded: &str) -> bool {
85 folded
86 .match_indices(term)
87 .any(|(at, _)| word_start(folded, at))
88}
89
90fn word_start(text: &str, at: usize) -> bool {
91 match text[..at].chars().next_back() {
92 None => true,
93 Some(before) => {
94 !before.is_alphanumeric()
95 || ideographic(before)
96 || text[at..].chars().next().is_some_and(ideographic)
97 }
98 }
99}
100
101fn diacritic(character: char) -> bool {
102 matches!(u32::from(character),
103 0x300..=0x36f | 0x1ab0..=0x1aff | 0x1dc0..=0x1dff | 0x20d0..=0x20ff | 0xfe20..=0xfe2f)
104}
105
106/// Appends `character` lowered and without diacritics; line breaks stay and other spaces
107/// become one space each.
108fn fold_char(character: char, out: &mut String) {
109 const DECOMPOSE: DecomposingNormalizerBorrowed = DecomposingNormalizerBorrowed::new_nfd();
110 if character.is_ascii() {
111 out.push(match character {
112 '\n' => '\n',
113 space if space.is_ascii_whitespace() => ' ',
114 other => other.to_ascii_lowercase(),
115 });
116 return;
117 }
118 match character {
119 '\u{2018}' | '\u{2019}' | '\u{02bc}' => out.push('\''),
120 '\u{201c}' | '\u{201d}' => out.push('"'),
121 'ß' => out.push_str("ss"),
122 'æ' | 'Æ' => out.push_str("ae"),
123 'œ' | 'Œ' => out.push_str("oe"),
124 'ø' | 'Ø' => out.push('o'),
125 'ł' | 'Ł' => out.push('l'),
126 'đ' | 'Đ' => out.push('d'),
127 'ı' => out.push('i'),
128 space if space.is_whitespace() => out.push(' '),
129 other => out.extend(
130 DECOMPOSE
131 .normalize_iter(other.to_lowercase())
132 .filter(|part| !diacritic(*part)),
133 ),
134 }
135}
136
137/// `text` as search compares it: lowered and without diacritics.
138pub fn fold(text: &str) -> String {
139 let mut out = String::with_capacity(text.len());
140 for character in text.chars() {
141 fold_char(character, &mut out);
142 }
143 out
144}
145
146/// `fold`, with the byte of `text` each folded byte came from.
147fn fold_mapped(text: &str) -> (String, Vec<usize>) {
148 let mut out = String::with_capacity(text.len());
149 let mut source = Vec::with_capacity(text.len());
150 for (at, character) in text.char_indices() {
151 fold_char(character, &mut out);
152 source.resize(out.len(), at);
153 }
154 (out, source)
155}
156
157/// The bytes of `text` a range of its folding came from, whole characters.
158fn source_range(text: &str, source: &[usize], hit: Range<usize>) -> Range<usize> {
159 let start = source[hit.start];
160 let last = source[hit.end - 1];
161 let end = last + text[last..].chars().next().map_or(0, char::len_utf8);
162 start..end
163}
164
165/// The text a paragraph shows, without hidden field codes.
166pub fn shown(paragraph: &Paragraph) -> String {
167 let mut start = 0;
168 paragraph
169 .spans()
170 .iter()
171 .filter_map(|span| {
172 let run = &paragraph.text()[start..span.end];
173 start = span.end;
174 (span.format.hidden != Some(true)).then_some(run)
175 })
176 .collect()
177}
178
179/// A page's text outside its title, a paragraph to a line: outlines in page order, tables
180/// cell by cell.
181pub fn page_text(page: &Page) -> String {
182 fn walk(paragraphs: &[PageParagraph], out: &mut Vec<String>) {
183 for paragraph in paragraphs {
184 match &paragraph.content {
185 ParagraphContent::Text(text) => out.push(shown(&text.text)),
186 ParagraphContent::Table(table) => {
187 for cell in table.rows.iter().flat_map(|row| &row.cells) {
188 walk(&cell.paragraphs, out);
189 }
190 }
191 _ => {}
192 }
193 }
194 }
195 let mut out = Vec::new();
196 for object in &page.objects {
197 if let PageObject::Outline(outline) = object {
198 walk(&outline.paragraphs, &mut out);
199 }
200 }
201 out.retain(|line| !line.trim().is_empty());
202 out.join("\n")
203}
204
205/// A page as search knows it.
206pub struct Entry {
207 /// The section's key, which the host chooses.
208 pub section: String,
209 pub space: ExGuid,
210 pub title: String,
211 /// When the page last changed, in any unit that orders.
212 pub modified: u64,
213 text: String,
214 folded_title: String,
215 folded_text: String,
216}
217
218/// A page matching a query.
219#[derive(Clone, Debug, PartialEq)]
220pub struct Found {
221 pub section: String,
222 pub space: ExGuid,
223 pub title: String,
224 /// Every word is in the title, as OneNote's "Title contains" lists it.
225 pub in_title: bool,
226 /// Byte ranges of the title the words match.
227 pub title_hits: Vec<Range<usize>>,
228 /// The line around the first match in the page's text, or its first line.
229 pub snippet: String,
230 pub snippet_hits: Vec<Range<usize>>,
231}
232
233/// Pages of any number of sections, searchable together.
234#[derive(Default)]
235pub struct Index {
236 entries: Vec<Entry>,
237}
238
239impl Index {
240 pub fn len(&self) -> usize {
241 self.entries.len()
242 }
243
244 pub fn is_empty(&self) -> bool {
245 self.entries.is_empty()
246 }
247
248 /// Adds or replaces page `space` of `section` as `page` shows it.
249 pub fn set(&mut self, section: &str, space: ExGuid, page: &Page, modified: u64) {
250 let text = page_text(page);
251 let entry = Entry {
252 section: section.to_owned(),
253 space,
254 folded_title: fold(&page.title),
255 folded_text: fold(&text),
256 title: page.title.clone(),
257 modified,
258 text,
259 };
260 match self
261 .entries
262 .iter_mut()
263 .find(|entry| entry.space == space && entry.section == section)
264 {
265 Some(old) => *old = entry,
266 None => self.entries.push(entry),
267 }
268 }
269
270 /// Keeps the pages whose section `keep` accepts.
271 pub fn retain(&mut self, mut keep: impl FnMut(&Entry) -> bool) {
272 self.entries.retain(|entry| keep(entry));
273 }
274
275 /// The page `space` of `section`, when indexed.
276 pub fn get(&self, section: &str, space: ExGuid) -> Option<&Entry> {
277 self.entries
278 .iter()
279 .find(|entry| entry.space == space && entry.section == section)
280 }
281
282 /// Pages of the sections `scope` accepts that hold every word of `query`, title hits
283 /// first, then the most recently modified.
284 pub fn search(&self, query: &Query, scope: impl Fn(&str) -> bool) -> Vec<Found> {
285 if query.is_empty() {
286 return Vec::new();
287 }
288 let mut found: Vec<(&Entry, bool)> = self
289 .entries
290 .iter()
291 .filter(|entry| scope(&entry.section))
292 .filter_map(|entry| {
293 let in_title = query.all_in(&entry.folded_title);
294 let matches = in_title
295 || query.terms.iter().all(|term| {
296 starts_word(term, &entry.folded_title)
297 || starts_word(term, &entry.folded_text)
298 });
299 matches.then_some((entry, in_title))
300 })
301 .collect();
302 found.sort_by_key(|(entry, in_title)| (!in_title, std::cmp::Reverse(entry.modified)));
303 found
304 .into_iter()
305 .map(|(entry, in_title)| {
306 let (snippet, snippet_hits) = snippet(entry, query);
307 Found {
308 section: entry.section.clone(),
309 space: entry.space,
310 title: entry.title.clone(),
311 in_title,
312 title_hits: query.find(&entry.title),
313 snippet,
314 snippet_hits,
315 }
316 })
317 .collect()
318 }
319}
320
321/// Part of the line holding the first match in the entry's text, the matches within it;
322/// the text's first line when nothing there matches.
323fn snippet(entry: &Entry, query: &Query) -> (String, Vec<Range<usize>>) {
324 let first = query.hits(&entry.folded_text).first().map(|hit| hit.start);
325 let line = first.map_or(0, |at| {
326 entry.folded_text[..at].bytes().filter(|byte| *byte == b'\n').count()
327 });
328 let text = entry.text.split('\n').nth(line).unwrap_or_default();
329 let hits = query.find(text);
330 let chars: Vec<(usize, char)> = text.char_indices().collect();
331 let at = hits.first().map_or(0, |hit| {
332 chars.partition_point(|(byte, _)| *byte < hit.start)
333 });
334 let mut start = at.saturating_sub(BEFORE);
335 let mut end = (start + SNIPPET).min(chars.len());
336 // Cut ends fall back to the nearest space, so no word shows in part.
337 if start > 0
338 && let Some(space) = chars[start..at].iter().position(|(_, char)| char.is_whitespace())
339 {
340 start += space + 1;
341 }
342 if end < chars.len()
343 && let Some(space) = chars[at..end].iter().rposition(|(_, char)| char.is_whitespace())
344 {
345 end = at + space;
346 }
347 let byte = |index: usize| chars.get(index).map_or(text.len(), |(byte, _)| *byte);
348 let [from, to] = [byte(start), byte(end)];
349 let lead = if start > 0 { "…" } else { "" };
350 let shown = format!(
351 "{lead}{}{}",
352 text[from..to].trim_end(),
353 if end < chars.len() { "…" } else { "" }
354 );
355 let hits = hits
356 .into_iter()
357 .filter(|hit| hit.start >= from && hit.end <= to)
358 .map(|hit| hit.start - from + lead.len()..hit.end - from + lead.len())
359 .filter(|hit| hit.end <= shown.len())
360 .collect();
361 (shown, hits)
362}
363
364/// A match on a page: the outline it is in and its range there.
365pub type PageMatch = (ExGuid, Selection);
366
367/// Where `query` matches the page `editor` shows, from the page's top: outlines by where
368/// they stand, then their shown paragraphs in order.
369pub fn page_matches(editor: &CanvasEditor, query: &Query) -> Vec<PageMatch> {
370 if query.is_empty() {
371 return Vec::new();
372 }
373 let mut outlines: Vec<_> = editor.outlines().iter().collect();
374 outlines.sort_by(|a, b| {
375 let [ax, ay] = a.origin();
376 let [bx, by] = b.origin();
377 ay.total_cmp(&by).then(ax.total_cmp(&bx))
378 });
379 let mut matches = Vec::new();
380 for outline in outlines {
381 for (index, _) in outline.layouts() {
382 let Some(paragraph) = outline.document().paragraph(index) else {
383 continue;
384 };
385 // Hidden field codes are left out, and each shown byte keeps its source byte.
386 let mut shown = String::new();
387 let mut source = Vec::new();
388 let mut start = 0;
389 for span in paragraph.spans() {
390 if span.format.hidden != Some(true) {
391 shown.push_str(&paragraph.text()[start..span.end]);
392 source.extend(start..span.end);
393 }
394 start = span.end;
395 }
396 for hit in query.find(&shown) {
397 let end = source[hit.end - 1]
398 + paragraph.text()[source[hit.end - 1]..]
399 .chars()
400 .next()
401 .map_or(0, char::len_utf8);
402 let (Ok(from), Ok(to)) = (
403 paragraph.utf16_offset(source[hit.start]),
404 paragraph.utf16_offset(end),
405 ) else {
406 continue;
407 };
408 let at = |offset| TextPosition {
409 paragraph: index,
410 offset,
411 };
412 matches.push((outline.id, [at(from), at(to)].into()));
413 }
414 }
415 }
416 matches
417}
crates/canvas/src/template.rs+1-1
...@@ -603,7 +603,7 @@ pub const TEMPLATES: &[Template] = &[...@@ -603,7 +603,7 @@ pub const TEMPLATES: &[Template] = &[
603603
604/// OneNote 2010's page colours in its View, Page Color menu order, with the names its604/// OneNote 2010's page colours in its View, Page Color menu order, with the names its
605/// tooltips give them; COLORREF, as the page XML reports each605/// tooltips give them; COLORREF, as the page XML reports each
606/// (evidence/notebook-management/page-colors).606/// (`corpus/notebook-management/native/page-color`).
607pub const PAGE_COLORS: &[(&str, u32)] = &[607pub const PAGE_COLORS: &[(&str, u32)] = &[
608 ("Blue", 0xfef5ed),608 ("Blue", 0xfef5ed),
609 ("Red", 0xefeeff),609 ("Red", 0xefeeff),
crates/snowbound/Cargo.toml+2-1
...@@ -10,7 +10,7 @@ draw = { path = "../draw" }...@@ -10,7 +10,7 @@ draw = { path = "../draw" }
10ui = { path = "../ui" }10ui = { path = "../ui" }
11png = "0.18"11png = "0.18"
12onestore = { path = "../onestore" }12onestore = { path = "../onestore" }
13notebook = { path = "../notebook" }13notebook = { path = "../notebook", features = ["smb"] }
14serde = { version = "1", features = ["derive"] }14serde = { version = "1", features = ["derive"] }
15serde_json = "1"15serde_json = "1"
16pollster = "0.4"16pollster = "0.4"
...@@ -22,6 +22,7 @@ accesskit = "0.25.0"...@@ -22,6 +22,7 @@ accesskit = "0.25.0"
22accesskit_winit = { version = "0.34.0", default-features = false, features = ["rwh_06"] }22accesskit_winit = { version = "0.34.0", default-features = false, features = ["rwh_06"] }
2323
24[target.'cfg(target_os = "macos")'.dependencies]24[target.'cfg(target_os = "macos")'.dependencies]
25libc = "0.2"
25objc2 = "0.5.2"26objc2 = "0.5.2"
26objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAlert", "NSButton", "NSControl", "NSView", "NSResponder", "NSWindow", "NSApplication", "NSEvent", "NSColor", "NSColorSpace", "NSDatePicker", "NSDatePickerCell", "NSOpenPanel", "NSPanel", "NSSavePanel"] }27objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAlert", "NSButton", "NSControl", "NSView", "NSResponder", "NSWindow", "NSApplication", "NSEvent", "NSColor", "NSColorSpace", "NSDatePicker", "NSDatePickerCell", "NSOpenPanel", "NSPanel", "NSSavePanel"] }
27objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSGeometry", "NSLocale", "NSPathUtilities", "NSTimeZone", "NSURL", "NSArray"] }28objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSGeometry", "NSLocale", "NSPathUtilities", "NSTimeZone", "NSURL", "NSArray"] }
crates/snowbound/linux/README.md+7-2
...@@ -21,8 +21,13 @@ Edits save to the notebook folder as you type. Settings live in...@@ -21,8 +21,13 @@ Edits save to the notebook folder as you type. Settings live in
21puts the latter elsewhere.21puts the latter elsewhere.
2222
23Run `./install.sh` to add Snowbound to your app launcher; it copies the app23Run `./install.sh` to add Snowbound to your app launcher; it copies the app
24into `~/.local`. On Wayland the taskbar finds Snowbound's icon only through24into `~/.local`. On Wayland the taskbar, dock and KDE's title bar find
25this launcher entry, so without it the taskbar shows a generic icon.25Snowbound's icon only through this launcher entry, so without it they show a
26generic icon.
27
28The window's title bar is your desktop's own: KDE's on KDE, and on GNOME and
29other desktops without server-side decorations an Adwaita-style one drawn by
30Snowbound.
26`./install.sh ~/Notebooks/MyNotebook` makes the launcher open that notebook as31`./install.sh ~/Notebooks/MyNotebook` makes the launcher open that notebook as
27well.32well.
2833
crates/snowbound/src/conflict_render.rs+1-1
...@@ -254,7 +254,7 @@ fn conflict_views_offer_the_versions_and_render_offscreen() {...@@ -254,7 +254,7 @@ fn conflict_views_offer_the_versions_and_render_offscreen() {
254 let highlighted = kept.objects.iter().any(|object| match object {254 let highlighted = kept.objects.iter().any(|object| match object {
255 onestore::page::PageObject::Outline(outline) => outline.paragraphs.iter().any(|paragraph| {255 onestore::page::PageObject::Outline(outline) => outline.paragraphs.iter().any(|paragraph| {
256 paragraph.text().is_some_and(|text| marked.contains(&text.id))256 paragraph.text().is_some_and(|text| marked.contains(&text.id))
257 && paragraph.format.highlight == Some(CONFLICTING)257 && paragraph.format.highlight == Some(canvas::conflict::CONFLICTING)
258 }),258 }),
259 _ => false,259 _ => false,
260 });260 });
crates/snowbound/src/library.rs+452-9
...@@ -2,11 +2,117 @@...@@ -2,11 +2,117 @@
22
3use notebook::discover::{Folder, SectionState};3use notebook::discover::{Folder, SectionState};
4use notebook::session::{Notebook, Section};4use notebook::session::{Notebook, Section};
5use notebook::smb::{Client, Credentials};
5use std::{6use std::{
6 error::Error,7 error::Error,
8 io,
7 path::{Path, PathBuf},9 path::{Path, PathBuf},
10 sync::Arc,
11 time::Duration,
8};12};
913
14/// How long an SMB request may take before the share counts as unreachable.
15const TIMEOUT: Duration = Duration::from_secs(10);
16/// The largest section file read whole over SMB.
17const LIMIT: usize = 256 * 1024 * 1024;
18
19/// A folder on a mounted SMB share: where the embedded client finds it.
20#[derive(Clone, Debug, PartialEq)]
21pub struct Mount {
22 pub server: String,
23 pub share: String,
24 /// The account the mount signed in as, where the mount names one.
25 pub user: Option<String>,
26 pub domain: String,
27 /// The folder within the share, `/`-separated.
28 pub root: String,
29}
30
31impl Mount {
32 /// The mount of `source` (`//[domain;][user[:…]@]server/share[/folder]`), holding the
33 /// folder `within` its mount point; `options` are the mount's (`username=`, `user=`,
34 /// `domain=`), where the source names no account.
35 pub fn parse(source: &str, within: &str, options: &str) -> Option<Self> {
36 let source = source.strip_prefix("//")?;
37 let (account, rest) = match source.rsplit_once('@') {
38 Some((account, rest)) => (Some(account), rest),
39 None => (None, source),
40 };
41 let mut parts = rest.split('/').filter(|part| !part.is_empty());
42 let (server, share) = (parts.next()?, parts.next()?);
43 let option = |names: &[&str]| {
44 options.split(',').find_map(|option| {
45 let (name, value) = option.split_once('=')?;
46 names.contains(&name).then(|| value.to_owned())
47 })
48 };
49 let (domain, user) = match account {
50 Some(account) => {
51 let account = account.split(':').next().unwrap_or_default();
52 match account.split_once(';') {
53 Some((domain, user)) => (Some(domain.to_owned()), user.to_owned()),
54 None => (None, account.to_owned()),
55 }
56 }
57 None => (None, option(&["username", "user"]).unwrap_or_default()),
58 };
59 let root: Vec<&str> = parts
60 .chain(within.split('/'))
61 .filter(|part| !part.is_empty())
62 .collect();
63 Some(Self {
64 server: server.to_owned(),
65 share: share.to_owned(),
66 // "GUEST" is how macOS names a guest mount.
67 user: (!user.is_empty() && !user.eq_ignore_ascii_case("guest")).then_some(user),
68 domain: domain
69 .or_else(|| option(&["domain", "dom"]))
70 .unwrap_or_default(),
71 root: root.join("/"),
72 })
73 }
74}
75
76/// An account on an SMB server.
77#[derive(Clone)]
78pub struct Login {
79 pub user: String,
80 pub password: String,
81 pub domain: String,
82}
83
84impl Login {
85 /// The guest account a mount without one signed in as.
86 pub fn guest(mount: &Mount) -> Self {
87 Self {
88 user: String::new(),
89 password: String::new(),
90 domain: mount.domain.clone(),
91 }
92 }
93}
94
95/// A share and the account that reaches it, kept to reconnect.
96struct Server {
97 mount: Mount,
98 login: Login,
99}
100
101impl Server {
102 fn connect(&self) -> io::Result<Client> {
103 Client::connect(
104 &self.mount.server,
105 &self.mount.share,
106 Credentials {
107 username: &self.login.user,
108 password: &self.login.password,
109 domain: &self.login.domain,
110 },
111 TIMEOUT,
112 )
113 }
114}
115
10/// A section tab: where the section opens from and how it is labelled.116/// A section tab: where the section opens from and how it is labelled.
11pub struct Tab {117pub struct Tab {
12 /// The notebook catalog path, or the file of a section opened on its own.118 /// The notebook catalog path, or the file of a section opened on its own.
...@@ -24,28 +130,95 @@ pub struct Library {...@@ -24,28 +130,95 @@ pub struct Library {
24 pub name: String,130 pub name: String,
25 /// `Err` holds why a notebook listed as open could not be read this time.131 /// `Err` holds why a notebook listed as open could not be read this time.
26 pub notebook: Result<Option<Notebook>, String>,132 pub notebook: Result<Option<Notebook>, String>,
133 /// The share a notebook on a mounted SMB share opens through, by the embedded client.
134 server: Option<Arc<Server>>,
135 /// Why a notebook on a mounted SMB share opened through the mount instead.
136 pub notice: Option<String>,
27 cache: PathBuf,137 cache: PathBuf,
28}138}
29139
30impl Library {140impl Library {
31 /// The notebook in the folder at `location`, or why it cannot be read.141 /// The notebook in the folder at `location`, or why it cannot be read. A folder on a
142 /// mounted SMB share opens through Snowbound's own SMB client, signed in with the
143 /// account the system keeps for the mount, as OneNote's own client coordinates with
144 /// OneNote; without that account it opens through the mount.
32 pub fn notebook(location: &str, cache: &Path) -> Self {145 pub fn notebook(location: &str, cache: &Path) -> Self {
146 let mut notice = None;
147 if let Some(mount) = crate::platform::smb_mount(Path::new(location)) {
148 match crate::platform::smb_login(&mount)
149 .and_then(|login| Self::on_share(location, mount, login, cache))
150 {
151 Ok(library) => return library,
152 Err(reason) => {
153 eprintln!("{location}: opening through the mounted share: {reason}");
154 notice = Some(reason);
155 }
156 }
157 }
33 Self {158 Self {
34 location: location.to_owned(),159 location: location.to_owned(),
35 name: file_name(Path::new(location)),160 name: file_name(Path::new(location)),
36 notebook: Notebook::open(location, cache)161 notebook: Notebook::open(location, cache)
37 .map(Some)162 .map(Some)
38 .map_err(|error| error.to_string()),163 .map_err(|error| error.to_string()),
164 server: None,
165 notice,
39 cache: cache.to_owned(),166 cache: cache.to_owned(),
40 }167 }
41 }168 }
42169
43 /// `notebook`, open at `location`.170 /// The notebook at `location`, which `mount` names on its server, opened through the
44 pub fn open_notebook(location: &str, notebook: Notebook, cache: &Path) -> Self {171 /// embedded client signed in as `login`.
172 pub fn on_share(
173 location: &str,
174 mount: Mount,
175 login: Login,
176 cache: &Path,
177 ) -> Result<Self, String> {
178 let server = Arc::new(Server { mount, login });
179 let client = server.connect().map_err(|error| error.to_string())?;
180 let notebook = Notebook::open_smb(Arc::new(client), &server.mount.root, cache)
181 .map_err(|error| error.to_string())?;
182 Ok(Self {
183 location: location.to_owned(),
184 name: file_name(Path::new(location)),
185 notebook: Ok(Some(notebook)),
186 server: Some(server),
187 notice: None,
188 cache: cache.to_owned(),
189 })
190 }
191
192 /// This notebook read again, the way it was opened, for changing its structure.
193 pub fn reopen(&self) -> Result<Notebook, Box<dyn Error>> {
194 Ok(match &self.server {
195 Some(server) => {
196 Notebook::open_smb(Arc::new(server.connect()?), &server.mount.root, &self.cache)?
197 }
198 None => Notebook::open(&self.location, &self.cache)?,
199 })
200 }
201
202 /// This notebook as `notebook`, read again after a change.
203 pub fn with(&self, notebook: Notebook) -> Self {
204 Self {
205 location: self.location.clone(),
206 name: self.name.clone(),
207 notebook: Ok(Some(notebook)),
208 server: self.server.clone(),
209 notice: self.notice.clone(),
210 cache: self.cache.clone(),
211 }
212 }
213
214 /// A notebook `Notebook::create` just made in the folder at `location`.
215 pub fn created(location: &str, notebook: Notebook, cache: &Path) -> Self {
45 Self {216 Self {
46 location: location.to_owned(),217 location: location.to_owned(),
47 name: file_name(Path::new(location)),218 name: file_name(Path::new(location)),
48 notebook: Ok(Some(notebook)),219 notebook: Ok(Some(notebook)),
220 server: None,
221 notice: None,
49 cache: cache.to_owned(),222 cache: cache.to_owned(),
50 }223 }
51 }224 }
...@@ -56,19 +229,65 @@ impl Library {...@@ -56,19 +229,65 @@ impl Library {
56 location: file.to_string_lossy().into_owned(),229 location: file.to_string_lossy().into_owned(),
57 name: file_name(file.parent().unwrap_or(file)),230 name: file_name(file.parent().unwrap_or(file)),
58 notebook: Ok(None),231 notebook: Ok(None),
232 server: None,
233 notice: None,
59 cache: cache.to_owned(),234 cache: cache.to_owned(),
60 }235 }
61 }236 }
62237
63 /// Opens the section at catalog `path`.238 /// Opens the section at catalog `path`; on a share, through a replica named by its
64 pub fn open(&self, path: &str, notify: impl Fn() + Send + 'static) -> Result<Section, Box<dyn Error>> {239 /// file identity that publishes over the embedded client.
65 Ok(match &self.notebook {240 pub fn open(
66 Ok(Some(notebook)) => notebook.section(path, notify)?,241 &self,
67 Ok(None) => Section::open(path, &self.cache, notify)?,242 path: &str,
68 Err(error) => return Err(error.clone().into()),243 notify: impl Fn() + Send + 'static,
244 ) -> Result<Section, Box<dyn Error>> {
245 Ok(match (&self.notebook, &self.server) {
246 (Ok(Some(notebook)), Some(server)) => {
247 let identity = self
248 .catalog_section(notebook.catalog(), path)
249 .ok_or("The notebook doesn’t list this section")?;
250 let file = match server.mount.root.as_str() {
251 "" => path.to_owned(),
252 root => format!("{root}/{path}"),
253 };
254 let cache = self.cache.join("smb").join(format!("{identity}.sqlite"));
255 std::fs::create_dir_all(self.cache.join("smb"))?;
256 let replica = if cache.exists() {
257 notebook::Replica::open(&cache)?
258 } else {
259 notebook::Replica::create(
260 &cache,
261 &server.connect()?.read_storage(&file, LIMIT)?,
262 )?
263 };
264 let server = Arc::clone(server);
265 Section::resume_smb(file, replica, LIMIT, move || server.connect(), notify)?
266 }
267 (Ok(Some(notebook)), None) => notebook.section(path, notify)?,
268 (Ok(None), _) => Section::open(path, &self.cache, notify)?,
269 (Err(error), _) => return Err(error.clone().into()),
69 })270 })
70 }271 }
71272
273 /// The file identity, in hex, of the section at catalog `path`.
274 fn catalog_section(&self, catalog: &Folder, path: &str) -> Option<String> {
275 let mut folders = vec![catalog];
276 while let Some(folder) = folders.pop() {
277 if let Some(section) = folder.sections.iter().find(|section| section.path == path) {
278 return Some(
279 section
280 .file_id
281 .iter()
282 .map(|byte| format!("{byte:02x}"))
283 .collect(),
284 );
285 }
286 folders.extend(&folder.groups);
287 }
288 None
289 }
290
72 /// Names the section at catalog `path` across every open notebook, for remembering291 /// Names the section at catalog `path` across every open notebook, for remembering
73 /// its pages.292 /// its pages.
74 pub fn key(&self, path: &str) -> String {293 pub fn key(&self, path: &str) -> String {
...@@ -177,3 +396,227 @@ fn tabs(catalog: &Folder) -> Vec<Tab> {...@@ -177,3 +396,227 @@ fn tabs(catalog: &Folder) -> Vec<Tab> {
177 })396 })
178 .collect()397 .collect()
179}398}
399
400/// What a path chosen to open opens.
401#[derive(Debug, PartialEq)]
402pub enum Located {
403 /// The notebook in folder `root`, at the section at catalog path `section`.
404 Notebook {
405 root: PathBuf,
406 section: Option<String>,
407 },
408 /// A section file outside any notebook.
409 Section(PathBuf),
410 Nothing,
411}
412
413/// Whether `folder` holds a table of contents, as a notebook's or a section group's does.
414fn has_toc(folder: &Path) -> bool {
415 std::fs::read_dir(folder).is_ok_and(|entries| {
416 entries.flatten().any(|entry| {
417 entry
418 .path()
419 .extension()
420 .is_some_and(|extension| extension.eq_ignore_ascii_case("onetoc2"))
421 })
422 })
423}
424
425/// What `path` opens: a folder is a notebook; a table of contents opens its folder's; a
426/// section opens in the notebook the folders above it with tables of contents make up,
427/// section groups included, or alone.
428pub fn locate(path: &Path) -> Located {
429 let extension = path
430 .extension()
431 .map(|extension| extension.to_string_lossy().to_ascii_lowercase());
432 match extension.as_deref() {
433 _ if path.is_dir() => Located::Notebook {
434 root: path.to_owned(),
435 section: None,
436 },
437 Some("onetoc2") => match path.parent() {
438 Some(root) => Located::Notebook {
439 root: root.to_owned(),
440 section: None,
441 },
442 None => Located::Nothing,
443 },
444 Some("one") => {
445 let mut root = None;
446 let mut folder = path.parent();
447 while let Some(candidate) = folder.filter(|folder| has_toc(folder)) {
448 root = Some(candidate);
449 folder = candidate.parent();
450 }
451 match root {
452 Some(root) => Located::Notebook {
453 root: root.to_owned(),
454 section: path.strip_prefix(root).ok().map(|relative| {
455 relative
456 .components()
457 .map(|part| part.as_os_str().to_string_lossy())
458 .collect::<Vec<_>>()
459 .join("/")
460 }),
461 },
462 None => Located::Section(path.to_owned()),
463 }
464 }
465 _ => Located::Nothing,
466 }
467}
468
469#[cfg(test)]
470mod tests {
471 use super::*;
472
473 /// A notebook on a share opens, edits and publishes through the embedded client, as a
474 /// mounted share's notebook does: `ONESTORE_SMB_LAB=127.0.0.1:PORT` (a disposable Samba
475 /// share `agent`, guest access; `tools/w7/linux_vm.py up NAME`).
476 #[test]
477 #[ignore = "requires an owned Samba share at ONESTORE_SMB_LAB"]
478 fn a_notebook_on_a_share_opens_through_the_embedded_client() {
479 let address = std::env::var("ONESTORE_SMB_LAB").unwrap();
480 let root = format!("snowbound-{}", std::process::id());
481 let mount = Mount {
482 server: address,
483 share: "agent".into(),
484 user: None,
485 domain: String::new(),
486 root: root.clone(),
487 };
488 let login = Login::guest(&mount);
489 let server = Server {
490 mount: mount.clone(),
491 login: login.clone(),
492 };
493 let client = Arc::new(server.connect().unwrap());
494 client.create_directory(&root).unwrap();
495 let cache = std::env::temp_dir().join(format!("snowbound-share-{}", std::process::id()));
496 let page = onestore::PageCreation::new(None, Some(""), "Rust Author").unwrap();
497 Notebook::open_smb(Arc::clone(&client), &root, &cache)
498 .unwrap()
499 .create_section("", "New Section 1", &page)
500 .unwrap();
501 let library = Library::on_share("/Volumes/agent/lab", mount, login, &cache).unwrap();
502 let path = library.first_section().unwrap();
503 let section = library.open(&path, || {}).unwrap();
504 let (space, ..) = section.pages().unwrap()[0].clone();
505 let stored = section.page(space).unwrap();
506 let title = stored
507 .objects
508 .iter()
509 .find_map(|object| match object {
510 onestore::page::PageObject::Title(title) => {
511 title.outlines[0].paragraphs[0].text().map(|text| text.id)
512 }
513 _ => None,
514 })
515 .unwrap();
516 section
517 .apply(
518 "Rust Author",
519 onestore::op::Edit {
520 at: crate::filetime(),
521 ops: vec![onestore::op::Op::Page {
522 space,
523 op: onestore::op::PageOp::Text {
524 text: title,
525 range: 0..0,
526 with: "Over SMB".into(),
527 },
528 }],
529 },
530 )
531 .unwrap();
532 let deadline = std::time::Instant::now() + Duration::from_secs(60);
533 let file = format!("{root}/{path}");
534 loop {
535 let bytes = client.read_storage(&file, LIMIT).unwrap();
536 let arena = onestore::Arena::default();
537 let titles = onestore::Section::open(&arena, bytes)
538 .unwrap()
539 .pages()
540 .unwrap();
541 if titles.iter().any(|(_, title, _)| title == "Over SMB") {
542 break;
543 }
544 assert!(std::time::Instant::now() < deadline, "{titles:?}");
545 section.wake();
546 std::thread::sleep(Duration::from_millis(200));
547 }
548 section.close().unwrap();
549 let _ = std::fs::remove_dir_all(&cache);
550 }
551
552 #[test]
553 fn mounts_name_their_server_share_account_and_folder() {
554 assert_eq!(
555 Mount::parse("//WORK;clover@nas.local/notes", "Personal/Garden", ""),
556 Some(Mount {
557 server: "nas.local".into(),
558 share: "notes".into(),
559 user: Some("clover".into()),
560 domain: "WORK".into(),
561 root: "Personal/Garden".into(),
562 })
563 );
564 assert_eq!(
565 Mount::parse(
566 "//nas/notes/sub",
567 "Personal",
568 "rw,vers=3.0,username=amy,domain=HOME"
569 ),
570 Some(Mount {
571 server: "nas".into(),
572 share: "notes".into(),
573 user: Some("amy".into()),
574 domain: "HOME".into(),
575 root: "sub/Personal".into(),
576 })
577 );
578 let guest = Mount::parse("//GUEST:@nas/public", "", "").unwrap();
579 assert_eq!((guest.user, guest.root), (None, String::new()));
580 assert_eq!(Mount::parse("/dev/disk1", "", ""), None);
581 }
582
583 #[test]
584 fn chosen_paths_open_their_notebook_or_section() {
585 let root = std::env::temp_dir().join(format!("snowbound-locate-{}", std::process::id()));
586 let _ = std::fs::remove_dir_all(&root);
587 let notebook = root.join("Notebook");
588 std::fs::create_dir_all(notebook.join("Group")).unwrap();
589 for file in [
590 "Open Notebook.onetoc2",
591 "Group/Open Notebook.onetoc2",
592 "Group/Inner.one",
593 "Top.one",
594 ] {
595 std::fs::write(notebook.join(file), b"").unwrap();
596 }
597 std::fs::write(root.join("Loose.one"), b"").unwrap();
598 let at = |root: &Path, section: Option<&str>| Located::Notebook {
599 root: root.to_owned(),
600 section: section.map(str::to_owned),
601 };
602 assert_eq!(locate(&notebook), at(&notebook, None));
603 assert_eq!(
604 locate(&notebook.join("Open Notebook.onetoc2")),
605 at(&notebook, None)
606 );
607 assert_eq!(
608 locate(&notebook.join("Group/Inner.one")),
609 at(&notebook, Some("Group/Inner.one"))
610 );
611 assert_eq!(
612 locate(&notebook.join("Top.one")),
613 at(&notebook, Some("Top.one"))
614 );
615 assert_eq!(
616 locate(&root.join("Loose.one")),
617 Located::Section(root.join("Loose.one"))
618 );
619 assert_eq!(locate(&root.join("missing.txt")), Located::Nothing);
620 std::fs::remove_dir_all(&root).unwrap();
621 }
622}
crates/snowbound/src/linux.rs+58
...@@ -221,6 +221,64 @@ pub fn cache_dir() -> Option<PathBuf> {...@@ -221,6 +221,64 @@ pub fn cache_dir() -> Option<PathBuf> {
221 xdg_dir("XDG_CACHE_HOME", ".cache")221 xdg_dir("XDG_CACHE_HOME", ".cache")
222}222}
223223
224/// The share holding `path` where it is mounted from an SMB server (CIFS or SMB 3).
225pub fn smb_mount(path: &std::path::Path) -> Option<crate::library::Mount> {
226 let mounts = std::fs::read_to_string("/proc/self/mounts").ok()?;
227 // The mount holding the path is the one at its longest ancestor.
228 let (source, point, options) = mounts
229 .lines()
230 .filter_map(|line| {
231 let mut fields = line.split_whitespace();
232 let (source, point, kind, options) =
233 (fields.next()?, fields.next()?, fields.next()?, fields.next()?);
234 let point = point.replace("\\040", " ");
235 (matches!(kind, "cifs" | "smb3") && path.starts_with(&point))
236 .then(|| (source.replace("\\040", " "), point, options.to_owned()))
237 })
238 .max_by_key(|(_, point, _)| point.len())?;
239 let within = path.strip_prefix(&point).ok()?.to_string_lossy().into_owned();
240 crate::library::Mount::parse(&source, &within, &options)
241}
242
243/// The password the Secret Service keeps for `mount`'s account, as GNOME's file manager
244/// saves one, or what the user types when it keeps none.
245pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login, String> {
246 let user = mount.user.clone().unwrap_or_default();
247 let mut lookup = Command::new("secret-tool");
248 lookup.args(["lookup", "protocol", "smb", "server", &mount.server]);
249 if !user.is_empty() {
250 lookup.args(["user", &user]);
251 }
252 if let Ok(output) = lookup.output()
253 && output.status.success()
254 && !output.stdout.is_empty()
255 {
256 return Ok(crate::library::Login {
257 user,
258 password: String::from_utf8_lossy(&output.stdout).trim_end_matches('\n').to_owned(),
259 domain: mount.domain.clone(),
260 });
261 }
262 let title = format!("Sign in to {}", mount.server);
263 let asked = dialog(
264 ["--password", "--username", &format!("--title={title}")],
265 ["--password", &title],
266 )
267 .map_err(str::to_owned)?
268 .ok_or_else(|| "Signing in was cancelled".to_owned())?;
269 // zenity answers "user|password"; kdialog only the password.
270 let (typed, password) = asked
271 .split_once('|')
272 .map_or((user.clone(), asked.clone()), |(typed, password)| {
273 (typed.to_owned(), password.to_owned())
274 });
275 Ok(crate::library::Login {
276 user: if typed.is_empty() { user } else { typed },
277 password,
278 domain: mount.domain.clone(),
279 })
280}
281
224pub fn settings_dir() -> Option<PathBuf> {282pub fn settings_dir() -> Option<PathBuf> {
225 xdg_dir("XDG_CONFIG_HOME", ".config")283 xdg_dir("XDG_CONFIG_HOME", ".config")
226}284}
crates/snowbound/src/macos.rs+86
...@@ -117,6 +117,92 @@ pub fn cache_dir() -> Option<std::path::PathBuf> {...@@ -117,6 +117,92 @@ pub fn cache_dir() -> Option<std::path::PathBuf> {
117 Some(std::path::PathBuf::from(std::env::var_os("HOME")?).join("Library/Caches/snowbound"))117 Some(std::path::PathBuf::from(std::env::var_os("HOME")?).join("Library/Caches/snowbound"))
118}118}
119119
120/// The share holding `path` where macOS mounted it from an SMB server.
121pub fn smb_mount(path: &std::path::Path) -> Option<crate::library::Mount> {
122 use std::os::unix::ffi::OsStrExt;
123 let name = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
124 let mut mount: libc::statfs = unsafe { std::mem::zeroed() };
125 if unsafe { libc::statfs(name.as_ptr(), &mut mount) } != 0 {
126 return None;
127 }
128 let text = |chars: &[libc::c_char]| {
129 unsafe { std::ffi::CStr::from_ptr(chars.as_ptr()) }
130 .to_string_lossy()
131 .into_owned()
132 };
133 if text(&mount.f_fstypename) != "smbfs" {
134 return None;
135 }
136 let within = path.strip_prefix(text(&mount.f_mntonname)).ok()?;
137 crate::library::Mount::parse(&text(&mount.f_mntfromname), &within.to_string_lossy(), "")
138}
139
140#[link(name = "Security", kind = "framework")]
141unsafe extern "C" {
142 fn SecKeychainFindInternetPassword(
143 keychain: *const std::ffi::c_void,
144 server_length: u32,
145 server: *const u8,
146 domain_length: u32,
147 domain: *const u8,
148 account_length: u32,
149 account: *const u8,
150 path_length: u32,
151 path: *const u8,
152 port: u16,
153 protocol: u32,
154 authentication: u32,
155 password_length: *mut u32,
156 password: *mut *mut std::ffi::c_void,
157 item: *mut *const std::ffi::c_void,
158 ) -> i32;
159 fn SecKeychainItemFreeContent(list: *const std::ffi::c_void, data: *mut std::ffi::c_void) -> i32;
160}
161
162/// The password the keychain keeps for `mount`'s account on its server, as macOS saved it
163/// when the share was mounted; the system asks the user to allow the app to read it.
164pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login, String> {
165 let Some(user) = &mount.user else {
166 return Ok(crate::library::Login::guest(mount));
167 };
168 // kSecProtocolTypeSMB is 'smb '.
169 const SMB: u32 = u32::from_be_bytes(*b"smb ");
170 let (mut length, mut data) = (0, std::ptr::null_mut());
171 let status = unsafe {
172 SecKeychainFindInternetPassword(
173 std::ptr::null(),
174 mount.server.len() as u32,
175 mount.server.as_ptr(),
176 0,
177 std::ptr::null(),
178 user.len() as u32,
179 user.as_ptr(),
180 0,
181 std::ptr::null(),
182 0,
183 SMB,
184 0,
185 &mut length,
186 &mut data,
187 std::ptr::null_mut(),
188 )
189 };
190 if status != 0 {
191 return Err(format!(
192 "The keychain has no password for {user} on {} ({status})",
193 mount.server
194 ));
195 }
196 let password = unsafe { std::slice::from_raw_parts(data.cast::<u8>(), length as usize) };
197 let password = String::from_utf8_lossy(password).into_owned();
198 unsafe { SecKeychainItemFreeContent(std::ptr::null(), data) };
199 Ok(crate::library::Login {
200 user: user.clone(),
201 password,
202 domain: mount.domain.clone(),
203 })
204}
205
120pub fn settings_dir() -> Option<std::path::PathBuf> {206pub fn settings_dir() -> Option<std::path::PathBuf> {
121 Some(207 Some(
122 std::path::PathBuf::from(std::env::var_os("HOME")?)208 std::path::PathBuf::from(std::env::var_os("HOME")?)
crates/snowbound/src/main.rs+180-94
...@@ -3,6 +3,7 @@ mod art;...@@ -3,6 +3,7 @@ mod art;
3mod conflict_render;3mod conflict_render;
4mod library;4mod library;
5mod manage;5mod manage;
6mod meeting;
6mod menus;7mod menus;
7#[cfg_attr(target_os = "linux", path = "linux.rs")]8#[cfg_attr(target_os = "linux", path = "linux.rs")]
8#[cfg_attr(target_os = "macos", path = "macos.rs")]9#[cfg_attr(target_os = "macos", path = "macos.rs")]
...@@ -80,8 +81,6 @@ const FONT_COLORS: [u32; 20] = [...@@ -80,8 +81,6 @@ const FONT_COLORS: [u32; 20] = [
80/// while the gaps still take the pointer.81/// while the gaps still take the pointer.
81const ROW: f32 = 29.0;82const ROW: f32 = 29.0;
82const ROW_GAP: f32 = 3.0;83const ROW_GAP: f32 = 3.0;
83/// OneNote's highlight for conflicting changes on a conflict page, COLORREF.
84const CONFLICTING: u32 = 0xd6d6ff;
85/// Space between the page and a page tab that isn't open.84/// Space between the page and a page tab that isn't open.
86const PILL_MARGIN: f32 = 3.0;85const PILL_MARGIN: f32 = 3.0;
87/// Rounding where the open page tab meets the page, concentric with its neighbours'.86/// Rounding where the open page tab meets the page, concentric with its neighbours'.
...@@ -122,6 +121,7 @@ enum Replay {...@@ -122,6 +121,7 @@ enum Replay {
122 /// A frame during a wait, as a visible window's display would ask for.121 /// A frame during a wait, as a visible window's display would ask for.
123 Tick,122 Tick,
124 Appearance(winit::window::Theme),123 Appearance(winit::window::Theme),
124 Quit,
125}125}
126126
127impl From<accesskit_winit::Event> for UserEvent {127impl From<accesskit_winit::Event> for UserEvent {
...@@ -165,9 +165,8 @@ struct App {...@@ -165,9 +165,8 @@ struct App {
165 interface_font: Vec<Vec<u8>>,165 interface_font: Vec<Vec<u8>>,
166 /// Where `--screenshot` writes its PNGs, drawn from a window never shown.166 /// Where `--screenshot` writes its PNGs, drawn from a window never shown.
167 screenshot: Option<PathBuf>,167 screenshot: Option<PathBuf>,
168 /// Where settings are saved, and what they held at launch, until the window opens.168 /// What the window starts from besides its input, until it opens.
169 settings: Option<(Option<PathBuf>, settings::Settings)>,169 launch: Option<settings::Launch>,
170 cache: PathBuf,
171 state: Option<State>,170 state: Option<State>,
172 startup_error: Option<Box<dyn Error>>,171 startup_error: Option<Box<dyn Error>>,
173}172}
...@@ -226,6 +225,16 @@ enum Bar {...@@ -226,6 +225,16 @@ enum Bar {
226}225}
227226
228impl Session {227impl Session {
228 /// Saving's state, and that the notebook opened through the system's mount of its
229 /// share where Snowbound's own SMB client could not sign in.
230 fn status(&self) -> String {
231 match (&self.library.notice, self.status) {
232 (None, status) => status.to_owned(),
233 (Some(_), "") => "Via mounted share".to_owned(),
234 (Some(_), status) => format!("{status} · Via mounted share"),
235 }
236 }
237
229 fn title(&self) -> &str {238 fn title(&self) -> &str {
230 match self.conflict(self.space) {239 match self.conflict(self.space) {
231 Some((_, version)) => &version.title,240 Some((_, version)) => &version.title,
...@@ -285,26 +294,35 @@ impl Session {...@@ -285,26 +294,35 @@ impl Session {
285 move || {294 move || {
286 let page = replica.page(space)?;295 let page = replica.page(space)?;
287 Ok(match objects {296 Ok(match objects {
288 Some(objects) => highlighted(page, &objects),297 Some(objects) => canvas::conflict::highlighted(page, &objects),
289 None => page,298 None => page,
290 })299 })
291 }300 }
292 }301 }
293}302}
294303
295/// What a loader thread opened: a section, or another page of the open one.304/// What a loader thread read: a section and its page to show, another page of the open
305/// section, or a notebook read again after a change, with the catalog path the open
306/// section has in it.
296enum Loaded {307enum Loaded {
308 Section(Box<Session>, Page),
309 Page(ExGuid, Page),
310 Library(Arc<Library>, String),
311}
312
313/// What an opening page replaces: the section, or the page of the open one.
314enum Shown {
297 Section(Box<Session>),315 Section(Box<Session>),
298 Page(ExGuid),316 Page(ExGuid),
299}317}
300318
301/// A loader thread's request number, what it opened and the page it read.319/// A loader thread's request number and what it read.
302type Read = (u64, Result<(Loaded, Page), String>);320type Read = (u64, Result<Loaded, String>);
303321
304/// The newest page read, laid out and waiting for the pictures it shows first, so it322/// The newest page read, laid out and waiting for the pictures it shows first, so it
305/// never appears without them.323/// never appears without them.
306struct Opening {324struct Opening {
307 loaded: Loaded,325 loaded: Shown,
308 scene: (PageScene, [f32; 2]),326 scene: (PageScene, [f32; 2]),
309 editor: CanvasEditor,327 editor: CanvasEditor,
310 since: Instant,328 since: Instant,
...@@ -325,13 +343,15 @@ enum Command {...@@ -325,13 +343,15 @@ enum Command {
325 CloseNotebook(Arc<Library>),343 CloseNotebook(Arc<Library>),
326 /// Changes a notebook's sections and groups.344 /// Changes a notebook's sections and groups.
327 Structure(Arc<Library>, manage::Structure),345 Structure(Arc<Library>, manage::Structure),
328 /// Adds a page at the end of the open section, or a subpage after the open page, and346 /// Adds a page at the end of the open section, or a subpage of a page, and edits its
329 /// edits its title.347 /// title.
330 NewPage { subpage: bool },348 NewPage { under: Option<ExGuid> },
331 /// Deletes pages of the open section to the notebook's recycle bin.349 /// Deletes pages of the open section to the notebook's recycle bin.
332 DeletePages(Vec<ExGuid>),350 DeletePages(Vec<ExGuid>),
333 /// Moves or indents pages of the open section.351 /// Moves or indents pages of the open section.
334 Pages(Vec<onestore::PageEdit>),352 Pages(Vec<onestore::PageEdit>),
353 /// Moves a page of the open section to the end of another section of its folder.
354 MovePage { space: ExGuid, path: String },
335 /// Gives the open page a template's background or colour.355 /// Gives the open page a template's background or colour.
336 Template(templates::Choice),356 Template(templates::Choice),
337 OpenPage(ExGuid),357 OpenPage(ExGuid),
...@@ -386,10 +406,8 @@ struct State {...@@ -386,10 +406,8 @@ struct State {
386 menu: Option<(menus::Target, [f32; 2])>,406 menu: Option<(menus::Target, [f32; 2])>,
387 /// A section or group being renamed in the sidebar.407 /// A section or group being renamed in the sidebar.
388 renaming: Option<sidebar::Renaming>,408 renaming: Option<sidebar::Renaming>,
389 /// The page whose tab is being dragged.409 /// What the pointer is dragging: a page's tab, a section tab or a sidebar row.
390 dragging: Option<ExGuid>,410 drag: Option<menus::Drag>,
391 /// The section or group whose sidebar row is being dragged.
392 dragging_entry: Option<sidebar::Entry>,
393 /// Pages whose template strip was dismissed this run.411 /// Pages whose template strip was dismissed this run.
394 dismissed: HashSet<ExGuid>,412 dismissed: HashSet<ExGuid>,
395 /// A page just created, whose title takes the caret once it opens.413 /// A page just created, whose title takes the caret once it opens.
...@@ -461,8 +479,11 @@ impl State {...@@ -461,8 +479,11 @@ impl State {
461 substitutes: &[PathBuf],479 substitutes: &[PathBuf],
462 interface_font: Vec<Vec<u8>>,480 interface_font: Vec<Vec<u8>>,
463 visible: bool,481 visible: bool,
464 (settings, stored): (Option<PathBuf>, settings::Settings),482 settings::Launch {
465 cache: PathBuf,483 file: settings,
484 saved: stored,
485 cache,
486 }: settings::Launch,
466 ) -> Result<Self, Box<dyn Error>> {487 ) -> Result<Self, Box<dyn Error>> {
467 let window = Arc::new(488 let window = Arc::new(
468 event_loop.create_window(489 event_loop.create_window(
...@@ -652,8 +673,7 @@ impl State {...@@ -652,8 +673,7 @@ impl State {
652 title_focus: None,673 title_focus: None,
653 menu: None,674 menu: None,
654 renaming: None,675 renaming: None,
655 dragging: None,676 drag: None,
656 dragging_entry: None,
657 templates: templates::View::Strip,677 templates: templates::View::Strip,
658 thumbnails: templates::Thumbnails::default(),678 thumbnails: templates::Thumbnails::default(),
659 filter: String::new(),679 filter: String::new(),
...@@ -795,7 +815,9 @@ impl State {...@@ -795,7 +815,9 @@ impl State {
795 self.ui.theme.inactive_selection,815 self.ui.theme.inactive_selection,
796 ] = platform::text_colors(&self.window);816 ] = platform::text_colors(&self.window);
797 }817 }
798 let theme = self.ui.theme.clone();818 // The page, and the open page's tab joined to it, take the page's colour.
819 let mut theme = self.ui.theme.clone();
820 theme.paper = self.paper().color;
799 if !platform::system_titlebar(&self.window) {821 if !platform::system_titlebar(&self.window) {
800 self.title_bar(&theme);822 self.title_bar(&theme);
801 }823 }
...@@ -820,7 +842,7 @@ impl State {...@@ -820,7 +842,7 @@ impl State {
820 ..Spec::default()842 ..Spec::default()
821 },843 },
822 );844 );
823 self.ui.open(845 let tab_row = self.ui.open(
824 "tabs",846 "tabs",
825 Spec {847 Spec {
826 size: [fill(), px(TAB_ROW)],848 size: [fill(), px(TAB_ROW)],
...@@ -836,6 +858,7 @@ impl State {...@@ -836,6 +858,7 @@ impl State {
836 ..Spec::default()858 ..Spec::default()
837 },859 },
838 );860 );
861 let row = self.ui.id("sections");
839 let (clicked, open_tab) = match &self.session {862 let (clicked, open_tab) = match &self.session {
840 Some(session) => {863 Some(session) => {
841 let tabs: Vec<_> = session864 let tabs: Vec<_> = session
...@@ -843,14 +866,26 @@ impl State {...@@ -843,14 +866,26 @@ impl State {
843 .iter()866 .iter()
844 .map(|tab| (tab.name.as_str(), section_color(tab.color)))867 .map(|tab| (tab.name.as_str(), section_color(tab.color)))
845 .collect();868 .collect();
846 let (clicked, context, open_tab) = ui::shell::section_tabs(869 let lit = self.page_drop(row);
870 let ui::shell::Tabs {
871 clicked,
872 context,
873 held,
874 open: open_tab,
875 } = ui::shell::section_tabs(
847 &mut self.ui,876 &mut self.ui,
848 "sections",877 "sections",
849 &tabs,878 &tabs,
850 session.tab,879 session.tab,
880 lit,
851 &section,881 &section,
852 TAB_ROW,882 TAB_ROW,
853 );883 );
884 let bar = self.ui.rect(tab_row).unwrap_or_default();
885 self.drag_tabs(held, row, bar, section.accent);
886 let Some(session) = &self.session else {
887 unreachable!("The tabs are the session's")
888 };
854 let open = |tab: usize| {889 let open = |tab: usize| {
855 Command::OpenSection(Arc::clone(&session.library), session.tabs[tab].path.clone())890 Command::OpenSection(Arc::clone(&session.library), session.tabs[tab].path.clone())
856 };891 };
...@@ -869,8 +904,16 @@ impl State {...@@ -869,8 +904,16 @@ impl State {
869 None => {904 None => {
870 let tabs = [("Temporary page", section_color(None))];905 let tabs = [("Temporary page", section_color(None))];
871 let shown = if self.temporary { &tabs[..] } else { &[] };906 let shown = if self.temporary { &tabs[..] } else { &[] };
872 let (_, _, open_tab) =907 let open_tab = ui::shell::section_tabs(
873 ui::shell::section_tabs(&mut self.ui, "sections", shown, 0, &section, TAB_ROW);908 &mut self.ui,
909 "sections",
910 shown,
911 0,
912 None,
913 &section,
914 TAB_ROW,
915 )
916 .open;
874 (None, open_tab)917 (None, open_tab)
875 }918 }
876 };919 };
...@@ -949,7 +992,7 @@ impl State {...@@ -949,7 +992,7 @@ impl State {
949 }992 }
950 self.page_events(signal.events)?;993 self.page_events(signal.events)?;
951 self.ui.close();994 self.ui.close();
952 let open_page = self.page_list(&theme, &section);995 let open_page = self.page_list(&theme, &section, row);
953 self.ui.close();996 self.ui.close();
954 self.ui.close();997 self.ui.close();
955 self.ui.close();998 self.ui.close();
...@@ -1008,7 +1051,7 @@ impl State {...@@ -1008,7 +1051,7 @@ impl State {
1008 }1051 }
1009 outline.push(([left, bottom], ROUNDING));1052 outline.push(([left, bottom], ROUNDING));
1010 let height = (frame[3] - frame[1]).max(1.0);1053 let height = (frame[3] - frame[1]).max(1.0);
1011 let paper = self.ui.theme.paper;1054 let paper = self.paper().color;
1012 self.ui.round_corners(&outline, paper, |y| {1055 self.ui.round_corners(&outline, paper, |y| {
1013 ui::mix(section.frame[0], section.frame[1], (y - frame[1]) / height)1056 ui::mix(section.frame[0], section.frame[1], (y - frame[1]) / height)
1014 });1057 });
...@@ -1092,7 +1135,7 @@ impl State {...@@ -1092,7 +1135,7 @@ impl State {
1092 "status",1135 "status",
1093 Spec {1136 Spec {
1094 size: [fit(), px(TITLE)],1137 size: [fit(), px(TITLE)],
1095 text: Some(session.status),1138 text: Some(&session.status()),
1096 color: Some(theme.text_dim),1139 color: Some(theme.text_dim),
1097 pad: [12.0, 0.0],1140 pad: [12.0, 0.0],
1098 ..Spec::default()1141 ..Spec::default()
...@@ -1126,7 +1169,7 @@ impl State {...@@ -1126,7 +1169,7 @@ impl State {
1126 let engine = &self.view.engine;1169 let engine = &self.view.engine;
1127 // Under the system's title bar the save status joins the toolbar.1170 // Under the system's title bar the save status joins the toolbar.
1128 let status = platform::system_titlebar(&self.window)1171 let status = platform::system_titlebar(&self.window)
1129 .then(|| self.session.as_ref().map(|session| session.status))1172 .then(|| self.session.as_ref().map(Session::status))
1130 .flatten();1173 .flatten();
1131 let ui = &mut self.ui;1174 let ui = &mut self.ui;
1132 let text = theme.text;1175 let text = theme.text;
...@@ -1446,8 +1489,8 @@ impl State {...@@ -1446,8 +1489,8 @@ impl State {
1446 ui.leaf(1489 ui.leaf(
1447 "status",1490 "status",
1448 Spec {1491 Spec {
1449 size: [fit(), fill()],1492 size: [fit(), px(ui::shell::TOOL)],
1450 text: Some(status),1493 text: Some(&status),
1451 color: Some(theme.text_dim),1494 color: Some(theme.text_dim),
1452 center: true,1495 center: true,
1453 ..Spec::default()1496 ..Spec::default()
...@@ -1579,7 +1622,7 @@ impl State {...@@ -1579,7 +1622,7 @@ impl State {
1579 }1622 }
1580 }1623 }
1581 if ui::shell::tool_button(&mut self.ui, "new", art::PLUS, theme.text, false).clicked {1624 if ui::shell::tool_button(&mut self.ui, "new", art::PLUS, theme.text, false).clicked {
1582 self.commands.push(Command::NewPage { subpage: false });1625 self.commands.push(Command::NewPage { under: None });
1583 }1626 }
1584 let toggle = if self.pages_open {1627 let toggle = if self.pages_open {
1585 art::SIDEBAR_COLLAPSE1628 art::SIDEBAR_COLLAPSE
...@@ -1594,7 +1637,7 @@ impl State {...@@ -1594,7 +1637,7 @@ impl State {
15941637
1595 /// The section's pages as tabs down the frame's right side, returning the open page's1638 /// The section's pages as tabs down the frame's right side, returning the open page's
1596 /// tab, which is the page's colour and joins it.1639 /// tab, which is the page's colour and joins it.
1597 fn page_list(&mut self, theme: &Theme, section: &ui::Section) -> Option<Id> {1640 fn page_list(&mut self, theme: &Theme, section: &ui::Section, tabs: Id) -> Option<Id> {
1598 let session = self.session.as_ref()?;1641 let session = self.session.as_ref()?;
1599 let panel = self.ui.id("panel");1642 let panel = self.ui.id("panel");
1600 let width = self1643 let width = self
...@@ -1616,7 +1659,7 @@ impl State {...@@ -1616,7 +1659,7 @@ impl State {
1616 self.ui.open_popup(menus::id());1659 self.ui.open_popup(menus::id());
1617 }1660 }
1618 let rect = self.ui.rect(panel).unwrap_or_default();1661 let rect = self.ui.rect(panel).unwrap_or_default();
1619 self.drag_pages(rows.held, section, rect);1662 self.drag_pages(rows.held, section, rect, tabs);
1620 self.ui.close();1663 self.ui.close();
1621 rows.open1664 rows.open
1622 }1665 }
...@@ -1681,6 +1724,13 @@ impl State {...@@ -1681,6 +1724,13 @@ impl State {
1681 self.ui.set_focus(Some(filter()));1724 self.ui.set_focus(Some(filter()));
1682 continue;1725 continue;
1683 }1726 }
1727 // OneNote's New Page is Ctrl+N.
1728 let new = matches!(&key, Key::Character(character)
1729 if character.eq_ignore_ascii_case("n"));
1730 if modifiers.command && !modifiers.shift && new && self.session.is_some() {
1731 self.commands.push(Command::NewPage { under: None });
1732 continue;
1733 }
1684 self.view.key(&ui::edit_key(&key), text.as_deref())?1734 self.view.key(&ui::edit_key(&key), text.as_deref())?
1685 }1735 }
1686 ui::Event::Ime(Ime::Preedit(text, cursor)) => self.view.compose(text, cursor)?,1736 ui::Event::Ime(Ime::Preedit(text, cursor)) => self.view.compose(text, cursor)?,
...@@ -1715,13 +1765,13 @@ impl State {...@@ -1715,13 +1765,13 @@ impl State {
1715 self.load(move || {1765 self.load(move || {
1716 let section = library.open(&path, notify)?;1766 let section = library.open(&path, notify)?;
1717 let (session, page) = read_session(section, library, path, last)?;1767 let (session, page) = read_session(section, library, path, last)?;
1718 Ok((Loaded::Section(Box::new(session)), page))1768 Ok(Loaded::Section(Box::new(session), page))
1719 });1769 });
1720 }1770 }
1721 Command::OpenPage(space) => {1771 Command::OpenPage(space) => {
1722 let session = self.session.as_ref().ok_or("No section is open")?;1772 let session = self.session.as_ref().ok_or("No section is open")?;
1723 let read = session.reader(space);1773 let read = session.reader(space);
1724 self.load(move || Ok((Loaded::Page(space), read()?)));1774 self.load(move || Ok(Loaded::Page(space, read()?)));
1725 }1775 }
1726 Command::Versions { page, show } => {1776 Command::Versions { page, show } => {
1727 let session = self.session.as_mut().ok_or("No section is open")?;1777 let session = self.session.as_mut().ok_or("No section is open")?;
...@@ -1769,9 +1819,10 @@ impl State {...@@ -1769,9 +1819,10 @@ impl State {
1769 Command::NewNotebook => self.new_notebook()?,1819 Command::NewNotebook => self.new_notebook()?,
1770 Command::CloseNotebook(library) => self.close_notebook(&library),1820 Command::CloseNotebook(library) => self.close_notebook(&library),
1771 Command::Structure(library, change) => self.restructure(library, change),1821 Command::Structure(library, change) => self.restructure(library, change),
1772 Command::NewPage { subpage } => self.new_page(subpage)?,1822 Command::NewPage { under } => self.new_page(under)?,
1773 Command::DeletePages(pages) => self.delete_pages(pages)?,1823 Command::DeletePages(pages) => self.delete_pages(pages)?,
1774 Command::Pages(edits) => self.edit_pages(edits)?,1824 Command::Pages(edits) => self.edit_pages(edits)?,
1825 Command::MovePage { space, path } => self.move_page(space, path)?,
1775 Command::Template(choice) => self.apply_template(choice)?,1826 Command::Template(choice) => self.apply_template(choice)?,
1776 Command::Page(Request::EditDate(field)) => self.edit_date(field)?,1827 Command::Page(Request::EditDate(field)) => self.edit_date(field)?,
1777 Command::Page(Request::Copy(text)) => self.clipboard.set_text(text)?,1828 Command::Page(Request::Copy(text)) => self.clipboard.set_text(text)?,
...@@ -1790,7 +1841,7 @@ impl State {...@@ -1790,7 +1841,7 @@ impl State {
1790 /// read was asked for meanwhile.1841 /// read was asked for meanwhile.
1791 fn load(1842 fn load(
1792 &mut self,1843 &mut self,
1793 read: impl FnOnce() -> Result<(Loaded, Page), Box<dyn Error>> + Send + 'static,1844 read: impl FnOnce() -> Result<Loaded, Box<dyn Error>> + Send + 'static,
1794 ) {1845 ) {
1795 self.loading += 1;1846 self.loading += 1;
1796 let (id, sender, redraw) = (self.loading, self.loads.0.clone(), self.redraw.clone());1847 let (id, sender, redraw) = (self.loading, self.loads.0.clone(), self.redraw.clone());
...@@ -1803,13 +1854,20 @@ impl State {...@@ -1803,13 +1854,20 @@ impl State {
1803 /// Lays out the newest page read, and shows it once the pictures it shows first are1854 /// Lays out the newest page read, and shows it once the pictures it shows first are
1804 /// drawn, or after `HOLD`.1855 /// drawn, or after `HOLD`.
1805 fn open_loaded(&mut self) -> Result<(), Box<dyn Error>> {1856 fn open_loaded(&mut self) -> Result<(), Box<dyn Error>> {
1806 for (id, loaded) in self.loads.1.try_iter() {1857 let reads: Vec<Read> = self.loads.1.try_iter().collect();
1858 for (id, loaded) in reads {
1807 if id != self.loading {1859 if id != self.loading {
1808 continue;1860 continue;
1809 }1861 }
1810 let (loaded, page) = match loaded {1862 let (loaded, page) = match loaded {
1811 Ok(loaded) => loaded,1863 Ok(Loaded::Section(session, page)) => (Shown::Section(session), page),
1864 Ok(Loaded::Page(space, page)) => (Shown::Page(space), page),
1865 Ok(Loaded::Library(library, path)) => {
1866 self.adopt(library, &path)?;
1867 continue;
1868 }
1812 Err(error) => {1869 Err(error) => {
1870 eprintln!("{error}");
1813 platform::alert("Couldn't open", &error);1871 platform::alert("Couldn't open", &error);
1814 continue;1872 continue;
1815 }1873 }
...@@ -1828,7 +1886,8 @@ impl State {...@@ -1828,7 +1886,8 @@ impl State {
1828 let paper = canvas::gpu::Paper {1886 let paper = canvas::gpu::Paper {
1829 color: self.ui.theme.paper,1887 color: self.ui.theme.paper,
1830 ink: self.ui.theme.paper_ink,1888 ink: self.ui.theme.paper_ink,
1831 };1889 }
1890 .colored(opening.editor.page_color());
1832 if !self1891 if !self
1833 .view1892 .view
1834 .prepare(&mut opening.scene, &opening.editor, paper, &self.redraw)1893 .prepare(&mut opening.scene, &opening.editor, paper, &self.redraw)
...@@ -1845,7 +1904,7 @@ impl State {...@@ -1845,7 +1904,7 @@ impl State {
1845 self.last_pages.insert(key, session.space);1904 self.last_pages.insert(key, session.space);
1846 }1905 }
1847 match opening.loaded {1906 match opening.loaded {
1848 Loaded::Section(session) => {1907 Shown::Section(session) => {
1849 // A notebook read again after a change replaces the one it was.1908 // A notebook read again after a change replaces the one it was.
1850 match self1909 match self
1851 .notebooks1910 .notebooks
...@@ -1859,7 +1918,7 @@ impl State {...@@ -1859,7 +1918,7 @@ impl State {
1859 self.filter.clear();1918 self.filter.clear();
1860 self.save_settings();1919 self.save_settings();
1861 }1920 }
1862 Loaded::Page(space) => {1921 Shown::Page(space) => {
1863 let session = self.session.as_mut().ok_or("No section is open")?;1922 let session = self.session.as_mut().ok_or("No section is open")?;
1864 session.space = space;1923 session.space = space;
1865 session.change = None;1924 session.change = None;
...@@ -1886,6 +1945,46 @@ impl State {...@@ -1886,6 +1945,46 @@ impl State {
1886 Ok(())1945 Ok(())
1887 }1946 }
18881947
1948 /// Takes `library`, a notebook read again after a change, in place of the one it was;
1949 /// the open section, now at catalog `path` in it, stays open, reopened where it moved.
1950 fn adopt(&mut self, library: Arc<Library>, path: &str) -> Result<(), Box<dyn Error>> {
1951 match self
1952 .notebooks
1953 .iter_mut()
1954 .find(|open| open.location == library.location)
1955 {
1956 Some(open) => *open = Arc::clone(&library),
1957 None => self.notebooks.push(Arc::clone(&library)),
1958 }
1959 let Some(session) = &mut self.session else {
1960 return Ok(());
1961 };
1962 let folder = path.rsplit_once('/').map_or("", |(folder, _)| folder);
1963 let space = session.space;
1964 if session.tabs[session.tab].path == path {
1965 session.tabs = library.tabs(folder);
1966 session.tab = session
1967 .tabs
1968 .iter()
1969 .position(|tab| tab.path == path)
1970 .ok_or("The section is not in its notebook")?;
1971 session.library = library;
1972 } else {
1973 // Its file moved: the replica it holds reopens on the file's new path.
1974 self.persist()?;
1975 if let Some(session) = self.session.take() {
1976 session.section.close()?;
1977 }
1978 let section = library.open(path, notify(self.proxy.clone()))?;
1979 let (session, page) = read_session(section, library, path.to_owned(), Some(space))?;
1980 self.session = Some(session);
1981 let response = self.view.refresh(page)?;
1982 self.respond(response);
1983 }
1984 self.save_settings();
1985 Ok(())
1986 }
1987
1889 /// Follows a page shown in place of another.1988 /// Follows a page shown in place of another.
1890 fn opened(&mut self) -> Result<(), Box<dyn Error>> {1989 fn opened(&mut self) -> Result<(), Box<dyn Error>> {
1891 self.ui.set_focus(Some(page()));1990 self.ui.set_focus(Some(page()));
...@@ -2064,6 +2163,10 @@ impl State {...@@ -2064,6 +2163,10 @@ impl State {
2064 },2163 },
2065 )?;2164 )?;
2066 session.status = "Saving";2165 session.status = "Saving";
2166 // The page list shows the title as it is typed.
2167 if self.view.editor.active_outline().title {
2168 session.pages = session.section.pages()?;
2169 }
2067 Ok(())2170 Ok(())
2068 }2171 }
20692172
...@@ -2316,13 +2419,19 @@ impl State {...@@ -2316,13 +2419,19 @@ impl State {
2316 Ok(())2419 Ok(())
2317 }2420 }
23182421
2422 /// The paper the open page lies on: the theme's, in the page's colour.
2423 fn paper(&self) -> canvas::gpu::Paper {
2424 canvas::gpu::Paper {
2425 color: self.ui.theme.paper,
2426 ink: self.ui.theme.paper_ink,
2427 }
2428 .colored(self.view.editor.page_color())
2429 }
2430
2319 /// Paints the interface with the page in its box.2431 /// Paints the interface with the page in its box.
2320 fn paint(&mut self, target: &wgpu::TextureView) -> Result<(), Box<dyn Error>> {2432 fn paint(&mut self, target: &wgpu::TextureView) -> Result<(), Box<dyn Error>> {
2321 let start = Instant::now();2433 let start = Instant::now();
2322 let paper = canvas::gpu::Paper {2434 let paper = self.paper();
2323 color: self.ui.theme.paper,
2324 ink: self.ui.theme.paper_ink,
2325 };
2326 self.view.update_pictures(paper, &self.redraw);2435 self.view.update_pictures(paper, &self.redraw);
2327 let theme = &self.ui.theme;2436 let theme = &self.ui.theme;
2328 let page_primitives = self.view.primitives(TextColors {2437 let page_primitives = self.view.primitives(TextColors {
...@@ -2356,7 +2465,7 @@ impl State {...@@ -2356,7 +2465,7 @@ impl State {
2356 viewport.origin[1] + corner[1] * scale,2465 viewport.origin[1] + corner[1] * scale,
2357 ],2466 ],
2358 clip: Some(rect.map(|value| value * scale)),2467 clip: Some(rect.map(|value| value * scale)),
2359 backdrop: Some(self.ui.theme.paper),2468 backdrop: Some(paper.color),
2360 primitives: &page_primitives,2469 primitives: &page_primitives,
2361 },2470 },
2362 })2471 })
...@@ -2790,41 +2899,6 @@ fn page_tab(...@@ -2790,41 +2899,6 @@ fn page_tab(
2790 ui.signal(row)2899 ui.signal(row)
2791}2900}
27922901
2793/// `page` with its conflict objects marked as OneNote shows a conflict page's: a band
2794/// across each conflicting paragraph's outline.
2795fn highlighted(mut page: Page, objects: &[ExGuid]) -> Page {
2796 use onestore::page::{PageObject, PageParagraph, ParagraphContent};
2797 fn mark(paragraphs: &mut [PageParagraph], objects: &[ExGuid]) {
2798 for paragraph in paragraphs {
2799 match &mut paragraph.content {
2800 ParagraphContent::Table(table) => {
2801 for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) {
2802 mark(&mut cell.paragraphs, objects);
2803 }
2804 }
2805 ParagraphContent::Text(text)
2806 if objects.contains(&paragraph.id) || objects.contains(&text.id) =>
2807 {
2808 paragraph.format.highlight = Some(CONFLICTING);
2809 }
2810 _ => {}
2811 }
2812 }
2813 }
2814 for object in &mut page.objects {
2815 match object {
2816 PageObject::Outline(outline) => mark(&mut outline.paragraphs, objects),
2817 PageObject::Title(title) => {
2818 for outline in &mut title.outlines {
2819 mark(&mut outline.paragraphs, objects);
2820 }
2821 }
2822 _ => {}
2823 }
2824 }
2825 page
2826}
2827
2828/// The pages whose titles contain `filter`, ignoring case.2902/// The pages whose titles contain `filter`, ignoring case.
2829fn matching<'a>(2903fn matching<'a>(
2830 pages: &'a [(ExGuid, String, u32)],2904 pages: &'a [(ExGuid, String, u32)],
...@@ -3025,6 +3099,10 @@ impl ApplicationHandler<UserEvent> for App {...@@ -3025,6 +3099,10 @@ impl ApplicationHandler<UserEvent> for App {
3025 state.window.set_theme(Some(appearance));3099 state.window.set_theme(Some(appearance));
3026 state.ui.theme = theme(appearance);3100 state.ui.theme = theme(appearance);
3027 }3101 }
3102 Replay::Quit => {
3103 self.close(event_loop);
3104 return;
3105 }
3028 }3106 }
3029 // A covered window gets no redraws, so each step draws its own frame.3107 // A covered window gets no redraws, so each step draws its own frame.
3030 if let Err(error) = state.frame() {3108 if let Err(error) = state.frame() {
...@@ -3072,15 +3150,15 @@ impl ApplicationHandler<UserEvent> for App {...@@ -3072,15 +3150,15 @@ impl ApplicationHandler<UserEvent> for App {
3072 &self.substitutes,3150 &self.substitutes,
3073 std::mem::take(&mut self.interface_font),3151 std::mem::take(&mut self.interface_font),
3074 self.screenshot.is_none(),3152 self.screenshot.is_none(),
3075 self.settings.take().unwrap_or_default(),3153 self.launch.take().expect("The window opens once"),
3076 self.cache.clone(),
3077 )) {3154 )) {
3078 Ok(mut state) => match &self.screenshot {3155 Ok(mut state) => match &self.screenshot {
3079 Some(prefix) => {3156 // A replay drives the hidden window instead, its snapshots capturing it.
3157 Some(prefix) if std::env::var_os("SNOWBOUND_REPLAY").is_none() => {
3080 self.startup_error = state.screenshot(prefix).err();3158 self.startup_error = state.screenshot(prefix).err();
3081 event_loop.exit();3159 event_loop.exit();
3082 }3160 }
3083 None => self.state = Some(state),3161 _ => self.state = Some(state),
3084 },3162 },
3085 Err(error) => {3163 Err(error) => {
3086 self.startup_error = Some(error);3164 self.startup_error = Some(error);
...@@ -3217,9 +3295,9 @@ impl ApplicationHandler<UserEvent> for App {...@@ -3217,9 +3295,9 @@ impl ApplicationHandler<UserEvent> for App {
3217}3295}
32183296
3219/// Feeds a development script to the window from another thread, one command per line3297/// Feeds a development script to the window from another thread, one command per line
3220/// in logical pixels: `move X Y`, `press`, `release`, `wheel DX DY`, `key NAME`, `type3298/// in logical pixels: `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `key NAME`, `type
3221/// TEXT`, `modifiers [shift] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH` and3299/// TEXT`, `modifiers [shift] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`,
3222/// `appearance light|dark`.3300/// `appearance light|dark` and `quit`.
3223fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dyn Error>> {3301fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dyn Error>> {
3224 let mut steps = Vec::new();3302 let mut steps = Vec::new();
3225 for line in script.lines().filter(|line| !line.trim().is_empty()) {3303 for line in script.lines().filter(|line| !line.trim().is_empty()) {
...@@ -3228,7 +3306,11 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy...@@ -3228,7 +3306,11 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy
3228 rest.split_whitespace().map(str::parse).collect()3306 rest.split_whitespace().map(str::parse).collect()
3229 };3307 };
3230 let button = |pressed| ui::Event::Button {3308 let button = |pressed| ui::Event::Button {
3231 button: MouseButton::Left,3309 button: if rest == "right" {
3310 MouseButton::Right
3311 } else {
3312 MouseButton::Left
3313 },
3232 pressed,3314 pressed,
3233 at: Instant::now(),3315 at: Instant::now(),
3234 };3316 };
...@@ -3269,6 +3351,7 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy...@@ -3269,6 +3351,7 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy
3269 .try_fold(ModifiersState::empty(), |all, one| one.map(|one| all | one))?,3351 .try_fold(ModifiersState::empty(), |all, one| one.map(|one| all | one))?,
3270 ))),3352 ))),
3271 "wait" => Err(std::time::Duration::from_millis(rest.parse()?)),3353 "wait" => Err(std::time::Duration::from_millis(rest.parse()?)),
3354 "quit" => Ok(Replay::Quit),
3272 "snapshot" => Ok(Replay::Snapshot(rest.into())),3355 "snapshot" => Ok(Replay::Snapshot(rest.into())),
3273 "appearance" => Ok(Replay::Appearance(match rest {3356 "appearance" => Ok(Replay::Appearance(match rest {
3274 "light" => winit::window::Theme::Light,3357 "light" => winit::window::Theme::Light,
...@@ -3467,8 +3550,11 @@ fn main() -> Result<(), Box<dyn Error>> {...@@ -3467,8 +3550,11 @@ fn main() -> Result<(), Box<dyn Error>> {
3467 proxy: event_loop.create_proxy(),3550 proxy: event_loop.create_proxy(),
3468 input: Some(input),3551 input: Some(input),
3469 // A screenshot leaves the settings as it found them.3552 // A screenshot leaves the settings as it found them.
3470 settings: Some((settings_file.filter(|_| screenshot.is_none()), saved)),3553 launch: Some(settings::Launch {
3471 cache,3554 file: settings_file.filter(|_| screenshot.is_none()),
3555 saved,
3556 cache,
3557 }),
3472 substitutes,3558 substitutes,
3473 interface_font,3559 interface_font,
3474 screenshot,3560 screenshot,
crates/snowbound/src/manage.rs+489-90
...@@ -12,19 +12,35 @@ use std::{error::Error, sync::Arc};...@@ -12,19 +12,35 @@ use std::{error::Error, sync::Arc};
1212
13/// A change to a notebook's sections and groups, by catalog path.13/// A change to a notebook's sections and groups, by catalog path.
14pub enum Structure {14pub enum Structure {
15 NewSection { folder: String },15 NewSection {
16 NewGroup { folder: String },16 folder: String,
17 Rename { path: String, name: String },17 },
18 NewGroup {
19 folder: String,
20 },
21 Rename {
22 path: String,
23 name: String,
24 },
18 /// Moves a section or group to the recycle bin.25 /// Moves a section or group to the recycle bin.
19 Delete { path: String },26 Delete {
27 path: String,
28 },
20 /// Moves a section or group into another folder.29 /// Moves a section or group into another folder.
21 Move { path: String, folder: String },30 Move {
31 path: String,
32 folder: String,
33 },
22 /// Orders a folder's sections and groups.34 /// Orders a folder's sections and groups.
23 Reorder { folder: String, paths: Vec<String> },35 Reorder {
36 folder: String,
37 paths: Vec<String>,
38 },
24}39}
2540
26/// Notebook colours for new notebooks, in turn: OneNote 2010's section colours.41/// The colour OneNote 2010 gave each new notebook beside its default one, COLORREF
27const NOTEBOOK_COLORS: [u32; 3] = [0x00e4a88a, 0x0078b0f6, 0x00bba4d5];42/// (`corpus/notebook-management/native/new-notebook`).
43const NOTEBOOK_COLOR: u32 = 0x00aeba91;
2844
29/// `current`, the path of a section, after `from` moved to `to`: a section moved itself,45/// `current`, the path of a section, after `from` moved to `to`: a section moved itself,
30/// or one inside a moved group.46/// or one inside a moved group.
...@@ -56,18 +72,18 @@ impl State {...@@ -56,18 +72,18 @@ impl State {
56 Ok(creation.dated(&date, &time)?)72 Ok(creation.dated(&date, &time)?)
57 }73 }
5874
59 /// Adds a page at the end of the open section, or a subpage after the open page, and75 /// Adds a page at the end of the open section, or a subpage of page `under`, and opens
60 /// opens it with its title ready for typing, as OneNote's New Page and New Subpage do.76 /// it with its title ready for typing, as OneNote's New Page and New Subpage do.
61 pub(crate) fn new_page(&mut self, subpage: bool) -> Result<(), Box<dyn Error>> {77 pub(crate) fn new_page(&mut self, under: Option<ExGuid>) -> Result<(), Box<dyn Error>> {
62 self.persist()?;78 self.persist()?;
63 let session = self.session.as_ref().ok_or("No section is open")?;79 let session = self.session.as_ref().ok_or("No section is open")?;
64 // A subpage follows the open page and the subpages already under it.80 // A subpage follows its page and the subpages already under it.
65 let (before, level) = if subpage {81 let (before, level) = if let Some(under) = under {
66 let at = session82 let at = session
67 .pages83 .pages
68 .iter()84 .iter()
69 .position(|(space, ..)| *space == session.space)85 .position(|(space, ..)| *space == under)
70 .ok_or("The open page is not listed")?;86 .ok_or("That page is not listed")?;
71 let level = session.pages[at].2;87 let level = session.pages[at].2;
72 let next = session.pages[at + 1..]88 let next = session.pages[at + 1..]
73 .iter()89 .iter()
...@@ -80,7 +96,7 @@ impl State {...@@ -80,7 +96,7 @@ impl State {
80 let creation = self.dated_page(None)?;96 let creation = self.dated_page(None)?;
81 let space = creation.space();97 let space = creation.space();
82 let mut ops = vec![Op::Section(SectionOp::Create(creation))];98 let mut ops = vec![Op::Section(SectionOp::Create(creation))];
83 if subpage {99 if under.is_some() {
84 ops.push(Op::Section(SectionOp::Pages(vec![PageEdit::move_to(100 ops.push(Op::Section(SectionOp::Pages(vec![PageEdit::move_to(
85 space, before, level,101 space, before, level,
86 )?])));102 )?])));
...@@ -105,59 +121,12 @@ impl State {...@@ -105,59 +121,12 @@ impl State {
105 &mut self,121 &mut self,
106 choice: crate::templates::Choice,122 choice: crate::templates::Choice,
107 ) -> Result<(), Box<dyn Error>> {123 ) -> Result<(), Box<dyn Error>> {
108 use crate::templates::Choice;
109 self.persist()?;124 self.persist()?;
110 let session = self.session.as_ref().ok_or("No section is open")?;125 let session = self.session.as_ref().ok_or("No section is open")?;
111 let space = session.space;126 let space = session.space;
112 let page = session.section.page(space)?;127 let ops = template_ops(&session.section.page(space)?, choice)?;
113 let mut ops: Vec<PageOp> = page128 if ops.is_empty() {
114 .objects129 return Ok(());
115 .iter()
116 .filter(|object| matches!(object, PageObject::Image(image) if image.background))
117 .map(|object| PageOp::Delete {
118 object: object.id(),
119 })
120 .collect();
121 match choice {
122 Choice::Template(name) => {
123 let template =
124 canvas::template::find(name).ok_or("That template is not available")?;
125 if page.color.is_some() {
126 ops.push(PageOp::Color(None));
127 }
128 // Art lies under everything else on the page, first in its order.
129 let under = page
130 .objects
131 .iter()
132 .find(|object| !matches!(object, PageObject::Image(image) if image.background))
133 .map(PageObject::id);
134 for art in template.art {
135 let bytes = canvas::gpu::page::template_picture(art.art)
136 .ok_or("That template's art is missing")?;
137 ops.push(PageOp::Add {
138 object: PageObject::Image(Image {
139 id: onestore::page::text::new_id()?,
140 layout: onestore::document::Layout {
141 x: Some(art.position[0]),
142 y: Some(art.position[1]),
143 max_width: art.size.map(|size| size[0]),
144 max_height: art.size.map(|size| size[1]),
145 width_set_by_user: art.size.map(|_| true),
146 ..Default::default()
147 },
148 size: art.size,
149 bytes: Some(bytes.into()),
150 alt: None,
151 background: true,
152 }),
153 before: under,
154 });
155 }
156 }
157 Choice::Color(index) => {
158 ops.push(PageOp::Color(Some(canvas::template::PAGE_COLORS[index].1)))
159 }
160 Choice::More | Choice::Dismiss | Choice::Colors => return Ok(()),
161 }130 }
162 session.section.apply(131 session.section.apply(
163 &self.author,132 &self.author,
...@@ -175,18 +144,17 @@ impl State {...@@ -175,18 +144,17 @@ impl State {
175 return Ok(());144 return Ok(());
176 };145 };
177 let page = self.dated_page(None)?;146 let page = self.dated_page(None)?;
178 let color = NOTEBOOK_COLORS[self.notebooks.len() % NOTEBOOK_COLORS.len()];
179 let (cache, notify) = (self.cache.clone(), notify(self.proxy.clone()));147 let (cache, notify) = (self.cache.clone(), notify(self.proxy.clone()));
180 self.load(move || {148 self.load(move || {
181 let location = std::path::absolute(&root)?.to_string_lossy().into_owned();149 let location = std::path::absolute(&root)?.to_string_lossy().into_owned();
182 let notebook = Notebook::create(&location, &cache, color, &page)?;150 let notebook = Notebook::create(&location, &cache, NOTEBOOK_COLOR, &page)?;
183 let library = Arc::new(Library::open_notebook(&location, notebook, &cache));151 let library = Arc::new(Library::created(&location, notebook, &cache));
184 let path = library152 let path = library
185 .first_section()153 .first_section()
186 .ok_or("The new notebook has no section")?;154 .ok_or("The new notebook has no section")?;
187 let section = library.open(&path, notify)?;155 let section = library.open(&path, notify)?;
188 let (session, page) = read_session(section, library, path, None)?;156 let (session, page) = read_session(section, library, path, None)?;
189 Ok((Loaded::Section(Box::new(session)), page))157 Ok(Loaded::Section(Box::new(session), page))
190 });158 });
191 Ok(())159 Ok(())
192 }160 }
...@@ -220,7 +188,9 @@ impl State {...@@ -220,7 +188,9 @@ impl State {
220 let page = match change {188 let page = match change {
221 Structure::NewSection { .. } => match self.dated_page(None) {189 Structure::NewSection { .. } => match self.dated_page(None) {
222 Ok(page) => Some(page),190 Ok(page) => Some(page),
223 Err(error) => return platform::alert("Couldn't add the section", &error.to_string()),191 Err(error) => {
192 return platform::alert("Couldn't add the section", &error.to_string());
193 }
224 },194 },
225 _ => None,195 _ => None,
226 };196 };
...@@ -229,9 +199,9 @@ impl State {...@@ -229,9 +199,9 @@ impl State {
229 .as_ref()199 .as_ref()
230 .filter(|session| Arc::ptr_eq(&session.library, &library))200 .filter(|session| Arc::ptr_eq(&session.library, &library))
231 .map(|session| session.tabs[session.tab].path.clone());201 .map(|session| session.tabs[session.tab].path.clone());
232 let (cache, notify) = (self.cache.clone(), notify(self.proxy.clone()));202 let notify = notify(self.proxy.clone());
233 self.load(move || {203 self.load(move || {
234 let mut notebook = Notebook::open(&library.location, &cache)?;204 let mut notebook = library.reopen()?;
235 let names = |notebook: &Notebook, folder: &str| -> Vec<String> {205 let names = |notebook: &Notebook, folder: &str| -> Vec<String> {
236 let mut folders = vec![notebook.catalog()];206 let mut folders = vec![notebook.catalog()];
237 while let Some(candidate) = folders.pop() {207 while let Some(candidate) = folders.pop() {
...@@ -251,43 +221,56 @@ impl State {...@@ -251,43 +221,56 @@ impl State {
251 }221 }
252 Vec::new()222 Vec::new()
253 };223 };
254 let open = match change {224 // The open section where the change leaves it, and the section to show.
225 let (followed, created) = match change {
255 Structure::NewSection { folder } => {226 Structure::NewSection { folder } => {
256 let name = unused(&names(&notebook, &folder), "New Section", true);227 let name = unused(&names(&notebook, &folder), "New Section", true);
257 let page = page.ok_or("The new section has no page")?;228 let page = page.ok_or("The new section has no page")?;
258 Some(notebook.create_section(&folder, &name, &page)?)229 (
230 current,
231 Some(notebook.create_section(&folder, &name, &page)?),
232 )
259 }233 }
260 Structure::NewGroup { folder } => {234 Structure::NewGroup { folder } => {
261 let name = unused(&names(&notebook, &folder), "New Section Group", false);235 let name = unused(&names(&notebook, &folder), "New Section Group", false);
262 notebook.create_group(&folder, &name)?;236 notebook.create_group(&folder, &name)?;
263 current237 (current, None)
264 }238 }
265 Structure::Rename { path, name } => {239 Structure::Rename { path, name } => {
266 let renamed = notebook.rename(&path, &name)?;240 let renamed = notebook.rename(&path, &name)?;
267 current.map(|current| follow(&current, &path, &renamed))241 (
242 current.map(|current| follow(&current, &path, &renamed)),
243 None,
244 )
268 }245 }
269 Structure::Delete { path } => {246 Structure::Delete { path } => {
270 notebook.delete(&path)?;247 notebook.delete(&path)?;
271 current248 (current, None)
272 }249 }
273 Structure::Move { path, folder } => {250 Structure::Move { path, folder } => {
274 let moved = notebook.move_entry(&path, &folder)?;251 let moved = notebook.move_entry(&path, &folder)?;
275 current.map(|current| follow(&current, &path, &moved))252 (current.map(|current| follow(&current, &path, &moved)), None)
276 }253 }
277 Structure::Reorder { folder, paths } => {254 Structure::Reorder { folder, paths } => {
278 let paths: Vec<&str> = paths.iter().map(String::as_str).collect();255 let paths: Vec<&str> = paths.iter().map(String::as_str).collect();
279 notebook.reorder(&folder, &paths)?;256 notebook.reorder(&folder, &paths)?;
280 current257 (current, None)
281 }258 }
282 };259 };
283 let library = Arc::new(Library::open_notebook(&library.location, notebook, &cache));260 let open = created.or(followed.clone());
261 let library = Arc::new(library.with(notebook));
284 let path = open262 let path = open
285 .filter(|path| library.contains(path))263 .filter(|path| library.contains(path))
286 .or_else(|| library.first_section())264 .or_else(|| library.first_section())
287 .ok_or("The notebook has no sections left")?;265 .ok_or("The notebook has no sections left")?;
266 // The open section, perhaps renamed or moved, stays open: its replica is this
267 // thread's to reopen only where no section holds it.
268 if followed.as_deref() == Some(path.as_str()) {
269 return Ok(Loaded::Library(library, path));
270 }
288 let section = library.open(&path, notify)?;271 let section = library.open(&path, notify)?;
289 let (session, page) = read_session(section, library, path, None)?;272 let (session, page) = read_session(section, library, path, None)?;
290 Ok((Loaded::Section(Box::new(session)), page))273 Ok(Loaded::Section(Box::new(session), page))
291 });274 });
292 }275 }
293276
...@@ -327,16 +310,66 @@ impl State {...@@ -327,16 +310,66 @@ impl State {
327 space310 space
328 }311 }
329 };312 };
330 let notebook = match &session.library.notebook {313 let notebook =
331 Ok(Some(_)) => Some((session.library.location.clone(), self.cache.clone())),314 matches!(session.library.notebook, Ok(Some(_))).then(|| Arc::clone(&session.library));
332 _ => None,
333 };
334 let replica = Arc::clone(session.section.replica());315 let replica = Arc::clone(session.section.replica());
335 let author = self.author.clone();316 let author = self.author.clone();
336 self.load(move || {317 self.load(move || {
337 if let Some((location, cache)) = notebook {318 if let Some(library) = notebook {
338 Notebook::open(&location, &cache)?.recycle_pages(&pages, &author)?;319 library.reopen()?.recycle_pages(&pages, &author)?;
320 }
321 replica.apply(
322 &author,
323 Edit {
324 at: crate::filetime(),
325 ops,
326 },
327 )?;
328 Ok(Loaded::Page(next, replica.page(next)?))
329 });
330 Ok(())
331 }
332
333 /// Moves page `space` of the open section to the end of the section at catalog `path`,
334 /// as OneNote moves a page dropped on a section's tab: the page keeps its identity,
335 /// title, date and content there and leaves this section.
336 pub(crate) fn move_page(&mut self, space: ExGuid, path: String) -> Result<(), Box<dyn Error>> {
337 self.persist()?;
338 let session = self.session.as_ref().ok_or("No section is open")?;
339 let import = notebook::session::moved(&session.section.page(space)?, &self.author)?;
340 let at = session
341 .pages
342 .iter()
343 .position(|(listed, ..)| *listed == space)
344 .unwrap_or_default();
345 let next = session.pages[at + 1..]
346 .iter()
347 .chain(session.pages[..at].iter().rev())
348 .map(|(listed, ..)| *listed)
349 .next();
350 let mut ops = vec![Op::Section(SectionOp::Delete(vec![space]))];
351 let next = match next {
352 Some(next) => next,
353 None => {
354 let fresh = self.dated_page(None)?;
355 let space = fresh.space();
356 ops.push(Op::Section(SectionOp::Create(fresh)));
357 space
339 }358 }
359 };
360 let library = Arc::clone(&session.library);
361 let replica = Arc::clone(session.section.replica());
362 let author = self.author.clone();
363 self.load(move || {
364 let target = library.open(&path, || {})?;
365 target.replica().apply(
366 &author,
367 Edit {
368 at: crate::filetime(),
369 ops: vec![import],
370 },
371 )?;
372 target.close()?;
340 replica.apply(373 replica.apply(
341 &author,374 &author,
342 Edit {375 Edit {
...@@ -344,7 +377,7 @@ impl State {...@@ -344,7 +377,7 @@ impl State {
344 ops,377 ops,
345 },378 },
346 )?;379 )?;
347 Ok((Loaded::Page(next), replica.page(next)?))380 Ok(Loaded::Page(next, replica.page(next)?))
348 });381 });
349 Ok(())382 Ok(())
350 }383 }
...@@ -366,9 +399,371 @@ impl State {...@@ -366,9 +399,371 @@ impl State {
366 }399 }
367}400}
368401
402/// The ops giving `page` `choice`'s background: its template art (OneNote's pictures'
403/// places, our recreations' bytes) or a page colour, in place of the background it had.
404pub fn template_ops(
405 page: &Page,
406 choice: crate::templates::Choice,
407) -> Result<Vec<PageOp>, Box<dyn Error>> {
408 use crate::templates::Choice;
409 let mut ops: Vec<PageOp> = page
410 .objects
411 .iter()
412 .filter(|object| matches!(object, PageObject::Image(image) if image.background))
413 .map(|object| PageOp::Delete {
414 object: object.id(),
415 })
416 .collect();
417 match choice {
418 Choice::Template(name) => {
419 let template = canvas::template::find(name).ok_or("That template is not available")?;
420 if page.color.is_some() {
421 ops.push(PageOp::Color(None));
422 }
423 // Art lies under everything else on the page, first of its children (the title
424 // is no child).
425 let under = page
426 .objects
427 .iter()
428 .find(|object| match object {
429 PageObject::Title(_) => false,
430 PageObject::Image(image) => !image.background,
431 _ => true,
432 })
433 .map(PageObject::id);
434 for art in template.art {
435 let bytes = canvas::gpu::page::template_picture(art.art)
436 .ok_or("That template's art is missing")?;
437 ops.push(PageOp::Add {
438 object: PageObject::Image(Image {
439 id: onestore::page::text::new_id()?,
440 layout: onestore::document::Layout {
441 x: Some(art.position[0]),
442 y: Some(art.position[1]),
443 max_width: art.size.map(|size| size[0]),
444 max_height: art.size.map(|size| size[1]),
445 width_set_by_user: art.size.map(|_| true),
446 ..Default::default()
447 },
448 size: art.size,
449 bytes: Some(bytes.into()),
450 alt: None,
451 background: true,
452 }),
453 before: under,
454 });
455 }
456 // The one template with content worth keeping.
457 if name == "Informal Meeting Notes" {
458 ops.extend(onestore::op::lower_page(
459 page,
460 &crate::meeting::content(page)?,
461 )?);
462 }
463 }
464 Choice::Color(index) => {
465 ops.push(PageOp::Color(Some(canvas::template::PAGE_COLORS[index].1)))
466 }
467 Choice::More | Choice::Dismiss | Choice::Colors => return Ok(Vec::new()),
468 }
469 Ok(ops)
470}
471
369#[cfg(test)]472#[cfg(test)]
370mod tests {473mod tests {
371 use super::*;474 use super::*;
475 use crate::templates::Choice;
476 use std::path::Path;
477
478 const AUTHOR: &str = "Rust Author";
479
480 fn dated() -> PageCreation {
481 let creation = PageCreation::new(None, Some(""), AUTHOR).unwrap();
482 let [date, time] = platform::date_text(creation.created());
483 creation.dated(&date, &time).unwrap()
484 }
485
486 /// Applies `ops` to the section file at `file` as one edit, as the section thread does,
487 /// and publishes it.
488 fn edit(file: &Path, ops: Vec<Op>) {
489 let arena = onestore::Arena::default();
490 let mut section =
491 onestore::Section::open(&arena, onestore::read_file(file).unwrap()).unwrap();
492 section
493 .apply(
494 AUTHOR,
495 &Edit {
496 at: crate::filetime(),
497 ops,
498 },
499 )
500 .unwrap();
501 if let Some(transaction) = section.seal().unwrap() {
502 transaction.commit_file(file).unwrap();
503 }
504 }
505
506 fn pages(file: &Path) -> Vec<(ExGuid, String, u32)> {
507 let arena = onestore::Arena::default();
508 onestore::Section::open(&arena, onestore::read_file(file).unwrap())
509 .unwrap()
510 .pages()
511 .unwrap()
512 }
513
514 fn page(file: &Path, space: ExGuid) -> Page {
515 let arena = onestore::Arena::default();
516 onestore::Section::open(&arena, onestore::read_file(file).unwrap())
517 .unwrap()
518 .page(space)
519 .unwrap()
520 }
521
522 fn copy(from: &Path, to: &Path) {
523 std::fs::create_dir_all(to).unwrap();
524 for entry in std::fs::read_dir(from).unwrap().flatten() {
525 let target = to.join(entry.file_name());
526 if entry.file_type().unwrap().is_dir() {
527 copy(&entry.path(), &target);
528 } else {
529 std::fs::copy(entry.path(), target).unwrap();
530 }
531 }
532 }
533
534 /// A notebook made and changed the way the app makes and changes one: created with a
535 /// dated first page; sections and groups added, renamed, moved, reordered and deleted to
536 /// the recycle bin; pages titled, given a template's art or a colour, made subpages and
537 /// deleted to the recycle bin. `SNOWBOUND_MANAGEMENT_EXPORT` names a new directory that
538 /// receives it for a cold reopen in OneNote 2010.
539 #[test]
540 fn a_notebook_is_managed_as_onenote_manages_one() {
541 let temporary =
542 std::env::temp_dir().join(format!("snowbound-manage-{}", std::process::id()));
543 let _ = std::fs::remove_dir_all(&temporary);
544 std::fs::create_dir_all(&temporary).unwrap();
545 let root = temporary.join("Managed");
546 let cache = temporary.join("cache");
547 let mut notebook = Notebook::create(&root, &cache, NOTEBOOK_COLOR, &dated()).unwrap();
548 for (folder, name) in [("", "New Section 2"), ("", "Binned")] {
549 notebook.create_section(folder, name, &dated()).unwrap();
550 }
551 for (group, section) in [("Kept", "Inner"), ("Doomed", "Gone")] {
552 notebook.create_group("", group).unwrap();
553 notebook.create_section(group, section, &dated()).unwrap();
554 }
555
556 // Pages of the first section, each titled.
557 let file = root.join("New Section 1.one");
558 let first = pages(&file)[0].0;
559 let title = |space: ExGuid, text: &str| {
560 let page = page(&file, space);
561 let title = page
562 .objects
563 .iter()
564 .find_map(|object| match object {
565 PageObject::Title(title) => title.outlines[0].paragraphs[0].text(),
566 _ => None,
567 })
568 .unwrap()
569 .id;
570 Op::Page {
571 space,
572 op: PageOp::Text {
573 text: title,
574 range: 0..0,
575 with: text.to_owned(),
576 },
577 }
578 };
579 edit(&file, vec![title(first, "Garden plan")]);
580 let mut spaces = Vec::new();
581 for text in [
582 "Ivy page",
583 "Teal page",
584 "Subpage",
585 "Deleted page",
586 "Meeting page",
587 ] {
588 let creation = dated();
589 let space = creation.space();
590 edit(&file, vec![Op::Section(SectionOp::Create(creation))]);
591 edit(&file, vec![title(space, text)]);
592 spaces.push(space);
593 }
594 let [ivy, teal, subpage, deleted, meeting] = spaces[..] else {
595 unreachable!()
596 };
597 let background = |space: ExGuid, choice: Choice| {
598 template_ops(&page(&file, space), choice)
599 .unwrap()
600 .into_iter()
601 .map(|op| Op::Page { space, op })
602 .collect::<Vec<_>>()
603 };
604 edit(&file, background(ivy, Choice::Template("Ivy")));
605 edit(
606 &file,
607 background(meeting, Choice::Template("Informal Meeting Notes")),
608 );
609 let teal_index = canvas::template::PAGE_COLORS
610 .iter()
611 .position(|(name, _)| *name == "Teal")
612 .unwrap();
613 edit(&file, background(teal, Choice::Color(teal_index)));
614 edit(
615 &file,
616 vec![Op::Section(SectionOp::Pages(vec![
617 PageEdit::set_level(subpage, 2).unwrap(),
618 ]))],
619 );
620 let recycled = page(&file, deleted);
621 notebook
622 .recycle_pages(std::slice::from_ref(&recycled), AUTHOR)
623 .unwrap();
624 edit(&file, vec![Op::Section(SectionOp::Delete(vec![deleted]))]);
625
626 // A page moved to another section, as a drop on its tab moves it.
627 let moved_space = {
628 let creation = dated();
629 let space = creation.space();
630 edit(&file, vec![Op::Section(SectionOp::Create(creation))]);
631 edit(&file, vec![title(space, "Moved page")]);
632 space
633 };
634 let moving = page(&file, moved_space);
635 let other = root.join("New Section 2.one");
636 edit(
637 &other,
638 vec![notebook::session::moved(&moving, AUTHOR).unwrap()],
639 );
640 edit(
641 &file,
642 vec![Op::Section(SectionOp::Delete(vec![moved_space]))],
643 );
644 let arrived = pages(&other).last().unwrap().0;
645 let arrived = page(&other, arrived);
646 assert_eq!(
647 (
648 arrived.title.as_str(),
649 arrived.identity,
650 arrived.date_text()
651 ),
652 ("Moved page", moving.identity, moving.date_text())
653 );
654
655 // Structure.
656 assert_eq!(
657 notebook.rename("New Section 2.one", "Kitchen").unwrap(),
658 "Kitchen.one"
659 );
660 assert_eq!(
661 notebook.move_entry("Kitchen.one", "Kept").unwrap(),
662 "Kept/Kitchen.one"
663 );
664 assert_eq!(notebook.rename("Kept", "Archive").unwrap(), "Archive");
665 notebook.delete("Doomed").unwrap();
666 notebook.delete("Binned.one").unwrap();
667 notebook
668 .reorder("", &["Archive", "New Section 1.one"])
669 .unwrap();
670
671 let catalog = notebook.catalog();
672 let names = |folder: &notebook::discover::Folder| {
673 folder
674 .sections
675 .iter()
676 .map(|section| section.path.clone())
677 .chain(folder.groups.iter().map(|group| group.path.clone()))
678 .collect::<Vec<_>>()
679 };
680 assert_eq!(
681 names(catalog),
682 ["New Section 1.one", "Archive", "OneNote_RecycleBin"]
683 );
684 assert_eq!(
685 names(&catalog.groups[0]),
686 ["Archive/Inner.one", "Archive/Kitchen.one"]
687 );
688 let mut binned = names(&catalog.groups[1]);
689 binned.sort();
690 assert_eq!(
691 binned,
692 [
693 "OneNote_RecycleBin/Binned.one",
694 "OneNote_RecycleBin/Gone.one",
695 "OneNote_RecycleBin/OneNote_DeletedPages.one",
696 ]
697 );
698 assert!(!root.join("Doomed").exists());
699
700 let listed = pages(&file);
701 assert_eq!(
702 listed
703 .iter()
704 .map(|(_, title, level)| (title.as_str(), *level))
705 .collect::<Vec<_>>(),
706 [
707 ("Garden plan", 1),
708 ("Ivy page", 1),
709 ("Teal page", 1),
710 ("Subpage", 2),
711 ("Meeting page", 1)
712 ]
713 );
714 let shown = page(&file, teal);
715 assert_eq!(
716 shown.color,
717 Some(canvas::template::PAGE_COLORS[teal_index].1)
718 );
719 assert!(shown.date_text().is_some());
720 assert!(
721 page(&file, ivy)
722 .objects
723 .iter()
724 .any(|object| matches!(object, PageObject::Image(image) if image.background))
725 );
726 let written = page(&file, meeting);
727 let outlines: Vec<_> = written
728 .objects
729 .iter()
730 .filter_map(|object| match object {
731 PageObject::Outline(outline) => Some(outline),
732 _ => None,
733 })
734 .collect();
735 assert_eq!(outlines.len(), 4);
736 let lines: Vec<&str> = outlines
737 .iter()
738 .flat_map(|outline| &outline.paragraphs)
739 .filter_map(|paragraph| paragraph.text())
740 .map(|text| text.text.text())
741 .collect();
742 assert!(lines.contains(&"Meeting Details") && lines.contains(&"Attendees:"));
743 assert!(
744 outlines
745 .iter()
746 .flat_map(|outline| &outline.paragraphs)
747 .filter_map(|paragraph| paragraph.text())
748 .any(|text| !text.tags.is_empty())
749 );
750 let bin = root.join("OneNote_RecycleBin/OneNote_DeletedPages.one");
751 let [(space, title, 1)] = &pages(&bin)[..] else {
752 panic!("{:?}", pages(&bin))
753 };
754 assert_eq!(title, "Deleted page");
755 let kept = page(&bin, *space);
756 assert_eq!(
757 (kept.identity, kept.created),
758 (recycled.identity, recycled.created)
759 );
760 assert_eq!(kept.date_text(), recycled.date_text());
761
762 if let Some(directory) = std::env::var_os("SNOWBOUND_MANAGEMENT_EXPORT") {
763 copy(&root, Path::new(&directory));
764 }
765 std::fs::remove_dir_all(&temporary).unwrap();
766 }
372767
373 #[test]768 #[test]
374 fn moved_sections_are_followed_and_new_names_take_the_next_number() {769 fn moved_sections_are_followed_and_new_names_take_the_next_number() {
...@@ -379,7 +774,11 @@ mod tests {...@@ -379,7 +774,11 @@ mod tests {
379 assert_eq!(unused(&taken, "New Section", true), "New Section 3");774 assert_eq!(unused(&taken, "New Section", true), "New Section 3");
380 assert_eq!(unused(&[], "New Section Group", false), "New Section Group");775 assert_eq!(unused(&[], "New Section Group", false), "New Section Group");
381 assert_eq!(776 assert_eq!(
382 unused(&["New Section Group".to_owned()], "New Section Group", false),777 unused(
778 &["New Section Group".to_owned()],
779 "New Section Group",
780 false
781 ),
383 "New Section Group 2"782 "New Section Group 2"
384 );783 );
385 }784 }
crates/snowbound/src/meeting.rs created+237
...@@ -0,0 +1,237 @@
1//! OneNote 2010's Informal Meeting Notes content (Business templates;
2//! `corpus/notebook-management/native/meeting-template`): four outlines of bold headings over
3//! numbered, to-do and arrow-bulleted lines, placed where the template places them.
4
5use onestore::{
6 ExGuid,
7 document::{Format, Kind, Layout, Tag},
8 page::{
9 Definition, MediaIndex, Outline, Page, PageObject, PageParagraph, ParagraphContent,
10 TextObject, text::new_id,
11 },
12};
13use std::error::Error;
14
15/// OneNote's automatic colour.
16const AUTOMATIC: u32 = 0xff00_0000;
17
18/// A list line's marker.
19enum Marker {
20 Numbered,
21 Arrow,
22 ToDo,
23 None,
24}
25
26/// A heading and the lines under it.
27type Heading = (&'static str, &'static [(&'static str, Marker)]);
28
29/// An outline of the template: position, width, indents, then each heading's lines.
30type Block = ([f32; 2], f32, [f32; 4], &'static [Heading]);
31
32const OUTLINES: [Block; 4] = [
33 (
34 [36.0, 77.4],
35 141.75,
36 [18.0, 0.0, 21.6, 36.0],
37 &[("Agenda", &[("", Marker::Numbered)])],
38 ),
39 (
40 [234.0, 77.4],
41 137.25,
42 [18.0, 0.0, 18.0, 36.0],
43 &[("Action Items", &[("", Marker::ToDo)])],
44 ),
45 (
46 [423.0, 77.4],
47 136.5,
48 [18.0, 0.0, 21.6, 36.0],
49 &[("Important Dates", &[("", Marker::Arrow)])],
50 ),
51 (
52 [36.0, 221.4],
53 529.5,
54 [18.0, 0.0, 36.0, 36.0],
55 &[
56 (
57 "Meeting Details",
58 &[
59 ("Date and Time:", Marker::Arrow),
60 ("Location:", Marker::Arrow),
61 ("Attendees:", Marker::Arrow),
62 ],
63 ),
64 ("Announcements", &[("", Marker::Arrow)]),
65 ("Discussion", &[("", Marker::Arrow)]),
66 ("Summary", &[("", Marker::Arrow), ("", Marker::None)]),
67 (
68 "Next Meeting",
69 &[
70 ("Date and Time:", Marker::Arrow),
71 ("Location:", Marker::Arrow),
72 ("Agenda:", Marker::Arrow),
73 ("Notes:", Marker::Arrow),
74 ],
75 ),
76 ],
77 ),
78];
79
80/// `page` with the template's outlines added after what it holds.
81pub fn content(page: &Page) -> Result<Page, Box<dyn Error>> {
82 let mut page = page.clone();
83 let text = |bold: bool| Format {
84 bold: Some(bold),
85 italic: Some(false),
86 underline: Some(false),
87 strike: Some(false),
88 superscript: Some(false),
89 subscript: Some(false),
90 font: Some("Calibri".into()),
91 font_size: Some(11.0),
92 color: Some(AUTOMATIC),
93 highlight: Some(AUTOMATIC),
94 language: Some(0x409),
95 space_before: Some(0.0),
96 space_after: Some(0.0),
97 ..Format::default()
98 };
99 let style = new_id()?;
100 page.definitions.insert(
101 style,
102 Definition {
103 kind: Kind::Style {
104 name: Some("p".into()),
105 },
106 format: Format {
107 language: None,
108 ..text(false)
109 },
110 },
111 );
112 // Tag dates count seconds since 1980.
113 let created = u32::try_from(crate::filetime() / 10_000_000 - 11_644_473_600 - 315_532_800)?;
114 let to_do = new_id()?;
115 page.definitions.insert(
116 to_do,
117 Definition {
118 kind: Kind::TagDefinition {
119 label: Some("To Do".into()),
120 action_type: Some(0),
121 shape: Some(3),
122 color: None,
123 highlight: None,
124 },
125 format: Format::default(),
126 },
127 );
128 let paragraph = |parent: Option<ExGuid>,
129 line: &str,
130 bold: bool,
131 lists: Vec<ExGuid>,
132 tags: Vec<Tag>|
133 -> Result<PageParagraph, Box<dyn Error>> {
134 Ok(PageParagraph {
135 id: new_id()?,
136 parent,
137 level: if parent.is_some() { 2 } else { 1 },
138 style: Some(style),
139 format: Format::default(),
140 content: ParagraphContent::Text(TextObject {
141 id: new_id()?,
142 date_field: None,
143 text: onestore::page::Paragraph::new(line.to_owned(), text(bold)),
144 // OneNote tags the line's text, not its paragraph.
145 tags,
146 }),
147 lists,
148 tags: Vec::new(),
149 media: MediaIndex::default(),
150 collapsed: false,
151 })
152 };
153 for (position, width, indents, headings) in OUTLINES {
154 let mut paragraphs = Vec::new();
155 for (heading, lines) in headings {
156 let parent = paragraph(None, heading, true, Vec::new(), Vec::new())?;
157 let id = parent.id;
158 paragraphs.push(parent);
159 for (line, marker) in *lines {
160 let (lists, tags) = match marker {
161 Marker::None => (Vec::new(), Vec::new()),
162 Marker::ToDo => (
163 Vec::new(),
164 vec![Tag {
165 definition: Some(to_do),
166 action_type: None,
167 status: 0,
168 created: Some(created),
169 completed: Some(0),
170 start: None,
171 due: None,
172 task_id: None,
173 extra_set: 0,
174 }],
175 ),
176 Marker::Numbered | Marker::Arrow => {
177 let list = new_id()?;
178 page.definitions.insert(list, list_definition(marker));
179 (vec![list], Vec::new())
180 }
181 };
182 paragraphs.push(paragraph(Some(id), line, false, lists, tags)?);
183 }
184 }
185 page.objects.push(PageObject::Outline(Outline {
186 id: new_id()?,
187 title: false,
188 min_width: None,
189 layout: Layout {
190 x: Some(position[0]),
191 y: Some(position[1]),
192 max_width: Some(width),
193 width_set_by_user: Some(true),
194 ..Layout::default()
195 },
196 indents: indents.to_vec(),
197 paragraphs,
198 unsupported: Vec::new(),
199 }));
200 }
201 Ok(page)
202}
203
204/// The template's `##.` numbering, or its Wingdings 3 arrow bullet.
205fn list_definition(marker: &Marker) -> Definition {
206 let format = Format {
207 font_size: Some(11.0),
208 color: Some(AUTOMATIC),
209 ..Format::default()
210 };
211 match marker {
212 Marker::Numbered => Definition {
213 kind: Kind::List {
214 font: None,
215 format: Some("\u{fffd}\u{0}.".into()),
216 restart: None,
217 bullet: None,
218 },
219 format: Format {
220 bold: Some(false),
221 italic: Some(false),
222 font: Some("Calibri".into()),
223 language: Some(0x409),
224 ..format
225 },
226 },
227 _ => Definition {
228 kind: Kind::List {
229 font: Some("Wingdings 3".into()),
230 format: Some("}".into()),
231 restart: None,
232 bullet: Some(11),
233 },
234 format,
235 },
236 }
237}
crates/snowbound/src/menus.rs+120-8
...@@ -6,6 +6,15 @@ use onestore::{ExGuid, PageEdit};...@@ -6,6 +6,15 @@ use onestore::{ExGuid, PageEdit};
6use std::sync::Arc;6use std::sync::Arc;
7use ui::{Anchor, Id, popup::Item};7use ui::{Anchor, Id, popup::Item};
88
9/// What a drag holds.
10pub enum Drag {
11 Page(ExGuid),
12 /// A sidebar row.
13 Entry(crate::sidebar::Entry),
14 /// A section tab, by index.
15 Tab(usize),
16}
17
9/// What a context menu was opened on.18/// What a context menu was opened on.
10pub enum Target {19pub enum Target {
11 Page(ExGuid),20 Page(ExGuid),
...@@ -55,6 +64,8 @@ impl State {...@@ -55,6 +64,8 @@ impl State {
55 separated: true,64 separated: true,
56 ..item("New Page")65 ..item("New Page")
57 },66 },
67 // OneNote offers this beside New Page, which is a plain + here.
68 item("New Subpage"),
58 Item {69 Item {
59 separated: true,70 separated: true,
60 disabled: level > above || level >= 3,71 disabled: level > above || level >= 3,
...@@ -93,7 +104,8 @@ impl State {...@@ -93,7 +104,8 @@ impl State {
93 };104 };
94 let command = match (target, items[chosen].text) {105 let command = match (target, items[chosen].text) {
95 (Target::Page(space), "Delete") => Some(Command::DeletePages(vec![space])),106 (Target::Page(space), "Delete") => Some(Command::DeletePages(vec![space])),
96 (Target::Page(_), "New Page") => Some(Command::NewPage { subpage: false }),107 (Target::Page(_), "New Page") => Some(Command::NewPage { under: None }),
108 (Target::Page(space), "New Subpage") => Some(Command::NewPage { under: Some(space) }),
97 (Target::Page(space), text) => self.session.as_ref().and_then(|session| {109 (Target::Page(space), text) => self.session.as_ref().and_then(|session| {
98 let level = session110 let level = session
99 .pages111 .pages
...@@ -151,14 +163,33 @@ impl State {...@@ -151,14 +163,33 @@ impl State {
151 self.commands.extend(command);163 self.commands.extend(command);
152 }164 }
153165
166 /// The section tab, of those built as `row`, under the pointer while a page is dragged
167 /// there, which a drop moves the page into.
168 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())
171 else {
172 return None;
173 };
174 (0..session.tabs.len())
175 .filter(|tab| *tab != session.tab)
176 .find(|tab| {
177 self.ui.rect(ui::shell::tab_id(row, *tab)).is_some_and(
178 |[left, top, right, bottom]| x >= left && x < right && y >= top && y < bottom,
179 )
180 })
181 }
182
154 /// Moves a page by dragging its tab: while `held`, a line shows where it would go; let183 /// Moves a page by dragging its tab: while `held`, a line shows where it would go; let
155 /// go, it moves there at its level, or under a page at most one level above it.184 /// go, it moves there at its level, or under a page at most one level above it. Onto a
156 /// `panel` is the page list's rectangle.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.
157 pub(crate) fn drag_pages(187 pub(crate) fn drag_pages(
158 &mut self,188 &mut self,
159 held: Option<ExGuid>,189 held: Option<ExGuid>,
160 section: &ui::Section,190 section: &ui::Section,
161 panel: [f32; 4],191 panel: [f32; 4],
192 row: Id,
162 ) {193 ) {
163 let Some(session) = &self.session else {194 let Some(session) = &self.session else {
164 return;195 return;
...@@ -166,17 +197,35 @@ impl State {...@@ -166,17 +197,35 @@ impl State {
166 if held.is_some() && !self.filter.is_empty() {197 if held.is_some() && !self.filter.is_empty() {
167 return;198 return;
168 }199 }
169 let dragged = held.or(self.dragging.take());200 let dropped = match (&self.drag, held) {
170 self.dragging = held;201 (_, Some(space)) => Some(space),
171 let (Some(space), Some([_, y])) = (dragged, self.ui.pointer()) else {202 (Some(Drag::Page(space)), None) => Some(*space),
203 _ => None,
204 };
205 let target = self.page_drop(row);
206 if let Some(space) = held {
207 self.drag = Some(Drag::Page(space));
208 } else if matches!(self.drag, Some(Drag::Page(_))) {
209 self.drag = None;
210 }
211 let (Some(space), Some([_, y])) = (dropped, self.ui.pointer()) else {
172 return;212 return;
173 };213 };
214 if let Some(tab) = target {
215 if held.is_none() {
216 let path = session.tabs[tab].path.clone();
217 self.commands.push(Command::MovePage { space, path });
218 }
219 return;
220 }
174 // Rows other than the dragged one, with where each lies.221 // Rows other than the dragged one, with where each lies.
175 let rows: Vec<(ExGuid, u32, [f32; 4])> = session222 let rows: Vec<(ExGuid, u32, [f32; 4])> = session
176 .pages223 .pages
177 .iter()224 .iter()
178 .filter(|(listed, ..)| *listed != space)225 .filter(|(listed, ..)| *listed != space)
179 .filter_map(|(listed, _, level)| Some((*listed, *level, self.ui.rect(self.ui.id(listed))?)))226 .filter_map(|(listed, _, level)| {
227 Some((*listed, *level, self.ui.rect(self.ui.id(listed))?))
228 })
180 .collect();229 .collect();
181 let Some(level) = session230 let Some(level) = session
182 .pages231 .pages
...@@ -217,11 +266,74 @@ impl State {...@@ -217,11 +266,74 @@ impl State {
217 section.accent,266 section.accent,
218 1.5,267 1.5,
219 );268 );
220 self.dragging = held;
221 } else if let Ok(edit) = PageEdit::move_to(space, before, level) {269 } else if let Ok(edit) = PageEdit::move_to(space, before, level) {
222 self.commands.push(Command::Pages(vec![edit]));270 self.commands.push(Command::Pages(vec![edit]));
223 }271 }
224 }272 }
273
274 /// Reorders section tabs by dragging one: while `held`, a line shows where it would go
275 /// among the tabs built as `row` in the tab row `bar`; let go, the folder takes that
276 /// order.
277 pub(crate) fn drag_tabs(
278 &mut self,
279 held: Option<usize>,
280 row: Id,
281 bar: [f32; 4],
282 accent: [f32; 4],
283 ) {
284 let Some(session) = &self.session else {
285 return;
286 };
287 let dragged = match (&self.drag, held) {
288 (_, Some(tab)) => Some(tab),
289 (Some(Drag::Tab(tab)), None) => Some(*tab),
290 _ => None,
291 };
292 if let Some(tab) = held {
293 self.drag = Some(Drag::Tab(tab));
294 } else if matches!(self.drag, Some(Drag::Tab(_))) {
295 self.drag = None;
296 }
297 let (Some(tab), Some([x, _])) = (dragged, self.ui.pointer()) else {
298 return;
299 };
300 let others: Vec<(usize, [f32; 4])> = (0..session.tabs.len())
301 .filter(|other| *other != tab)
302 .filter_map(|other| Some((other, self.ui.rect(ui::shell::tab_id(row, other))?)))
303 .collect();
304 let at = others
305 .iter()
306 .position(|(_, rect)| x < (rect[0] + rect[2]) / 2.0)
307 .unwrap_or(others.len());
308 let mut order: Vec<usize> = others.iter().map(|(other, _)| *other).collect();
309 order.insert(at, tab);
310 if order.iter().copied().eq(0..session.tabs.len()) {
311 return;
312 }
313 if held.is_some() {
314 let edge = match (
315 at.checked_sub(1).map(|before| others[before].1),
316 others.get(at),
317 ) {
318 (_, Some((_, after))) => after[0],
319 (Some(before), None) => before[2],
320 (None, None) => return,
321 } - bar[0];
322 self.ui
323 .mark([edge - 1.5, 4.0, edge + 1.5, bar[3] - bar[1]], accent, 1.5);
324 } else {
325 let path = &session.tabs[tab].path;
326 let folder = folder(path);
327 let paths = order
328 .into_iter()
329 .map(|index| session.tabs[index].path.clone())
330 .collect();
331 self.commands.push(Command::Structure(
332 Arc::clone(&session.library),
333 Structure::Reorder { folder, paths },
334 ));
335 }
336 }
225}337}
226338
227/// The new section or group a menu's `text` asks for in `folder`.339/// The new section or group a menu's `text` asks for in `folder`.
crates/snowbound/src/screenshot.rs+1-4
...@@ -40,10 +40,7 @@ impl State {...@@ -40,10 +40,7 @@ impl State {
40 self.ui.theme = theme(appearance);40 self.ui.theme = theme(appearance);
41 // The page's box sizes the view whose pictures settle.41 // The page's box sizes the view whose pictures settle.
42 self.layout(SIZE, SCALE)?;42 self.layout(SIZE, SCALE)?;
43 let paper = canvas::gpu::Paper {43 let paper = self.paper();
44 color: self.ui.theme.paper,
45 ink: self.ui.theme.paper_ink,
46 };
47 if let Some((scene, _)) = &mut self.view.scene {44 if let Some((scene, _)) = &mut self.view.scene {
48 scene.settle(Some(&self.view.editor), self.view.viewport.scale, paper);45 scene.settle(Some(&self.view.editor), self.view.viewport.scale, paper);
49 }46 }
crates/snowbound/src/settings.rs+17-3
...@@ -51,7 +51,8 @@ impl crate::State {...@@ -51,7 +51,8 @@ impl crate::State {
51 let Some(path) = &self.settings else {51 let Some(path) = &self.settings else {
52 return;52 return;
53 };53 };
54 let kept = |library: &&std::sync::Arc<crate::Library>| !matches!(library.notebook, Ok(None));54 let kept =
55 |library: &&std::sync::Arc<crate::Library>| !matches!(library.notebook, Ok(None));
55 let settings = Settings {56 let settings = Settings {
56 notebooks: self57 notebooks: self
57 .notebooks58 .notebooks
...@@ -74,6 +75,15 @@ impl crate::State {...@@ -74,6 +75,15 @@ impl crate::State {
74 }75 }
75}76}
7677
78/// What a launch starts from besides its input.
79pub struct Launch {
80 /// Where the settings are saved; `None` leaves them as they were read.
81 pub file: Option<PathBuf>,
82 pub saved: Settings,
83 /// The directory holding the sections' replicas.
84 pub cache: PathBuf,
85}
86
77/// Where the settings live unless `--settings` names a file.87/// Where the settings live unless `--settings` names a file.
78pub fn default_path() -> Option<PathBuf> {88pub fn default_path() -> Option<PathBuf> {
79 Some(crate::platform::settings_dir()?.join("settings.json"))89 Some(crate::platform::settings_dir()?.join("settings.json"))
...@@ -85,11 +95,15 @@ mod tests {...@@ -85,11 +95,15 @@ mod tests {
8595
86 #[test]96 #[test]
87 fn settings_round_trip_and_tolerate_missing_fields() {97 fn settings_round_trip_and_tolerate_missing_fields() {
88 let directory = std::env::temp_dir().join(format!("snowbound-settings-{}", std::process::id()));98 let directory =
99 std::env::temp_dir().join(format!("snowbound-settings-{}", std::process::id()));
89 let path = directory.join("nested/settings.json");100 let path = directory.join("nested/settings.json");
90 assert_eq!(Settings::load(&path), Settings::default());101 assert_eq!(Settings::load(&path), Settings::default());
91 let settings = Settings {102 let settings = Settings {
92 notebooks: vec!["/notebooks/Personal".into(), "smb://server/share/Work".into()],103 notebooks: vec![
104 "/notebooks/Personal".into(),
105 "smb://server/share/Work".into(),
106 ],
93 current: Some("/notebooks/Personal".into()),107 current: Some("/notebooks/Personal".into()),
94 sidebar: true,108 sidebar: true,
95 recent_fonts: vec!["Georgia".into()],109 recent_fonts: vec!["Georgia".into()],
crates/snowbound/src/sidebar.rs+170-97
...@@ -22,7 +22,10 @@ pub enum Action {...@@ -22,7 +22,10 @@ pub enum Action {
22 /// Shows a notebook where it was left.22 /// Shows a notebook where it was left.
23 Notebook(usize),23 Notebook(usize),
24 /// Opens a notebook's section by catalog path.24 /// Opens a notebook's section by catalog path.
25 Open { notebook: usize, path: String },25 Open {
26 notebook: usize,
27 path: String,
28 },
26 /// Folds or unfolds a notebook's or section group's rows, by `fold_key`.29 /// Folds or unfolds a notebook's or section group's rows, by `fold_key`.
27 Fold(String),30 Fold(String),
28 NewNotebook,31 NewNotebook,
...@@ -51,7 +54,7 @@ pub fn fold_key(library: &Library, path: &str) -> String {...@@ -51,7 +54,7 @@ pub fn fold_key(library: &Library, path: &str) -> String {
51}54}
5255
53/// What the tree's rows read and what they were asked.56/// What the tree's rows read and what they were asked.
54struct Tree<'a> {57pub struct Tree<'a> {
55 theme: &'a Theme,58 theme: &'a Theme,
56 /// The open section's notebook index and catalog path.59 /// The open section's notebook index and catalog path.
57 open: Option<(usize, &'a str)>,60 open: Option<(usize, &'a str)>,
...@@ -64,6 +67,25 @@ struct Tree<'a> {...@@ -64,6 +67,25 @@ struct Tree<'a> {
64 held: Option<Entry>,67 held: Option<Entry>,
65}68}
6669
70impl<'a> Tree<'a> {
71 fn new(
72 theme: &'a Theme,
73 open: Option<(usize, &'a str)>,
74 folded: &'a HashSet<String>,
75 renaming: Option<&'a mut Renaming>,
76 ) -> Self {
77 Self {
78 theme,
79 open,
80 folded,
81 renaming,
82 action: None,
83 rows: Vec::new(),
84 held: None,
85 }
86 }
87}
88
67/// A section or group row, for dragging one onto or between the others.89/// A section or group row, for dragging one onto or between the others.
68#[derive(Clone, PartialEq)]90#[derive(Clone, PartialEq)]
69pub struct Entry {91pub struct Entry {
...@@ -79,26 +101,10 @@ impl Entry {...@@ -79,26 +101,10 @@ impl Entry {
79 }101 }
80}102}
81103
82/// The sidebar under a header row `header` tall, listing `notebooks` with the open section104/// The sidebar's header row, `header` tall, then with `rows` the tree of `notebooks` with
83/// marked.105/// the open section marked.
84pub fn sidebar(106fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc<Library>], header: f32, rows: bool) {
85 ui: &mut Ui,107 let (theme, open, folded) = (tree.theme, tree.open, tree.folded);
86 theme: &Theme,
87 notebooks: &[Arc<Library>],
88 open: Option<(usize, &str)>,
89 folded: &HashSet<String>,
90 renaming: Option<&mut Renaming>,
91 header: f32,
92) -> (Option<Action>, Vec<Entry>, Option<Entry>) {
93 let mut tree = Tree {
94 theme,
95 open,
96 folded,
97 renaming,
98 action: None,
99 rows: Vec::new(),
100 held: None,
101 };
102 ui.open(108 ui.open(
103 "header",109 "header",
104 Spec {110 Spec {
...@@ -121,6 +127,9 @@ pub fn sidebar(...@@ -121,6 +127,9 @@ pub fn sidebar(
121 },127 },
122 );128 );
123 ui.close();129 ui.close();
130 if !rows {
131 return;
132 }
124 ui.open(133 ui.open(
125 "notebooks",134 "notebooks",
126 Spec {135 Spec {
...@@ -136,7 +145,7 @@ pub fn sidebar(...@@ -136,7 +145,7 @@ pub fn sidebar(
136 let unfolded = !folded.contains(&key);145 let unfolded = !folded.contains(&key);
137 let (row, fold) = tree_row(146 let (row, fold) = tree_row(
138 ui,147 ui,
139 &mut tree,148 tree,
140 ("notebook", index),149 ("notebook", index),
141 Row {150 Row {
142 label: &library.name,151 label: &library.name,
...@@ -157,7 +166,7 @@ pub fn sidebar(...@@ -157,7 +166,7 @@ pub fn sidebar(
157 tree.action = Some(Action::Menu(Target::Notebook(Arc::clone(library)), point));166 tree.action = Some(Action::Menu(Target::Notebook(Arc::clone(library)), point));
158 }167 }
159 if unfolded && let Some(catalog) = library.catalog() {168 if unfolded && let Some(catalog) = library.catalog() {
160 folder(ui, &mut tree, library, index, catalog, 1);169 folder(ui, tree, library, index, catalog, 1);
161 }170 }
162 }171 }
163 ui.close();172 ui.close();
...@@ -176,7 +185,7 @@ pub fn sidebar(...@@ -176,7 +185,7 @@ pub fn sidebar(
176 ] {185 ] {
177 let (row, _) = tree_row(186 let (row, _) = tree_row(
178 ui,187 ui,
179 &mut tree,188 tree,
180 part,189 part,
181 Row {190 Row {
182 label,191 label,
...@@ -193,7 +202,6 @@ pub fn sidebar(...@@ -193,7 +202,6 @@ pub fn sidebar(
193 }202 }
194 }203 }
195 ui.close();204 ui.close();
196 (tree.action, tree.rows, tree.held)
197}205}
198206
199/// A notebook's or group's sections, then its groups, as OneNote lists them.207/// A notebook's or group's sections, then its groups, as OneNote lists them.
...@@ -210,9 +218,9 @@ fn folder(...@@ -210,9 +218,9 @@ fn folder(
210 .filter(|(index, _)| *index == notebook)218 .filter(|(index, _)| *index == notebook)
211 .map(|(_, path)| path);219 .map(|(_, path)| path);
212 let renamed = |tree: &Tree, path: &str| {220 let renamed = |tree: &Tree, path: &str| {
213 tree.renaming221 tree.renaming.as_ref().is_some_and(|renaming| {
214 .as_ref()222 Arc::ptr_eq(&renaming.library, library) && renaming.path == path
215 .is_some_and(|renaming| Arc::ptr_eq(&renaming.library, library) && renaming.path == path)223 })
216 };224 };
217 for section in &folder.sections {225 for section in &folder.sections {
218 let (name, color, readable) = match &section.state {226 let (name, color, readable) = match &section.state {
...@@ -299,7 +307,8 @@ fn folder(...@@ -299,7 +307,8 @@ fn folder(
299307
300/// Records a section or group row for dragging.308/// Records a section or group row for dragging.
301fn entry(ui: &Ui, tree: &mut Tree, notebook: usize, path: &str, group: bool, row: &Signal) {309fn entry(ui: &Ui, tree: &mut Tree, notebook: usize, path: &str, group: bool, row: &Signal) {
302 let Some(rect) = ui.rect(ui.id(("section", notebook, path)))310 let Some(rect) = ui
311 .rect(ui.id(("section", notebook, path)))
303 .or_else(|| ui.rect(ui.id(("group", notebook, path))))312 .or_else(|| ui.rect(ui.id(("group", notebook, path))))
304 else {313 else {
305 return;314 return;
...@@ -454,9 +463,10 @@ impl crate::State {...@@ -454,9 +463,10 @@ impl crate::State {
454 if self.temporary {463 if self.temporary {
455 return;464 return;
456 }465 }
457 let width = self466 let width = self.ui.animate(
458 .ui467 self.ui.id("sidebar"),
459 .animate(self.ui.id("sidebar"), if self.sidebar { WIDTH } else { RAIL });468 if self.sidebar { WIDTH } else { RAIL },
469 );
460 self.ui.open(470 self.ui.open(
461 "sidebar",471 "sidebar",
462 Spec {472 Spec {
...@@ -483,15 +493,18 @@ impl crate::State {...@@ -483,15 +493,18 @@ impl crate::State {
483 .position(|library| Arc::ptr_eq(library, &session.library))?;493 .position(|library| Arc::ptr_eq(library, &session.library))?;
484 Some((index, session.tabs[session.tab].path.as_str()))494 Some((index, session.tabs[session.tab].path.as_str()))
485 });495 });
486 let (action, rows, held) = sidebar(496 let mut tree = Tree::new(theme, open, &self.folded, self.renaming.as_mut());
497 // The collapsed rail shows only its button.
498 sidebar(
487 &mut self.ui,499 &mut self.ui,
488 theme,500 &mut tree,
489 &self.notebooks,501 &self.notebooks,
490 open,
491 &self.folded,
492 self.renaming.as_mut(),
493 crate::TAB_ROW,502 crate::TAB_ROW,
503 width > RAIL + 0.5,
494 );504 );
505 let Tree {
506 action, rows, held, ..
507 } = tree;
495 let corner = self.ui.rect(rows_id).unwrap_or_default();508 let corner = self.ui.rect(rows_id).unwrap_or_default();
496 self.drag_entries(held, &rows, corner, theme);509 self.drag_entries(held, &rows, corner, theme);
497 self.ui.close();510 self.ui.close();
...@@ -509,12 +522,14 @@ impl crate::State {...@@ -509,12 +522,14 @@ impl crate::State {
509 Some(Action::Notebook(index)) => {522 Some(Action::Notebook(index)) => {
510 let library = Arc::clone(&self.notebooks[index]);523 let library = Arc::clone(&self.notebooks[index]);
511 if let Some(path) = library.first_section() {524 if let Some(path) = library.first_section() {
512 self.commands.push(crate::Command::OpenSection(library, path));525 self.commands
526 .push(crate::Command::OpenSection(library, path));
513 }527 }
514 }528 }
515 Some(Action::Open { notebook, path }) => {529 Some(Action::Open { notebook, path }) => {
516 let library = Arc::clone(&self.notebooks[notebook]);530 let library = Arc::clone(&self.notebooks[notebook]);
517 self.commands.push(crate::Command::OpenSection(library, path));531 self.commands
532 .push(crate::Command::OpenSection(library, path));
518 }533 }
519 Some(Action::Menu(target, point)) => {534 Some(Action::Menu(target, point)) => {
520 self.menu = Some((target, point));535 self.menu = Some((target, point));
...@@ -548,9 +563,23 @@ impl crate::State {...@@ -548,9 +563,23 @@ impl crate::State {
548 /// group, as OneNote moves one dropped on a group's tab; between rows of its folder it563 /// group, as OneNote moves one dropped on a group's tab; between rows of its folder it
549 /// takes that place; between rows of another folder it moves there, last. `corner` is564 /// takes that place; between rows of another folder it moves there, last. `corner` is
550 /// the rows' box.565 /// the rows' box.
551 fn drag_entries(&mut self, held: Option<Entry>, rows: &[Entry], corner: [f32; 4], theme: &Theme) {566 fn drag_entries(
552 let dragged = held.clone().or(self.dragging_entry.take());567 &mut self,
553 self.dragging_entry = held.clone();568 held: Option<Entry>,
569 rows: &[Entry],
570 corner: [f32; 4],
571 theme: &Theme,
572 ) {
573 let dragged = match (&self.drag, &held) {
574 (_, Some(entry)) => Some(entry.clone()),
575 (Some(crate::menus::Drag::Entry(entry)), None) => Some(entry.clone()),
576 _ => None,
577 };
578 if let Some(entry) = &held {
579 self.drag = Some(crate::menus::Drag::Entry(entry.clone()));
580 } else if matches!(self.drag, Some(crate::menus::Drag::Entry(_))) {
581 self.drag = None;
582 }
554 let (Some(dragged), Some([_, y])) = (dragged, self.ui.pointer()) else {583 let (Some(dragged), Some([_, y])) = (dragged, self.ui.pointer()) else {
555 return;584 return;
556 };585 };
...@@ -620,118 +649,162 @@ impl crate::State {...@@ -620,118 +649,162 @@ impl crate::State {
620 if into {649 if into {
621 let [left, top, right, bottom] = under.rect;650 let [left, top, right, bottom] = under.rect;
622 self.ui.mark(651 self.ui.mark(
623 [left - corner[0], top - corner[1], right - corner[0], bottom - corner[1]],652 [
653 left - corner[0],
654 top - corner[1],
655 right - corner[0],
656 bottom - corner[1],
657 ],
624 [accent[0], accent[1], accent[2], 0.3],658 [accent[0], accent[1], accent[2], 0.3],
625 4.0,659 4.0,
626 );660 );
627 } else {661 } else {
628 let line = if before == Some(under.path.as_str()) { top } else { bottom } - corner[1];662 let line = if before == Some(under.path.as_str()) {
663 top
664 } else {
665 bottom
666 } - corner[1];
629 self.ui.mark(667 self.ui.mark(
630 [under.rect[0] - corner[0], line - 1.5, under.rect[2] - corner[0], line + 1.5],668 [
669 under.rect[0] - corner[0],
670 line - 1.5,
671 under.rect[2] - corner[0],
672 line + 1.5,
673 ],
631 accent,674 accent,
632 1.5,675 1.5,
633 );676 );
634 }677 }
635 } else {678 } else {
636 let library = std::sync::Arc::clone(&self.notebooks[dragged.notebook]);679 let library = std::sync::Arc::clone(&self.notebooks[dragged.notebook]);
637 self.commands.push(crate::Command::Structure(library, change));680 self.commands
681 .push(crate::Command::Structure(library, change));
682 }
683 }
684
685 /// Opens what the open panel chose: a notebook folder, a notebook's table of contents,
686 /// or a section, in its notebook where it has one.
687 pub(crate) fn open_path(&mut self, path: &std::path::Path) {
688 match crate::library::locate(path) {
689 crate::library::Located::Notebook { root, section } => {
690 self.open_notebook(root.to_string_lossy().into_owned(), section)
691 }
692 crate::library::Located::Section(file) => {
693 let library = Arc::new(Library::section(&file, &self.cache));
694 let path = library.location.clone();
695 self.commands
696 .push(crate::Command::OpenSection(library, path));
697 }
698 crate::library::Located::Nothing => crate::platform::alert(
699 "Couldn't open",
700 "Choose a notebook folder, its Open Notebook file, or a section file.",
701 ),
638 }702 }
639 }703 }
640704
641 /// Shows the notebook at `location` at its first section, opening it unless it is open.705 /// Shows the notebook at `location` at `section`, or its first section, opening it
642 pub(crate) fn open_notebook(&mut self, location: String) {706 /// unless it is open.
707 pub(crate) fn open_notebook(&mut self, location: String, section: Option<String>) {
643 let open = self708 let open = self
644 .notebooks709 .notebooks
645 .iter()710 .iter()
646 .find(|library| library.location == location)711 .find(|library| library.location == location)
647 .cloned();712 .cloned();
713 // The notebook shown already shows a section, which only one reader may hold.
714 if let Some(session) = &self.session
715 && session.library.location == location
716 && section
717 .as_ref()
718 .is_none_or(|section| *section == session.tabs[session.tab].path)
719 {
720 return;
721 }
648 let (cache, notify) = (self.cache.clone(), crate::notify(self.proxy.clone()));722 let (cache, notify) = (self.cache.clone(), crate::notify(self.proxy.clone()));
649 self.load(move || {723 self.load(move || {
650 let library = open.unwrap_or_else(|| Arc::new(Library::notebook(&location, &cache)));724 let library = open.unwrap_or_else(|| Arc::new(Library::notebook(&location, &cache)));
651 if let Err(error) = &library.notebook {725 if let Err(error) = &library.notebook {
652 return Err(error.clone().into());726 return Err(error.clone().into());
653 }727 }
654 let path = library728 let path = section
655 .first_section()729 .filter(|path| library.contains(path))
730 .or_else(|| library.first_section())
656 .ok_or("This folder holds no notebook sections.")?;731 .ok_or("This folder holds no notebook sections.")?;
657 let section = library.open(&path, notify)?;732 let section = library.open(&path, notify)?;
658 let (session, page) = crate::read_session(section, library, path, None)?;733 let (session, page) = crate::read_session(section, library, path, None)?;
659 Ok((crate::Loaded::Section(Box::new(session)), page))734 Ok(crate::Loaded::Section(Box::new(session), page))
660 });735 });
661 }736 }
662737
663 /// The page's place on the first run, or once every notebook is closed.738 /// The window below the title bar on the first run, or once every notebook is closed:
739 /// what is missing, and the two ways to start.
664 pub(crate) fn welcome(&mut self, theme: &Theme) {740 pub(crate) fn welcome(&mut self, theme: &Theme) {
665 const SIZE: [f32; 2] = [420.0, 120.0];741 const BUTTON: [f32; 2] = [200.0, 32.0];
666 let [left, top, right, bottom] = self.ui.rect(crate::page()).unwrap_or_default();742 let [left, top, right, bottom] = self.ui.rect(self.ui.id("welcome")).unwrap_or_default();
667 self.ui.open_as(743 self.ui.open(
668 crate::page(),744 "welcome",
669 Spec {745 Spec {
670 size: [fill(), fill()],746 size: [fill(), fill()],
671 fill: Some(theme.paper),747 fill: Some(theme.base),
672 ..Spec::default()748 ..Spec::default()
673 },749 },
674 );750 );
675 self.ui.open(751 self.ui.open(
676 "welcome",752 "content",
677 Spec {753 Spec {
678 flags: Flags::FLOAT,754 flags: Flags::FLOAT,
679 axis: Axis::Y,755 axis: Axis::Y,
680 size: [px(SIZE[0]), px(SIZE[1])],756 size: [px(BUTTON[0]), ui::children()],
681 position: [757 position: [
682 ((right - left - SIZE[0]) / 2.0).max(0.0),758 ((right - left - BUTTON[0]) / 2.0).max(0.0),
683 ((bottom - top) * 0.4 - SIZE[1] / 2.0).max(0.0),759 ((bottom - top) * 0.4 - 60.0).max(0.0),
684 ],760 ],
685 gap: 4.0,761 gap: 10.0,
686 ..Spec::default()762 ..Spec::default()
687 },763 },
688 );764 );
689 let ink = theme.paper_ink;765 self.ui.leaf(
690 for (part, text, color) in [766 "title",
691 ("title", "No notebooks open", ink),767 Spec {
768 size: [fill(), px(28.0)],
769 text: Some("No notebooks open"),
770 center: true,
771 ..Spec::default()
772 },
773 );
774 let mut chosen = None;
775 for (part, icon, label, command) in [
776 (
777 "new",
778 art::PLUS,
779 "New Notebook",
780 crate::Command::NewNotebook,
781 ),
692 (782 (
693 "description",783 "open",
694 "Open a notebook folder, or start a new notebook.",784 art::NOTEBOOK,
695 ui::mix(ink, theme.paper, 0.45),785 "Open Existing",
786 crate::Command::OpenNotebook,
696 ),787 ),
697 ] {788 ] {
698 self.ui.leaf(789 let button = self.ui.leaf(
699 part,790 part,
700 Spec {791 Spec {
701 size: [fill(), px(26.0)],792 flags: Flags::CLICKABLE,
702 text: Some(text),793 size: [fill(), px(BUTTON[1])],
703 color: Some(color),794 icon: Some(icon),
795 text: Some(label),
796 fill: Some(theme.chip),
797 hover_fill: Some(theme.hover()),
798 hover_border: Some(theme.accent),
799 radius: 6.0,
704 center: true,800 center: true,
705 ..Spec::default()801 ..Spec::default()
706 },802 },
707 );803 );
708 }804 if button.clicked {
709 let labels = ["Open Notebook…", "New Notebook…"];
710 let pad = 0.75 * theme.font_size;
711 let width: f32 = labels
712 .iter()
713 .map(|label| self.ui.measure(label)[0] + 2.0 * pad)
714 .sum::<f32>()
715 + 8.0;
716 self.ui.open(
717 "actions",
718 Spec {
719 size: [fill(), ui::children()],
720 pad: [((SIZE[0] - width) / 2.0).max(0.0), 12.0],
721 gap: 8.0,
722 ..Spec::default()
723 },
724 );
725 let mut chosen = None;
726 for (label, command) in labels
727 .into_iter()
728 .zip([crate::Command::OpenNotebook, crate::Command::NewNotebook])
729 {
730 if ui::button(&mut self.ui, label, label).clicked {
731 chosen = Some(command);805 chosen = Some(command);
732 }806 }
733 }807 }
734 self.ui.close();
735 self.commands.extend(chosen);808 self.commands.extend(chosen);
736 self.ui.close();809 self.ui.close();
737 self.ui.close();810 self.ui.close();
crates/snowbound/src/templates.rs+45-17
...@@ -50,9 +50,12 @@ pub enum View {...@@ -50,9 +50,12 @@ pub enum View {
50 Gallery,50 Gallery,
51}51}
5252
53/// A thumbnail's art: each raster and where it lies in the tile.
54type Art = Vec<(RasterImage, [f32; 4])>;
55
53/// Rasters of each template's art for its thumbnail, by template and paper.56/// Rasters of each template's art for its thumbnail, by template and paper.
54#[derive(Default)]57#[derive(Default)]
55pub struct Thumbnails(HashMap<(&'static str, bool), Vec<(RasterImage, [f32; 4])>>);58pub struct Thumbnails(HashMap<(&'static str, bool), Art>);
5659
57impl Thumbnails {60impl Thumbnails {
58 /// `template`'s art and where it lies in a `TILE`, drawn at `scale` for `paper`.61 /// `template`'s art and where it lies in a `TILE`, drawn at `scale` for `paper`.
...@@ -69,12 +72,8 @@ impl Thumbnails {...@@ -69,12 +72,8 @@ impl Thumbnails {
69 .art72 .art
70 .iter()73 .iter()
71 .filter_map(|art| {74 .filter_map(|art| {
72 let (image, size) = canvas::gpu::page::template_art(75 let (image, size) =
73 art.art,76 canvas::gpu::page::template_art(art.art, art.size, points * scale, paper)?;
74 art.size,
75 points * scale,
76 paper,
77 )?;
78 let [x, y] = [77 let [x, y] = [
79 (art.position[0] - REGION[0]) * points,78 (art.position[0] - REGION[0]) * points,
80 (art.position[1] - REGION[1]) * points,79 (art.position[1] - REGION[1]) * points,
...@@ -86,7 +85,10 @@ impl Thumbnails {...@@ -86,7 +85,10 @@ impl Thumbnails {
86 }85 }
87}86}
8887
89/// The strip at `position` in the page box, with the page colours beneath while `colors`.88/// The strip at `position` in the page box, `room` wide at most, with the page colours
89/// beneath while `colors`. Picks that do not fit give way from the end; More templates
90/// stays.
91#[allow(clippy::too_many_arguments)]
90fn strip(92fn strip(
91 ui: &mut Ui,93 ui: &mut Ui,
92 theme: &Theme,94 theme: &Theme,
...@@ -94,8 +96,20 @@ fn strip(...@@ -94,8 +96,20 @@ fn strip(
94 paper: Paper,96 paper: Paper,
95 dark: bool,97 dark: bool,
96 position: [f32; 2],98 position: [f32; 2],
99 room: f32,
97 colors: bool,100 colors: bool,
98) -> Option<Choice> {101) -> Option<Choice> {
102 let more = (Choice::More, "More templates");
103 let mut used = 2.0 * PAD + tile_width(ui, more.1);
104 let mut shown = Vec::new();
105 for pick in STRIP {
106 used += GAP + tile_width(ui, pick.1);
107 if used > room {
108 break;
109 }
110 shown.push(pick);
111 }
112 shown.push(more);
99 let mut chosen = None;113 let mut chosen = None;
100 ui.open(114 ui.open(
101 "templates",115 "templates",
...@@ -120,22 +134,27 @@ fn strip(...@@ -120,22 +134,27 @@ fn strip(
120 ..Spec::default()134 ..Spec::default()
121 },135 },
122 );136 );
123 for (index, (choice, label)) in STRIP.into_iter().chain([(Choice::More, "More templates")]).enumerate() {137 for (index, (choice, label)) in shown.into_iter().enumerate() {
124 if tile(ui, theme, thumbnails, paper, dark, index, choice, label) {138 if tile(ui, theme, thumbnails, paper, dark, index, choice, label) {
125 chosen = Some(choice);139 chosen = Some(choice);
126 }140 }
127 }141 }
128 ui.close();142 ui.close();
129 if colors {143 let per_row = (((room - 2.0 * PAD + 6.0) / (SWATCH + 6.0)).floor() as usize).max(1);
144 for (row, swatches) in PAGE_COLORS.chunks(per_row).enumerate().filter(|_| colors) {
130 ui.open(145 ui.open(
131 "colors",146 ("colors", row),
132 Spec {147 Spec {
133 size: [ui::children(), px(SWATCH)],148 size: [ui::children(), px(SWATCH)],
134 gap: 6.0,149 gap: 6.0,
135 ..Spec::default()150 ..Spec::default()
136 },151 },
137 );152 );
138 for (index, (name, color)) in PAGE_COLORS.iter().enumerate() {153 for (index, (name, color)) in swatches
154 .iter()
155 .enumerate()
156 .map(|(index, color)| (row * per_row + index, color))
157 {
139 let swatch = ui.leaf(158 let swatch = ui.leaf(
140 (index, *name),159 (index, *name),
141 Spec {160 Spec {
...@@ -154,7 +173,9 @@ fn strip(...@@ -154,7 +173,9 @@ fn strip(
154 }173 }
155 ui.close();174 ui.close();
156 }175 }
157 let width = ui.rect(ui.id("tiles")).map_or(0.0, |rect| rect[2] - rect[0]);176 let width = ui
177 .rect(ui.id("tiles"))
178 .map_or(0.0, |rect| rect[2] - rect[0]);
158 let close = ui.leaf(179 let close = ui.leaf(
159 "dismiss",180 "dismiss",
160 Spec {181 Spec {
...@@ -279,6 +300,11 @@ fn page_color(color: u32, paper: Paper, dark: bool) -> [f32; 4] {...@@ -279,6 +300,11 @@ fn page_color(color: u32, paper: Paper, dark: bool) -> [f32; 4] {
279 [red, green, blue, 1.0]300 [red, green, blue, 1.0]
280}301}
281302
303/// How wide a tile labelled `label` stands: its thumbnail, or the label where it is wider.
304fn tile_width(ui: &mut Ui, label: &str) -> f32 {
305 TILE[0].max(ui.measure(label)[0] + 4.0)
306}
307
282/// One template's thumbnail over its label, as wide as the thumbnail or the label; true308/// One template's thumbnail over its label, as wide as the thumbnail or the label; true
283/// when clicked.309/// when clicked.
284#[allow(clippy::too_many_arguments)]310#[allow(clippy::too_many_arguments)]
...@@ -293,7 +319,7 @@ fn tile(...@@ -293,7 +319,7 @@ fn tile(
293 label: &str,319 label: &str,
294) -> bool {320) -> bool {
295 let scale = ui.scale();321 let scale = ui.scale();
296 let width = TILE[0].max(ui.measure(label)[0] + 4.0);322 let width = tile_width(ui, label);
297 let fill_color = match choice {323 let fill_color = match choice {
298 Choice::Color(index) => page_color(PAGE_COLORS[index].1, paper, dark),324 Choice::Color(index) => page_color(PAGE_COLORS[index].1, paper, dark),
299 Choice::More | Choice::Dismiss => theme.base,325 Choice::More | Choice::Dismiss => theme.base,
...@@ -405,11 +431,12 @@ impl crate::State {...@@ -405,11 +431,12 @@ impl crate::State {
405 (viewport.origin[0] + start[0] * viewport.scale) / scale,431 (viewport.origin[0] + start[0] * viewport.scale) / scale,
406 (viewport.origin[1] + (start[1] + LINES_ABOVE) * viewport.scale) / scale,432 (viewport.origin[1] + (start[1] + LINES_ABOVE) * viewport.scale) / scale,
407 ];433 ];
434 // Thumbnails show templates on plain paper, not the page's colour.
408 let paper = Paper {435 let paper = Paper {
409 color: theme.paper,436 color: self.ui.theme.paper,
410 ink: theme.paper_ink,437 ink: self.ui.theme.paper_ink,
411 };438 };
412 let dark = theme.paper_ink[0] > theme.paper[0];439 let dark = paper.ink[0] > paper.color[0];
413 let chosen = match self.templates {440 let chosen = match self.templates {
414 View::Gallery => {441 View::Gallery => {
415 let size = [rect[2] - rect[0], rect[3] - rect[1]];442 let size = [rect[2] - rect[0], rect[3] - rect[1]];
...@@ -422,6 +449,7 @@ impl crate::State {...@@ -422,6 +449,7 @@ impl crate::State {
422 paper,449 paper,
423 dark,450 dark,
424 position,451 position,
452 rect[2] - rect[0] - position[0] - 24.0,
425 view == View::Colors,453 view == View::Colors,
426 ),454 ),
427 };455 };
crates/ui/src/shell.rs+35-7
...@@ -17,17 +17,17 @@ pub const TOOL: f32 = 22.0;...@@ -17,17 +17,17 @@ pub const TOOL: f32 = 22.0;
1717
18/// Section tabs in a row `height` tall, as OneNote draws them: each leans over the next18/// Section tabs in a row `height` tall, as OneNote draws them: each leans over the next
19/// at 45°, and the open one lies on top in `section`'s colours, rising to meet the frame19/// at 45°, and the open one lies on top in `section`'s colours, rising to meet the frame
20/// below as its outline and shadow fade in. `tabs` are names and section colours. Returns20/// below as its outline and shadow fade in. `tabs` are names and section colours; `lit`
21/// the tab clicked, the tab whose context menu was asked for and where, and the open tab's21/// shows as hovered, as a tab something is dragged onto.
22/// id, for `tab_base`.
23pub fn section_tabs(22pub fn section_tabs(
24 ui: &mut Ui,23 ui: &mut Ui,
25 part: impl Hash,24 part: impl Hash,
26 tabs: &[(&str, [f32; 4])],25 tabs: &[(&str, [f32; 4])],
27 active: usize,26 active: usize,
27 lit: Option<usize>,
28 section: &Section,28 section: &Section,
29 height: f32,29 height: f32,
30) -> (Option<usize>, Option<(usize, [f32; 2])>, Id) {30) -> Tabs {
31 let theme = ui.theme.clone();31 let theme = ui.theme.clone();
32 ui.open(32 ui.open(
33 part,33 part,
...@@ -47,7 +47,7 @@ pub fn section_tabs(...@@ -47,7 +47,7 @@ pub fn section_tabs(
47 (left - width, width)47 (left - width, width)
48 })48 })
49 .collect();49 .collect();
50 let (mut clicked, mut context) = (None, None);50 let (mut clicked, mut context, mut held) = (None, None, None);
51 // Earlier tabs lie over later ones; the open tab over all.51 // Earlier tabs lie over later ones; the open tab over all.
52 let order = (0..tabs.len())52 let order = (0..tabs.len())
53 .rev()53 .rev()
...@@ -70,7 +70,11 @@ pub fn section_tabs(...@@ -70,7 +70,11 @@ pub fn section_tabs(
70 position: [x, height - tall],70 position: [x, height - tall],
71 text: Some(name),71 text: Some(name),
72 color: Some(mix(mix(theme.ink, fill, 0.2), theme.ink, open)),72 color: Some(mix(mix(theme.ink, fill, 0.2), theme.ink, open)),
73 fill: Some(mix(fill, [1.0; 4], 0.08)),73 fill: Some(if lit == Some(index) {
74 mix(colors.frame[0], [1.0; 4], 0.08)
75 } else {
76 mix(fill, [1.0; 4], 0.08)
77 }),
74 gradient: Some(fill),78 gradient: Some(fill),
75 hover_fill: (index != active).then(|| mix(colors.frame[0], [1.0; 4], 0.08)),79 hover_fill: (index != active).then(|| mix(colors.frame[0], [1.0; 4], 0.08)),
76 border: Some(fade(section.edge, open)),80 border: Some(fade(section.edge, open)),
...@@ -87,10 +91,34 @@ pub fn section_tabs(...@@ -87,10 +91,34 @@ pub fn section_tabs(
87 if let Some(point) = signal.context {91 if let Some(point) = signal.context {
88 context = Some((index, point));92 context = Some((index, point));
89 }93 }
94 if signal.dragging {
95 held = Some(index);
96 }
90 }97 }
91 let open_tab = ui.id(("tab", active));98 let open_tab = ui.id(("tab", active));
92 ui.close();99 ui.close();
93 (clicked, context, open_tab)100 Tabs {
101 clicked,
102 context,
103 held,
104 open: open_tab,
105 }
106}
107
108/// The id of tab `index` of the section tabs built as `row`.
109pub fn tab_id(row: Id, index: usize) -> Id {
110 row.child(("tab", index))
111}
112
113/// What the section tabs were asked this frame.
114pub struct Tabs {
115 pub clicked: Option<usize>,
116 /// The tab whose context menu was asked for, and where.
117 pub context: Option<(usize, [f32; 2])>,
118 /// The tab held down, which a drag moves.
119 pub held: Option<usize>,
120 /// The open tab's id, for `tab_base`.
121 pub open: Id,
94}122}
95123
96/// Where the tab laid out at `tab` in a row `height` tall meets the edge below it: from its124/// Where the tab laid out at `tab` in a row `height` tall meets the edge below it: from its
crates/ui/src/tests.rs+1-1
...@@ -162,7 +162,7 @@ fn a_click_within_one_frame_starts_an_animation_read_before_it() {...@@ -162,7 +162,7 @@ fn a_click_within_one_frame_starts_an_animation_read_before_it() {
162 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);162 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
163 let mut open = false;163 let mut open = false;
164 let mut width = 0.0;164 let mut width = 0.0;
165 let mut build = |ui: &mut Ui, open: &mut bool, width: &mut f32| {165 let build = |ui: &mut Ui, open: &mut bool, width: &mut f32| {
166 *width = ui.animate(Id::ROOT.child("panel"), if *open { 100.0 } else { 0.0 });166 *width = ui.animate(Id::ROOT.child("panel"), if *open { 100.0 } else { 0.0 });
167 if button(ui, "toggle", "Toggle").clicked {167 if button(ui, "toggle", "Toggle").clicked {
168 *open = true;168 *open = true;
readme.md+1-1
...@@ -12,7 +12,7 @@ pen and drawing tools, recording audio and video, revision history, multi-machin...@@ -12,7 +12,7 @@ pen and drawing tools, recording audio and video, revision history, multi-machin
12live collaboration, and much more.12live collaboration, and much more.
1313
14<!-- Regenerate from the sample notebook (edit it freely in OneNote or Snowbound):14<!-- Regenerate from the sample notebook (edit it freely in OneNote or Snowbound):
15python3 tools/canvas/build_macos.py --release && d=$(mktemp -d) && cp -R docs/sample-notebook/Personal "$d" && target/Snowbound.app/Contents/MacOS/Snowbound --notebook "$d/Personal" --cache "$d/cache" --screenshot docs/screenshot15python3 tools/canvas/build_macos.py --release && d=$(mktemp -d) && cp -R docs/sample-notebook/Personal "$d" && target/Snowbound.app/Contents/MacOS/Snowbound --notebook "$d/Personal" --cache "$d/cache" --settings "$d/settings.json" --screenshot docs/screenshot
16-->16-->
17<picture>17<picture>
18 <source media="(prefers-color-scheme: dark)" srcset="docs/screenshot-dark.png">18 <source media="(prefers-color-scheme: dark)" srcset="docs/screenshot-dark.png">