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.