From 1c5ad4e548ef0556a3810f749c2cee0e5e51f289 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Sun, 27 Sep 2026 11:01:00 -0700 Subject: [PATCH] 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 --- Cargo.lock | 17 + crates/canvas/Cargo.toml | 1 + crates/canvas/src/conflict.rs | 43 ++ crates/canvas/src/editor.rs | 40 ++ crates/canvas/src/gpu/mod.rs | 34 ++ crates/canvas/src/interaction/mod.rs | 31 +- crates/canvas/src/interaction/tests.rs | 6 +- crates/canvas/src/lib.rs | 2 + crates/canvas/src/search.rs | 417 +++++++++++++++++ crates/canvas/src/template.rs | 2 +- crates/snowbound/Cargo.toml | 3 +- crates/snowbound/linux/README.md | 9 +- crates/snowbound/src/conflict_render.rs | 2 +- crates/snowbound/src/library.rs | 461 ++++++++++++++++++- crates/snowbound/src/linux.rs | 58 +++ crates/snowbound/src/macos.rs | 86 ++++ crates/snowbound/src/main.rs | 274 +++++++---- crates/snowbound/src/manage.rs | 579 ++++++++++++++++++++---- crates/snowbound/src/meeting.rs | 237 ++++++++++ crates/snowbound/src/menus.rs | 128 +++++- crates/snowbound/src/screenshot.rs | 5 +- crates/snowbound/src/settings.rs | 20 +- crates/snowbound/src/sidebar.rs | 275 ++++++----- crates/snowbound/src/templates.rs | 62 ++- crates/ui/src/shell.rs | 42 +- crates/ui/src/tests.rs | 2 +- readme.md | 2 +- 27 files changed, 2489 insertions(+), 349 deletions(-) create mode 100644 crates/canvas/src/conflict.rs create mode 100644 crates/canvas/src/search.rs create mode 100644 crates/snowbound/src/meeting.rs diff --git a/Cargo.lock b/Cargo.lock index e2a39ca4388b18081a0b0b634566336a96ea27be..1170052ad8d1e4fc2b0fa88aa340c3caff4be1f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -625,6 +625,7 @@ dependencies = [ "accesskit_consumer", "color_quant", "draw", + "icu_normalizer", "image", "onestore", "parley", @@ -1468,6 +1469,9 @@ dependencies = [ "icu_properties", "icu_provider", "smallvec", + "utf16_iter", + "utf8_iter", + "write16", "zerovec", ] @@ -1886,6 +1890,7 @@ dependencies = [ "pollster", "serde", "serde_json", + "tempfile", "wgpu", ] @@ -3666,6 +3671,12 @@ dependencies = [ "tiny-skia-path 0.12.0", ] +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4460,6 +4471,12 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + [[package]] name = "writeable" version = "0.6.4" diff --git a/crates/canvas/Cargo.toml b/crates/canvas/Cargo.toml index cba1ec195c3878c773e22b5bb1df73e319925fbf..ca9046de6dc18c8f2fa821eb53c442b397e39b6b 100644 --- a/crates/canvas/Cargo.toml +++ b/crates/canvas/Cargo.toml @@ -15,6 +15,7 @@ onestore = { path = "../onestore" } parley.workspace = true serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" +icu_normalizer = "2.3" skrifa = "0.44.0" accesskit = { version = "0.25.0", optional = true } diff --git a/crates/canvas/src/conflict.rs b/crates/canvas/src/conflict.rs new file mode 100644 index 0000000000000000000000000000000000000000..9ae6e5e70479ea7e8367f49acf570cc607f19ec5 --- /dev/null +++ b/crates/canvas/src/conflict.rs @@ -0,0 +1,43 @@ +//! Conflict pages as OneNote shows them. + +use onestore::{ + ExGuid, + page::{Page, PageObject, PageParagraph, ParagraphContent}, +}; + +/// OneNote's highlight for conflicting changes on a conflict page, COLORREF. +pub const CONFLICTING: u32 = 0xd6d6ff; + +/// `page` with its conflict `objects` marked as OneNote shows a conflict page's: a band +/// across each conflicting paragraph. +pub fn highlighted(mut page: Page, objects: &[ExGuid]) -> Page { + fn mark(paragraphs: &mut [PageParagraph], objects: &[ExGuid]) { + for paragraph in paragraphs { + match &mut paragraph.content { + ParagraphContent::Table(table) => { + for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) { + mark(&mut cell.paragraphs, objects); + } + } + ParagraphContent::Text(text) + if objects.contains(¶graph.id) || objects.contains(&text.id) => + { + paragraph.format.highlight = Some(CONFLICTING); + } + _ => {} + } + } + } + for object in &mut page.objects { + match object { + PageObject::Outline(outline) => mark(&mut outline.paragraphs, objects), + PageObject::Title(title) => { + for outline in &mut title.outlines { + mark(&mut outline.paragraphs, objects); + } + } + _ => {} + } + } + page +} diff --git a/crates/canvas/src/editor.rs b/crates/canvas/src/editor.rs index 83c8cba1059efbcc83ac1e6823c6c702d363af5c..94d693ea056d83f09b868c0d068e46b7fe99d5cf 100644 --- a/crates/canvas/src/editor.rs +++ b/crates/canvas/src/editor.rs @@ -1095,6 +1095,11 @@ impl CanvasEditor { ] } + /// The page's colour (View, Page Color), COLORREF. + pub fn page_color(&self) -> Option { + self.header.color + } + /// Where the page's first body line starts, in points, under its title. pub fn body_start(&self) -> Option<[f32; 2]> { let title = self @@ -1759,6 +1764,41 @@ impl CanvasEditor { Ok(true) } + /// Puts `image` in the active outline's flow after the caret's paragraph, as OneNote + /// inserts a picture into an outline; the caret stays. + pub fn insert_picture( + &mut self, + engine: &mut TextEngine, + image: onestore::page::Image, + ) -> Result<(), EditorError> { + let selection = self.selection(); + let caret = selection.positions[0].max(selection.positions[1]); + let (container, index, node) = self + .active_outline() + .document + .leaf(caret.paragraph) + .ok_or(EditError::InvalidRange)?; + let picture = PageParagraph { + id: onestore::page::text::new_id()?, + content: ParagraphContent::Image(image), + style: None, + lists: Vec::new(), + tags: Vec::new(), + collapsed: false, + ..node.clone() + }; + self.commit( + engine, + DocumentEdit { + columns: BTreeMap::new(), + container, + range: index + 1..index + 1, + replacement: vec![picture], + }, + selection, + ) + } + pub fn remove_image(&mut self, engine: &mut TextEngine, id: ExGuid) -> Result<(), EditorError> { if self.outline_picture(id).is_some() { return self.edit_outline_picture(engine, id, None); diff --git a/crates/canvas/src/gpu/mod.rs b/crates/canvas/src/gpu/mod.rs index 5d01688070d92858d827cff791835453729a4851..6baef908a8cf2b4bfa10b1c756cb4521dd8bdbc3 100644 --- a/crates/canvas/src/gpu/mod.rs +++ b/crates/canvas/src/gpu/mod.rs @@ -52,6 +52,20 @@ impl Paper { ink: [0.0, 0.0, 0.0, 1.0], }; + /// This paper under a page coloured `color` (COLORREF, OneNote's View, Page Color): the + /// colour as OneNote paints it on white paper, moved onto this paper as a fill is, so + /// dark paper takes the colour's hue at its own depth. Text keeps its ink; template art + /// lies over the colour. + pub fn colored(self, color: Option) -> Self { + match color { + Some(color) => Self { + color: self.tint(colorref(color)), + ..self + }, + None => self, + } + } + /// A fill OneNote draws on white paper, moved onto this paper: as far from it in /// OKLab lightness as from white, in the same hue and chroma, so text keeps its /// contrast on it. @@ -171,6 +185,26 @@ mod tests { use onestore::page::text::Paragraph; use std::time::Duration; + /// A page colour paints as OneNote paints it on white paper, and on dark paper as a + /// dark paper of its hue; no colour leaves the paper as it was. + #[test] + fn page_colours_colour_the_paper() { + let teal = 0x00f2f9d4; + assert_eq!(Paper::WHITE.colored(Some(teal)).color.map(|c| (c * 1000.0).round()), colorref(teal).map(|c| (c * 1000.0).round())); + let dark = Paper { + color: draw::srgb(0x1f, 0x20, 0x22), + ink: draw::srgb(0xe6, 0xe6, 0xe6), + }; + let colored = dark.colored(Some(teal)); + let [lightness, a, b] = draw::oklab(colored.color); + let [paper, ..] = draw::oklab(dark.color); + let [_, ta, tb] = draw::oklab(colorref(teal)); + assert!(lightness > paper && lightness < 0.5); + assert!((a - ta).abs() < 1e-3 && (b - tb).abs() < 1e-3); + assert_eq!(colored.ink, dark.ink); + assert_eq!(dark.colored(None).color, dark.color); + } + #[test] fn runs_carry_their_highlight_as_what_lies_behind_them() { let run = |highlight, color| { diff --git a/crates/canvas/src/interaction/mod.rs b/crates/canvas/src/interaction/mod.rs index a16ce9755be8591bd40d1695aeeead6ccb3b0f79..80bc6baa4304acd0560c79939dbd7b2b92ff9604 100644 --- a/crates/canvas/src/interaction/mod.rs +++ b/crates/canvas/src/interaction/mod.rs @@ -199,6 +199,9 @@ pub struct PageView { /// Input is by finger: the focused outline's grips reach further, and outline chrome /// shows only while the view is focused, as there is no hover to reveal it. pub touch: bool, + /// A platform scroll view owns the viewport: changes neither clamp it nor reveal the + /// caret, which the host does knowing its bars and keyboard. + pub host_viewport: bool, /// The caret's opacity in its blink. caret: f32, /// When the caret last moved, which restarts its blink. @@ -256,6 +259,7 @@ impl PageView { modifiers: Modifiers::default(), focused: true, touch: false, + host_viewport: false, caret: 1.0, blink_from: Instant::now(), }; @@ -403,7 +407,12 @@ impl PageView { fn hit_test(&self, point: [f32; 2]) -> Option { if let Some(ObjectFocus::Image(id)) = self.object_focus && let Some((origin, size)) = self.editor.image_placement(id) - && let Some(handle) = image_handle_at(image_rect(origin, size), self.pixel(), point) + && let Some(handle) = image_handle_at( + image_rect(origin, size), + self.pixel(), + point, + if self.touch { TOUCH_REACH } else { 0.0 }, + ) { return Some(Hit::Image { id, handle }); } @@ -456,14 +465,18 @@ impl PageView { /// Keeps the view in bounds and restarts the caret blink after a change. fn changed(&mut self) -> Result { - self.scroll().clamp(&mut self.viewport); + if !self.host_viewport { + self.scroll().clamp(&mut self.viewport); + } self.caret = 1.0; self.blink_from = Instant::now(); Ok(Response::changed()) } fn moved(&mut self) -> Result { - self.scroll().clamp(&mut self.viewport); + if !self.host_viewport { + self.scroll().clamp(&mut self.viewport); + } Ok(Response { moved: true, redraw: true, @@ -563,7 +576,9 @@ impl PageView { /// Reveals the caret or focused object after an edit, then reports the change. fn edited(&mut self) -> Result { - self.reveal_focus()?; + if !self.host_viewport { + self.reveal_focus()?; + } self.changed() } @@ -1641,9 +1656,13 @@ fn image_handles(rect: [f32; 4], pixel: f32) -> impl Iterator Option<[i8; 2]> { +/// The handle of a picture at `rect` under `point`, each reaching `reach` pixels beyond its +/// drawn square. +fn image_handle_at(rect: [f32; 4], pixel: f32, point: [f32; 2], reach: f32) -> Option<[i8; 2]> { image_handles(rect, pixel) - .find(|(_, center)| (0..2).all(|axis| (point[axis] - center[axis]).abs() <= 5.0 * pixel)) + .find(|(_, center)| { + (0..2).all(|axis| (point[axis] - center[axis]).abs() <= (5.0 + reach) * pixel) + }) .map(|(handle, _)| handle) } diff --git a/crates/canvas/src/interaction/tests.rs b/crates/canvas/src/interaction/tests.rs index 92b322633d88db7e0c5c69583a081c739d2f82dc..96fdcb873c1bfca3ccafe141b10e28db6f53415b 100644 --- a/crates/canvas/src/interaction/tests.rs +++ b/crates/canvas/src/interaction/tests.rs @@ -691,14 +691,14 @@ fn picture_handles_resize_as_onenote_does() { let pixel = 0.75; let rect = image_rect(origin, size); assert_eq!( - image_handle_at(rect, pixel, [468.0 - 3.75, 86.4 - 3.75]), + image_handle_at(rect, pixel, [468.0 - 3.75, 86.4 - 3.75], 0.0), Some([-1, -1]) ); assert_eq!( - image_handle_at(rect, pixel, [468.0 + 166.5, 286.5 + 3.75]), + image_handle_at(rect, pixel, [468.0 + 166.5, 286.5 + 3.75], 0.0), Some([0, 1]) ); - assert_eq!(image_handle_at(rect, pixel, [600.0, 150.0]), None); + assert_eq!(image_handle_at(rect, pixel, [600.0, 150.0], 0.0), None); } const DEFAULT_MARGIN: [f32; 2] = [36.0, 14.4]; diff --git a/crates/canvas/src/lib.rs b/crates/canvas/src/lib.rs index d47ac7e1766288ae0a210fbf4f665606568dfe20..64424605cccdf8c590921a217b41dcf3d6a01471 100644 --- a/crates/canvas/src/lib.rs +++ b/crates/canvas/src/lib.rs @@ -1,9 +1,11 @@ +pub mod conflict; pub mod document; pub mod editor; pub mod language; pub mod layout; pub mod math; pub mod outline; +pub mod search; pub mod template; pub mod date; diff --git a/crates/canvas/src/search.rs b/crates/canvas/src/search.rs new file mode 100644 index 0000000000000000000000000000000000000000..60ad2b9486d4f54e8a380483f5025537e7fef404 --- /dev/null +++ b/crates/canvas/src/search.rs @@ -0,0 +1,417 @@ +//! Search as OneNote 2010 searches: each query word matches the start of a word, ignoring +//! case and diacritics; a page matches when all its words appear in its title or text, and +//! pages whose titles hold every word come first, then the most recently modified. + +use crate::document::TextPosition; +use crate::editor::{CanvasEditor, Selection}; +use icu_normalizer::DecomposingNormalizerBorrowed; +use onestore::ExGuid; +use onestore::page::{Page, PageObject, PageParagraph, ParagraphContent, text::Paragraph}; +use std::ops::Range; + +/// Characters around a hit a snippet shows before it, and in all. +const BEFORE: usize = 24; +const SNIPPET: usize = 90; + +/// A query's words, lowered and stripped of diacritics; a quoted phrase is one word. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Query { + terms: Vec, +} + +impl Query { + pub fn new(text: &str) -> Self { + let mut terms = Vec::new(); + for (index, part) in text.split('"').enumerate() { + if index % 2 == 1 { + terms.push(fold(part).split_whitespace().collect::>().join(" ")); + } else { + terms.extend(fold(part).split_whitespace().map(str::to_owned)); + } + } + terms.retain(|term| !term.is_empty()); + Self { terms } + } + + pub fn is_empty(&self) -> bool { + self.terms.is_empty() + } + + /// Where the words start words of `folded`, as `fold` leaves text, in order and merged + /// where they overlap. + fn hits(&self, folded: &str) -> Vec> { + let mut hits: Vec> = self + .terms + .iter() + .flat_map(|term| { + folded + .match_indices(term.as_str()) + .filter(|(at, _)| word_start(folded, *at)) + .map(|(at, term)| at..at + term.len()) + }) + .collect(); + hits.sort_by_key(|hit| (hit.start, std::cmp::Reverse(hit.end))); + let mut merged: Vec> = Vec::with_capacity(hits.len()); + for hit in hits { + match merged.last_mut() { + Some(last) if hit.start <= last.end => last.end = last.end.max(hit.end), + _ => merged.push(hit), + } + } + merged + } + + fn all_in(&self, folded: &str) -> bool { + self.terms.iter().all(|term| starts_word(term, folded)) + } + + /// Where the query's words start words of `text`, as byte ranges of it. + pub fn find(&self, text: &str) -> Vec> { + let (folded, source) = fold_mapped(text); + self.hits(&folded) + .into_iter() + .map(|hit| source_range(text, &source, hit)) + .collect() + } +} + +/// Ideographs and kana are words of their own, as their text has no spaces between words. +fn ideographic(character: char) -> bool { + matches!(u32::from(character), + 0x3040..=0x30ff | 0x3400..=0x4dbf | 0x4e00..=0x9fff | 0xf900..=0xfaff | 0x20000..=0x3ffff) +} + +fn starts_word(term: &str, folded: &str) -> bool { + folded + .match_indices(term) + .any(|(at, _)| word_start(folded, at)) +} + +fn word_start(text: &str, at: usize) -> bool { + match text[..at].chars().next_back() { + None => true, + Some(before) => { + !before.is_alphanumeric() + || ideographic(before) + || text[at..].chars().next().is_some_and(ideographic) + } + } +} + +fn diacritic(character: char) -> bool { + matches!(u32::from(character), + 0x300..=0x36f | 0x1ab0..=0x1aff | 0x1dc0..=0x1dff | 0x20d0..=0x20ff | 0xfe20..=0xfe2f) +} + +/// Appends `character` lowered and without diacritics; line breaks stay and other spaces +/// become one space each. +fn fold_char(character: char, out: &mut String) { + const DECOMPOSE: DecomposingNormalizerBorrowed = DecomposingNormalizerBorrowed::new_nfd(); + if character.is_ascii() { + out.push(match character { + '\n' => '\n', + space if space.is_ascii_whitespace() => ' ', + other => other.to_ascii_lowercase(), + }); + return; + } + match character { + '\u{2018}' | '\u{2019}' | '\u{02bc}' => out.push('\''), + '\u{201c}' | '\u{201d}' => out.push('"'), + 'ß' => out.push_str("ss"), + 'æ' | 'Æ' => out.push_str("ae"), + 'œ' | 'Œ' => out.push_str("oe"), + 'ø' | 'Ø' => out.push('o'), + 'ł' | 'Ł' => out.push('l'), + 'đ' | 'Đ' => out.push('d'), + 'ı' => out.push('i'), + space if space.is_whitespace() => out.push(' '), + other => out.extend( + DECOMPOSE + .normalize_iter(other.to_lowercase()) + .filter(|part| !diacritic(*part)), + ), + } +} + +/// `text` as search compares it: lowered and without diacritics. +pub fn fold(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for character in text.chars() { + fold_char(character, &mut out); + } + out +} + +/// `fold`, with the byte of `text` each folded byte came from. +fn fold_mapped(text: &str) -> (String, Vec) { + let mut out = String::with_capacity(text.len()); + let mut source = Vec::with_capacity(text.len()); + for (at, character) in text.char_indices() { + fold_char(character, &mut out); + source.resize(out.len(), at); + } + (out, source) +} + +/// The bytes of `text` a range of its folding came from, whole characters. +fn source_range(text: &str, source: &[usize], hit: Range) -> Range { + let start = source[hit.start]; + let last = source[hit.end - 1]; + let end = last + text[last..].chars().next().map_or(0, char::len_utf8); + start..end +} + +/// The text a paragraph shows, without hidden field codes. +pub fn shown(paragraph: &Paragraph) -> String { + let mut start = 0; + paragraph + .spans() + .iter() + .filter_map(|span| { + let run = ¶graph.text()[start..span.end]; + start = span.end; + (span.format.hidden != Some(true)).then_some(run) + }) + .collect() +} + +/// A page's text outside its title, a paragraph to a line: outlines in page order, tables +/// cell by cell. +pub fn page_text(page: &Page) -> String { + fn walk(paragraphs: &[PageParagraph], out: &mut Vec) { + for paragraph in paragraphs { + match ¶graph.content { + ParagraphContent::Text(text) => out.push(shown(&text.text)), + ParagraphContent::Table(table) => { + for cell in table.rows.iter().flat_map(|row| &row.cells) { + walk(&cell.paragraphs, out); + } + } + _ => {} + } + } + } + let mut out = Vec::new(); + for object in &page.objects { + if let PageObject::Outline(outline) = object { + walk(&outline.paragraphs, &mut out); + } + } + out.retain(|line| !line.trim().is_empty()); + out.join("\n") +} + +/// A page as search knows it. +pub struct Entry { + /// The section's key, which the host chooses. + pub section: String, + pub space: ExGuid, + pub title: String, + /// When the page last changed, in any unit that orders. + pub modified: u64, + text: String, + folded_title: String, + folded_text: String, +} + +/// A page matching a query. +#[derive(Clone, Debug, PartialEq)] +pub struct Found { + pub section: String, + pub space: ExGuid, + pub title: String, + /// Every word is in the title, as OneNote's "Title contains" lists it. + pub in_title: bool, + /// Byte ranges of the title the words match. + pub title_hits: Vec>, + /// The line around the first match in the page's text, or its first line. + pub snippet: String, + pub snippet_hits: Vec>, +} + +/// Pages of any number of sections, searchable together. +#[derive(Default)] +pub struct Index { + entries: Vec, +} + +impl Index { + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Adds or replaces page `space` of `section` as `page` shows it. + pub fn set(&mut self, section: &str, space: ExGuid, page: &Page, modified: u64) { + let text = page_text(page); + let entry = Entry { + section: section.to_owned(), + space, + folded_title: fold(&page.title), + folded_text: fold(&text), + title: page.title.clone(), + modified, + text, + }; + match self + .entries + .iter_mut() + .find(|entry| entry.space == space && entry.section == section) + { + Some(old) => *old = entry, + None => self.entries.push(entry), + } + } + + /// Keeps the pages whose section `keep` accepts. + pub fn retain(&mut self, mut keep: impl FnMut(&Entry) -> bool) { + self.entries.retain(|entry| keep(entry)); + } + + /// The page `space` of `section`, when indexed. + pub fn get(&self, section: &str, space: ExGuid) -> Option<&Entry> { + self.entries + .iter() + .find(|entry| entry.space == space && entry.section == section) + } + + /// Pages of the sections `scope` accepts that hold every word of `query`, title hits + /// first, then the most recently modified. + pub fn search(&self, query: &Query, scope: impl Fn(&str) -> bool) -> Vec { + if query.is_empty() { + return Vec::new(); + } + let mut found: Vec<(&Entry, bool)> = self + .entries + .iter() + .filter(|entry| scope(&entry.section)) + .filter_map(|entry| { + let in_title = query.all_in(&entry.folded_title); + let matches = in_title + || query.terms.iter().all(|term| { + starts_word(term, &entry.folded_title) + || starts_word(term, &entry.folded_text) + }); + matches.then_some((entry, in_title)) + }) + .collect(); + found.sort_by_key(|(entry, in_title)| (!in_title, std::cmp::Reverse(entry.modified))); + found + .into_iter() + .map(|(entry, in_title)| { + let (snippet, snippet_hits) = snippet(entry, query); + Found { + section: entry.section.clone(), + space: entry.space, + title: entry.title.clone(), + in_title, + title_hits: query.find(&entry.title), + snippet, + snippet_hits, + } + }) + .collect() + } +} + +/// Part of the line holding the first match in the entry's text, the matches within it; +/// the text's first line when nothing there matches. +fn snippet(entry: &Entry, query: &Query) -> (String, Vec>) { + let first = query.hits(&entry.folded_text).first().map(|hit| hit.start); + let line = first.map_or(0, |at| { + entry.folded_text[..at].bytes().filter(|byte| *byte == b'\n').count() + }); + let text = entry.text.split('\n').nth(line).unwrap_or_default(); + let hits = query.find(text); + let chars: Vec<(usize, char)> = text.char_indices().collect(); + let at = hits.first().map_or(0, |hit| { + chars.partition_point(|(byte, _)| *byte < hit.start) + }); + let mut start = at.saturating_sub(BEFORE); + let mut end = (start + SNIPPET).min(chars.len()); + // Cut ends fall back to the nearest space, so no word shows in part. + if start > 0 + && let Some(space) = chars[start..at].iter().position(|(_, char)| char.is_whitespace()) + { + start += space + 1; + } + if end < chars.len() + && let Some(space) = chars[at..end].iter().rposition(|(_, char)| char.is_whitespace()) + { + end = at + space; + } + let byte = |index: usize| chars.get(index).map_or(text.len(), |(byte, _)| *byte); + let [from, to] = [byte(start), byte(end)]; + let lead = if start > 0 { "…" } else { "" }; + let shown = format!( + "{lead}{}{}", + text[from..to].trim_end(), + if end < chars.len() { "…" } else { "" } + ); + let hits = hits + .into_iter() + .filter(|hit| hit.start >= from && hit.end <= to) + .map(|hit| hit.start - from + lead.len()..hit.end - from + lead.len()) + .filter(|hit| hit.end <= shown.len()) + .collect(); + (shown, hits) +} + +/// A match on a page: the outline it is in and its range there. +pub type PageMatch = (ExGuid, Selection); + +/// Where `query` matches the page `editor` shows, from the page's top: outlines by where +/// they stand, then their shown paragraphs in order. +pub fn page_matches(editor: &CanvasEditor, query: &Query) -> Vec { + if query.is_empty() { + return Vec::new(); + } + let mut outlines: Vec<_> = editor.outlines().iter().collect(); + outlines.sort_by(|a, b| { + let [ax, ay] = a.origin(); + let [bx, by] = b.origin(); + ay.total_cmp(&by).then(ax.total_cmp(&bx)) + }); + let mut matches = Vec::new(); + for outline in outlines { + for (index, _) in outline.layouts() { + let Some(paragraph) = outline.document().paragraph(index) else { + continue; + }; + // Hidden field codes are left out, and each shown byte keeps its source byte. + let mut shown = String::new(); + let mut source = Vec::new(); + let mut start = 0; + for span in paragraph.spans() { + if span.format.hidden != Some(true) { + shown.push_str(¶graph.text()[start..span.end]); + source.extend(start..span.end); + } + start = span.end; + } + for hit in query.find(&shown) { + let end = source[hit.end - 1] + + paragraph.text()[source[hit.end - 1]..] + .chars() + .next() + .map_or(0, char::len_utf8); + let (Ok(from), Ok(to)) = ( + paragraph.utf16_offset(source[hit.start]), + paragraph.utf16_offset(end), + ) else { + continue; + }; + let at = |offset| TextPosition { + paragraph: index, + offset, + }; + matches.push((outline.id, [at(from), at(to)].into())); + } + } + } + matches +} diff --git a/crates/canvas/src/template.rs b/crates/canvas/src/template.rs index ac1936500fe2d3511ef00a1fd7d6714ef016179e..de51d86a97fb279450d2be6dcafafd768556dd6d 100644 --- a/crates/canvas/src/template.rs +++ b/crates/canvas/src/template.rs @@ -603,7 +603,7 @@ pub const TEMPLATES: &[Template] = &[ /// OneNote 2010's page colours in its View, Page Color menu order, with the names its /// tooltips give them; COLORREF, as the page XML reports each -/// (evidence/notebook-management/page-colors). +/// (`corpus/notebook-management/native/page-color`). pub const PAGE_COLORS: &[(&str, u32)] = &[ ("Blue", 0xfef5ed), ("Red", 0xefeeff), diff --git a/crates/snowbound/Cargo.toml b/crates/snowbound/Cargo.toml index d68498d9e347c88f618ee5973bb43e6c4dc24f24..6187dcbcb0ce6ec2290ef922661e52a8bc86e2c5 100644 --- a/crates/snowbound/Cargo.toml +++ b/crates/snowbound/Cargo.toml @@ -10,7 +10,7 @@ draw = { path = "../draw" } ui = { path = "../ui" } png = "0.18" onestore = { path = "../onestore" } -notebook = { path = "../notebook" } +notebook = { path = "../notebook", features = ["smb"] } serde = { version = "1", features = ["derive"] } serde_json = "1" pollster = "0.4" @@ -22,6 +22,7 @@ accesskit = "0.25.0" accesskit_winit = { version = "0.34.0", default-features = false, features = ["rwh_06"] } [target.'cfg(target_os = "macos")'.dependencies] +libc = "0.2" objc2 = "0.5.2" objc2-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"] } objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSGeometry", "NSLocale", "NSPathUtilities", "NSTimeZone", "NSURL", "NSArray"] } diff --git a/crates/snowbound/linux/README.md b/crates/snowbound/linux/README.md index 56364374ad23c04d78bf1dedafc09332b85f54a0..d27b73a8ccafab98b1bd9ba992b1dff34b9f5db4 100644 --- a/crates/snowbound/linux/README.md +++ b/crates/snowbound/linux/README.md @@ -21,8 +21,13 @@ Edits save to the notebook folder as you type. Settings live in puts the latter elsewhere. Run `./install.sh` to add Snowbound to your app launcher; it copies the app -into `~/.local`. On Wayland the taskbar finds Snowbound's icon only through -this launcher entry, so without it the taskbar shows a generic icon. +into `~/.local`. On Wayland the taskbar, dock and KDE's title bar find +Snowbound's icon only through this launcher entry, so without it they show a +generic icon. + +The window's title bar is your desktop's own: KDE's on KDE, and on GNOME and +other desktops without server-side decorations an Adwaita-style one drawn by +Snowbound. `./install.sh ~/Notebooks/MyNotebook` makes the launcher open that notebook as well. diff --git a/crates/snowbound/src/conflict_render.rs b/crates/snowbound/src/conflict_render.rs index 951d6d1804b1214c1f105a58308cba9df82a2da0..8459cceb798002d0b11cad30d2a964a4686ae0d1 100644 --- a/crates/snowbound/src/conflict_render.rs +++ b/crates/snowbound/src/conflict_render.rs @@ -254,7 +254,7 @@ fn conflict_views_offer_the_versions_and_render_offscreen() { let highlighted = kept.objects.iter().any(|object| match object { onestore::page::PageObject::Outline(outline) => outline.paragraphs.iter().any(|paragraph| { paragraph.text().is_some_and(|text| marked.contains(&text.id)) - && paragraph.format.highlight == Some(CONFLICTING) + && paragraph.format.highlight == Some(canvas::conflict::CONFLICTING) }), _ => false, }); diff --git a/crates/snowbound/src/library.rs b/crates/snowbound/src/library.rs index d840d55360a3828c40eb1418337366d616f38638..8c419811c8596e804b27461cd983f0e76a3ed61d 100644 --- a/crates/snowbound/src/library.rs +++ b/crates/snowbound/src/library.rs @@ -2,11 +2,117 @@ use notebook::discover::{Folder, SectionState}; use notebook::session::{Notebook, Section}; +use notebook::smb::{Client, Credentials}; use std::{ error::Error, + io, path::{Path, PathBuf}, + sync::Arc, + time::Duration, }; +/// How long an SMB request may take before the share counts as unreachable. +const TIMEOUT: Duration = Duration::from_secs(10); +/// The largest section file read whole over SMB. +const LIMIT: usize = 256 * 1024 * 1024; + +/// A folder on a mounted SMB share: where the embedded client finds it. +#[derive(Clone, Debug, PartialEq)] +pub struct Mount { + pub server: String, + pub share: String, + /// The account the mount signed in as, where the mount names one. + pub user: Option, + pub domain: String, + /// The folder within the share, `/`-separated. + pub root: String, +} + +impl Mount { + /// The mount of `source` (`//[domain;][user[:…]@]server/share[/folder]`), holding the + /// folder `within` its mount point; `options` are the mount's (`username=`, `user=`, + /// `domain=`), where the source names no account. + pub fn parse(source: &str, within: &str, options: &str) -> Option { + let source = source.strip_prefix("//")?; + let (account, rest) = match source.rsplit_once('@') { + Some((account, rest)) => (Some(account), rest), + None => (None, source), + }; + let mut parts = rest.split('/').filter(|part| !part.is_empty()); + let (server, share) = (parts.next()?, parts.next()?); + let option = |names: &[&str]| { + options.split(',').find_map(|option| { + let (name, value) = option.split_once('=')?; + names.contains(&name).then(|| value.to_owned()) + }) + }; + let (domain, user) = match account { + Some(account) => { + let account = account.split(':').next().unwrap_or_default(); + match account.split_once(';') { + Some((domain, user)) => (Some(domain.to_owned()), user.to_owned()), + None => (None, account.to_owned()), + } + } + None => (None, option(&["username", "user"]).unwrap_or_default()), + }; + let root: Vec<&str> = parts + .chain(within.split('/')) + .filter(|part| !part.is_empty()) + .collect(); + Some(Self { + server: server.to_owned(), + share: share.to_owned(), + // "GUEST" is how macOS names a guest mount. + user: (!user.is_empty() && !user.eq_ignore_ascii_case("guest")).then_some(user), + domain: domain + .or_else(|| option(&["domain", "dom"])) + .unwrap_or_default(), + root: root.join("/"), + }) + } +} + +/// An account on an SMB server. +#[derive(Clone)] +pub struct Login { + pub user: String, + pub password: String, + pub domain: String, +} + +impl Login { + /// The guest account a mount without one signed in as. + pub fn guest(mount: &Mount) -> Self { + Self { + user: String::new(), + password: String::new(), + domain: mount.domain.clone(), + } + } +} + +/// A share and the account that reaches it, kept to reconnect. +struct Server { + mount: Mount, + login: Login, +} + +impl Server { + fn connect(&self) -> io::Result { + Client::connect( + &self.mount.server, + &self.mount.share, + Credentials { + username: &self.login.user, + password: &self.login.password, + domain: &self.login.domain, + }, + TIMEOUT, + ) + } +} + /// A section tab: where the section opens from and how it is labelled. pub struct Tab { /// The notebook catalog path, or the file of a section opened on its own. @@ -24,28 +130,95 @@ pub struct Library { pub name: String, /// `Err` holds why a notebook listed as open could not be read this time. pub notebook: Result, String>, + /// The share a notebook on a mounted SMB share opens through, by the embedded client. + server: Option>, + /// Why a notebook on a mounted SMB share opened through the mount instead. + pub notice: Option, cache: PathBuf, } impl Library { - /// The notebook in the folder at `location`, or why it cannot be read. + /// The notebook in the folder at `location`, or why it cannot be read. A folder on a + /// mounted SMB share opens through Snowbound's own SMB client, signed in with the + /// account the system keeps for the mount, as OneNote's own client coordinates with + /// OneNote; without that account it opens through the mount. pub fn notebook(location: &str, cache: &Path) -> Self { + let mut notice = None; + if let Some(mount) = crate::platform::smb_mount(Path::new(location)) { + match crate::platform::smb_login(&mount) + .and_then(|login| Self::on_share(location, mount, login, cache)) + { + Ok(library) => return library, + Err(reason) => { + eprintln!("{location}: opening through the mounted share: {reason}"); + notice = Some(reason); + } + } + } Self { location: location.to_owned(), name: file_name(Path::new(location)), notebook: Notebook::open(location, cache) .map(Some) .map_err(|error| error.to_string()), + server: None, + notice, cache: cache.to_owned(), } } - /// `notebook`, open at `location`. - pub fn open_notebook(location: &str, notebook: Notebook, cache: &Path) -> Self { + /// The notebook at `location`, which `mount` names on its server, opened through the + /// embedded client signed in as `login`. + pub fn on_share( + location: &str, + mount: Mount, + login: Login, + cache: &Path, + ) -> Result { + let server = Arc::new(Server { mount, login }); + let client = server.connect().map_err(|error| error.to_string())?; + let notebook = Notebook::open_smb(Arc::new(client), &server.mount.root, cache) + .map_err(|error| error.to_string())?; + Ok(Self { + location: location.to_owned(), + name: file_name(Path::new(location)), + notebook: Ok(Some(notebook)), + server: Some(server), + notice: None, + cache: cache.to_owned(), + }) + } + + /// This notebook read again, the way it was opened, for changing its structure. + pub fn reopen(&self) -> Result> { + Ok(match &self.server { + Some(server) => { + Notebook::open_smb(Arc::new(server.connect()?), &server.mount.root, &self.cache)? + } + None => Notebook::open(&self.location, &self.cache)?, + }) + } + + /// This notebook as `notebook`, read again after a change. + pub fn with(&self, notebook: Notebook) -> Self { + Self { + location: self.location.clone(), + name: self.name.clone(), + notebook: Ok(Some(notebook)), + server: self.server.clone(), + notice: self.notice.clone(), + cache: self.cache.clone(), + } + } + + /// A notebook `Notebook::create` just made in the folder at `location`. + pub fn created(location: &str, notebook: Notebook, cache: &Path) -> Self { Self { location: location.to_owned(), name: file_name(Path::new(location)), notebook: Ok(Some(notebook)), + server: None, + notice: None, cache: cache.to_owned(), } } @@ -56,19 +229,65 @@ impl Library { location: file.to_string_lossy().into_owned(), name: file_name(file.parent().unwrap_or(file)), notebook: Ok(None), + server: None, + notice: None, cache: cache.to_owned(), } } - /// Opens the section at catalog `path`. - pub fn open(&self, path: &str, notify: impl Fn() + Send + 'static) -> Result> { - Ok(match &self.notebook { - Ok(Some(notebook)) => notebook.section(path, notify)?, - Ok(None) => Section::open(path, &self.cache, notify)?, - Err(error) => return Err(error.clone().into()), + /// Opens the section at catalog `path`; on a share, through a replica named by its + /// file identity that publishes over the embedded client. + pub fn open( + &self, + path: &str, + notify: impl Fn() + Send + 'static, + ) -> Result> { + Ok(match (&self.notebook, &self.server) { + (Ok(Some(notebook)), Some(server)) => { + let identity = self + .catalog_section(notebook.catalog(), path) + .ok_or("The notebook doesn’t list this section")?; + let file = match server.mount.root.as_str() { + "" => path.to_owned(), + root => format!("{root}/{path}"), + }; + let cache = self.cache.join("smb").join(format!("{identity}.sqlite")); + std::fs::create_dir_all(self.cache.join("smb"))?; + let replica = if cache.exists() { + notebook::Replica::open(&cache)? + } else { + notebook::Replica::create( + &cache, + &server.connect()?.read_storage(&file, LIMIT)?, + )? + }; + let server = Arc::clone(server); + Section::resume_smb(file, replica, LIMIT, move || server.connect(), notify)? + } + (Ok(Some(notebook)), None) => notebook.section(path, notify)?, + (Ok(None), _) => Section::open(path, &self.cache, notify)?, + (Err(error), _) => return Err(error.clone().into()), }) } + /// The file identity, in hex, of the section at catalog `path`. + fn catalog_section(&self, catalog: &Folder, path: &str) -> Option { + let mut folders = vec![catalog]; + while let Some(folder) = folders.pop() { + if let Some(section) = folder.sections.iter().find(|section| section.path == path) { + return Some( + section + .file_id + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(), + ); + } + folders.extend(&folder.groups); + } + None + } + /// Names the section at catalog `path` across every open notebook, for remembering /// its pages. pub fn key(&self, path: &str) -> String { @@ -177,3 +396,227 @@ fn tabs(catalog: &Folder) -> Vec { }) .collect() } + +/// What a path chosen to open opens. +#[derive(Debug, PartialEq)] +pub enum Located { + /// The notebook in folder `root`, at the section at catalog path `section`. + Notebook { + root: PathBuf, + section: Option, + }, + /// A section file outside any notebook. + Section(PathBuf), + Nothing, +} + +/// Whether `folder` holds a table of contents, as a notebook's or a section group's does. +fn has_toc(folder: &Path) -> bool { + std::fs::read_dir(folder).is_ok_and(|entries| { + entries.flatten().any(|entry| { + entry + .path() + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("onetoc2")) + }) + }) +} + +/// What `path` opens: a folder is a notebook; a table of contents opens its folder's; a +/// section opens in the notebook the folders above it with tables of contents make up, +/// section groups included, or alone. +pub fn locate(path: &Path) -> Located { + let extension = path + .extension() + .map(|extension| extension.to_string_lossy().to_ascii_lowercase()); + match extension.as_deref() { + _ if path.is_dir() => Located::Notebook { + root: path.to_owned(), + section: None, + }, + Some("onetoc2") => match path.parent() { + Some(root) => Located::Notebook { + root: root.to_owned(), + section: None, + }, + None => Located::Nothing, + }, + Some("one") => { + let mut root = None; + let mut folder = path.parent(); + while let Some(candidate) = folder.filter(|folder| has_toc(folder)) { + root = Some(candidate); + folder = candidate.parent(); + } + match root { + Some(root) => Located::Notebook { + root: root.to_owned(), + section: path.strip_prefix(root).ok().map(|relative| { + relative + .components() + .map(|part| part.as_os_str().to_string_lossy()) + .collect::>() + .join("/") + }), + }, + None => Located::Section(path.to_owned()), + } + } + _ => Located::Nothing, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A notebook on a share opens, edits and publishes through the embedded client, as a + /// mounted share's notebook does: `ONESTORE_SMB_LAB=127.0.0.1:PORT` (a disposable Samba + /// share `agent`, guest access; `tools/w7/linux_vm.py up NAME`). + #[test] + #[ignore = "requires an owned Samba share at ONESTORE_SMB_LAB"] + fn a_notebook_on_a_share_opens_through_the_embedded_client() { + let address = std::env::var("ONESTORE_SMB_LAB").unwrap(); + let root = format!("snowbound-{}", std::process::id()); + let mount = Mount { + server: address, + share: "agent".into(), + user: None, + domain: String::new(), + root: root.clone(), + }; + let login = Login::guest(&mount); + let server = Server { + mount: mount.clone(), + login: login.clone(), + }; + let client = Arc::new(server.connect().unwrap()); + client.create_directory(&root).unwrap(); + let cache = std::env::temp_dir().join(format!("snowbound-share-{}", std::process::id())); + let page = onestore::PageCreation::new(None, Some(""), "Rust Author").unwrap(); + Notebook::open_smb(Arc::clone(&client), &root, &cache) + .unwrap() + .create_section("", "New Section 1", &page) + .unwrap(); + let library = Library::on_share("/Volumes/agent/lab", mount, login, &cache).unwrap(); + let path = library.first_section().unwrap(); + let section = library.open(&path, || {}).unwrap(); + let (space, ..) = section.pages().unwrap()[0].clone(); + let stored = section.page(space).unwrap(); + let title = stored + .objects + .iter() + .find_map(|object| match object { + onestore::page::PageObject::Title(title) => { + title.outlines[0].paragraphs[0].text().map(|text| text.id) + } + _ => None, + }) + .unwrap(); + section + .apply( + "Rust Author", + onestore::op::Edit { + at: crate::filetime(), + ops: vec![onestore::op::Op::Page { + space, + op: onestore::op::PageOp::Text { + text: title, + range: 0..0, + with: "Over SMB".into(), + }, + }], + }, + ) + .unwrap(); + let deadline = std::time::Instant::now() + Duration::from_secs(60); + let file = format!("{root}/{path}"); + loop { + let bytes = client.read_storage(&file, LIMIT).unwrap(); + let arena = onestore::Arena::default(); + let titles = onestore::Section::open(&arena, bytes) + .unwrap() + .pages() + .unwrap(); + if titles.iter().any(|(_, title, _)| title == "Over SMB") { + break; + } + assert!(std::time::Instant::now() < deadline, "{titles:?}"); + section.wake(); + std::thread::sleep(Duration::from_millis(200)); + } + section.close().unwrap(); + let _ = std::fs::remove_dir_all(&cache); + } + + #[test] + fn mounts_name_their_server_share_account_and_folder() { + assert_eq!( + Mount::parse("//WORK;clover@nas.local/notes", "Personal/Garden", ""), + Some(Mount { + server: "nas.local".into(), + share: "notes".into(), + user: Some("clover".into()), + domain: "WORK".into(), + root: "Personal/Garden".into(), + }) + ); + assert_eq!( + Mount::parse( + "//nas/notes/sub", + "Personal", + "rw,vers=3.0,username=amy,domain=HOME" + ), + Some(Mount { + server: "nas".into(), + share: "notes".into(), + user: Some("amy".into()), + domain: "HOME".into(), + root: "sub/Personal".into(), + }) + ); + let guest = Mount::parse("//GUEST:@nas/public", "", "").unwrap(); + assert_eq!((guest.user, guest.root), (None, String::new())); + assert_eq!(Mount::parse("/dev/disk1", "", ""), None); + } + + #[test] + fn chosen_paths_open_their_notebook_or_section() { + let root = std::env::temp_dir().join(format!("snowbound-locate-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let notebook = root.join("Notebook"); + std::fs::create_dir_all(notebook.join("Group")).unwrap(); + for file in [ + "Open Notebook.onetoc2", + "Group/Open Notebook.onetoc2", + "Group/Inner.one", + "Top.one", + ] { + std::fs::write(notebook.join(file), b"").unwrap(); + } + std::fs::write(root.join("Loose.one"), b"").unwrap(); + let at = |root: &Path, section: Option<&str>| Located::Notebook { + root: root.to_owned(), + section: section.map(str::to_owned), + }; + assert_eq!(locate(¬ebook), at(¬ebook, None)); + assert_eq!( + locate(¬ebook.join("Open Notebook.onetoc2")), + at(¬ebook, None) + ); + assert_eq!( + locate(¬ebook.join("Group/Inner.one")), + at(¬ebook, Some("Group/Inner.one")) + ); + assert_eq!( + locate(¬ebook.join("Top.one")), + at(¬ebook, Some("Top.one")) + ); + assert_eq!( + locate(&root.join("Loose.one")), + Located::Section(root.join("Loose.one")) + ); + assert_eq!(locate(&root.join("missing.txt")), Located::Nothing); + std::fs::remove_dir_all(&root).unwrap(); + } +} diff --git a/crates/snowbound/src/linux.rs b/crates/snowbound/src/linux.rs index d08a923e5a1ac4735a5c91e5ba0d79ba6b06fc07..f6408c04f2cecec9526c045a9dbf7ae641ff1910 100644 --- a/crates/snowbound/src/linux.rs +++ b/crates/snowbound/src/linux.rs @@ -221,6 +221,64 @@ pub fn cache_dir() -> Option { xdg_dir("XDG_CACHE_HOME", ".cache") } +/// The share holding `path` where it is mounted from an SMB server (CIFS or SMB 3). +pub fn smb_mount(path: &std::path::Path) -> Option { + let mounts = std::fs::read_to_string("/proc/self/mounts").ok()?; + // The mount holding the path is the one at its longest ancestor. + let (source, point, options) = mounts + .lines() + .filter_map(|line| { + let mut fields = line.split_whitespace(); + let (source, point, kind, options) = + (fields.next()?, fields.next()?, fields.next()?, fields.next()?); + let point = point.replace("\\040", " "); + (matches!(kind, "cifs" | "smb3") && path.starts_with(&point)) + .then(|| (source.replace("\\040", " "), point, options.to_owned())) + }) + .max_by_key(|(_, point, _)| point.len())?; + let within = path.strip_prefix(&point).ok()?.to_string_lossy().into_owned(); + crate::library::Mount::parse(&source, &within, &options) +} + +/// The password the Secret Service keeps for `mount`'s account, as GNOME's file manager +/// saves one, or what the user types when it keeps none. +pub fn smb_login(mount: &crate::library::Mount) -> Result { + let user = mount.user.clone().unwrap_or_default(); + let mut lookup = Command::new("secret-tool"); + lookup.args(["lookup", "protocol", "smb", "server", &mount.server]); + if !user.is_empty() { + lookup.args(["user", &user]); + } + if let Ok(output) = lookup.output() + && output.status.success() + && !output.stdout.is_empty() + { + return Ok(crate::library::Login { + user, + password: String::from_utf8_lossy(&output.stdout).trim_end_matches('\n').to_owned(), + domain: mount.domain.clone(), + }); + } + let title = format!("Sign in to {}", mount.server); + let asked = dialog( + ["--password", "--username", &format!("--title={title}")], + ["--password", &title], + ) + .map_err(str::to_owned)? + .ok_or_else(|| "Signing in was cancelled".to_owned())?; + // zenity answers "user|password"; kdialog only the password. + let (typed, password) = asked + .split_once('|') + .map_or((user.clone(), asked.clone()), |(typed, password)| { + (typed.to_owned(), password.to_owned()) + }); + Ok(crate::library::Login { + user: if typed.is_empty() { user } else { typed }, + password, + domain: mount.domain.clone(), + }) +} + pub fn settings_dir() -> Option { xdg_dir("XDG_CONFIG_HOME", ".config") } diff --git a/crates/snowbound/src/macos.rs b/crates/snowbound/src/macos.rs index 58a669dc60e8f061cad1d2beee20746ec2870e09..be31fe80e4ff6ee1eb0ef37d12e702ec7debd8e7 100644 --- a/crates/snowbound/src/macos.rs +++ b/crates/snowbound/src/macos.rs @@ -117,6 +117,92 @@ pub fn cache_dir() -> Option { Some(std::path::PathBuf::from(std::env::var_os("HOME")?).join("Library/Caches/snowbound")) } +/// The share holding `path` where macOS mounted it from an SMB server. +pub fn smb_mount(path: &std::path::Path) -> Option { + use std::os::unix::ffi::OsStrExt; + let name = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?; + let mut mount: libc::statfs = unsafe { std::mem::zeroed() }; + if unsafe { libc::statfs(name.as_ptr(), &mut mount) } != 0 { + return None; + } + let text = |chars: &[libc::c_char]| { + unsafe { std::ffi::CStr::from_ptr(chars.as_ptr()) } + .to_string_lossy() + .into_owned() + }; + if text(&mount.f_fstypename) != "smbfs" { + return None; + } + let within = path.strip_prefix(text(&mount.f_mntonname)).ok()?; + crate::library::Mount::parse(&text(&mount.f_mntfromname), &within.to_string_lossy(), "") +} + +#[link(name = "Security", kind = "framework")] +unsafe extern "C" { + fn SecKeychainFindInternetPassword( + keychain: *const std::ffi::c_void, + server_length: u32, + server: *const u8, + domain_length: u32, + domain: *const u8, + account_length: u32, + account: *const u8, + path_length: u32, + path: *const u8, + port: u16, + protocol: u32, + authentication: u32, + password_length: *mut u32, + password: *mut *mut std::ffi::c_void, + item: *mut *const std::ffi::c_void, + ) -> i32; + fn SecKeychainItemFreeContent(list: *const std::ffi::c_void, data: *mut std::ffi::c_void) -> i32; +} + +/// The password the keychain keeps for `mount`'s account on its server, as macOS saved it +/// when the share was mounted; the system asks the user to allow the app to read it. +pub fn smb_login(mount: &crate::library::Mount) -> Result { + let Some(user) = &mount.user else { + return Ok(crate::library::Login::guest(mount)); + }; + // kSecProtocolTypeSMB is 'smb '. + const SMB: u32 = u32::from_be_bytes(*b"smb "); + let (mut length, mut data) = (0, std::ptr::null_mut()); + let status = unsafe { + SecKeychainFindInternetPassword( + std::ptr::null(), + mount.server.len() as u32, + mount.server.as_ptr(), + 0, + std::ptr::null(), + user.len() as u32, + user.as_ptr(), + 0, + std::ptr::null(), + 0, + SMB, + 0, + &mut length, + &mut data, + std::ptr::null_mut(), + ) + }; + if status != 0 { + return Err(format!( + "The keychain has no password for {user} on {} ({status})", + mount.server + )); + } + let password = unsafe { std::slice::from_raw_parts(data.cast::(), length as usize) }; + let password = String::from_utf8_lossy(password).into_owned(); + unsafe { SecKeychainItemFreeContent(std::ptr::null(), data) }; + Ok(crate::library::Login { + user: user.clone(), + password, + domain: mount.domain.clone(), + }) +} + pub fn settings_dir() -> Option { Some( std::path::PathBuf::from(std::env::var_os("HOME")?) diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 238462277399624dfdeee8e93a732dfc5a24b911..6943aa57df2462682b98ecc789108d78ac4efeb3 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -3,6 +3,7 @@ mod art; mod conflict_render; mod library; mod manage; +mod meeting; mod menus; #[cfg_attr(target_os = "linux", path = "linux.rs")] #[cfg_attr(target_os = "macos", path = "macos.rs")] @@ -80,8 +81,6 @@ const FONT_COLORS: [u32; 20] = [ /// while the gaps still take the pointer. const ROW: f32 = 29.0; const ROW_GAP: f32 = 3.0; -/// OneNote's highlight for conflicting changes on a conflict page, COLORREF. -const CONFLICTING: u32 = 0xd6d6ff; /// Space between the page and a page tab that isn't open. const PILL_MARGIN: f32 = 3.0; /// Rounding where the open page tab meets the page, concentric with its neighbours'. @@ -122,6 +121,7 @@ enum Replay { /// A frame during a wait, as a visible window's display would ask for. Tick, Appearance(winit::window::Theme), + Quit, } impl From for UserEvent { @@ -165,9 +165,8 @@ struct App { interface_font: Vec>, /// Where `--screenshot` writes its PNGs, drawn from a window never shown. screenshot: Option, - /// Where settings are saved, and what they held at launch, until the window opens. - settings: Option<(Option, settings::Settings)>, - cache: PathBuf, + /// What the window starts from besides its input, until it opens. + launch: Option, state: Option, startup_error: Option>, } @@ -226,6 +225,16 @@ enum Bar { } impl Session { + /// Saving's state, and that the notebook opened through the system's mount of its + /// share where Snowbound's own SMB client could not sign in. + fn status(&self) -> String { + match (&self.library.notice, self.status) { + (None, status) => status.to_owned(), + (Some(_), "") => "Via mounted share".to_owned(), + (Some(_), status) => format!("{status} · Via mounted share"), + } + } + fn title(&self) -> &str { match self.conflict(self.space) { Some((_, version)) => &version.title, @@ -285,26 +294,35 @@ impl Session { move || { let page = replica.page(space)?; Ok(match objects { - Some(objects) => highlighted(page, &objects), + Some(objects) => canvas::conflict::highlighted(page, &objects), None => page, }) } } } -/// What a loader thread opened: a section, or another page of the open one. +/// What a loader thread read: a section and its page to show, another page of the open +/// section, or a notebook read again after a change, with the catalog path the open +/// section has in it. enum Loaded { + Section(Box, Page), + Page(ExGuid, Page), + Library(Arc, String), +} + +/// What an opening page replaces: the section, or the page of the open one. +enum Shown { Section(Box), Page(ExGuid), } -/// A loader thread's request number, what it opened and the page it read. -type Read = (u64, Result<(Loaded, Page), String>); +/// A loader thread's request number and what it read. +type Read = (u64, Result); /// The newest page read, laid out and waiting for the pictures it shows first, so it /// never appears without them. struct Opening { - loaded: Loaded, + loaded: Shown, scene: (PageScene, [f32; 2]), editor: CanvasEditor, since: Instant, @@ -325,13 +343,15 @@ enum Command { CloseNotebook(Arc), /// Changes a notebook's sections and groups. Structure(Arc, manage::Structure), - /// Adds a page at the end of the open section, or a subpage after the open page, and - /// edits its title. - NewPage { subpage: bool }, + /// Adds a page at the end of the open section, or a subpage of a page, and edits its + /// title. + NewPage { under: Option }, /// Deletes pages of the open section to the notebook's recycle bin. DeletePages(Vec), /// Moves or indents pages of the open section. Pages(Vec), + /// Moves a page of the open section to the end of another section of its folder. + MovePage { space: ExGuid, path: String }, /// Gives the open page a template's background or colour. Template(templates::Choice), OpenPage(ExGuid), @@ -386,10 +406,8 @@ struct State { menu: Option<(menus::Target, [f32; 2])>, /// A section or group being renamed in the sidebar. renaming: Option, - /// The page whose tab is being dragged. - dragging: Option, - /// The section or group whose sidebar row is being dragged. - dragging_entry: Option, + /// What the pointer is dragging: a page's tab, a section tab or a sidebar row. + drag: Option, /// Pages whose template strip was dismissed this run. dismissed: HashSet, /// A page just created, whose title takes the caret once it opens. @@ -461,8 +479,11 @@ impl State { substitutes: &[PathBuf], interface_font: Vec>, visible: bool, - (settings, stored): (Option, settings::Settings), - cache: PathBuf, + settings::Launch { + file: settings, + saved: stored, + cache, + }: settings::Launch, ) -> Result> { let window = Arc::new( event_loop.create_window( @@ -652,8 +673,7 @@ impl State { title_focus: None, menu: None, renaming: None, - dragging: None, - dragging_entry: None, + drag: None, templates: templates::View::Strip, thumbnails: templates::Thumbnails::default(), filter: String::new(), @@ -795,7 +815,9 @@ impl State { self.ui.theme.inactive_selection, ] = platform::text_colors(&self.window); } - let theme = self.ui.theme.clone(); + // The page, and the open page's tab joined to it, take the page's colour. + let mut theme = self.ui.theme.clone(); + theme.paper = self.paper().color; if !platform::system_titlebar(&self.window) { self.title_bar(&theme); } @@ -820,7 +842,7 @@ impl State { ..Spec::default() }, ); - self.ui.open( + let tab_row = self.ui.open( "tabs", Spec { size: [fill(), px(TAB_ROW)], @@ -836,6 +858,7 @@ impl State { ..Spec::default() }, ); + let row = self.ui.id("sections"); let (clicked, open_tab) = match &self.session { Some(session) => { let tabs: Vec<_> = session @@ -843,14 +866,26 @@ impl State { .iter() .map(|tab| (tab.name.as_str(), section_color(tab.color))) .collect(); - let (clicked, context, open_tab) = ui::shell::section_tabs( + let lit = self.page_drop(row); + let ui::shell::Tabs { + clicked, + context, + held, + open: open_tab, + } = ui::shell::section_tabs( &mut self.ui, "sections", &tabs, session.tab, + lit, §ion, TAB_ROW, ); + let bar = self.ui.rect(tab_row).unwrap_or_default(); + self.drag_tabs(held, row, bar, section.accent); + let Some(session) = &self.session else { + unreachable!("The tabs are the session's") + }; let open = |tab: usize| { Command::OpenSection(Arc::clone(&session.library), session.tabs[tab].path.clone()) }; @@ -869,8 +904,16 @@ impl State { None => { let tabs = [("Temporary page", section_color(None))]; let shown = if self.temporary { &tabs[..] } else { &[] }; - let (_, _, open_tab) = - ui::shell::section_tabs(&mut self.ui, "sections", shown, 0, §ion, TAB_ROW); + let open_tab = ui::shell::section_tabs( + &mut self.ui, + "sections", + shown, + 0, + None, + §ion, + TAB_ROW, + ) + .open; (None, open_tab) } }; @@ -949,7 +992,7 @@ impl State { } self.page_events(signal.events)?; self.ui.close(); - let open_page = self.page_list(&theme, §ion); + let open_page = self.page_list(&theme, §ion, row); self.ui.close(); self.ui.close(); self.ui.close(); @@ -1008,7 +1051,7 @@ impl State { } outline.push(([left, bottom], ROUNDING)); let height = (frame[3] - frame[1]).max(1.0); - let paper = self.ui.theme.paper; + let paper = self.paper().color; self.ui.round_corners(&outline, paper, |y| { ui::mix(section.frame[0], section.frame[1], (y - frame[1]) / height) }); @@ -1092,7 +1135,7 @@ impl State { "status", Spec { size: [fit(), px(TITLE)], - text: Some(session.status), + text: Some(&session.status()), color: Some(theme.text_dim), pad: [12.0, 0.0], ..Spec::default() @@ -1126,7 +1169,7 @@ impl State { let engine = &self.view.engine; // Under the system's title bar the save status joins the toolbar. let status = platform::system_titlebar(&self.window) - .then(|| self.session.as_ref().map(|session| session.status)) + .then(|| self.session.as_ref().map(Session::status)) .flatten(); let ui = &mut self.ui; let text = theme.text; @@ -1446,8 +1489,8 @@ impl State { ui.leaf( "status", Spec { - size: [fit(), fill()], - text: Some(status), + size: [fit(), px(ui::shell::TOOL)], + text: Some(&status), color: Some(theme.text_dim), center: true, ..Spec::default() @@ -1579,7 +1622,7 @@ impl State { } } if ui::shell::tool_button(&mut self.ui, "new", art::PLUS, theme.text, false).clicked { - self.commands.push(Command::NewPage { subpage: false }); + self.commands.push(Command::NewPage { under: None }); } let toggle = if self.pages_open { art::SIDEBAR_COLLAPSE @@ -1594,7 +1637,7 @@ impl State { /// The section's pages as tabs down the frame's right side, returning the open page's /// tab, which is the page's colour and joins it. - fn page_list(&mut self, theme: &Theme, section: &ui::Section) -> Option { + fn page_list(&mut self, theme: &Theme, section: &ui::Section, tabs: Id) -> Option { let session = self.session.as_ref()?; let panel = self.ui.id("panel"); let width = self @@ -1616,7 +1659,7 @@ impl State { self.ui.open_popup(menus::id()); } let rect = self.ui.rect(panel).unwrap_or_default(); - self.drag_pages(rows.held, section, rect); + self.drag_pages(rows.held, section, rect, tabs); self.ui.close(); rows.open } @@ -1681,6 +1724,13 @@ impl State { self.ui.set_focus(Some(filter())); continue; } + // OneNote's New Page is Ctrl+N. + let new = matches!(&key, Key::Character(character) + if character.eq_ignore_ascii_case("n")); + if modifiers.command && !modifiers.shift && new && self.session.is_some() { + self.commands.push(Command::NewPage { under: None }); + continue; + } self.view.key(&ui::edit_key(&key), text.as_deref())? } ui::Event::Ime(Ime::Preedit(text, cursor)) => self.view.compose(text, cursor)?, @@ -1715,13 +1765,13 @@ impl State { self.load(move || { let section = library.open(&path, notify)?; let (session, page) = read_session(section, library, path, last)?; - Ok((Loaded::Section(Box::new(session)), page)) + Ok(Loaded::Section(Box::new(session), page)) }); } Command::OpenPage(space) => { let session = self.session.as_ref().ok_or("No section is open")?; let read = session.reader(space); - self.load(move || Ok((Loaded::Page(space), read()?))); + self.load(move || Ok(Loaded::Page(space, read()?))); } Command::Versions { page, show } => { let session = self.session.as_mut().ok_or("No section is open")?; @@ -1769,9 +1819,10 @@ impl State { Command::NewNotebook => self.new_notebook()?, Command::CloseNotebook(library) => self.close_notebook(&library), Command::Structure(library, change) => self.restructure(library, change), - Command::NewPage { subpage } => self.new_page(subpage)?, + Command::NewPage { under } => self.new_page(under)?, Command::DeletePages(pages) => self.delete_pages(pages)?, Command::Pages(edits) => self.edit_pages(edits)?, + Command::MovePage { space, path } => self.move_page(space, path)?, Command::Template(choice) => self.apply_template(choice)?, Command::Page(Request::EditDate(field)) => self.edit_date(field)?, Command::Page(Request::Copy(text)) => self.clipboard.set_text(text)?, @@ -1790,7 +1841,7 @@ impl State { /// read was asked for meanwhile. fn load( &mut self, - read: impl FnOnce() -> Result<(Loaded, Page), Box> + Send + 'static, + read: impl FnOnce() -> Result> + Send + 'static, ) { self.loading += 1; let (id, sender, redraw) = (self.loading, self.loads.0.clone(), self.redraw.clone()); @@ -1803,13 +1854,20 @@ impl State { /// Lays out the newest page read, and shows it once the pictures it shows first are /// drawn, or after `HOLD`. fn open_loaded(&mut self) -> Result<(), Box> { - for (id, loaded) in self.loads.1.try_iter() { + let reads: Vec = self.loads.1.try_iter().collect(); + for (id, loaded) in reads { if id != self.loading { continue; } let (loaded, page) = match loaded { - Ok(loaded) => loaded, + Ok(Loaded::Section(session, page)) => (Shown::Section(session), page), + Ok(Loaded::Page(space, page)) => (Shown::Page(space), page), + Ok(Loaded::Library(library, path)) => { + self.adopt(library, &path)?; + continue; + } Err(error) => { + eprintln!("{error}"); platform::alert("Couldn't open", &error); continue; } @@ -1828,7 +1886,8 @@ impl State { let paper = canvas::gpu::Paper { color: self.ui.theme.paper, ink: self.ui.theme.paper_ink, - }; + } + .colored(opening.editor.page_color()); if !self .view .prepare(&mut opening.scene, &opening.editor, paper, &self.redraw) @@ -1845,7 +1904,7 @@ impl State { self.last_pages.insert(key, session.space); } match opening.loaded { - Loaded::Section(session) => { + Shown::Section(session) => { // A notebook read again after a change replaces the one it was. match self .notebooks @@ -1859,7 +1918,7 @@ impl State { self.filter.clear(); self.save_settings(); } - Loaded::Page(space) => { + Shown::Page(space) => { let session = self.session.as_mut().ok_or("No section is open")?; session.space = space; session.change = None; @@ -1886,6 +1945,46 @@ impl State { Ok(()) } + /// Takes `library`, a notebook read again after a change, in place of the one it was; + /// the open section, now at catalog `path` in it, stays open, reopened where it moved. + fn adopt(&mut self, library: Arc, path: &str) -> Result<(), Box> { + match self + .notebooks + .iter_mut() + .find(|open| open.location == library.location) + { + Some(open) => *open = Arc::clone(&library), + None => self.notebooks.push(Arc::clone(&library)), + } + let Some(session) = &mut self.session else { + return Ok(()); + }; + let folder = path.rsplit_once('/').map_or("", |(folder, _)| folder); + let space = session.space; + if session.tabs[session.tab].path == path { + session.tabs = library.tabs(folder); + session.tab = session + .tabs + .iter() + .position(|tab| tab.path == path) + .ok_or("The section is not in its notebook")?; + session.library = library; + } else { + // Its file moved: the replica it holds reopens on the file's new path. + self.persist()?; + if let Some(session) = self.session.take() { + session.section.close()?; + } + let section = library.open(path, notify(self.proxy.clone()))?; + let (session, page) = read_session(section, library, path.to_owned(), Some(space))?; + self.session = Some(session); + let response = self.view.refresh(page)?; + self.respond(response); + } + self.save_settings(); + Ok(()) + } + /// Follows a page shown in place of another. fn opened(&mut self) -> Result<(), Box> { self.ui.set_focus(Some(page())); @@ -2064,6 +2163,10 @@ impl State { }, )?; session.status = "Saving"; + // The page list shows the title as it is typed. + if self.view.editor.active_outline().title { + session.pages = session.section.pages()?; + } Ok(()) } @@ -2316,13 +2419,19 @@ impl State { Ok(()) } + /// The paper the open page lies on: the theme's, in the page's colour. + fn paper(&self) -> canvas::gpu::Paper { + canvas::gpu::Paper { + color: self.ui.theme.paper, + ink: self.ui.theme.paper_ink, + } + .colored(self.view.editor.page_color()) + } + /// Paints the interface with the page in its box. fn paint(&mut self, target: &wgpu::TextureView) -> Result<(), Box> { let start = Instant::now(); - let paper = canvas::gpu::Paper { - color: self.ui.theme.paper, - ink: self.ui.theme.paper_ink, - }; + let paper = self.paper(); self.view.update_pictures(paper, &self.redraw); let theme = &self.ui.theme; let page_primitives = self.view.primitives(TextColors { @@ -2356,7 +2465,7 @@ impl State { viewport.origin[1] + corner[1] * scale, ], clip: Some(rect.map(|value| value * scale)), - backdrop: Some(self.ui.theme.paper), + backdrop: Some(paper.color), primitives: &page_primitives, }, }) @@ -2790,41 +2899,6 @@ fn page_tab( ui.signal(row) } -/// `page` with its conflict objects marked as OneNote shows a conflict page's: a band -/// across each conflicting paragraph's outline. -fn highlighted(mut page: Page, objects: &[ExGuid]) -> Page { - use onestore::page::{PageObject, PageParagraph, ParagraphContent}; - fn mark(paragraphs: &mut [PageParagraph], objects: &[ExGuid]) { - for paragraph in paragraphs { - match &mut paragraph.content { - ParagraphContent::Table(table) => { - for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) { - mark(&mut cell.paragraphs, objects); - } - } - ParagraphContent::Text(text) - if objects.contains(¶graph.id) || objects.contains(&text.id) => - { - paragraph.format.highlight = Some(CONFLICTING); - } - _ => {} - } - } - } - for object in &mut page.objects { - match object { - PageObject::Outline(outline) => mark(&mut outline.paragraphs, objects), - PageObject::Title(title) => { - for outline in &mut title.outlines { - mark(&mut outline.paragraphs, objects); - } - } - _ => {} - } - } - page -} - /// The pages whose titles contain `filter`, ignoring case. fn matching<'a>( pages: &'a [(ExGuid, String, u32)], @@ -3025,6 +3099,10 @@ impl ApplicationHandler for App { state.window.set_theme(Some(appearance)); state.ui.theme = theme(appearance); } + Replay::Quit => { + self.close(event_loop); + return; + } } // A covered window gets no redraws, so each step draws its own frame. if let Err(error) = state.frame() { @@ -3072,15 +3150,15 @@ impl ApplicationHandler for App { &self.substitutes, std::mem::take(&mut self.interface_font), self.screenshot.is_none(), - self.settings.take().unwrap_or_default(), - self.cache.clone(), + self.launch.take().expect("The window opens once"), )) { Ok(mut state) => match &self.screenshot { - Some(prefix) => { + // A replay drives the hidden window instead, its snapshots capturing it. + Some(prefix) if std::env::var_os("SNOWBOUND_REPLAY").is_none() => { self.startup_error = state.screenshot(prefix).err(); event_loop.exit(); } - None => self.state = Some(state), + _ => self.state = Some(state), }, Err(error) => { self.startup_error = Some(error); @@ -3217,9 +3295,9 @@ impl ApplicationHandler for App { } /// Feeds a development script to the window from another thread, one command per line -/// in logical pixels: `move X Y`, `press`, `release`, `wheel DX DY`, `key NAME`, `type -/// TEXT`, `modifiers [shift] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH` and -/// `appearance light|dark`. +/// in logical pixels: `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `key NAME`, `type +/// TEXT`, `modifiers [shift] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`, +/// `appearance light|dark` and `quit`. fn replay(script: String, proxy: EventLoopProxy) -> Result<(), Box> { let mut steps = Vec::new(); for line in script.lines().filter(|line| !line.trim().is_empty()) { @@ -3228,7 +3306,11 @@ fn replay(script: String, proxy: EventLoopProxy) -> Result<(), Box) -> Result<(), Box Err(std::time::Duration::from_millis(rest.parse()?)), + "quit" => Ok(Replay::Quit), "snapshot" => Ok(Replay::Snapshot(rest.into())), "appearance" => Ok(Replay::Appearance(match rest { "light" => winit::window::Theme::Light, @@ -3467,8 +3550,11 @@ fn main() -> Result<(), Box> { proxy: event_loop.create_proxy(), input: Some(input), // A screenshot leaves the settings as it found them. - settings: Some((settings_file.filter(|_| screenshot.is_none()), saved)), - cache, + launch: Some(settings::Launch { + file: settings_file.filter(|_| screenshot.is_none()), + saved, + cache, + }), substitutes, interface_font, screenshot, diff --git a/crates/snowbound/src/manage.rs b/crates/snowbound/src/manage.rs index cb621da20455d3d616eb309338696503bcedd89b..3e750314d4ba8db3fe39df769ea35fc52e417db6 100644 --- a/crates/snowbound/src/manage.rs +++ b/crates/snowbound/src/manage.rs @@ -12,19 +12,35 @@ use std::{error::Error, sync::Arc}; /// A change to a notebook's sections and groups, by catalog path. pub enum Structure { - NewSection { folder: String }, - NewGroup { folder: String }, - Rename { path: String, name: String }, + NewSection { + folder: String, + }, + NewGroup { + folder: String, + }, + Rename { + path: String, + name: String, + }, /// Moves a section or group to the recycle bin. - Delete { path: String }, + Delete { + path: String, + }, /// Moves a section or group into another folder. - Move { path: String, folder: String }, + Move { + path: String, + folder: String, + }, /// Orders a folder's sections and groups. - Reorder { folder: String, paths: Vec }, + Reorder { + folder: String, + paths: Vec, + }, } -/// Notebook colours for new notebooks, in turn: OneNote 2010's section colours. -const NOTEBOOK_COLORS: [u32; 3] = [0x00e4a88a, 0x0078b0f6, 0x00bba4d5]; +/// The colour OneNote 2010 gave each new notebook beside its default one, COLORREF +/// (`corpus/notebook-management/native/new-notebook`). +const NOTEBOOK_COLOR: u32 = 0x00aeba91; /// `current`, the path of a section, after `from` moved to `to`: a section moved itself, /// or one inside a moved group. @@ -56,18 +72,18 @@ impl State { Ok(creation.dated(&date, &time)?) } - /// Adds a page at the end of the open section, or a subpage after the open page, and - /// opens it with its title ready for typing, as OneNote's New Page and New Subpage do. - pub(crate) fn new_page(&mut self, subpage: bool) -> Result<(), Box> { + /// Adds a page at the end of the open section, or a subpage of page `under`, and opens + /// it with its title ready for typing, as OneNote's New Page and New Subpage do. + pub(crate) fn new_page(&mut self, under: Option) -> Result<(), Box> { self.persist()?; let session = self.session.as_ref().ok_or("No section is open")?; - // A subpage follows the open page and the subpages already under it. - let (before, level) = if subpage { + // A subpage follows its page and the subpages already under it. + let (before, level) = if let Some(under) = under { let at = session .pages .iter() - .position(|(space, ..)| *space == session.space) - .ok_or("The open page is not listed")?; + .position(|(space, ..)| *space == under) + .ok_or("That page is not listed")?; let level = session.pages[at].2; let next = session.pages[at + 1..] .iter() @@ -80,7 +96,7 @@ impl State { let creation = self.dated_page(None)?; let space = creation.space(); let mut ops = vec![Op::Section(SectionOp::Create(creation))]; - if subpage { + if under.is_some() { ops.push(Op::Section(SectionOp::Pages(vec![PageEdit::move_to( space, before, level, )?]))); @@ -105,59 +121,12 @@ impl State { &mut self, choice: crate::templates::Choice, ) -> Result<(), Box> { - use crate::templates::Choice; self.persist()?; let session = self.session.as_ref().ok_or("No section is open")?; let space = session.space; - let page = session.section.page(space)?; - let mut ops: Vec = page - .objects - .iter() - .filter(|object| matches!(object, PageObject::Image(image) if image.background)) - .map(|object| PageOp::Delete { - object: object.id(), - }) - .collect(); - match choice { - Choice::Template(name) => { - let template = - canvas::template::find(name).ok_or("That template is not available")?; - if page.color.is_some() { - ops.push(PageOp::Color(None)); - } - // Art lies under everything else on the page, first in its order. - let under = page - .objects - .iter() - .find(|object| !matches!(object, PageObject::Image(image) if image.background)) - .map(PageObject::id); - for art in template.art { - let bytes = canvas::gpu::page::template_picture(art.art) - .ok_or("That template's art is missing")?; - ops.push(PageOp::Add { - object: PageObject::Image(Image { - id: onestore::page::text::new_id()?, - layout: onestore::document::Layout { - x: Some(art.position[0]), - y: Some(art.position[1]), - max_width: art.size.map(|size| size[0]), - max_height: art.size.map(|size| size[1]), - width_set_by_user: art.size.map(|_| true), - ..Default::default() - }, - size: art.size, - bytes: Some(bytes.into()), - alt: None, - background: true, - }), - before: under, - }); - } - } - Choice::Color(index) => { - ops.push(PageOp::Color(Some(canvas::template::PAGE_COLORS[index].1))) - } - Choice::More | Choice::Dismiss | Choice::Colors => return Ok(()), + let ops = template_ops(&session.section.page(space)?, choice)?; + if ops.is_empty() { + return Ok(()); } session.section.apply( &self.author, @@ -175,18 +144,17 @@ impl State { return Ok(()); }; let page = self.dated_page(None)?; - let color = NOTEBOOK_COLORS[self.notebooks.len() % NOTEBOOK_COLORS.len()]; let (cache, notify) = (self.cache.clone(), notify(self.proxy.clone())); self.load(move || { let location = std::path::absolute(&root)?.to_string_lossy().into_owned(); - let notebook = Notebook::create(&location, &cache, color, &page)?; - let library = Arc::new(Library::open_notebook(&location, notebook, &cache)); + let notebook = Notebook::create(&location, &cache, NOTEBOOK_COLOR, &page)?; + let library = Arc::new(Library::created(&location, notebook, &cache)); let path = library .first_section() .ok_or("The new notebook has no section")?; let section = library.open(&path, notify)?; let (session, page) = read_session(section, library, path, None)?; - Ok((Loaded::Section(Box::new(session)), page)) + Ok(Loaded::Section(Box::new(session), page)) }); Ok(()) } @@ -220,7 +188,9 @@ impl State { let page = match change { Structure::NewSection { .. } => match self.dated_page(None) { Ok(page) => Some(page), - Err(error) => return platform::alert("Couldn't add the section", &error.to_string()), + Err(error) => { + return platform::alert("Couldn't add the section", &error.to_string()); + } }, _ => None, }; @@ -229,9 +199,9 @@ impl State { .as_ref() .filter(|session| Arc::ptr_eq(&session.library, &library)) .map(|session| session.tabs[session.tab].path.clone()); - let (cache, notify) = (self.cache.clone(), notify(self.proxy.clone())); + let notify = notify(self.proxy.clone()); self.load(move || { - let mut notebook = Notebook::open(&library.location, &cache)?; + let mut notebook = library.reopen()?; let names = |notebook: &Notebook, folder: &str| -> Vec { let mut folders = vec![notebook.catalog()]; while let Some(candidate) = folders.pop() { @@ -251,43 +221,56 @@ impl State { } Vec::new() }; - let open = match change { + // The open section where the change leaves it, and the section to show. + let (followed, created) = match change { Structure::NewSection { folder } => { let name = unused(&names(¬ebook, &folder), "New Section", true); let page = page.ok_or("The new section has no page")?; - Some(notebook.create_section(&folder, &name, &page)?) + ( + current, + Some(notebook.create_section(&folder, &name, &page)?), + ) } Structure::NewGroup { folder } => { let name = unused(&names(¬ebook, &folder), "New Section Group", false); notebook.create_group(&folder, &name)?; - current + (current, None) } Structure::Rename { path, name } => { let renamed = notebook.rename(&path, &name)?; - current.map(|current| follow(¤t, &path, &renamed)) + ( + current.map(|current| follow(¤t, &path, &renamed)), + None, + ) } Structure::Delete { path } => { notebook.delete(&path)?; - current + (current, None) } Structure::Move { path, folder } => { let moved = notebook.move_entry(&path, &folder)?; - current.map(|current| follow(¤t, &path, &moved)) + (current.map(|current| follow(¤t, &path, &moved)), None) } Structure::Reorder { folder, paths } => { let paths: Vec<&str> = paths.iter().map(String::as_str).collect(); notebook.reorder(&folder, &paths)?; - current + (current, None) } }; - let library = Arc::new(Library::open_notebook(&library.location, notebook, &cache)); + let open = created.or(followed.clone()); + let library = Arc::new(library.with(notebook)); let path = open .filter(|path| library.contains(path)) .or_else(|| library.first_section()) .ok_or("The notebook has no sections left")?; + // The open section, perhaps renamed or moved, stays open: its replica is this + // thread's to reopen only where no section holds it. + if followed.as_deref() == Some(path.as_str()) { + return Ok(Loaded::Library(library, path)); + } let section = library.open(&path, notify)?; let (session, page) = read_session(section, library, path, None)?; - Ok((Loaded::Section(Box::new(session)), page)) + Ok(Loaded::Section(Box::new(session), page)) }); } @@ -327,16 +310,66 @@ impl State { space } }; - let notebook = match &session.library.notebook { - Ok(Some(_)) => Some((session.library.location.clone(), self.cache.clone())), - _ => None, + let notebook = + matches!(session.library.notebook, Ok(Some(_))).then(|| Arc::clone(&session.library)); + let replica = Arc::clone(session.section.replica()); + let author = self.author.clone(); + self.load(move || { + if let Some(library) = notebook { + library.reopen()?.recycle_pages(&pages, &author)?; + } + replica.apply( + &author, + Edit { + at: crate::filetime(), + ops, + }, + )?; + Ok(Loaded::Page(next, replica.page(next)?)) + }); + Ok(()) + } + + /// Moves page `space` of the open section to the end of the section at catalog `path`, + /// as OneNote moves a page dropped on a section's tab: the page keeps its identity, + /// title, date and content there and leaves this section. + pub(crate) fn move_page(&mut self, space: ExGuid, path: String) -> Result<(), Box> { + self.persist()?; + let session = self.session.as_ref().ok_or("No section is open")?; + let import = notebook::session::moved(&session.section.page(space)?, &self.author)?; + let at = session + .pages + .iter() + .position(|(listed, ..)| *listed == space) + .unwrap_or_default(); + let next = session.pages[at + 1..] + .iter() + .chain(session.pages[..at].iter().rev()) + .map(|(listed, ..)| *listed) + .next(); + let mut ops = vec![Op::Section(SectionOp::Delete(vec![space]))]; + let next = match next { + Some(next) => next, + None => { + let fresh = self.dated_page(None)?; + let space = fresh.space(); + ops.push(Op::Section(SectionOp::Create(fresh))); + space + } }; + let library = Arc::clone(&session.library); let replica = Arc::clone(session.section.replica()); let author = self.author.clone(); self.load(move || { - if let Some((location, cache)) = notebook { - Notebook::open(&location, &cache)?.recycle_pages(&pages, &author)?; - } + let target = library.open(&path, || {})?; + target.replica().apply( + &author, + Edit { + at: crate::filetime(), + ops: vec![import], + }, + )?; + target.close()?; replica.apply( &author, Edit { @@ -344,7 +377,7 @@ impl State { ops, }, )?; - Ok((Loaded::Page(next), replica.page(next)?)) + Ok(Loaded::Page(next, replica.page(next)?)) }); Ok(()) } @@ -366,9 +399,371 @@ impl State { } } +/// The ops giving `page` `choice`'s background: its template art (OneNote's pictures' +/// places, our recreations' bytes) or a page colour, in place of the background it had. +pub fn template_ops( + page: &Page, + choice: crate::templates::Choice, +) -> Result, Box> { + use crate::templates::Choice; + let mut ops: Vec = page + .objects + .iter() + .filter(|object| matches!(object, PageObject::Image(image) if image.background)) + .map(|object| PageOp::Delete { + object: object.id(), + }) + .collect(); + match choice { + Choice::Template(name) => { + let template = canvas::template::find(name).ok_or("That template is not available")?; + if page.color.is_some() { + ops.push(PageOp::Color(None)); + } + // Art lies under everything else on the page, first of its children (the title + // is no child). + let under = page + .objects + .iter() + .find(|object| match object { + PageObject::Title(_) => false, + PageObject::Image(image) => !image.background, + _ => true, + }) + .map(PageObject::id); + for art in template.art { + let bytes = canvas::gpu::page::template_picture(art.art) + .ok_or("That template's art is missing")?; + ops.push(PageOp::Add { + object: PageObject::Image(Image { + id: onestore::page::text::new_id()?, + layout: onestore::document::Layout { + x: Some(art.position[0]), + y: Some(art.position[1]), + max_width: art.size.map(|size| size[0]), + max_height: art.size.map(|size| size[1]), + width_set_by_user: art.size.map(|_| true), + ..Default::default() + }, + size: art.size, + bytes: Some(bytes.into()), + alt: None, + background: true, + }), + before: under, + }); + } + // The one template with content worth keeping. + if name == "Informal Meeting Notes" { + ops.extend(onestore::op::lower_page( + page, + &crate::meeting::content(page)?, + )?); + } + } + Choice::Color(index) => { + ops.push(PageOp::Color(Some(canvas::template::PAGE_COLORS[index].1))) + } + Choice::More | Choice::Dismiss | Choice::Colors => return Ok(Vec::new()), + } + Ok(ops) +} + #[cfg(test)] mod tests { use super::*; + use crate::templates::Choice; + use std::path::Path; + + const AUTHOR: &str = "Rust Author"; + + fn dated() -> PageCreation { + let creation = PageCreation::new(None, Some(""), AUTHOR).unwrap(); + let [date, time] = platform::date_text(creation.created()); + creation.dated(&date, &time).unwrap() + } + + /// Applies `ops` to the section file at `file` as one edit, as the section thread does, + /// and publishes it. + fn edit(file: &Path, ops: Vec) { + let arena = onestore::Arena::default(); + let mut section = + onestore::Section::open(&arena, onestore::read_file(file).unwrap()).unwrap(); + section + .apply( + AUTHOR, + &Edit { + at: crate::filetime(), + ops, + }, + ) + .unwrap(); + if let Some(transaction) = section.seal().unwrap() { + transaction.commit_file(file).unwrap(); + } + } + + fn pages(file: &Path) -> Vec<(ExGuid, String, u32)> { + let arena = onestore::Arena::default(); + onestore::Section::open(&arena, onestore::read_file(file).unwrap()) + .unwrap() + .pages() + .unwrap() + } + + fn page(file: &Path, space: ExGuid) -> Page { + let arena = onestore::Arena::default(); + onestore::Section::open(&arena, onestore::read_file(file).unwrap()) + .unwrap() + .page(space) + .unwrap() + } + + fn copy(from: &Path, to: &Path) { + std::fs::create_dir_all(to).unwrap(); + for entry in std::fs::read_dir(from).unwrap().flatten() { + let target = to.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + copy(&entry.path(), &target); + } else { + std::fs::copy(entry.path(), target).unwrap(); + } + } + } + + /// A notebook made and changed the way the app makes and changes one: created with a + /// dated first page; sections and groups added, renamed, moved, reordered and deleted to + /// the recycle bin; pages titled, given a template's art or a colour, made subpages and + /// deleted to the recycle bin. `SNOWBOUND_MANAGEMENT_EXPORT` names a new directory that + /// receives it for a cold reopen in OneNote 2010. + #[test] + fn a_notebook_is_managed_as_onenote_manages_one() { + let temporary = + std::env::temp_dir().join(format!("snowbound-manage-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&temporary); + std::fs::create_dir_all(&temporary).unwrap(); + let root = temporary.join("Managed"); + let cache = temporary.join("cache"); + let mut notebook = Notebook::create(&root, &cache, NOTEBOOK_COLOR, &dated()).unwrap(); + for (folder, name) in [("", "New Section 2"), ("", "Binned")] { + notebook.create_section(folder, name, &dated()).unwrap(); + } + for (group, section) in [("Kept", "Inner"), ("Doomed", "Gone")] { + notebook.create_group("", group).unwrap(); + notebook.create_section(group, section, &dated()).unwrap(); + } + + // Pages of the first section, each titled. + let file = root.join("New Section 1.one"); + let first = pages(&file)[0].0; + let title = |space: ExGuid, text: &str| { + let page = page(&file, space); + let title = page + .objects + .iter() + .find_map(|object| match object { + PageObject::Title(title) => title.outlines[0].paragraphs[0].text(), + _ => None, + }) + .unwrap() + .id; + Op::Page { + space, + op: PageOp::Text { + text: title, + range: 0..0, + with: text.to_owned(), + }, + } + }; + edit(&file, vec![title(first, "Garden plan")]); + let mut spaces = Vec::new(); + for text in [ + "Ivy page", + "Teal page", + "Subpage", + "Deleted page", + "Meeting page", + ] { + let creation = dated(); + let space = creation.space(); + edit(&file, vec![Op::Section(SectionOp::Create(creation))]); + edit(&file, vec![title(space, text)]); + spaces.push(space); + } + let [ivy, teal, subpage, deleted, meeting] = spaces[..] else { + unreachable!() + }; + let background = |space: ExGuid, choice: Choice| { + template_ops(&page(&file, space), choice) + .unwrap() + .into_iter() + .map(|op| Op::Page { space, op }) + .collect::>() + }; + edit(&file, background(ivy, Choice::Template("Ivy"))); + edit( + &file, + background(meeting, Choice::Template("Informal Meeting Notes")), + ); + let teal_index = canvas::template::PAGE_COLORS + .iter() + .position(|(name, _)| *name == "Teal") + .unwrap(); + edit(&file, background(teal, Choice::Color(teal_index))); + edit( + &file, + vec![Op::Section(SectionOp::Pages(vec![ + PageEdit::set_level(subpage, 2).unwrap(), + ]))], + ); + let recycled = page(&file, deleted); + notebook + .recycle_pages(std::slice::from_ref(&recycled), AUTHOR) + .unwrap(); + edit(&file, vec![Op::Section(SectionOp::Delete(vec![deleted]))]); + + // A page moved to another section, as a drop on its tab moves it. + let moved_space = { + let creation = dated(); + let space = creation.space(); + edit(&file, vec![Op::Section(SectionOp::Create(creation))]); + edit(&file, vec![title(space, "Moved page")]); + space + }; + let moving = page(&file, moved_space); + let other = root.join("New Section 2.one"); + edit( + &other, + vec![notebook::session::moved(&moving, AUTHOR).unwrap()], + ); + edit( + &file, + vec![Op::Section(SectionOp::Delete(vec![moved_space]))], + ); + let arrived = pages(&other).last().unwrap().0; + let arrived = page(&other, arrived); + assert_eq!( + ( + arrived.title.as_str(), + arrived.identity, + arrived.date_text() + ), + ("Moved page", moving.identity, moving.date_text()) + ); + + // Structure. + assert_eq!( + notebook.rename("New Section 2.one", "Kitchen").unwrap(), + "Kitchen.one" + ); + assert_eq!( + notebook.move_entry("Kitchen.one", "Kept").unwrap(), + "Kept/Kitchen.one" + ); + assert_eq!(notebook.rename("Kept", "Archive").unwrap(), "Archive"); + notebook.delete("Doomed").unwrap(); + notebook.delete("Binned.one").unwrap(); + notebook + .reorder("", &["Archive", "New Section 1.one"]) + .unwrap(); + + let catalog = notebook.catalog(); + let names = |folder: ¬ebook::discover::Folder| { + folder + .sections + .iter() + .map(|section| section.path.clone()) + .chain(folder.groups.iter().map(|group| group.path.clone())) + .collect::>() + }; + assert_eq!( + names(catalog), + ["New Section 1.one", "Archive", "OneNote_RecycleBin"] + ); + assert_eq!( + names(&catalog.groups[0]), + ["Archive/Inner.one", "Archive/Kitchen.one"] + ); + let mut binned = names(&catalog.groups[1]); + binned.sort(); + assert_eq!( + binned, + [ + "OneNote_RecycleBin/Binned.one", + "OneNote_RecycleBin/Gone.one", + "OneNote_RecycleBin/OneNote_DeletedPages.one", + ] + ); + assert!(!root.join("Doomed").exists()); + + let listed = pages(&file); + assert_eq!( + listed + .iter() + .map(|(_, title, level)| (title.as_str(), *level)) + .collect::>(), + [ + ("Garden plan", 1), + ("Ivy page", 1), + ("Teal page", 1), + ("Subpage", 2), + ("Meeting page", 1) + ] + ); + let shown = page(&file, teal); + assert_eq!( + shown.color, + Some(canvas::template::PAGE_COLORS[teal_index].1) + ); + assert!(shown.date_text().is_some()); + assert!( + page(&file, ivy) + .objects + .iter() + .any(|object| matches!(object, PageObject::Image(image) if image.background)) + ); + let written = page(&file, meeting); + let outlines: Vec<_> = written + .objects + .iter() + .filter_map(|object| match object { + PageObject::Outline(outline) => Some(outline), + _ => None, + }) + .collect(); + assert_eq!(outlines.len(), 4); + let lines: Vec<&str> = outlines + .iter() + .flat_map(|outline| &outline.paragraphs) + .filter_map(|paragraph| paragraph.text()) + .map(|text| text.text.text()) + .collect(); + assert!(lines.contains(&"Meeting Details") && lines.contains(&"Attendees:")); + assert!( + outlines + .iter() + .flat_map(|outline| &outline.paragraphs) + .filter_map(|paragraph| paragraph.text()) + .any(|text| !text.tags.is_empty()) + ); + let bin = root.join("OneNote_RecycleBin/OneNote_DeletedPages.one"); + let [(space, title, 1)] = &pages(&bin)[..] else { + panic!("{:?}", pages(&bin)) + }; + assert_eq!(title, "Deleted page"); + let kept = page(&bin, *space); + assert_eq!( + (kept.identity, kept.created), + (recycled.identity, recycled.created) + ); + assert_eq!(kept.date_text(), recycled.date_text()); + + if let Some(directory) = std::env::var_os("SNOWBOUND_MANAGEMENT_EXPORT") { + copy(&root, Path::new(&directory)); + } + std::fs::remove_dir_all(&temporary).unwrap(); + } #[test] fn moved_sections_are_followed_and_new_names_take_the_next_number() { @@ -379,7 +774,11 @@ mod tests { assert_eq!(unused(&taken, "New Section", true), "New Section 3"); assert_eq!(unused(&[], "New Section Group", false), "New Section Group"); assert_eq!( - unused(&["New Section Group".to_owned()], "New Section Group", false), + unused( + &["New Section Group".to_owned()], + "New Section Group", + false + ), "New Section Group 2" ); } diff --git a/crates/snowbound/src/meeting.rs b/crates/snowbound/src/meeting.rs new file mode 100644 index 0000000000000000000000000000000000000000..f289ef2cb784afc795a94e92106c6cb3593db422 --- /dev/null +++ b/crates/snowbound/src/meeting.rs @@ -0,0 +1,237 @@ +//! OneNote 2010's Informal Meeting Notes content (Business templates; +//! `corpus/notebook-management/native/meeting-template`): four outlines of bold headings over +//! numbered, to-do and arrow-bulleted lines, placed where the template places them. + +use onestore::{ + ExGuid, + document::{Format, Kind, Layout, Tag}, + page::{ + Definition, MediaIndex, Outline, Page, PageObject, PageParagraph, ParagraphContent, + TextObject, text::new_id, + }, +}; +use std::error::Error; + +/// OneNote's automatic colour. +const AUTOMATIC: u32 = 0xff00_0000; + +/// A list line's marker. +enum Marker { + Numbered, + Arrow, + ToDo, + None, +} + +/// A heading and the lines under it. +type Heading = (&'static str, &'static [(&'static str, Marker)]); + +/// An outline of the template: position, width, indents, then each heading's lines. +type Block = ([f32; 2], f32, [f32; 4], &'static [Heading]); + +const OUTLINES: [Block; 4] = [ + ( + [36.0, 77.4], + 141.75, + [18.0, 0.0, 21.6, 36.0], + &[("Agenda", &[("", Marker::Numbered)])], + ), + ( + [234.0, 77.4], + 137.25, + [18.0, 0.0, 18.0, 36.0], + &[("Action Items", &[("", Marker::ToDo)])], + ), + ( + [423.0, 77.4], + 136.5, + [18.0, 0.0, 21.6, 36.0], + &[("Important Dates", &[("", Marker::Arrow)])], + ), + ( + [36.0, 221.4], + 529.5, + [18.0, 0.0, 36.0, 36.0], + &[ + ( + "Meeting Details", + &[ + ("Date and Time:", Marker::Arrow), + ("Location:", Marker::Arrow), + ("Attendees:", Marker::Arrow), + ], + ), + ("Announcements", &[("", Marker::Arrow)]), + ("Discussion", &[("", Marker::Arrow)]), + ("Summary", &[("", Marker::Arrow), ("", Marker::None)]), + ( + "Next Meeting", + &[ + ("Date and Time:", Marker::Arrow), + ("Location:", Marker::Arrow), + ("Agenda:", Marker::Arrow), + ("Notes:", Marker::Arrow), + ], + ), + ], + ), +]; + +/// `page` with the template's outlines added after what it holds. +pub fn content(page: &Page) -> Result> { + let mut page = page.clone(); + let text = |bold: bool| Format { + bold: Some(bold), + italic: Some(false), + underline: Some(false), + strike: Some(false), + superscript: Some(false), + subscript: Some(false), + font: Some("Calibri".into()), + font_size: Some(11.0), + color: Some(AUTOMATIC), + highlight: Some(AUTOMATIC), + language: Some(0x409), + space_before: Some(0.0), + space_after: Some(0.0), + ..Format::default() + }; + let style = new_id()?; + page.definitions.insert( + style, + Definition { + kind: Kind::Style { + name: Some("p".into()), + }, + format: Format { + language: None, + ..text(false) + }, + }, + ); + // Tag dates count seconds since 1980. + let created = u32::try_from(crate::filetime() / 10_000_000 - 11_644_473_600 - 315_532_800)?; + let to_do = new_id()?; + page.definitions.insert( + to_do, + Definition { + kind: Kind::TagDefinition { + label: Some("To Do".into()), + action_type: Some(0), + shape: Some(3), + color: None, + highlight: None, + }, + format: Format::default(), + }, + ); + let paragraph = |parent: Option, + line: &str, + bold: bool, + lists: Vec, + tags: Vec| + -> Result> { + Ok(PageParagraph { + id: new_id()?, + parent, + level: if parent.is_some() { 2 } else { 1 }, + style: Some(style), + format: Format::default(), + content: ParagraphContent::Text(TextObject { + id: new_id()?, + date_field: None, + text: onestore::page::Paragraph::new(line.to_owned(), text(bold)), + // OneNote tags the line's text, not its paragraph. + tags, + }), + lists, + tags: Vec::new(), + media: MediaIndex::default(), + collapsed: false, + }) + }; + for (position, width, indents, headings) in OUTLINES { + let mut paragraphs = Vec::new(); + for (heading, lines) in headings { + let parent = paragraph(None, heading, true, Vec::new(), Vec::new())?; + let id = parent.id; + paragraphs.push(parent); + for (line, marker) in *lines { + let (lists, tags) = match marker { + Marker::None => (Vec::new(), Vec::new()), + Marker::ToDo => ( + Vec::new(), + vec![Tag { + definition: Some(to_do), + action_type: None, + status: 0, + created: Some(created), + completed: Some(0), + start: None, + due: None, + task_id: None, + extra_set: 0, + }], + ), + Marker::Numbered | Marker::Arrow => { + let list = new_id()?; + page.definitions.insert(list, list_definition(marker)); + (vec![list], Vec::new()) + } + }; + paragraphs.push(paragraph(Some(id), line, false, lists, tags)?); + } + } + page.objects.push(PageObject::Outline(Outline { + id: new_id()?, + title: false, + min_width: None, + layout: Layout { + x: Some(position[0]), + y: Some(position[1]), + max_width: Some(width), + width_set_by_user: Some(true), + ..Layout::default() + }, + indents: indents.to_vec(), + paragraphs, + unsupported: Vec::new(), + })); + } + Ok(page) +} + +/// The template's `##.` numbering, or its Wingdings 3 arrow bullet. +fn list_definition(marker: &Marker) -> Definition { + let format = Format { + font_size: Some(11.0), + color: Some(AUTOMATIC), + ..Format::default() + }; + match marker { + Marker::Numbered => Definition { + kind: Kind::List { + font: None, + format: Some("\u{fffd}\u{0}.".into()), + restart: None, + bullet: None, + }, + format: Format { + bold: Some(false), + italic: Some(false), + font: Some("Calibri".into()), + language: Some(0x409), + ..format + }, + }, + _ => Definition { + kind: Kind::List { + font: Some("Wingdings 3".into()), + format: Some("}".into()), + restart: None, + bullet: Some(11), + }, + format, + }, + } +} diff --git a/crates/snowbound/src/menus.rs b/crates/snowbound/src/menus.rs index eb4f769f507e643677dc26110be2425acd67f08c..9ec7fee52f9107de5bf5b8e0ff80f749acbee5d8 100644 --- a/crates/snowbound/src/menus.rs +++ b/crates/snowbound/src/menus.rs @@ -6,6 +6,15 @@ use onestore::{ExGuid, PageEdit}; use std::sync::Arc; use ui::{Anchor, Id, popup::Item}; +/// What a drag holds. +pub enum Drag { + Page(ExGuid), + /// A sidebar row. + Entry(crate::sidebar::Entry), + /// A section tab, by index. + Tab(usize), +} + /// What a context menu was opened on. pub enum Target { Page(ExGuid), @@ -55,6 +64,8 @@ impl State { separated: true, ..item("New Page") }, + // OneNote offers this beside New Page, which is a plain + here. + item("New Subpage"), Item { separated: true, disabled: level > above || level >= 3, @@ -93,7 +104,8 @@ impl State { }; let command = match (target, items[chosen].text) { (Target::Page(space), "Delete") => Some(Command::DeletePages(vec![space])), - (Target::Page(_), "New Page") => Some(Command::NewPage { subpage: false }), + (Target::Page(_), "New Page") => Some(Command::NewPage { under: None }), + (Target::Page(space), "New Subpage") => Some(Command::NewPage { under: Some(space) }), (Target::Page(space), text) => self.session.as_ref().and_then(|session| { let level = session .pages @@ -151,14 +163,33 @@ impl State { self.commands.extend(command); } + /// The section tab, of those built as `row`, under the pointer while a page is dragged + /// there, which a drop moves the page into. + pub(crate) fn page_drop(&self, row: Id) -> Option { + let (Some(Drag::Page(_)), Some(session), Some([x, y])) = + (&self.drag, &self.session, self.ui.pointer()) + else { + return None; + }; + (0..session.tabs.len()) + .filter(|tab| *tab != session.tab) + .find(|tab| { + self.ui.rect(ui::shell::tab_id(row, *tab)).is_some_and( + |[left, top, right, bottom]| x >= left && x < right && y >= top && y < bottom, + ) + }) + } + /// Moves a page by dragging its tab: while `held`, a line shows where it would go; let - /// go, it moves there at its level, or under a page at most one level above it. - /// `panel` is the page list's rectangle. + /// go, it moves there at its level, or under a page at most one level above it. Onto a + /// section tab of those built as `row`, it moves to the end of that section, as + /// OneNote moves a page dropped on a section's tab. `panel` is the page list's rectangle. pub(crate) fn drag_pages( &mut self, held: Option, section: &ui::Section, panel: [f32; 4], + row: Id, ) { let Some(session) = &self.session else { return; @@ -166,17 +197,35 @@ impl State { if held.is_some() && !self.filter.is_empty() { return; } - let dragged = held.or(self.dragging.take()); - self.dragging = held; - let (Some(space), Some([_, y])) = (dragged, self.ui.pointer()) else { + let dropped = match (&self.drag, held) { + (_, Some(space)) => Some(space), + (Some(Drag::Page(space)), None) => Some(*space), + _ => None, + }; + let target = self.page_drop(row); + if let Some(space) = held { + self.drag = Some(Drag::Page(space)); + } else if matches!(self.drag, Some(Drag::Page(_))) { + self.drag = None; + } + let (Some(space), Some([_, y])) = (dropped, self.ui.pointer()) else { return; }; + if let Some(tab) = target { + if held.is_none() { + let path = session.tabs[tab].path.clone(); + self.commands.push(Command::MovePage { space, path }); + } + return; + } // Rows other than the dragged one, with where each lies. let rows: Vec<(ExGuid, u32, [f32; 4])> = session .pages .iter() .filter(|(listed, ..)| *listed != space) - .filter_map(|(listed, _, level)| Some((*listed, *level, self.ui.rect(self.ui.id(listed))?))) + .filter_map(|(listed, _, level)| { + Some((*listed, *level, self.ui.rect(self.ui.id(listed))?)) + }) .collect(); let Some(level) = session .pages @@ -217,11 +266,74 @@ impl State { section.accent, 1.5, ); - self.dragging = held; } else if let Ok(edit) = PageEdit::move_to(space, before, level) { self.commands.push(Command::Pages(vec![edit])); } } + + /// Reorders section tabs by dragging one: while `held`, a line shows where it would go + /// among the tabs built as `row` in the tab row `bar`; let go, the folder takes that + /// order. + pub(crate) fn drag_tabs( + &mut self, + held: Option, + row: Id, + bar: [f32; 4], + accent: [f32; 4], + ) { + let Some(session) = &self.session else { + return; + }; + let dragged = match (&self.drag, held) { + (_, Some(tab)) => Some(tab), + (Some(Drag::Tab(tab)), None) => Some(*tab), + _ => None, + }; + if let Some(tab) = held { + self.drag = Some(Drag::Tab(tab)); + } else if matches!(self.drag, Some(Drag::Tab(_))) { + self.drag = None; + } + let (Some(tab), Some([x, _])) = (dragged, self.ui.pointer()) else { + return; + }; + let others: Vec<(usize, [f32; 4])> = (0..session.tabs.len()) + .filter(|other| *other != tab) + .filter_map(|other| Some((other, self.ui.rect(ui::shell::tab_id(row, other))?))) + .collect(); + let at = others + .iter() + .position(|(_, rect)| x < (rect[0] + rect[2]) / 2.0) + .unwrap_or(others.len()); + let mut order: Vec = others.iter().map(|(other, _)| *other).collect(); + order.insert(at, tab); + if order.iter().copied().eq(0..session.tabs.len()) { + return; + } + if held.is_some() { + let edge = match ( + at.checked_sub(1).map(|before| others[before].1), + others.get(at), + ) { + (_, Some((_, after))) => after[0], + (Some(before), None) => before[2], + (None, None) => return, + } - bar[0]; + self.ui + .mark([edge - 1.5, 4.0, edge + 1.5, bar[3] - bar[1]], accent, 1.5); + } else { + let path = &session.tabs[tab].path; + let folder = folder(path); + let paths = order + .into_iter() + .map(|index| session.tabs[index].path.clone()) + .collect(); + self.commands.push(Command::Structure( + Arc::clone(&session.library), + Structure::Reorder { folder, paths }, + )); + } + } } /// The new section or group a menu's `text` asks for in `folder`. diff --git a/crates/snowbound/src/screenshot.rs b/crates/snowbound/src/screenshot.rs index 55a620143597bfe0e2aecad13f8e32d465ad8eeb..b6fddb09e7adf32491a6c8af147c9ded852722f3 100644 --- a/crates/snowbound/src/screenshot.rs +++ b/crates/snowbound/src/screenshot.rs @@ -40,10 +40,7 @@ impl State { self.ui.theme = theme(appearance); // The page's box sizes the view whose pictures settle. self.layout(SIZE, SCALE)?; - let paper = canvas::gpu::Paper { - color: self.ui.theme.paper, - ink: self.ui.theme.paper_ink, - }; + let paper = self.paper(); if let Some((scene, _)) = &mut self.view.scene { scene.settle(Some(&self.view.editor), self.view.viewport.scale, paper); } diff --git a/crates/snowbound/src/settings.rs b/crates/snowbound/src/settings.rs index 2c950b35f9748f57f61c97f4a61c6b42407559bd..56bd14011e04a27fd61113a86a4e8a72a54be845 100644 --- a/crates/snowbound/src/settings.rs +++ b/crates/snowbound/src/settings.rs @@ -51,7 +51,8 @@ impl crate::State { let Some(path) = &self.settings else { return; }; - let kept = |library: &&std::sync::Arc| !matches!(library.notebook, Ok(None)); + let kept = + |library: &&std::sync::Arc| !matches!(library.notebook, Ok(None)); let settings = Settings { notebooks: self .notebooks @@ -74,6 +75,15 @@ impl crate::State { } } +/// What a launch starts from besides its input. +pub struct Launch { + /// Where the settings are saved; `None` leaves them as they were read. + pub file: Option, + pub saved: Settings, + /// The directory holding the sections' replicas. + pub cache: PathBuf, +} + /// Where the settings live unless `--settings` names a file. pub fn default_path() -> Option { Some(crate::platform::settings_dir()?.join("settings.json")) @@ -85,11 +95,15 @@ mod tests { #[test] fn settings_round_trip_and_tolerate_missing_fields() { - let directory = std::env::temp_dir().join(format!("snowbound-settings-{}", std::process::id())); + let directory = + std::env::temp_dir().join(format!("snowbound-settings-{}", std::process::id())); let path = directory.join("nested/settings.json"); assert_eq!(Settings::load(&path), Settings::default()); let settings = Settings { - notebooks: vec!["/notebooks/Personal".into(), "smb://server/share/Work".into()], + notebooks: vec![ + "/notebooks/Personal".into(), + "smb://server/share/Work".into(), + ], current: Some("/notebooks/Personal".into()), sidebar: true, recent_fonts: vec!["Georgia".into()], diff --git a/crates/snowbound/src/sidebar.rs b/crates/snowbound/src/sidebar.rs index 43462e36d6a10d923b0eef0ed824fd439df41d32..16f372e22d87056315888cd75ca6f0418d5589d2 100644 --- a/crates/snowbound/src/sidebar.rs +++ b/crates/snowbound/src/sidebar.rs @@ -22,7 +22,10 @@ pub enum Action { /// Shows a notebook where it was left. Notebook(usize), /// Opens a notebook's section by catalog path. - Open { notebook: usize, path: String }, + Open { + notebook: usize, + path: String, + }, /// Folds or unfolds a notebook's or section group's rows, by `fold_key`. Fold(String), NewNotebook, @@ -51,7 +54,7 @@ pub fn fold_key(library: &Library, path: &str) -> String { } /// What the tree's rows read and what they were asked. -struct Tree<'a> { +pub struct Tree<'a> { theme: &'a Theme, /// The open section's notebook index and catalog path. open: Option<(usize, &'a str)>, @@ -64,6 +67,25 @@ struct Tree<'a> { held: Option, } +impl<'a> Tree<'a> { + fn new( + theme: &'a Theme, + open: Option<(usize, &'a str)>, + folded: &'a HashSet, + renaming: Option<&'a mut Renaming>, + ) -> Self { + Self { + theme, + open, + folded, + renaming, + action: None, + rows: Vec::new(), + held: None, + } + } +} + /// A section or group row, for dragging one onto or between the others. #[derive(Clone, PartialEq)] pub struct Entry { @@ -79,26 +101,10 @@ impl Entry { } } -/// The sidebar under a header row `header` tall, listing `notebooks` with the open section -/// marked. -pub fn sidebar( - ui: &mut Ui, - theme: &Theme, - notebooks: &[Arc], - open: Option<(usize, &str)>, - folded: &HashSet, - renaming: Option<&mut Renaming>, - header: f32, -) -> (Option, Vec, Option) { - let mut tree = Tree { - theme, - open, - folded, - renaming, - action: None, - rows: Vec::new(), - held: None, - }; +/// The sidebar's header row, `header` tall, then with `rows` the tree of `notebooks` with +/// the open section marked. +fn sidebar(ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc], header: f32, rows: bool) { + let (theme, open, folded) = (tree.theme, tree.open, tree.folded); ui.open( "header", Spec { @@ -121,6 +127,9 @@ pub fn sidebar( }, ); ui.close(); + if !rows { + return; + } ui.open( "notebooks", Spec { @@ -136,7 +145,7 @@ pub fn sidebar( let unfolded = !folded.contains(&key); let (row, fold) = tree_row( ui, - &mut tree, + tree, ("notebook", index), Row { label: &library.name, @@ -157,7 +166,7 @@ pub fn sidebar( tree.action = Some(Action::Menu(Target::Notebook(Arc::clone(library)), point)); } if unfolded && let Some(catalog) = library.catalog() { - folder(ui, &mut tree, library, index, catalog, 1); + folder(ui, tree, library, index, catalog, 1); } } ui.close(); @@ -176,7 +185,7 @@ pub fn sidebar( ] { let (row, _) = tree_row( ui, - &mut tree, + tree, part, Row { label, @@ -193,7 +202,6 @@ pub fn sidebar( } } ui.close(); - (tree.action, tree.rows, tree.held) } /// A notebook's or group's sections, then its groups, as OneNote lists them. @@ -210,9 +218,9 @@ fn folder( .filter(|(index, _)| *index == notebook) .map(|(_, path)| path); let renamed = |tree: &Tree, path: &str| { - tree.renaming - .as_ref() - .is_some_and(|renaming| Arc::ptr_eq(&renaming.library, library) && renaming.path == path) + tree.renaming.as_ref().is_some_and(|renaming| { + Arc::ptr_eq(&renaming.library, library) && renaming.path == path + }) }; for section in &folder.sections { let (name, color, readable) = match §ion.state { @@ -299,7 +307,8 @@ fn folder( /// Records a section or group row for dragging. fn entry(ui: &Ui, tree: &mut Tree, notebook: usize, path: &str, group: bool, row: &Signal) { - let Some(rect) = ui.rect(ui.id(("section", notebook, path))) + let Some(rect) = ui + .rect(ui.id(("section", notebook, path))) .or_else(|| ui.rect(ui.id(("group", notebook, path)))) else { return; @@ -454,9 +463,10 @@ impl crate::State { if self.temporary { return; } - let width = self - .ui - .animate(self.ui.id("sidebar"), if self.sidebar { WIDTH } else { RAIL }); + let width = self.ui.animate( + self.ui.id("sidebar"), + if self.sidebar { WIDTH } else { RAIL }, + ); self.ui.open( "sidebar", Spec { @@ -483,15 +493,18 @@ impl crate::State { .position(|library| Arc::ptr_eq(library, &session.library))?; Some((index, session.tabs[session.tab].path.as_str())) }); - let (action, rows, held) = sidebar( + let mut tree = Tree::new(theme, open, &self.folded, self.renaming.as_mut()); + // The collapsed rail shows only its button. + sidebar( &mut self.ui, - theme, + &mut tree, &self.notebooks, - open, - &self.folded, - self.renaming.as_mut(), crate::TAB_ROW, + width > RAIL + 0.5, ); + let Tree { + action, rows, held, .. + } = tree; let corner = self.ui.rect(rows_id).unwrap_or_default(); self.drag_entries(held, &rows, corner, theme); self.ui.close(); @@ -509,12 +522,14 @@ impl crate::State { Some(Action::Notebook(index)) => { let library = Arc::clone(&self.notebooks[index]); if let Some(path) = library.first_section() { - self.commands.push(crate::Command::OpenSection(library, path)); + self.commands + .push(crate::Command::OpenSection(library, path)); } } Some(Action::Open { notebook, path }) => { let library = Arc::clone(&self.notebooks[notebook]); - self.commands.push(crate::Command::OpenSection(library, path)); + self.commands + .push(crate::Command::OpenSection(library, path)); } Some(Action::Menu(target, point)) => { self.menu = Some((target, point)); @@ -548,9 +563,23 @@ impl crate::State { /// group, as OneNote moves one dropped on a group's tab; between rows of its folder it /// takes that place; between rows of another folder it moves there, last. `corner` is /// the rows' box. - fn drag_entries(&mut self, held: Option, rows: &[Entry], corner: [f32; 4], theme: &Theme) { - let dragged = held.clone().or(self.dragging_entry.take()); - self.dragging_entry = held.clone(); + fn drag_entries( + &mut self, + held: Option, + rows: &[Entry], + corner: [f32; 4], + theme: &Theme, + ) { + let dragged = match (&self.drag, &held) { + (_, Some(entry)) => Some(entry.clone()), + (Some(crate::menus::Drag::Entry(entry)), None) => Some(entry.clone()), + _ => None, + }; + if let Some(entry) = &held { + self.drag = Some(crate::menus::Drag::Entry(entry.clone())); + } else if matches!(self.drag, Some(crate::menus::Drag::Entry(_))) { + self.drag = None; + } let (Some(dragged), Some([_, y])) = (dragged, self.ui.pointer()) else { return; }; @@ -620,118 +649,162 @@ impl crate::State { if into { let [left, top, right, bottom] = under.rect; self.ui.mark( - [left - corner[0], top - corner[1], right - corner[0], bottom - corner[1]], + [ + left - corner[0], + top - corner[1], + right - corner[0], + bottom - corner[1], + ], [accent[0], accent[1], accent[2], 0.3], 4.0, ); } else { - let line = if before == Some(under.path.as_str()) { top } else { bottom } - corner[1]; + let line = if before == Some(under.path.as_str()) { + top + } else { + bottom + } - corner[1]; self.ui.mark( - [under.rect[0] - corner[0], line - 1.5, under.rect[2] - corner[0], line + 1.5], + [ + under.rect[0] - corner[0], + line - 1.5, + under.rect[2] - corner[0], + line + 1.5, + ], accent, 1.5, ); } } else { let library = std::sync::Arc::clone(&self.notebooks[dragged.notebook]); - self.commands.push(crate::Command::Structure(library, change)); + self.commands + .push(crate::Command::Structure(library, change)); } } - /// Shows the notebook at `location` at its first section, opening it unless it is open. - pub(crate) fn open_notebook(&mut self, location: String) { + /// Opens what the open panel chose: a notebook folder, a notebook's table of contents, + /// or a section, in its notebook where it has one. + pub(crate) fn open_path(&mut self, path: &std::path::Path) { + match crate::library::locate(path) { + crate::library::Located::Notebook { root, section } => { + self.open_notebook(root.to_string_lossy().into_owned(), section) + } + crate::library::Located::Section(file) => { + let library = Arc::new(Library::section(&file, &self.cache)); + let path = library.location.clone(); + self.commands + .push(crate::Command::OpenSection(library, path)); + } + crate::library::Located::Nothing => crate::platform::alert( + "Couldn't open", + "Choose a notebook folder, its Open Notebook file, or a section file.", + ), + } + } + + /// Shows the notebook at `location` at `section`, or its first section, opening it + /// unless it is open. + pub(crate) fn open_notebook(&mut self, location: String, section: Option) { let open = self .notebooks .iter() .find(|library| library.location == location) .cloned(); + // The notebook shown already shows a section, which only one reader may hold. + if let Some(session) = &self.session + && session.library.location == location + && section + .as_ref() + .is_none_or(|section| *section == session.tabs[session.tab].path) + { + return; + } let (cache, notify) = (self.cache.clone(), crate::notify(self.proxy.clone())); self.load(move || { let library = open.unwrap_or_else(|| Arc::new(Library::notebook(&location, &cache))); if let Err(error) = &library.notebook { return Err(error.clone().into()); } - let path = library - .first_section() + let path = section + .filter(|path| library.contains(path)) + .or_else(|| library.first_section()) .ok_or("This folder holds no notebook sections.")?; let section = library.open(&path, notify)?; let (session, page) = crate::read_session(section, library, path, None)?; - Ok((crate::Loaded::Section(Box::new(session)), page)) + Ok(crate::Loaded::Section(Box::new(session), page)) }); } - /// The page's place on the first run, or once every notebook is closed. + /// The window below the title bar on the first run, or once every notebook is closed: + /// what is missing, and the two ways to start. pub(crate) fn welcome(&mut self, theme: &Theme) { - const SIZE: [f32; 2] = [420.0, 120.0]; - let [left, top, right, bottom] = self.ui.rect(crate::page()).unwrap_or_default(); - self.ui.open_as( - crate::page(), - Spec { - size: [fill(), fill()], - fill: Some(theme.paper), - ..Spec::default() - }, - ); + const BUTTON: [f32; 2] = [200.0, 32.0]; + let [left, top, right, bottom] = self.ui.rect(self.ui.id("welcome")).unwrap_or_default(); self.ui.open( "welcome", + Spec { + size: [fill(), fill()], + fill: Some(theme.base), + ..Spec::default() + }, + ); + self.ui.open( + "content", Spec { flags: Flags::FLOAT, axis: Axis::Y, - size: [px(SIZE[0]), px(SIZE[1])], + size: [px(BUTTON[0]), ui::children()], position: [ - ((right - left - SIZE[0]) / 2.0).max(0.0), - ((bottom - top) * 0.4 - SIZE[1] / 2.0).max(0.0), + ((right - left - BUTTON[0]) / 2.0).max(0.0), + ((bottom - top) * 0.4 - 60.0).max(0.0), ], - gap: 4.0, + gap: 10.0, ..Spec::default() }, ); - let ink = theme.paper_ink; - for (part, text, color) in [ - ("title", "No notebooks open", ink), + self.ui.leaf( + "title", + Spec { + size: [fill(), px(28.0)], + text: Some("No notebooks open"), + center: true, + ..Spec::default() + }, + ); + let mut chosen = None; + for (part, icon, label, command) in [ + ( + "new", + art::PLUS, + "New Notebook", + crate::Command::NewNotebook, + ), ( - "description", - "Open a notebook folder, or start a new notebook.", - ui::mix(ink, theme.paper, 0.45), + "open", + art::NOTEBOOK, + "Open Existing", + crate::Command::OpenNotebook, ), ] { - self.ui.leaf( + let button = self.ui.leaf( part, Spec { - size: [fill(), px(26.0)], - text: Some(text), - color: Some(color), + flags: Flags::CLICKABLE, + size: [fill(), px(BUTTON[1])], + icon: Some(icon), + text: Some(label), + fill: Some(theme.chip), + hover_fill: Some(theme.hover()), + hover_border: Some(theme.accent), + radius: 6.0, center: true, ..Spec::default() }, ); - } - let labels = ["Open Notebook…", "New Notebook…"]; - let pad = 0.75 * theme.font_size; - let width: f32 = labels - .iter() - .map(|label| self.ui.measure(label)[0] + 2.0 * pad) - .sum::() - + 8.0; - self.ui.open( - "actions", - Spec { - size: [fill(), ui::children()], - pad: [((SIZE[0] - width) / 2.0).max(0.0), 12.0], - gap: 8.0, - ..Spec::default() - }, - ); - let mut chosen = None; - for (label, command) in labels - .into_iter() - .zip([crate::Command::OpenNotebook, crate::Command::NewNotebook]) - { - if ui::button(&mut self.ui, label, label).clicked { + if button.clicked { chosen = Some(command); } } - self.ui.close(); self.commands.extend(chosen); self.ui.close(); self.ui.close(); diff --git a/crates/snowbound/src/templates.rs b/crates/snowbound/src/templates.rs index fffa4f7c3c1925fb625ea5f465b00d1ba0b83202..e5552cd36be5d46d682adf1c2cd3d23e4351218a 100644 --- a/crates/snowbound/src/templates.rs +++ b/crates/snowbound/src/templates.rs @@ -50,9 +50,12 @@ pub enum View { Gallery, } +/// A thumbnail's art: each raster and where it lies in the tile. +type Art = Vec<(RasterImage, [f32; 4])>; + /// Rasters of each template's art for its thumbnail, by template and paper. #[derive(Default)] -pub struct Thumbnails(HashMap<(&'static str, bool), Vec<(RasterImage, [f32; 4])>>); +pub struct Thumbnails(HashMap<(&'static str, bool), Art>); impl Thumbnails { /// `template`'s art and where it lies in a `TILE`, drawn at `scale` for `paper`. @@ -69,12 +72,8 @@ impl Thumbnails { .art .iter() .filter_map(|art| { - let (image, size) = canvas::gpu::page::template_art( - art.art, - art.size, - points * scale, - paper, - )?; + let (image, size) = + canvas::gpu::page::template_art(art.art, art.size, points * scale, paper)?; let [x, y] = [ (art.position[0] - REGION[0]) * points, (art.position[1] - REGION[1]) * points, @@ -86,7 +85,10 @@ impl Thumbnails { } } -/// The strip at `position` in the page box, with the page colours beneath while `colors`. +/// The strip at `position` in the page box, `room` wide at most, with the page colours +/// beneath while `colors`. Picks that do not fit give way from the end; More templates +/// stays. +#[allow(clippy::too_many_arguments)] fn strip( ui: &mut Ui, theme: &Theme, @@ -94,8 +96,20 @@ fn strip( paper: Paper, dark: bool, position: [f32; 2], + room: f32, colors: bool, ) -> Option { + let more = (Choice::More, "More templates"); + let mut used = 2.0 * PAD + tile_width(ui, more.1); + let mut shown = Vec::new(); + for pick in STRIP { + used += GAP + tile_width(ui, pick.1); + if used > room { + break; + } + shown.push(pick); + } + shown.push(more); let mut chosen = None; ui.open( "templates", @@ -120,22 +134,27 @@ fn strip( ..Spec::default() }, ); - for (index, (choice, label)) in STRIP.into_iter().chain([(Choice::More, "More templates")]).enumerate() { + for (index, (choice, label)) in shown.into_iter().enumerate() { if tile(ui, theme, thumbnails, paper, dark, index, choice, label) { chosen = Some(choice); } } ui.close(); - if colors { + let per_row = (((room - 2.0 * PAD + 6.0) / (SWATCH + 6.0)).floor() as usize).max(1); + for (row, swatches) in PAGE_COLORS.chunks(per_row).enumerate().filter(|_| colors) { ui.open( - "colors", + ("colors", row), Spec { size: [ui::children(), px(SWATCH)], gap: 6.0, ..Spec::default() }, ); - for (index, (name, color)) in PAGE_COLORS.iter().enumerate() { + for (index, (name, color)) in swatches + .iter() + .enumerate() + .map(|(index, color)| (row * per_row + index, color)) + { let swatch = ui.leaf( (index, *name), Spec { @@ -154,7 +173,9 @@ fn strip( } ui.close(); } - let width = ui.rect(ui.id("tiles")).map_or(0.0, |rect| rect[2] - rect[0]); + let width = ui + .rect(ui.id("tiles")) + .map_or(0.0, |rect| rect[2] - rect[0]); let close = ui.leaf( "dismiss", Spec { @@ -279,6 +300,11 @@ fn page_color(color: u32, paper: Paper, dark: bool) -> [f32; 4] { [red, green, blue, 1.0] } +/// How wide a tile labelled `label` stands: its thumbnail, or the label where it is wider. +fn tile_width(ui: &mut Ui, label: &str) -> f32 { + TILE[0].max(ui.measure(label)[0] + 4.0) +} + /// One template's thumbnail over its label, as wide as the thumbnail or the label; true /// when clicked. #[allow(clippy::too_many_arguments)] @@ -293,7 +319,7 @@ fn tile( label: &str, ) -> bool { let scale = ui.scale(); - let width = TILE[0].max(ui.measure(label)[0] + 4.0); + let width = tile_width(ui, label); let fill_color = match choice { Choice::Color(index) => page_color(PAGE_COLORS[index].1, paper, dark), Choice::More | Choice::Dismiss => theme.base, @@ -405,11 +431,12 @@ impl crate::State { (viewport.origin[0] + start[0] * viewport.scale) / scale, (viewport.origin[1] + (start[1] + LINES_ABOVE) * viewport.scale) / scale, ]; + // Thumbnails show templates on plain paper, not the page's colour. let paper = Paper { - color: theme.paper, - ink: theme.paper_ink, + color: self.ui.theme.paper, + ink: self.ui.theme.paper_ink, }; - let dark = theme.paper_ink[0] > theme.paper[0]; + let dark = paper.ink[0] > paper.color[0]; let chosen = match self.templates { View::Gallery => { let size = [rect[2] - rect[0], rect[3] - rect[1]]; @@ -422,6 +449,7 @@ impl crate::State { paper, dark, position, + rect[2] - rect[0] - position[0] - 24.0, view == View::Colors, ), }; diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 7228b920324cc9bf303635da29596da92203377c..0334c893756677771ca9d28cd2a52682e447e309 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -17,17 +17,17 @@ pub const TOOL: f32 = 22.0; /// Section tabs in a row `height` tall, as OneNote draws them: each leans over the next /// at 45°, and the open one lies on top in `section`'s colours, rising to meet the frame -/// below as its outline and shadow fade in. `tabs` are names and section colours. Returns -/// the tab clicked, the tab whose context menu was asked for and where, and the open tab's -/// id, for `tab_base`. +/// below as its outline and shadow fade in. `tabs` are names and section colours; `lit` +/// shows as hovered, as a tab something is dragged onto. pub fn section_tabs( ui: &mut Ui, part: impl Hash, tabs: &[(&str, [f32; 4])], active: usize, + lit: Option, section: &Section, height: f32, -) -> (Option, Option<(usize, [f32; 2])>, Id) { +) -> Tabs { let theme = ui.theme.clone(); ui.open( part, @@ -47,7 +47,7 @@ pub fn section_tabs( (left - width, width) }) .collect(); - let (mut clicked, mut context) = (None, None); + let (mut clicked, mut context, mut held) = (None, None, None); // Earlier tabs lie over later ones; the open tab over all. let order = (0..tabs.len()) .rev() @@ -70,7 +70,11 @@ pub fn section_tabs( position: [x, height - tall], text: Some(name), color: Some(mix(mix(theme.ink, fill, 0.2), theme.ink, open)), - fill: Some(mix(fill, [1.0; 4], 0.08)), + fill: Some(if lit == Some(index) { + mix(colors.frame[0], [1.0; 4], 0.08) + } else { + mix(fill, [1.0; 4], 0.08) + }), gradient: Some(fill), hover_fill: (index != active).then(|| mix(colors.frame[0], [1.0; 4], 0.08)), border: Some(fade(section.edge, open)), @@ -87,10 +91,34 @@ pub fn section_tabs( if let Some(point) = signal.context { context = Some((index, point)); } + if signal.dragging { + held = Some(index); + } } let open_tab = ui.id(("tab", active)); ui.close(); - (clicked, context, open_tab) + Tabs { + clicked, + context, + held, + open: open_tab, + } +} + +/// The id of tab `index` of the section tabs built as `row`. +pub fn tab_id(row: Id, index: usize) -> Id { + row.child(("tab", index)) +} + +/// What the section tabs were asked this frame. +pub struct Tabs { + pub clicked: Option, + /// The tab whose context menu was asked for, and where. + pub context: Option<(usize, [f32; 2])>, + /// The tab held down, which a drag moves. + pub held: Option, + /// The open tab's id, for `tab_base`. + pub open: Id, } /// Where the tab laid out at `tab` in a row `height` tall meets the edge below it: from its diff --git a/crates/ui/src/tests.rs b/crates/ui/src/tests.rs index 20132daf56f7d95b0e3fb2f2c51f6fbd7511a442..4e2f0b10fa285abc6d826fa64f7c5f4f9eda2ecf 100644 --- a/crates/ui/src/tests.rs +++ b/crates/ui/src/tests.rs @@ -162,7 +162,7 @@ fn a_click_within_one_frame_starts_an_animation_read_before_it() { let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); let mut open = false; let mut width = 0.0; - let mut build = |ui: &mut Ui, open: &mut bool, width: &mut f32| { + let build = |ui: &mut Ui, open: &mut bool, width: &mut f32| { *width = ui.animate(Id::ROOT.child("panel"), if *open { 100.0 } else { 0.0 }); if button(ui, "toggle", "Toggle").clicked { *open = true; diff --git a/readme.md b/readme.md index 7be0aead1ba8c41e7998820d1c5ef3c4fb852262..1e74c7ca9d09f0ad5e1b2e525ce0482918b58741 100644 --- a/readme.md +++ b/readme.md @@ -12,7 +12,7 @@ pen and drawing tools, recording audio and video, revision history, multi-machin live collaboration, and much more. -- 2.54.0