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