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 = [
625625 "accesskit_consumer",
626626 "color_quant",
627627 "draw",
628 "icu_normalizer",
628629 "image",
629630 "onestore",
630631 "parley",
......@@ -1468,6 +1469,9 @@ dependencies = [
14681469 "icu_properties",
14691470 "icu_provider",
14701471 "smallvec",
1472 "utf16_iter",
1473 "utf8_iter",
1474 "write16",
14711475 "zerovec",
14721476]
14731477
......@@ -1886,6 +1890,7 @@ dependencies = [
18861890 "pollster",
18871891 "serde",
18881892 "serde_json",
1893 "tempfile",
18891894 "wgpu",
18901895]
18911896
......@@ -3666,6 +3671,12 @@ dependencies = [
36663671 "tiny-skia-path 0.12.0",
36673672]
36683673
3674[[package]]
3675name = "utf16_iter"
3676version = "1.0.5"
3677source = "registry+https://github.com/rust-lang/crates.io-index"
3678checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
3679
36693680[[package]]
36703681name = "utf8_iter"
36713682version = "1.0.4"
......@@ -4460,6 +4471,12 @@ version = "0.57.1"
44604471source = "registry+https://github.com/rust-lang/crates.io-index"
44614472checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
44624473
4474[[package]]
4475name = "write16"
4476version = "1.0.0"
4477source = "registry+https://github.com/rust-lang/crates.io-index"
4478checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"
4479
44634480[[package]]
44644481name = "writeable"
44654482version = "0.6.4"
crates/canvas/Cargo.toml+1
......@@ -15,6 +15,7 @@ onestore = { path = "../onestore" }
1515parley.workspace = true
1616serde = { version = "1.0.229", features = ["derive"] }
1717serde_json = "1.0.151"
18icu_normalizer = "2.3"
1819skrifa = "0.44.0"
1920
2021accesskit = { 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 {
10951095 ]
10961096 }
10971097
1098 /// The page's colour (View, Page Color), COLORREF.
1099 pub fn page_color(&self) -> Option<u32> {
1100 self.header.color
1101 }
1102
10981103 /// Where the page's first body line starts, in points, under its title.
10991104 pub fn body_start(&self) -> Option<[f32; 2]> {
11001105 let title = self
......@@ -1759,6 +1764,41 @@ impl CanvasEditor {
17591764 Ok(true)
17601765 }
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
17621802 pub fn remove_image(&mut self, engine: &mut TextEngine, id: ExGuid) -> Result<(), EditorError> {
17631803 if self.outline_picture(id).is_some() {
17641804 return self.edit_outline_picture(engine, id, None);
crates/canvas/src/gpu/mod.rs+34
......@@ -52,6 +52,20 @@ impl Paper {
5252 ink: [0.0, 0.0, 0.0, 1.0],
5353 };
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
5569 /// A fill OneNote draws on white paper, moved onto this paper: as far from it in
5670 /// OKLab lightness as from white, in the same hue and chroma, so text keeps its
5771 /// contrast on it.
......@@ -171,6 +185,26 @@ mod tests {
171185 use onestore::page::text::Paragraph;
172186 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
174208 #[test]
175209 fn runs_carry_their_highlight_as_what_lies_behind_them() {
176210 let run = |highlight, color| {
crates/canvas/src/interaction/mod.rs+25-6
......@@ -199,6 +199,9 @@ pub struct PageView {
199199 /// Input is by finger: the focused outline's grips reach further, and outline chrome
200200 /// shows only while the view is focused, as there is no hover to reveal it.
201201 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,
202205 /// The caret's opacity in its blink.
203206 caret: f32,
204207 /// When the caret last moved, which restarts its blink.
......@@ -256,6 +259,7 @@ impl PageView {
256259 modifiers: Modifiers::default(),
257260 focused: true,
258261 touch: false,
262 host_viewport: false,
259263 caret: 1.0,
260264 blink_from: Instant::now(),
261265 };
......@@ -403,7 +407,12 @@ impl PageView {
403407 fn hit_test(&self, point: [f32; 2]) -> Option<Hit> {
404408 if let Some(ObjectFocus::Image(id)) = self.object_focus
405409 && 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 )
407416 {
408417 return Some(Hit::Image { id, handle });
409418 }
......@@ -456,14 +465,18 @@ impl PageView {
456465
457466 /// Keeps the view in bounds and restarts the caret blink after a change.
458467 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 }
460471 self.caret = 1.0;
461472 self.blink_from = Instant::now();
462473 Ok(Response::changed())
463474 }
464475
465476 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 }
467480 Ok(Response {
468481 moved: true,
469482 redraw: true,
......@@ -563,7 +576,9 @@ impl PageView {
563576
564577 /// Reveals the caret or focused object after an edit, then reports the change.
565578 fn edited(&mut self) -> Result<Response> {
566 self.reveal_focus()?;
579 if !self.host_viewport {
580 self.reveal_focus()?;
581 }
567582 self.changed()
568583 }
569584
......@@ -1641,9 +1656,13 @@ fn image_handles(rect: [f32; 4], pixel: f32) -> impl Iterator<Item = ([i8; 2], [
16411656 })
16421657}
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]> {
16451662 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 })
16471666 .map(|(handle, _)| handle)
16481667}
16491668
crates/canvas/src/interaction/tests.rs+3-3
......@@ -691,14 +691,14 @@ fn picture_handles_resize_as_onenote_does() {
691691 let pixel = 0.75;
692692 let rect = image_rect(origin, size);
693693 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),
695695 Some([-1, -1])
696696 );
697697 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),
699699 Some([0, 1])
700700 );
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);
702702}
703703
704704const DEFAULT_MARGIN: [f32; 2] = [36.0, 14.4];
crates/canvas/src/lib.rs+2
......@@ -1,9 +1,11 @@
1pub mod conflict;
12pub mod document;
23pub mod editor;
34pub mod language;
45pub mod layout;
56pub mod math;
67pub mod outline;
8pub mod search;
79pub mod template;
810
911pub 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] = &[
603603
604604/// OneNote 2010's page colours in its View, Page Color menu order, with the names its
605605/// tooltips give them; COLORREF, as the page XML reports each
606/// (evidence/notebook-management/page-colors).
606/// (`corpus/notebook-management/native/page-color`).
607607pub const PAGE_COLORS: &[(&str, u32)] = &[
608608 ("Blue", 0xfef5ed),
609609 ("Red", 0xefeeff),
crates/snowbound/Cargo.toml+2-1
......@@ -10,7 +10,7 @@ draw = { path = "../draw" }
1010ui = { path = "../ui" }
1111png = "0.18"
1212onestore = { path = "../onestore" }
13notebook = { path = "../notebook" }
13notebook = { path = "../notebook", features = ["smb"] }
1414serde = { version = "1", features = ["derive"] }
1515serde_json = "1"
1616pollster = "0.4"
......@@ -22,6 +22,7 @@ accesskit = "0.25.0"
2222accesskit_winit = { version = "0.34.0", default-features = false, features = ["rwh_06"] }
2323
2424[target.'cfg(target_os = "macos")'.dependencies]
25libc = "0.2"
2526objc2 = "0.5.2"
2627objc2-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"] }
2728objc2-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
2121puts the latter elsewhere.
2222
2323Run `./install.sh` to add Snowbound to your app launcher; it copies the app
24into `~/.local`. On Wayland the taskbar finds Snowbound's icon only through
25this launcher entry, so without it the taskbar shows a generic icon.
24into `~/.local`. On Wayland the taskbar, dock and KDE's title bar find
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.
2631`./install.sh ~/Notebooks/MyNotebook` makes the launcher open that notebook as
2732well.
2833
crates/snowbound/src/conflict_render.rs+1-1
......@@ -254,7 +254,7 @@ fn conflict_views_offer_the_versions_and_render_offscreen() {
254254 let highlighted = kept.objects.iter().any(|object| match object {
255255 onestore::page::PageObject::Outline(outline) => outline.paragraphs.iter().any(|paragraph| {
256256 paragraph.text().is_some_and(|text| marked.contains(&text.id))
257 && paragraph.format.highlight == Some(CONFLICTING)
257 && paragraph.format.highlight == Some(canvas::conflict::CONFLICTING)
258258 }),
259259 _ => false,
260260 });
crates/snowbound/src/library.rs+452-9
......@@ -2,11 +2,117 @@
22
33use notebook::discover::{Folder, SectionState};
44use notebook::session::{Notebook, Section};
5use notebook::smb::{Client, Credentials};
56use std::{
67 error::Error,
8 io,
79 path::{Path, PathBuf},
10 sync::Arc,
11 time::Duration,
812};
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
10116/// A section tab: where the section opens from and how it is labelled.
11117pub struct Tab {
12118 /// The notebook catalog path, or the file of a section opened on its own.
......@@ -24,28 +130,95 @@ pub struct Library {
24130 pub name: String,
25131 /// `Err` holds why a notebook listed as open could not be read this time.
26132 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>,
27137 cache: PathBuf,
28138}
29139
30140impl 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.
32145 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 }
33158 Self {
34159 location: location.to_owned(),
35160 name: file_name(Path::new(location)),
36161 notebook: Notebook::open(location, cache)
37162 .map(Some)
38163 .map_err(|error| error.to_string()),
164 server: None,
165 notice,
39166 cache: cache.to_owned(),
40167 }
41168 }
42169
43 /// `notebook`, open at `location`.
44 pub fn open_notebook(location: &str, notebook: Notebook, cache: &Path) -> Self {
170 /// The notebook at `location`, which `mount` names on its server, opened through the
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 {
45216 Self {
46217 location: location.to_owned(),
47218 name: file_name(Path::new(location)),
48219 notebook: Ok(Some(notebook)),
220 server: None,
221 notice: None,
49222 cache: cache.to_owned(),
50223 }
51224 }
......@@ -56,19 +229,65 @@ impl Library {
56229 location: file.to_string_lossy().into_owned(),
57230 name: file_name(file.parent().unwrap_or(file)),
58231 notebook: Ok(None),
232 server: None,
233 notice: None,
59234 cache: cache.to_owned(),
60235 }
61236 }
62237
63 /// Opens the section at catalog `path`.
64 pub fn open(&self, path: &str, notify: impl Fn() + Send + 'static) -> Result<Section, Box<dyn Error>> {
65 Ok(match &self.notebook {
66 Ok(Some(notebook)) => notebook.section(path, notify)?,
67 Ok(None) => Section::open(path, &self.cache, notify)?,
68 Err(error) => return Err(error.clone().into()),
238 /// Opens the section at catalog `path`; on a share, through a replica named by its
239 /// file identity that publishes over the embedded client.
240 pub fn open(
241 &self,
242 path: &str,
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()),
69270 })
70271 }
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
72291 /// Names the section at catalog `path` across every open notebook, for remembering
73292 /// its pages.
74293 pub fn key(&self, path: &str) -> String {
......@@ -177,3 +396,227 @@ fn tabs(catalog: &Folder) -> Vec<Tab> {
177396 })
178397 .collect()
179398}
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> {
221221 xdg_dir("XDG_CACHE_HOME", ".cache")
222222}
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
224282pub fn settings_dir() -> Option<PathBuf> {
225283 xdg_dir("XDG_CONFIG_HOME", ".config")
226284}
crates/snowbound/src/macos.rs+86
......@@ -117,6 +117,92 @@ pub fn cache_dir() -> Option<std::path::PathBuf> {
117117 Some(std::path::PathBuf::from(std::env::var_os("HOME")?).join("Library/Caches/snowbound"))
118118}
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
120206pub fn settings_dir() -> Option<std::path::PathBuf> {
121207 Some(
122208 std::path::PathBuf::from(std::env::var_os("HOME")?)
crates/snowbound/src/main.rs+180-94
......@@ -3,6 +3,7 @@ mod art;
33mod conflict_render;
44mod library;
55mod manage;
6mod meeting;
67mod menus;
78#[cfg_attr(target_os = "linux", path = "linux.rs")]
89#[cfg_attr(target_os = "macos", path = "macos.rs")]
......@@ -80,8 +81,6 @@ const FONT_COLORS: [u32; 20] = [
8081/// while the gaps still take the pointer.
8182const ROW: f32 = 29.0;
8283const ROW_GAP: f32 = 3.0;
83/// OneNote's highlight for conflicting changes on a conflict page, COLORREF.
84const CONFLICTING: u32 = 0xd6d6ff;
8584/// Space between the page and a page tab that isn't open.
8685const PILL_MARGIN: f32 = 3.0;
8786/// Rounding where the open page tab meets the page, concentric with its neighbours'.
......@@ -122,6 +121,7 @@ enum Replay {
122121 /// A frame during a wait, as a visible window's display would ask for.
123122 Tick,
124123 Appearance(winit::window::Theme),
124 Quit,
125125}
126126
127127impl From<accesskit_winit::Event> for UserEvent {
......@@ -165,9 +165,8 @@ struct App {
165165 interface_font: Vec<Vec<u8>>,
166166 /// Where `--screenshot` writes its PNGs, drawn from a window never shown.
167167 screenshot: Option<PathBuf>,
168 /// Where settings are saved, and what they held at launch, until the window opens.
169 settings: Option<(Option<PathBuf>, settings::Settings)>,
170 cache: PathBuf,
168 /// What the window starts from besides its input, until it opens.
169 launch: Option<settings::Launch>,
171170 state: Option<State>,
172171 startup_error: Option<Box<dyn Error>>,
173172}
......@@ -226,6 +225,16 @@ enum Bar {
226225}
227226
228227impl 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
229238 fn title(&self) -> &str {
230239 match self.conflict(self.space) {
231240 Some((_, version)) => &version.title,
......@@ -285,26 +294,35 @@ impl Session {
285294 move || {
286295 let page = replica.page(space)?;
287296 Ok(match objects {
288 Some(objects) => highlighted(page, &objects),
297 Some(objects) => canvas::conflict::highlighted(page, &objects),
289298 None => page,
290299 })
291300 }
292301 }
293302}
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.
296307enum 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 {
297315 Section(Box<Session>),
298316 Page(ExGuid),
299317}
300318
301/// A loader thread's request number, what it opened and the page it read.
302type Read = (u64, Result<(Loaded, Page), String>);
319/// A loader thread's request number and what it read.
320type Read = (u64, Result<Loaded, String>);
303321
304322/// The newest page read, laid out and waiting for the pictures it shows first, so it
305323/// never appears without them.
306324struct Opening {
307 loaded: Loaded,
325 loaded: Shown,
308326 scene: (PageScene, [f32; 2]),
309327 editor: CanvasEditor,
310328 since: Instant,
......@@ -325,13 +343,15 @@ enum Command {
325343 CloseNotebook(Arc<Library>),
326344 /// Changes a notebook's sections and groups.
327345 Structure(Arc<Library>, manage::Structure),
328 /// Adds a page at the end of the open section, or a subpage after the open page, and
329 /// edits its title.
330 NewPage { subpage: bool },
346 /// Adds a page at the end of the open section, or a subpage of a page, and edits its
347 /// title.
348 NewPage { under: Option<ExGuid> },
331349 /// Deletes pages of the open section to the notebook's recycle bin.
332350 DeletePages(Vec<ExGuid>),
333351 /// Moves or indents pages of the open section.
334352 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 },
335355 /// Gives the open page a template's background or colour.
336356 Template(templates::Choice),
337357 OpenPage(ExGuid),
......@@ -386,10 +406,8 @@ struct State {
386406 menu: Option<(menus::Target, [f32; 2])>,
387407 /// A section or group being renamed in the sidebar.
388408 renaming: Option<sidebar::Renaming>,
389 /// The page whose tab is being dragged.
390 dragging: Option<ExGuid>,
391 /// The section or group whose sidebar row is being dragged.
392 dragging_entry: Option<sidebar::Entry>,
409 /// What the pointer is dragging: a page's tab, a section tab or a sidebar row.
410 drag: Option<menus::Drag>,
393411 /// Pages whose template strip was dismissed this run.
394412 dismissed: HashSet<ExGuid>,
395413 /// A page just created, whose title takes the caret once it opens.
......@@ -461,8 +479,11 @@ impl State {
461479 substitutes: &[PathBuf],
462480 interface_font: Vec<Vec<u8>>,
463481 visible: bool,
464 (settings, stored): (Option<PathBuf>, settings::Settings),
465 cache: PathBuf,
482 settings::Launch {
483 file: settings,
484 saved: stored,
485 cache,
486 }: settings::Launch,
466487 ) -> Result<Self, Box<dyn Error>> {
467488 let window = Arc::new(
468489 event_loop.create_window(
......@@ -652,8 +673,7 @@ impl State {
652673 title_focus: None,
653674 menu: None,
654675 renaming: None,
655 dragging: None,
656 dragging_entry: None,
676 drag: None,
657677 templates: templates::View::Strip,
658678 thumbnails: templates::Thumbnails::default(),
659679 filter: String::new(),
......@@ -795,7 +815,9 @@ impl State {
795815 self.ui.theme.inactive_selection,
796816 ] = platform::text_colors(&self.window);
797817 }
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;
799821 if !platform::system_titlebar(&self.window) {
800822 self.title_bar(&theme);
801823 }
......@@ -820,7 +842,7 @@ impl State {
820842 ..Spec::default()
821843 },
822844 );
823 self.ui.open(
845 let tab_row = self.ui.open(
824846 "tabs",
825847 Spec {
826848 size: [fill(), px(TAB_ROW)],
......@@ -836,6 +858,7 @@ impl State {
836858 ..Spec::default()
837859 },
838860 );
861 let row = self.ui.id("sections");
839862 let (clicked, open_tab) = match &self.session {
840863 Some(session) => {
841864 let tabs: Vec<_> = session
......@@ -843,14 +866,26 @@ impl State {
843866 .iter()
844867 .map(|tab| (tab.name.as_str(), section_color(tab.color)))
845868 .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(
847876 &mut self.ui,
848877 "sections",
849878 &tabs,
850879 session.tab,
880 lit,
851881 &section,
852882 TAB_ROW,
853883 );
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 };
854889 let open = |tab: usize| {
855890 Command::OpenSection(Arc::clone(&session.library), session.tabs[tab].path.clone())
856891 };
......@@ -869,8 +904,16 @@ impl State {
869904 None => {
870905 let tabs = [("Temporary page", section_color(None))];
871906 let shown = if self.temporary { &tabs[..] } else { &[] };
872 let (_, _, open_tab) =
873 ui::shell::section_tabs(&mut self.ui, "sections", shown, 0, &section, TAB_ROW);
907 let open_tab = ui::shell::section_tabs(
908 &mut self.ui,
909 "sections",
910 shown,
911 0,
912 None,
913 &section,
914 TAB_ROW,
915 )
916 .open;
874917 (None, open_tab)
875918 }
876919 };
......@@ -949,7 +992,7 @@ impl State {
949992 }
950993 self.page_events(signal.events)?;
951994 self.ui.close();
952 let open_page = self.page_list(&theme, &section);
995 let open_page = self.page_list(&theme, &section, row);
953996 self.ui.close();
954997 self.ui.close();
955998 self.ui.close();
......@@ -1008,7 +1051,7 @@ impl State {
10081051 }
10091052 outline.push(([left, bottom], ROUNDING));
10101053 let height = (frame[3] - frame[1]).max(1.0);
1011 let paper = self.ui.theme.paper;
1054 let paper = self.paper().color;
10121055 self.ui.round_corners(&outline, paper, |y| {
10131056 ui::mix(section.frame[0], section.frame[1], (y - frame[1]) / height)
10141057 });
......@@ -1092,7 +1135,7 @@ impl State {
10921135 "status",
10931136 Spec {
10941137 size: [fit(), px(TITLE)],
1095 text: Some(session.status),
1138 text: Some(&session.status()),
10961139 color: Some(theme.text_dim),
10971140 pad: [12.0, 0.0],
10981141 ..Spec::default()
......@@ -1126,7 +1169,7 @@ impl State {
11261169 let engine = &self.view.engine;
11271170 // Under the system's title bar the save status joins the toolbar.
11281171 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))
11301173 .flatten();
11311174 let ui = &mut self.ui;
11321175 let text = theme.text;
......@@ -1446,8 +1489,8 @@ impl State {
14461489 ui.leaf(
14471490 "status",
14481491 Spec {
1449 size: [fit(), fill()],
1450 text: Some(status),
1492 size: [fit(), px(ui::shell::TOOL)],
1493 text: Some(&status),
14511494 color: Some(theme.text_dim),
14521495 center: true,
14531496 ..Spec::default()
......@@ -1579,7 +1622,7 @@ impl State {
15791622 }
15801623 }
15811624 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 });
15831626 }
15841627 let toggle = if self.pages_open {
15851628 art::SIDEBAR_COLLAPSE
......@@ -1594,7 +1637,7 @@ impl State {
15941637
15951638 /// The section's pages as tabs down the frame's right side, returning the open page's
15961639 /// 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> {
15981641 let session = self.session.as_ref()?;
15991642 let panel = self.ui.id("panel");
16001643 let width = self
......@@ -1616,7 +1659,7 @@ impl State {
16161659 self.ui.open_popup(menus::id());
16171660 }
16181661 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);
16201663 self.ui.close();
16211664 rows.open
16221665 }
......@@ -1681,6 +1724,13 @@ impl State {
16811724 self.ui.set_focus(Some(filter()));
16821725 continue;
16831726 }
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 }
16841734 self.view.key(&ui::edit_key(&key), text.as_deref())?
16851735 }
16861736 ui::Event::Ime(Ime::Preedit(text, cursor)) => self.view.compose(text, cursor)?,
......@@ -1715,13 +1765,13 @@ impl State {
17151765 self.load(move || {
17161766 let section = library.open(&path, notify)?;
17171767 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))
17191769 });
17201770 }
17211771 Command::OpenPage(space) => {
17221772 let session = self.session.as_ref().ok_or("No section is open")?;
17231773 let read = session.reader(space);
1724 self.load(move || Ok((Loaded::Page(space), read()?)));
1774 self.load(move || Ok(Loaded::Page(space, read()?)));
17251775 }
17261776 Command::Versions { page, show } => {
17271777 let session = self.session.as_mut().ok_or("No section is open")?;
......@@ -1769,9 +1819,10 @@ impl State {
17691819 Command::NewNotebook => self.new_notebook()?,
17701820 Command::CloseNotebook(library) => self.close_notebook(&library),
17711821 Command::Structure(library, change) => self.restructure(library, change),
1772 Command::NewPage { subpage } => self.new_page(subpage)?,
1822 Command::NewPage { under } => self.new_page(under)?,
17731823 Command::DeletePages(pages) => self.delete_pages(pages)?,
17741824 Command::Pages(edits) => self.edit_pages(edits)?,
1825 Command::MovePage { space, path } => self.move_page(space, path)?,
17751826 Command::Template(choice) => self.apply_template(choice)?,
17761827 Command::Page(Request::EditDate(field)) => self.edit_date(field)?,
17771828 Command::Page(Request::Copy(text)) => self.clipboard.set_text(text)?,
......@@ -1790,7 +1841,7 @@ impl State {
17901841 /// read was asked for meanwhile.
17911842 fn load(
17921843 &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,
17941845 ) {
17951846 self.loading += 1;
17961847 let (id, sender, redraw) = (self.loading, self.loads.0.clone(), self.redraw.clone());
......@@ -1803,13 +1854,20 @@ impl State {
18031854 /// Lays out the newest page read, and shows it once the pictures it shows first are
18041855 /// drawn, or after `HOLD`.
18051856 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 {
18071859 if id != self.loading {
18081860 continue;
18091861 }
18101862 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 }
18121869 Err(error) => {
1870 eprintln!("{error}");
18131871 platform::alert("Couldn't open", &error);
18141872 continue;
18151873 }
......@@ -1828,7 +1886,8 @@ impl State {
18281886 let paper = canvas::gpu::Paper {
18291887 color: self.ui.theme.paper,
18301888 ink: self.ui.theme.paper_ink,
1831 };
1889 }
1890 .colored(opening.editor.page_color());
18321891 if !self
18331892 .view
18341893 .prepare(&mut opening.scene, &opening.editor, paper, &self.redraw)
......@@ -1845,7 +1904,7 @@ impl State {
18451904 self.last_pages.insert(key, session.space);
18461905 }
18471906 match opening.loaded {
1848 Loaded::Section(session) => {
1907 Shown::Section(session) => {
18491908 // A notebook read again after a change replaces the one it was.
18501909 match self
18511910 .notebooks
......@@ -1859,7 +1918,7 @@ impl State {
18591918 self.filter.clear();
18601919 self.save_settings();
18611920 }
1862 Loaded::Page(space) => {
1921 Shown::Page(space) => {
18631922 let session = self.session.as_mut().ok_or("No section is open")?;
18641923 session.space = space;
18651924 session.change = None;
......@@ -1886,6 +1945,46 @@ impl State {
18861945 Ok(())
18871946 }
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
18891988 /// Follows a page shown in place of another.
18901989 fn opened(&mut self) -> Result<(), Box<dyn Error>> {
18911990 self.ui.set_focus(Some(page()));
......@@ -2064,6 +2163,10 @@ impl State {
20642163 },
20652164 )?;
20662165 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 }
20672170 Ok(())
20682171 }
20692172
......@@ -2316,13 +2419,19 @@ impl State {
23162419 Ok(())
23172420 }
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
23192431 /// Paints the interface with the page in its box.
23202432 fn paint(&mut self, target: &wgpu::TextureView) -> Result<(), Box<dyn Error>> {
23212433 let start = Instant::now();
2322 let paper = canvas::gpu::Paper {
2323 color: self.ui.theme.paper,
2324 ink: self.ui.theme.paper_ink,
2325 };
2434 let paper = self.paper();
23262435 self.view.update_pictures(paper, &self.redraw);
23272436 let theme = &self.ui.theme;
23282437 let page_primitives = self.view.primitives(TextColors {
......@@ -2356,7 +2465,7 @@ impl State {
23562465 viewport.origin[1] + corner[1] * scale,
23572466 ],
23582467 clip: Some(rect.map(|value| value * scale)),
2359 backdrop: Some(self.ui.theme.paper),
2468 backdrop: Some(paper.color),
23602469 primitives: &page_primitives,
23612470 },
23622471 })
......@@ -2790,41 +2899,6 @@ fn page_tab(
27902899 ui.signal(row)
27912900}
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
28282902/// The pages whose titles contain `filter`, ignoring case.
28292903fn matching<'a>(
28302904 pages: &'a [(ExGuid, String, u32)],
......@@ -3025,6 +3099,10 @@ impl ApplicationHandler<UserEvent> for App {
30253099 state.window.set_theme(Some(appearance));
30263100 state.ui.theme = theme(appearance);
30273101 }
3102 Replay::Quit => {
3103 self.close(event_loop);
3104 return;
3105 }
30283106 }
30293107 // A covered window gets no redraws, so each step draws its own frame.
30303108 if let Err(error) = state.frame() {
......@@ -3072,15 +3150,15 @@ impl ApplicationHandler<UserEvent> for App {
30723150 &self.substitutes,
30733151 std::mem::take(&mut self.interface_font),
30743152 self.screenshot.is_none(),
3075 self.settings.take().unwrap_or_default(),
3076 self.cache.clone(),
3153 self.launch.take().expect("The window opens once"),
30773154 )) {
30783155 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() => {
30803158 self.startup_error = state.screenshot(prefix).err();
30813159 event_loop.exit();
30823160 }
3083 None => self.state = Some(state),
3161 _ => self.state = Some(state),
30843162 },
30853163 Err(error) => {
30863164 self.startup_error = Some(error);
......@@ -3217,9 +3295,9 @@ impl ApplicationHandler<UserEvent> for App {
32173295}
32183296
32193297/// 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`, `type
3221/// TEXT`, `modifiers [shift] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH` and
3222/// `appearance light|dark`.
3298/// in logical pixels: `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `key NAME`, `type
3299/// TEXT`, `modifiers [shift] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`,
3300/// `appearance light|dark` and `quit`.
32233301fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dyn Error>> {
32243302 let mut steps = Vec::new();
32253303 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
32283306 rest.split_whitespace().map(str::parse).collect()
32293307 };
32303308 let button = |pressed| ui::Event::Button {
3231 button: MouseButton::Left,
3309 button: if rest == "right" {
3310 MouseButton::Right
3311 } else {
3312 MouseButton::Left
3313 },
32323314 pressed,
32333315 at: Instant::now(),
32343316 };
......@@ -3269,6 +3351,7 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy
32693351 .try_fold(ModifiersState::empty(), |all, one| one.map(|one| all | one))?,
32703352 ))),
32713353 "wait" => Err(std::time::Duration::from_millis(rest.parse()?)),
3354 "quit" => Ok(Replay::Quit),
32723355 "snapshot" => Ok(Replay::Snapshot(rest.into())),
32733356 "appearance" => Ok(Replay::Appearance(match rest {
32743357 "light" => winit::window::Theme::Light,
......@@ -3467,8 +3550,11 @@ fn main() -> Result<(), Box<dyn Error>> {
34673550 proxy: event_loop.create_proxy(),
34683551 input: Some(input),
34693552 // A screenshot leaves the settings as it found them.
3470 settings: Some((settings_file.filter(|_| screenshot.is_none()), saved)),
3471 cache,
3553 launch: Some(settings::Launch {
3554 file: settings_file.filter(|_| screenshot.is_none()),
3555 saved,
3556 cache,
3557 }),
34723558 substitutes,
34733559 interface_font,
34743560 screenshot,
crates/snowbound/src/manage.rs+489-90
......@@ -12,19 +12,35 @@ use std::{error::Error, sync::Arc};
1212
1313/// A change to a notebook's sections and groups, by catalog path.
1414pub enum Structure {
15 NewSection { folder: String },
16 NewGroup { folder: String },
17 Rename { path: String, name: String },
15 NewSection {
16 folder: String,
17 },
18 NewGroup {
19 folder: String,
20 },
21 Rename {
22 path: String,
23 name: String,
24 },
1825 /// Moves a section or group to the recycle bin.
19 Delete { path: String },
26 Delete {
27 path: String,
28 },
2029 /// Moves a section or group into another folder.
21 Move { path: String, folder: String },
30 Move {
31 path: String,
32 folder: String,
33 },
2234 /// Orders a folder's sections and groups.
23 Reorder { folder: String, paths: Vec<String> },
35 Reorder {
36 folder: String,
37 paths: Vec<String>,
38 },
2439}
2540
26/// Notebook colours for new notebooks, in turn: OneNote 2010's section colours.
27const NOTEBOOK_COLORS: [u32; 3] = [0x00e4a88a, 0x0078b0f6, 0x00bba4d5];
41/// The colour OneNote 2010 gave each new notebook beside its default one, COLORREF
42/// (`corpus/notebook-management/native/new-notebook`).
43const NOTEBOOK_COLOR: u32 = 0x00aeba91;
2844
2945/// `current`, the path of a section, after `from` moved to `to`: a section moved itself,
3046/// or one inside a moved group.
......@@ -56,18 +72,18 @@ impl State {
5672 Ok(creation.dated(&date, &time)?)
5773 }
5874
59 /// Adds a page at the end of the open section, or a subpage after the open page, and
60 /// opens 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>> {
75 /// Adds a page at the end of the open section, or a subpage of page `under`, and opens
76 /// it with its title ready for typing, as OneNote's New Page and New Subpage do.
77 pub(crate) fn new_page(&mut self, under: Option<ExGuid>) -> Result<(), Box<dyn Error>> {
6278 self.persist()?;
6379 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.
65 let (before, level) = if subpage {
80 // A subpage follows its page and the subpages already under it.
81 let (before, level) = if let Some(under) = under {
6682 let at = session
6783 .pages
6884 .iter()
69 .position(|(space, ..)| *space == session.space)
70 .ok_or("The open page is not listed")?;
85 .position(|(space, ..)| *space == under)
86 .ok_or("That page is not listed")?;
7187 let level = session.pages[at].2;
7288 let next = session.pages[at + 1..]
7389 .iter()
......@@ -80,7 +96,7 @@ impl State {
8096 let creation = self.dated_page(None)?;
8197 let space = creation.space();
8298 let mut ops = vec![Op::Section(SectionOp::Create(creation))];
83 if subpage {
99 if under.is_some() {
84100 ops.push(Op::Section(SectionOp::Pages(vec![PageEdit::move_to(
85101 space, before, level,
86102 )?])));
......@@ -105,59 +121,12 @@ impl State {
105121 &mut self,
106122 choice: crate::templates::Choice,
107123 ) -> Result<(), Box<dyn Error>> {
108 use crate::templates::Choice;
109124 self.persist()?;
110125 let session = self.session.as_ref().ok_or("No section is open")?;
111126 let space = session.space;
112 let page = session.section.page(space)?;
113 let mut ops: Vec<PageOp> = page
114 .objects
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(()),
127 let ops = template_ops(&session.section.page(space)?, choice)?;
128 if ops.is_empty() {
129 return Ok(());
161130 }
162131 session.section.apply(
163132 &self.author,
......@@ -175,18 +144,17 @@ impl State {
175144 return Ok(());
176145 };
177146 let page = self.dated_page(None)?;
178 let color = NOTEBOOK_COLORS[self.notebooks.len() % NOTEBOOK_COLORS.len()];
179147 let (cache, notify) = (self.cache.clone(), notify(self.proxy.clone()));
180148 self.load(move || {
181149 let location = std::path::absolute(&root)?.to_string_lossy().into_owned();
182 let notebook = Notebook::create(&location, &cache, color, &page)?;
183 let library = Arc::new(Library::open_notebook(&location, notebook, &cache));
150 let notebook = Notebook::create(&location, &cache, NOTEBOOK_COLOR, &page)?;
151 let library = Arc::new(Library::created(&location, notebook, &cache));
184152 let path = library
185153 .first_section()
186154 .ok_or("The new notebook has no section")?;
187155 let section = library.open(&path, notify)?;
188156 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))
190158 });
191159 Ok(())
192160 }
......@@ -220,7 +188,9 @@ impl State {
220188 let page = match change {
221189 Structure::NewSection { .. } => match self.dated_page(None) {
222190 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 }
224194 },
225195 _ => None,
226196 };
......@@ -229,9 +199,9 @@ impl State {
229199 .as_ref()
230200 .filter(|session| Arc::ptr_eq(&session.library, &library))
231201 .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());
233203 self.load(move || {
234 let mut notebook = Notebook::open(&library.location, &cache)?;
204 let mut notebook = library.reopen()?;
235205 let names = |notebook: &Notebook, folder: &str| -> Vec<String> {
236206 let mut folders = vec![notebook.catalog()];
237207 while let Some(candidate) = folders.pop() {
......@@ -251,43 +221,56 @@ impl State {
251221 }
252222 Vec::new()
253223 };
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 {
255226 Structure::NewSection { folder } => {
256227 let name = unused(&names(&notebook, &folder), "New Section", true);
257228 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 )
259233 }
260234 Structure::NewGroup { folder } => {
261235 let name = unused(&names(&notebook, &folder), "New Section Group", false);
262236 notebook.create_group(&folder, &name)?;
263 current
237 (current, None)
264238 }
265239 Structure::Rename { path, name } => {
266240 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 )
268245 }
269246 Structure::Delete { path } => {
270247 notebook.delete(&path)?;
271 current
248 (current, None)
272249 }
273250 Structure::Move { path, folder } => {
274251 let moved = notebook.move_entry(&path, &folder)?;
275 current.map(|current| follow(&current, &path, &moved))
252 (current.map(|current| follow(&current, &path, &moved)), None)
276253 }
277254 Structure::Reorder { folder, paths } => {
278255 let paths: Vec<&str> = paths.iter().map(String::as_str).collect();
279256 notebook.reorder(&folder, &paths)?;
280 current
257 (current, None)
281258 }
282259 };
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));
284262 let path = open
285263 .filter(|path| library.contains(path))
286264 .or_else(|| library.first_section())
287265 .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 }
288271 let section = library.open(&path, notify)?;
289272 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))
291274 });
292275 }
293276
......@@ -327,16 +310,66 @@ impl State {
327310 space
328311 }
329312 };
330 let notebook = match &session.library.notebook {
331 Ok(Some(_)) => Some((session.library.location.clone(), self.cache.clone())),
332 _ => None,
333 };
313 let notebook =
314 matches!(session.library.notebook, Ok(Some(_))).then(|| Arc::clone(&session.library));
334315 let replica = Arc::clone(session.section.replica());
335316 let author = self.author.clone();
336317 self.load(move || {
337 if let Some((location, cache)) = notebook {
338 Notebook::open(&location, &cache)?.recycle_pages(&pages, &author)?;
318 if let Some(library) = notebook {
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
339358 }
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()?;
340373 replica.apply(
341374 &author,
342375 Edit {
......@@ -344,7 +377,7 @@ impl State {
344377 ops,
345378 },
346379 )?;
347 Ok((Loaded::Page(next), replica.page(next)?))
380 Ok(Loaded::Page(next, replica.page(next)?))
348381 });
349382 Ok(())
350383 }
......@@ -366,9 +399,371 @@ impl State {
366399 }
367400}
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
369472#[cfg(test)]
370473mod tests {
371474 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
373768 #[test]
374769 fn moved_sections_are_followed_and_new_names_take_the_next_number() {
......@@ -379,7 +774,11 @@ mod tests {
379774 assert_eq!(unused(&taken, "New Section", true), "New Section 3");
380775 assert_eq!(unused(&[], "New Section Group", false), "New Section Group");
381776 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 ),
383782 "New Section Group 2"
384783 );
385784 }
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};
66use std::sync::Arc;
77use 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
918/// What a context menu was opened on.
1019pub enum Target {
1120 Page(ExGuid),
......@@ -55,6 +64,8 @@ impl State {
5564 separated: true,
5665 ..item("New Page")
5766 },
67 // OneNote offers this beside New Page, which is a plain + here.
68 item("New Subpage"),
5869 Item {
5970 separated: true,
6071 disabled: level > above || level >= 3,
......@@ -93,7 +104,8 @@ impl State {
93104 };
94105 let command = match (target, items[chosen].text) {
95106 (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) }),
97109 (Target::Page(space), text) => self.session.as_ref().and_then(|session| {
98110 let level = session
99111 .pages
......@@ -151,14 +163,33 @@ impl State {
151163 self.commands.extend(command);
152164 }
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
154183 /// 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.
156 /// `panel` is the page list's rectangle.
184 /// go, it moves there at its level, or under a page at most one level above it. Onto a
185 /// section tab of those built as `row`, it moves to the end of that section, as
186 /// OneNote moves a page dropped on a section's tab. `panel` is the page list's rectangle.
157187 pub(crate) fn drag_pages(
158188 &mut self,
159189 held: Option<ExGuid>,
160190 section: &ui::Section,
161191 panel: [f32; 4],
192 row: Id,
162193 ) {
163194 let Some(session) = &self.session else {
164195 return;
......@@ -166,17 +197,35 @@ impl State {
166197 if held.is_some() && !self.filter.is_empty() {
167198 return;
168199 }
169 let dragged = held.or(self.dragging.take());
170 self.dragging = held;
171 let (Some(space), Some([_, y])) = (dragged, self.ui.pointer()) else {
200 let dropped = match (&self.drag, held) {
201 (_, Some(space)) => Some(space),
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 {
172212 return;
173213 };
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 }
174221 // Rows other than the dragged one, with where each lies.
175222 let rows: Vec<(ExGuid, u32, [f32; 4])> = session
176223 .pages
177224 .iter()
178225 .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 })
180229 .collect();
181230 let Some(level) = session
182231 .pages
......@@ -217,11 +266,74 @@ impl State {
217266 section.accent,
218267 1.5,
219268 );
220 self.dragging = held;
221269 } else if let Ok(edit) = PageEdit::move_to(space, before, level) {
222270 self.commands.push(Command::Pages(vec![edit]));
223271 }
224272 }
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 }
225337}
226338
227339/// 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 {
4040 self.ui.theme = theme(appearance);
4141 // The page's box sizes the view whose pictures settle.
4242 self.layout(SIZE, SCALE)?;
43 let paper = canvas::gpu::Paper {
44 color: self.ui.theme.paper,
45 ink: self.ui.theme.paper_ink,
46 };
43 let paper = self.paper();
4744 if let Some((scene, _)) = &mut self.view.scene {
4845 scene.settle(Some(&self.view.editor), self.view.viewport.scale, paper);
4946 }
crates/snowbound/src/settings.rs+17-3
......@@ -51,7 +51,8 @@ impl crate::State {
5151 let Some(path) = &self.settings else {
5252 return;
5353 };
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));
5556 let settings = Settings {
5657 notebooks: self
5758 .notebooks
......@@ -74,6 +75,15 @@ impl crate::State {
7475 }
7576}
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
7787/// Where the settings live unless `--settings` names a file.
7888pub fn default_path() -> Option<PathBuf> {
7989 Some(crate::platform::settings_dir()?.join("settings.json"))
......@@ -85,11 +95,15 @@ mod tests {
8595
8696 #[test]
8797 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()));
89100 let path = directory.join("nested/settings.json");
90101 assert_eq!(Settings::load(&path), Settings::default());
91102 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 ],
93107 current: Some("/notebooks/Personal".into()),
94108 sidebar: true,
95109 recent_fonts: vec!["Georgia".into()],
crates/snowbound/src/sidebar.rs+170-97
......@@ -22,7 +22,10 @@ pub enum Action {
2222 /// Shows a notebook where it was left.
2323 Notebook(usize),
2424 /// Opens a notebook's section by catalog path.
25 Open { notebook: usize, path: String },
25 Open {
26 notebook: usize,
27 path: String,
28 },
2629 /// Folds or unfolds a notebook's or section group's rows, by `fold_key`.
2730 Fold(String),
2831 NewNotebook,
......@@ -51,7 +54,7 @@ pub fn fold_key(library: &Library, path: &str) -> String {
5154}
5255
5356/// What the tree's rows read and what they were asked.
54struct Tree<'a> {
57pub struct Tree<'a> {
5558 theme: &'a Theme,
5659 /// The open section's notebook index and catalog path.
5760 open: Option<(usize, &'a str)>,
......@@ -64,6 +67,25 @@ struct Tree<'a> {
6467 held: Option<Entry>,
6568}
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
6789/// A section or group row, for dragging one onto or between the others.
6890#[derive(Clone, PartialEq)]
6991pub struct Entry {
......@@ -79,26 +101,10 @@ impl Entry {
79101 }
80102}
81103
82/// The sidebar under a header row `header` tall, listing `notebooks` with the open section
83/// marked.
84pub fn sidebar(
85 ui: &mut Ui,
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 };
104/// The sidebar's header row, `header` tall, then with `rows` the tree of `notebooks` with
105/// the open section marked.
106fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc<Library>], header: f32, rows: bool) {
107 let (theme, open, folded) = (tree.theme, tree.open, tree.folded);
102108 ui.open(
103109 "header",
104110 Spec {
......@@ -121,6 +127,9 @@ pub fn sidebar(
121127 },
122128 );
123129 ui.close();
130 if !rows {
131 return;
132 }
124133 ui.open(
125134 "notebooks",
126135 Spec {
......@@ -136,7 +145,7 @@ pub fn sidebar(
136145 let unfolded = !folded.contains(&key);
137146 let (row, fold) = tree_row(
138147 ui,
139 &mut tree,
148 tree,
140149 ("notebook", index),
141150 Row {
142151 label: &library.name,
......@@ -157,7 +166,7 @@ pub fn sidebar(
157166 tree.action = Some(Action::Menu(Target::Notebook(Arc::clone(library)), point));
158167 }
159168 if unfolded && let Some(catalog) = library.catalog() {
160 folder(ui, &mut tree, library, index, catalog, 1);
169 folder(ui, tree, library, index, catalog, 1);
161170 }
162171 }
163172 ui.close();
......@@ -176,7 +185,7 @@ pub fn sidebar(
176185 ] {
177186 let (row, _) = tree_row(
178187 ui,
179 &mut tree,
188 tree,
180189 part,
181190 Row {
182191 label,
......@@ -193,7 +202,6 @@ pub fn sidebar(
193202 }
194203 }
195204 ui.close();
196 (tree.action, tree.rows, tree.held)
197205}
198206
199207/// A notebook's or group's sections, then its groups, as OneNote lists them.
......@@ -210,9 +218,9 @@ fn folder(
210218 .filter(|(index, _)| *index == notebook)
211219 .map(|(_, path)| path);
212220 let renamed = |tree: &Tree, path: &str| {
213 tree.renaming
214 .as_ref()
215 .is_some_and(|renaming| Arc::ptr_eq(&renaming.library, library) && renaming.path == path)
221 tree.renaming.as_ref().is_some_and(|renaming| {
222 Arc::ptr_eq(&renaming.library, library) && renaming.path == path
223 })
216224 };
217225 for section in &folder.sections {
218226 let (name, color, readable) = match &section.state {
......@@ -299,7 +307,8 @@ fn folder(
299307
300308/// Records a section or group row for dragging.
301309fn 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)))
303312 .or_else(|| ui.rect(ui.id(("group", notebook, path))))
304313 else {
305314 return;
......@@ -454,9 +463,10 @@ impl crate::State {
454463 if self.temporary {
455464 return;
456465 }
457 let width = self
458 .ui
459 .animate(self.ui.id("sidebar"), if self.sidebar { WIDTH } else { RAIL });
466 let width = self.ui.animate(
467 self.ui.id("sidebar"),
468 if self.sidebar { WIDTH } else { RAIL },
469 );
460470 self.ui.open(
461471 "sidebar",
462472 Spec {
......@@ -483,15 +493,18 @@ impl crate::State {
483493 .position(|library| Arc::ptr_eq(library, &session.library))?;
484494 Some((index, session.tabs[session.tab].path.as_str()))
485495 });
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(
487499 &mut self.ui,
488 theme,
500 &mut tree,
489501 &self.notebooks,
490 open,
491 &self.folded,
492 self.renaming.as_mut(),
493502 crate::TAB_ROW,
503 width > RAIL + 0.5,
494504 );
505 let Tree {
506 action, rows, held, ..
507 } = tree;
495508 let corner = self.ui.rect(rows_id).unwrap_or_default();
496509 self.drag_entries(held, &rows, corner, theme);
497510 self.ui.close();
......@@ -509,12 +522,14 @@ impl crate::State {
509522 Some(Action::Notebook(index)) => {
510523 let library = Arc::clone(&self.notebooks[index]);
511524 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));
513527 }
514528 }
515529 Some(Action::Open { notebook, path }) => {
516530 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));
518533 }
519534 Some(Action::Menu(target, point)) => {
520535 self.menu = Some((target, point));
......@@ -548,9 +563,23 @@ impl crate::State {
548563 /// group, as OneNote moves one dropped on a group's tab; between rows of its folder it
549564 /// takes that place; between rows of another folder it moves there, last. `corner` is
550565 /// the rows' box.
551 fn drag_entries(&mut self, held: Option<Entry>, rows: &[Entry], corner: [f32; 4], theme: &Theme) {
552 let dragged = held.clone().or(self.dragging_entry.take());
553 self.dragging_entry = held.clone();
566 fn drag_entries(
567 &mut self,
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 }
554583 let (Some(dragged), Some([_, y])) = (dragged, self.ui.pointer()) else {
555584 return;
556585 };
......@@ -620,118 +649,162 @@ impl crate::State {
620649 if into {
621650 let [left, top, right, bottom] = under.rect;
622651 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 ],
624658 [accent[0], accent[1], accent[2], 0.3],
625659 4.0,
626660 );
627661 } 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];
629667 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 ],
631674 accent,
632675 1.5,
633676 );
634677 }
635678 } else {
636679 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 ),
638702 }
639703 }
640704
641 /// Shows the notebook at `location` at its first section, opening it unless it is open.
642 pub(crate) fn open_notebook(&mut self, location: String) {
705 /// Shows the notebook at `location` at `section`, or its first section, opening it
706 /// unless it is open.
707 pub(crate) fn open_notebook(&mut self, location: String, section: Option<String>) {
643708 let open = self
644709 .notebooks
645710 .iter()
646711 .find(|library| library.location == location)
647712 .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 }
648722 let (cache, notify) = (self.cache.clone(), crate::notify(self.proxy.clone()));
649723 self.load(move || {
650724 let library = open.unwrap_or_else(|| Arc::new(Library::notebook(&location, &cache)));
651725 if let Err(error) = &library.notebook {
652726 return Err(error.clone().into());
653727 }
654 let path = library
655 .first_section()
728 let path = section
729 .filter(|path| library.contains(path))
730 .or_else(|| library.first_section())
656731 .ok_or("This folder holds no notebook sections.")?;
657732 let section = library.open(&path, notify)?;
658733 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))
660735 });
661736 }
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.
664740 pub(crate) fn welcome(&mut self, theme: &Theme) {
665 const SIZE: [f32; 2] = [420.0, 120.0];
666 let [left, top, right, bottom] = self.ui.rect(crate::page()).unwrap_or_default();
667 self.ui.open_as(
668 crate::page(),
741 const BUTTON: [f32; 2] = [200.0, 32.0];
742 let [left, top, right, bottom] = self.ui.rect(self.ui.id("welcome")).unwrap_or_default();
743 self.ui.open(
744 "welcome",
669745 Spec {
670746 size: [fill(), fill()],
671 fill: Some(theme.paper),
747 fill: Some(theme.base),
672748 ..Spec::default()
673749 },
674750 );
675751 self.ui.open(
676 "welcome",
752 "content",
677753 Spec {
678754 flags: Flags::FLOAT,
679755 axis: Axis::Y,
680 size: [px(SIZE[0]), px(SIZE[1])],
756 size: [px(BUTTON[0]), ui::children()],
681757 position: [
682 ((right - left - SIZE[0]) / 2.0).max(0.0),
683 ((bottom - top) * 0.4 - SIZE[1] / 2.0).max(0.0),
758 ((right - left - BUTTON[0]) / 2.0).max(0.0),
759 ((bottom - top) * 0.4 - 60.0).max(0.0),
684760 ],
685 gap: 4.0,
761 gap: 10.0,
686762 ..Spec::default()
687763 },
688764 );
689 let ink = theme.paper_ink;
690 for (part, text, color) in [
691 ("title", "No notebooks open", ink),
765 self.ui.leaf(
766 "title",
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 ),
692782 (
693 "description",
694 "Open a notebook folder, or start a new notebook.",
695 ui::mix(ink, theme.paper, 0.45),
783 "open",
784 art::NOTEBOOK,
785 "Open Existing",
786 crate::Command::OpenNotebook,
696787 ),
697788 ] {
698 self.ui.leaf(
789 let button = self.ui.leaf(
699790 part,
700791 Spec {
701 size: [fill(), px(26.0)],
702 text: Some(text),
703 color: Some(color),
792 flags: Flags::CLICKABLE,
793 size: [fill(), px(BUTTON[1])],
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,
704800 center: true,
705801 ..Spec::default()
706802 },
707803 );
708 }
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 {
804 if button.clicked {
731805 chosen = Some(command);
732806 }
733807 }
734 self.ui.close();
735808 self.commands.extend(chosen);
736809 self.ui.close();
737810 self.ui.close();
crates/snowbound/src/templates.rs+45-17
......@@ -50,9 +50,12 @@ pub enum View {
5050 Gallery,
5151}
5252
53/// A thumbnail's art: each raster and where it lies in the tile.
54type Art = Vec<(RasterImage, [f32; 4])>;
55
5356/// Rasters of each template's art for its thumbnail, by template and paper.
5457#[derive(Default)]
55pub struct Thumbnails(HashMap<(&'static str, bool), Vec<(RasterImage, [f32; 4])>>);
58pub struct Thumbnails(HashMap<(&'static str, bool), Art>);
5659
5760impl Thumbnails {
5861 /// `template`'s art and where it lies in a `TILE`, drawn at `scale` for `paper`.
......@@ -69,12 +72,8 @@ impl Thumbnails {
6972 .art
7073 .iter()
7174 .filter_map(|art| {
72 let (image, size) = canvas::gpu::page::template_art(
73 art.art,
74 art.size,
75 points * scale,
76 paper,
77 )?;
75 let (image, size) =
76 canvas::gpu::page::template_art(art.art, art.size, points * scale, paper)?;
7877 let [x, y] = [
7978 (art.position[0] - REGION[0]) * points,
8079 (art.position[1] - REGION[1]) * points,
......@@ -86,7 +85,10 @@ impl Thumbnails {
8685 }
8786}
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)]
9092fn strip(
9193 ui: &mut Ui,
9294 theme: &Theme,
......@@ -94,8 +96,20 @@ fn strip(
9496 paper: Paper,
9597 dark: bool,
9698 position: [f32; 2],
99 room: f32,
97100 colors: bool,
98101) -> 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);
99113 let mut chosen = None;
100114 ui.open(
101115 "templates",
......@@ -120,22 +134,27 @@ fn strip(
120134 ..Spec::default()
121135 },
122136 );
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() {
124138 if tile(ui, theme, thumbnails, paper, dark, index, choice, label) {
125139 chosen = Some(choice);
126140 }
127141 }
128142 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) {
130145 ui.open(
131 "colors",
146 ("colors", row),
132147 Spec {
133148 size: [ui::children(), px(SWATCH)],
134149 gap: 6.0,
135150 ..Spec::default()
136151 },
137152 );
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 {
139158 let swatch = ui.leaf(
140159 (index, *name),
141160 Spec {
......@@ -154,7 +173,9 @@ fn strip(
154173 }
155174 ui.close();
156175 }
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]);
158179 let close = ui.leaf(
159180 "dismiss",
160181 Spec {
......@@ -279,6 +300,11 @@ fn page_color(color: u32, paper: Paper, dark: bool) -> [f32; 4] {
279300 [red, green, blue, 1.0]
280301}
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
282308/// One template's thumbnail over its label, as wide as the thumbnail or the label; true
283309/// when clicked.
284310#[allow(clippy::too_many_arguments)]
......@@ -293,7 +319,7 @@ fn tile(
293319 label: &str,
294320) -> bool {
295321 let scale = ui.scale();
296 let width = TILE[0].max(ui.measure(label)[0] + 4.0);
322 let width = tile_width(ui, label);
297323 let fill_color = match choice {
298324 Choice::Color(index) => page_color(PAGE_COLORS[index].1, paper, dark),
299325 Choice::More | Choice::Dismiss => theme.base,
......@@ -405,11 +431,12 @@ impl crate::State {
405431 (viewport.origin[0] + start[0] * viewport.scale) / scale,
406432 (viewport.origin[1] + (start[1] + LINES_ABOVE) * viewport.scale) / scale,
407433 ];
434 // Thumbnails show templates on plain paper, not the page's colour.
408435 let paper = Paper {
409 color: theme.paper,
410 ink: theme.paper_ink,
436 color: self.ui.theme.paper,
437 ink: self.ui.theme.paper_ink,
411438 };
412 let dark = theme.paper_ink[0] > theme.paper[0];
439 let dark = paper.ink[0] > paper.color[0];
413440 let chosen = match self.templates {
414441 View::Gallery => {
415442 let size = [rect[2] - rect[0], rect[3] - rect[1]];
......@@ -422,6 +449,7 @@ impl crate::State {
422449 paper,
423450 dark,
424451 position,
452 rect[2] - rect[0] - position[0] - 24.0,
425453 view == View::Colors,
426454 ),
427455 };
crates/ui/src/shell.rs+35-7
......@@ -17,17 +17,17 @@ pub const TOOL: f32 = 22.0;
1717
1818/// Section tabs in a row `height` tall, as OneNote draws them: each leans over the next
1919/// 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. Returns
21/// the tab clicked, the tab whose context menu was asked for and where, and the open tab's
22/// id, for `tab_base`.
20/// below as its outline and shadow fade in. `tabs` are names and section colours; `lit`
21/// shows as hovered, as a tab something is dragged onto.
2322pub fn section_tabs(
2423 ui: &mut Ui,
2524 part: impl Hash,
2625 tabs: &[(&str, [f32; 4])],
2726 active: usize,
27 lit: Option<usize>,
2828 section: &Section,
2929 height: f32,
30) -> (Option<usize>, Option<(usize, [f32; 2])>, Id) {
30) -> Tabs {
3131 let theme = ui.theme.clone();
3232 ui.open(
3333 part,
......@@ -47,7 +47,7 @@ pub fn section_tabs(
4747 (left - width, width)
4848 })
4949 .collect();
50 let (mut clicked, mut context) = (None, None);
50 let (mut clicked, mut context, mut held) = (None, None, None);
5151 // Earlier tabs lie over later ones; the open tab over all.
5252 let order = (0..tabs.len())
5353 .rev()
......@@ -70,7 +70,11 @@ pub fn section_tabs(
7070 position: [x, height - tall],
7171 text: Some(name),
7272 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 }),
7478 gradient: Some(fill),
7579 hover_fill: (index != active).then(|| mix(colors.frame[0], [1.0; 4], 0.08)),
7680 border: Some(fade(section.edge, open)),
......@@ -87,10 +91,34 @@ pub fn section_tabs(
8791 if let Some(point) = signal.context {
8892 context = Some((index, point));
8993 }
94 if signal.dragging {
95 held = Some(index);
96 }
9097 }
9198 let open_tab = ui.id(("tab", active));
9299 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,
94122}
95123
96124/// 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() {
162162 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
163163 let mut open = false;
164164 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| {
166166 *width = ui.animate(Id::ROOT.child("panel"), if *open { 100.0 } else { 0.0 });
167167 if button(ui, "toggle", "Toggle").clicked {
168168 *open = true;
readme.md+1-1
......@@ -12,7 +12,7 @@ pen and drawing tools, recording audio and video, revision history, multi-machin
1212live collaboration, and much more.
1313
1414<!-- 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/screenshot
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" --settings "$d/settings.json" --screenshot docs/screenshot
1616-->
1717<picture>
1818 <source media="(prefers-color-scheme: dark)" srcset="docs/screenshot-dark.png">