| author | |
| committer | |
| log | c5f2777d27e890082216a8d8eb1dacee4d4a104f |
| tree | 2107307d3317eeb448ab24311182fdd1d7d15fd6 |
| parent | 30328408481c8d7949dcfabb9d032a380c6f93b5 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
A page counts as unread only when another author changed it last: the author of
its latest modified object (AuthorMostRecent) is compared with the user name edits
are stored under and the account's name, so changes made on another of the
reader's computers, OneNote 2010's included, are not marked. A page the reader
changed last also stops being unread. `stored_pages` now names each page's latest
author.
What is unread shows an accent dot at the start of its row, as Mail marks unread
mail, in place of OneNote's bold: page tabs, section tabs and the sidebar's
sections, groups and notebooks. Page tabs leave room for it, so labels stay put.
Assisted-by: claude-opus-5.58 files changed, 193 insertions(+), 41 deletions(-)
crates/notebook/README.md+2-1| ... | ... | @@ -352,7 +352,8 @@ targets are `None`. |
| 352 | 352 | |
| 353 | 353 | `Notebook::read_section(path)` reads a section file as stored, without opening a |
| 354 | 354 | replica, and `session::stored_pages(image)` builds its pages with each page's |
| 355 | `LastModifiedTime`: the app's search indexes the sections it has not opened this way. | |
| 355 | `LastModifiedTime` and the author of its latest change: the app's search indexes the | |
| 356 | sections it has not opened this way, and unread changes leave out the reader's own. | |
| 356 | 357 | |
| 357 | 358 | A password-protected section lists as `Locked`. `Notebook::unlock(path, password)` opens |
| 358 | 359 | its key (a wrong password is `Error::Protected(PasswordMismatch)`), and |
crates/notebook/src/session.rs+12| ... | ... | @@ -1719,6 +1719,8 @@ pub struct StoredPage { |
| 1719 | 1719 | pub page: Page, |
| 1720 | 1720 | /// The page's `LastModifiedTime`, Time32 seconds since 1980. |
| 1721 | 1721 | pub modified: Option<u32>, |
| 1722 | /// Who changed it last: the `AuthorMostRecent` of its latest modified object. | |
| 1723 | pub author: Option<String>, | |
| 1722 | 1724 | } |
| 1723 | 1725 | |
| 1724 | 1726 | /// The pages of the section file `image` holds, in section order; pages the model cannot |
| ... | ... | @@ -1743,10 +1745,20 @@ fn pages_of(document: &Document<'_>) -> Result<Vec<StoredPage>> { |
| 1743 | 1745 | .into_iter() |
| 1744 | 1746 | .filter_map(|(space, id)| { |
| 1745 | 1747 | let revision = document.active(space).ok()?; |
| 1748 | let latest = (revision.nodes.values()) | |
| 1749 | .filter(|node| node.latest_author.is_some()) | |
| 1750 | .max_by_key(|node| node.modified); | |
| 1751 | let author = latest | |
| 1752 | .and_then(|node| revision.nodes.get(&node.latest_author?)) | |
| 1753 | .and_then(|node| match &node.kind { | |
| 1754 | onestore::document::Kind::Author { name } => name.clone(), | |
| 1755 | _ => None, | |
| 1756 | }); | |
| 1746 | 1757 | Some(StoredPage { |
| 1747 | 1758 | space, |
| 1748 | 1759 | page: Page::from_revision(revision, id).ok()?, |
| 1749 | 1760 | modified: revision.nodes.get(&id).and_then(|node| node.modified), |
| 1761 | author, | |
| 1750 | 1762 | }) |
| 1751 | 1763 | }) |
| 1752 | 1764 | .collect()) |
crates/notebook/tests/session.rs+32| ... | ... | @@ -1393,3 +1393,35 @@ fn walkdir(dir: &std::path::Path) -> Vec<std::path::PathBuf> { |
| 1393 | 1393 | } |
| 1394 | 1394 | files |
| 1395 | 1395 | } |
| 1396 | ||
| 1397 | /// A stored page names who changed it last, which tells another author's changes from | |
| 1398 | /// one's own. | |
| 1399 | #[test] | |
| 1400 | fn a_stored_page_names_who_changed_it_last() { | |
| 1401 | let image = onestore::create_section("Authors.one", "Body", "Alice").unwrap(); | |
| 1402 | let first = &notebook::session::stored_pages(&image).unwrap()[0]; | |
| 1403 | assert_eq!(first.author.as_deref(), Some("Alice")); | |
| 1404 | let arena = onestore::Arena::default(); | |
| 1405 | let mut section = onestore::Section::open(&arena, image).unwrap(); | |
| 1406 | // Enter, which stamps both paragraphs with who pressed it. | |
| 1407 | let new_id = || onestore::page::text::new_id().unwrap(); | |
| 1408 | let op = PageOp::Split { | |
| 1409 | text: first_text(&first.page), | |
| 1410 | at: 0, | |
| 1411 | paragraph: new_id(), | |
| 1412 | right: new_id(), | |
| 1413 | lists: Vec::new(), | |
| 1414 | }; | |
| 1415 | let edit = Edit { | |
| 1416 | // A minute on, so the edit is the page's latest change. | |
| 1417 | at: model_ops::now() + 600_000_000, | |
| 1418 | ops: vec![Op::Page { | |
| 1419 | space: first.space, | |
| 1420 | op, | |
| 1421 | }], | |
| 1422 | }; | |
| 1423 | section.apply("Bob", &edit).unwrap(); | |
| 1424 | section.seal().unwrap(); | |
| 1425 | let edited = &notebook::session::stored_pages(&section.image()).unwrap()[0]; | |
| 1426 | assert_eq!(edited.author.as_deref(), Some("Bob")); | |
| 1427 | } |
crates/snowbound/src/main.rs+12-4| ... | ... | @@ -5211,7 +5211,7 @@ struct PageTab<'a> { |
| 5211 | 5211 | conflicted: bool, |
| 5212 | 5212 | /// A search found the page, which OneNote marks yellow. |
| 5213 | 5213 | found: bool, |
| 5214 | /// Another author changed it since it was last viewed, which OneNote sets bold. | |
| 5214 | /// Another author changed it since it was last viewed, which its dot marks. | |
| 5215 | 5215 | unread: bool, |
| 5216 | 5216 | /// The colours of the others who have it open. |
| 5217 | 5217 | peers: &'a [[f32; 4]], |
| ... | ... | @@ -5235,8 +5235,8 @@ fn page_tab( |
| 5235 | 5235 | selected: bool, |
| 5236 | 5236 | (rounding, left): (f32, bool), |
| 5237 | 5237 | ) -> (ui::Signal, Option<bool>) { |
| 5238 | // A rename field's text stands where the label did. | |
| 5239 | let pad = 10.0 + 16.0 * tab.indent as f32 | |
| 5238 | // A rename field's text stands where the label did, past room for the unread dot. | |
| 5239 | let pad = 16.0 + 16.0 * tab.indent as f32 | |
| 5240 | 5240 | - if tab.renaming.is_some() { |
| 5241 | 5241 | rename::PAD |
| 5242 | 5242 | } else { |
| ... | ... | @@ -5290,11 +5290,20 @@ fn page_tab( |
| 5290 | 5290 | }; |
| 5291 | 5291 | (spec, color) |
| 5292 | 5292 | }; |
| 5293 | // Just before the label, in the tab's leading pad. | |
| 5294 | let dot = ui::shell::DOT; | |
| 5295 | let unread = [ | |
| 5296 | (spec.pad[0] - dot - 2.0).max(spec.inset[0]), | |
| 5297 | (ROW - ROW_GAP - dot) / 2.0, | |
| 5298 | ]; | |
| 5293 | 5299 | let row = ui.open(id, spec); |
| 5294 | 5300 | if let Some(node) = ui.access(row) { |
| 5295 | 5301 | node.set_selected(selected); |
| 5296 | 5302 | node.set_level(tab.indent as usize + 1); |
| 5297 | 5303 | } |
| 5304 | if tab.unread { | |
| 5305 | ui::shell::unread_dot(ui, "unread", unread); | |
| 5306 | } | |
| 5298 | 5307 | let kept = match tab.renaming { |
| 5299 | 5308 | Some(name) => rename::edit(ui, theme, name, ROW - ROW_GAP), |
| 5300 | 5309 | None => { |
| ... | ... | @@ -5303,7 +5312,6 @@ fn page_tab( |
| 5303 | 5312 | Spec { |
| 5304 | 5313 | size: [fill(), px(ROW - ROW_GAP)], |
| 5305 | 5314 | text: Some(tab.label), |
| 5306 | bold: tab.unread, | |
| 5307 | 5315 | overflow: ui::Overflow::Ellipsis, |
| 5308 | 5316 | color: Some(color), |
| 5309 | 5317 | ..Spec::default() |
crates/snowbound/src/sidebar.rs+16-11| ... | ... | @@ -98,7 +98,7 @@ struct Ghost { |
| 98 | 98 | depth: u32, |
| 99 | 99 | dim: bool, |
| 100 | 100 | selected: Option<[f32; 4]>, |
| 101 | bold: bool, | |
| 101 | unread: bool, | |
| 102 | 102 | } |
| 103 | 103 | |
| 104 | 104 | impl Ghost { |
| ... | ... | @@ -109,7 +109,7 @@ impl Ghost { |
| 109 | 109 | depth: row.depth, |
| 110 | 110 | dim: row.dim, |
| 111 | 111 | selected: row.selected, |
| 112 | bold: row.bold, | |
| 112 | unread: row.unread, | |
| 113 | 113 | } |
| 114 | 114 | } |
| 115 | 115 | } |
| ... | ... | @@ -255,7 +255,7 @@ fn sidebar( |
| 255 | 255 | fold: Some(unfolded), |
| 256 | 256 | selected: None, |
| 257 | 257 | renamed, |
| 258 | bold: tree.unread.contains(&key), | |
| 258 | unread: tree.unread.contains(&key), | |
| 259 | 259 | }; |
| 260 | 260 | if let Some((lifted, slot)) = tree.lifted_notebook { |
| 261 | 261 | if lifted == index { |
| ... | ... | @@ -362,7 +362,7 @@ fn sidebar( |
| 362 | 362 | fold: None, |
| 363 | 363 | selected: None, |
| 364 | 364 | renamed: false, |
| 365 | bold: false, | |
| 365 | unread: false, | |
| 366 | 366 | }, |
| 367 | 367 | ); |
| 368 | 368 | if row.clicked { |
| ... | ... | @@ -420,7 +420,7 @@ fn folder( |
| 420 | 420 | fold: None, |
| 421 | 421 | selected: (open == Some(section.path.as_str())).then_some(section_color(color)), |
| 422 | 422 | renamed: renaming, |
| 423 | bold: tree.unread.contains(&library.key(&section.path)), | |
| 423 | unread: tree.unread.contains(&library.key(&section.path)), | |
| 424 | 424 | }; |
| 425 | 425 | if lifts(tree, notebook, &section.path, &row) { |
| 426 | 426 | continue; |
| ... | ... | @@ -467,7 +467,7 @@ fn folder( |
| 467 | 467 | fold: Some(unfolded), |
| 468 | 468 | selected: None, |
| 469 | 469 | renamed: renaming, |
| 470 | bold: tree.unread.contains(&key), | |
| 470 | unread: tree.unread.contains(&key), | |
| 471 | 471 | }; |
| 472 | 472 | // A dragged group's rows fold away beneath it. |
| 473 | 473 | let lifted = lifts(tree, notebook, &group.path, &row); |
| ... | ... | @@ -529,7 +529,7 @@ fn folder( |
| 529 | 529 | fold: None, |
| 530 | 530 | selected: None, |
| 531 | 531 | renamed: false, |
| 532 | bold: false, | |
| 532 | unread: false, | |
| 533 | 533 | }, |
| 534 | 534 | ); |
| 535 | 535 | if row.clicked { |
| ... | ... | @@ -726,8 +726,8 @@ struct Row<'a> { |
| 726 | 726 | selected: Option<[f32; 4]>, |
| 727 | 727 | /// The row is being renamed: its label is the tree's rename field. |
| 728 | 728 | renamed: bool, |
| 729 | /// It holds unread pages, which OneNote sets bold. | |
| 730 | bold: bool, | |
| 729 | /// It holds unread pages, which its dot marks. | |
| 730 | unread: bool, | |
| 731 | 731 | } |
| 732 | 732 | |
| 733 | 733 | /// One row of the tree: its signal, and whether its fold arrow was clicked. |
| ... | ... | @@ -755,6 +755,12 @@ fn tree_row(ui: &mut Ui, tree: &mut Tree, id: Id, row: Row) -> (Signal, bool) { |
| 755 | 755 | ..Spec::default() |
| 756 | 756 | }, |
| 757 | 757 | ); |
| 758 | // Just before the row's icon, a top row's reaching into the tree's margin. | |
| 759 | if row.unread { | |
| 760 | let (icon, dot) = (ROW_PAD + INDENT * row.depth as f32, ui::shell::DOT); | |
| 761 | let at = [icon - dot - 2.0, (ROW - dot) / 2.0]; | |
| 762 | ui::shell::unread_dot(ui, "unread", at); | |
| 763 | } | |
| 758 | 764 | if let Some(node) = ui.access(id) { |
| 759 | 765 | node.set_label(row.label); |
| 760 | 766 | node.set_level(row.depth as usize + 1); |
| ... | ... | @@ -788,7 +794,6 @@ fn tree_row(ui: &mut Ui, tree: &mut Tree, id: Id, row: Row) -> (Signal, bool) { |
| 788 | 794 | Spec { |
| 789 | 795 | size: [fill(), px(ROW)], |
| 790 | 796 | text: Some(row.label), |
| 791 | bold: row.bold, | |
| 792 | 797 | color: Some(color), |
| 793 | 798 | ..Spec::default() |
| 794 | 799 | }, |
| ... | ... | @@ -1093,7 +1098,7 @@ impl crate::State { |
| 1093 | 1098 | fold: None, |
| 1094 | 1099 | selected: ghost.selected, |
| 1095 | 1100 | renamed: false, |
| 1096 | bold: ghost.bold, | |
| 1101 | unread: ghost.unread, | |
| 1097 | 1102 | }, |
| 1098 | 1103 | ); |
| 1099 | 1104 | self.ui.close(); |
crates/snowbound/src/unread.rs+89-19| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | //! Unread changes, as OneNote 2010 shows them: a page another author changed since it was | |
| 2 | //! last viewed is bold in the page list, and so are its section's tab and row and the groups | |
| 3 | //! and notebook holding it, until the page is viewed and left or marked as read. OneNote keeps | |
| 1 | //! Unread changes, as OneNote 2010 keeps them: a page another author changed since it was | |
| 2 | //! last viewed has a dot in the page list, where OneNote sets it bold, and so do its section's | |
| 3 | //! tab and row and the groups and notebook holding it, until the page is viewed and left or marked as read. OneNote keeps | |
| 4 | 4 | //! this, and whether a notebook shows it, per user and machine in its cache, never in the |
| 5 | 5 | //! notebook's files; this keeps it in the cache folder's `read.json`. |
| 6 | 6 | |
| ... | ... | @@ -121,20 +121,23 @@ impl Reads { |
| 121 | 121 | self.save(); |
| 122 | 122 | } |
| 123 | 123 | |
| 124 | /// Takes in a section as its file stands, its pages with their `LastModifiedTime`: those | |
| 125 | /// changed since it was last known are unread. | |
| 124 | /// Takes in a section as its file stands, its pages with their `LastModifiedTime` and | |
| 125 | /// whether the reader changed them last: those another author changed since it was last | |
| 126 | /// known are unread. | |
| 126 | 127 | fn arrived( |
| 127 | 128 | &mut self, |
| 128 | 129 | notebook: &str, |
| 129 | 130 | section: [u8; 16], |
| 130 | pages: impl IntoIterator<Item = (ExGuid, Option<u32>)>, | |
| 131 | pages: impl IntoIterator<Item = (ExGuid, Option<u32>, bool)>, | |
| 131 | 132 | ) { |
| 132 | 133 | let now = now(); |
| 133 | 134 | let kept = self.section_mut(notebook, section); |
| 134 | 135 | let read = kept.read; |
| 135 | 136 | let mut newest = read.max(now); |
| 136 | for (space, modified) in pages { | |
| 137 | if modified.is_some_and(|modified| modified > read) { | |
| 137 | for (space, modified, mine) in pages { | |
| 138 | if mine { | |
| 139 | kept.unread.remove(&space); | |
| 140 | } else if modified.is_some_and(|modified| modified > read) { | |
| 138 | 141 | kept.unread.insert(space); |
| 139 | 142 | } |
| 140 | 143 | newest = newest.max(modified.unwrap_or(0)); |
| ... | ... | @@ -246,22 +249,56 @@ impl State { |
| 246 | 249 | viewing.map(|(notebook, section, space)| (notebook, section, space, false)); |
| 247 | 250 | } |
| 248 | 251 | |
| 249 | /// Pages of the open section another author changed, as its sync reports them. | |
| 252 | /// Pages of the open section another client changed, as its sync reports them: those | |
| 253 | /// another author changed last are unread, once read on a thread of their own. | |
| 250 | 254 | pub(crate) fn changed_elsewhere(&mut self, spaces: &[ExGuid]) { |
| 251 | if let Some(session) = &self.session | |
| 252 | && let Some(section) = session | |
| 253 | .library | |
| 254 | .section_identity(&session.tabs[session.tab].path) | |
| 255 | { | |
| 256 | self.reads | |
| 257 | .changed(&session.library.location, section, spaces.iter().copied()); | |
| 255 | let Some(session) = &self.session else { | |
| 256 | return; | |
| 257 | }; | |
| 258 | let Some(section) = session | |
| 259 | .library | |
| 260 | .section_identity(&session.tabs[session.tab].path) | |
| 261 | else { | |
| 262 | return; | |
| 263 | }; | |
| 264 | let (location, spaces) = (session.library.location.clone(), spaces.to_vec()); | |
| 265 | let (replica, proxy, me) = ( | |
| 266 | Arc::clone(session.section.replica()), | |
| 267 | self.proxy.clone(), | |
| 268 | self.me(), | |
| 269 | ); | |
| 270 | crate::spawn(move || { | |
| 271 | let pages = (replica.snapshot().ok()) | |
| 272 | .and_then(|image| notebook::session::stored_pages(&image).ok()) | |
| 273 | .unwrap_or_default(); | |
| 274 | let others: Vec<ExGuid> = (spaces.into_iter()) | |
| 275 | .filter(|space| { | |
| 276 | !(pages.iter()).any(|page| page.space == *space && me(page.author.as_deref())) | |
| 277 | }) | |
| 278 | .collect(); | |
| 279 | let _ = proxy.send_event(UserEvent::Then(Box::new(move |state: &mut State| { | |
| 280 | state.reads.changed(&location, section, others); | |
| 281 | Ok(()) | |
| 282 | }))); | |
| 283 | }); | |
| 284 | } | |
| 285 | ||
| 286 | /// Whether an author's name is the reader's: the user name edits are stored under, or the | |
| 287 | /// account's, as OneNote on another of the reader's computers names them. | |
| 288 | fn me(&self) -> impl Fn(Option<&str>) -> bool + Send + 'static { | |
| 289 | let names = [self.author.clone(), crate::platform::user_name()]; | |
| 290 | move |author| { | |
| 291 | author.map(str::trim).is_some_and(|author| { | |
| 292 | !author.is_empty() | |
| 293 | && (names.iter()).any(|name| name.trim().eq_ignore_ascii_case(author)) | |
| 294 | }) | |
| 258 | 295 | } |
| 259 | 296 | } |
| 260 | 297 | |
| 261 | 298 | /// Sections of `library` at catalog `paths` whose files another client changed: their |
| 262 | 299 | /// pages are read on a thread of their own, those changed since unread. |
| 263 | 300 | pub(crate) fn sections_changed(&self, library: &Arc<Library>, paths: Vec<String>) { |
| 264 | let (library, proxy) = (Arc::clone(library), self.proxy.clone()); | |
| 301 | let (library, proxy, me) = (Arc::clone(library), self.proxy.clone(), self.me()); | |
| 265 | 302 | crate::spawn(move || { |
| 266 | 303 | let Ok(Some(notebook)) = &library.notebook else { |
| 267 | 304 | return; |
| ... | ... | @@ -272,13 +309,15 @@ impl State { |
| 272 | 309 | let section = library.section_identity(path)?; |
| 273 | 310 | let image = notebook.read_section(path).ok()?; |
| 274 | 311 | let pages = notebook::session::stored_pages(&image).ok()?; |
| 312 | let pages: Vec<_> = (pages.into_iter()) | |
| 313 | .map(|page| (page.space, page.modified, me(page.author.as_deref()))) | |
| 314 | .collect(); | |
| 275 | 315 | Some((section, pages)) |
| 276 | 316 | }) |
| 277 | 317 | .collect(); |
| 278 | 318 | let location = library.location.clone(); |
| 279 | 319 | let _ = proxy.send_event(UserEvent::Then(Box::new(move |state: &mut State| { |
| 280 | 320 | for (section, pages) in read { |
| 281 | let pages = pages.iter().map(|page| (page.space, page.modified)); | |
| 282 | 321 | state.reads.arrived(&location, section, pages); |
| 283 | 322 | } |
| 284 | 323 | Ok(()) |
| ... | ... | @@ -380,13 +419,14 @@ impl State { |
| 380 | 419 | mod tests { |
| 381 | 420 | use super::*; |
| 382 | 421 | |
| 383 | fn page(n: u32, modified: Option<u32>) -> (ExGuid, Option<u32>) { | |
| 422 | fn page(n: u32, modified: Option<u32>) -> (ExGuid, Option<u32>, bool) { | |
| 384 | 423 | ( |
| 385 | 424 | ExGuid { |
| 386 | 425 | guid: [n as u8; 16], |
| 387 | 426 | n, |
| 388 | 427 | }, |
| 389 | 428 | modified, |
| 429 | false, | |
| 390 | 430 | ) |
| 391 | 431 | } |
| 392 | 432 | |
| ... | ... | @@ -436,4 +476,34 @@ mod tests { |
| 436 | 476 | assert!(reads.pages(notebook, section).is_none() && !reads.shown(notebook)); |
| 437 | 477 | notebook::fs::remove_dir_all(&folder).unwrap(); |
| 438 | 478 | } |
| 479 | ||
| 480 | /// A page the reader changed last, on this computer or another, is never unread, and | |
| 481 | /// stops being so once the reader changes it. | |
| 482 | #[test] | |
| 483 | fn the_readers_own_changes_are_never_unread() { | |
| 484 | let folder = | |
| 485 | std::env::temp_dir().join(format!("snowbound-unread-own-{}", std::process::id())); | |
| 486 | notebook::fs::create_dir_all(&folder).unwrap(); | |
| 487 | let (notebook, section) = ("/notebook", [3; 16]); | |
| 488 | let mut reads = Reads::load(&folder); | |
| 489 | let known = reads.section_mut(notebook, section).read; | |
| 490 | let mine = |n, modified| { | |
| 491 | let (space, modified, _) = page(n, Some(modified)); | |
| 492 | (space, modified, true) | |
| 493 | }; | |
| 494 | reads.arrived( | |
| 495 | notebook, | |
| 496 | section, | |
| 497 | [mine(1, known + 5), page(2, Some(known + 5))], | |
| 498 | ); | |
| 499 | let unread = |reads: &Reads| -> Vec<u32> { | |
| 500 | (reads.pages(notebook, section).into_iter().flatten()) | |
| 501 | .map(|space| space.n) | |
| 502 | .collect() | |
| 503 | }; | |
| 504 | assert_eq!(unread(&reads), [2]); | |
| 505 | reads.arrived(notebook, section, [mine(2, known + 9)]); | |
| 506 | assert!(unread(&reads).is_empty()); | |
| 507 | notebook::fs::remove_dir_all(&folder).unwrap(); | |
| 508 | } | |
| 439 | 509 | } |
crates/ui/src/shell.rs+29-5| ... | ... | @@ -22,6 +22,24 @@ const ARROW: f32 = 10.0; |
| 22 | 22 | const MORE: f32 = 14.0; |
| 23 | 23 | /// Where a section tab's label starts inside it. |
| 24 | 24 | pub const TAB_PAD: f32 = 10.0; |
| 25 | /// Side of the dot marking something unread, as Mail marks unread mail. | |
| 26 | pub const DOT: f32 = 6.0; | |
| 27 | ||
| 28 | /// The dot marking something unread, in the accent, floating in the box being built at | |
| 29 | /// `position`, its top left corner. | |
| 30 | pub fn unread_dot(ui: &mut Ui, part: impl std::hash::Hash, position: [f32; 2]) { | |
| 31 | ui.leaf( | |
| 32 | part, | |
| 33 | Spec { | |
| 34 | flags: Flags::FLOAT, | |
| 35 | size: [px(DOT), px(DOT)], | |
| 36 | position, | |
| 37 | fill: Some(ui.theme.accent), | |
| 38 | radius: DOT / 2.0, | |
| 39 | ..Spec::default() | |
| 40 | }, | |
| 41 | ); | |
| 42 | } | |
| 25 | 43 | /// How far section tabs fade out at an end of their row they are cut at, and how near it a |
| 26 | 44 | /// dragged tab scrolls the row on. |
| 27 | 45 | const EDGE: f32 = 20.0; |
| ... | ... | @@ -36,7 +54,7 @@ const EDGE: f32 = 20.0; |
| 36 | 54 | /// The row is as wide as the tabs, and gives up all of that where its own row lacks room. |
| 37 | 55 | /// Then the tabs scroll sideways under the wheel, either way it turns, fading out at the |
| 38 | 56 | /// ends they are cut at into the row's `fill`, and the open tab scrolls into view as it |
| 39 | /// opens. A tab given `true` after its colour reads bold, as an unread section's does. | |
| 57 | /// opens. A tab given `true` after its colour shows the unread dot in its leading pad. | |
| 40 | 58 | #[allow(clippy::too_many_arguments)] |
| 41 | 59 | pub fn section_tabs( |
| 42 | 60 | ui: &mut Ui, |
| ... | ... | @@ -54,10 +72,10 @@ pub fn section_tabs( |
| 54 | 72 | let mut left = crate::SHADOW[0]; |
| 55 | 73 | let placed: Vec<_> = tabs |
| 56 | 74 | .iter() |
| 57 | .map(|&(name, _, bold)| { | |
| 75 | .map(|&(name, ..)| { | |
| 58 | 76 | let label = ui |
| 59 | 77 | .texts |
| 60 | .styled(name, ui.theme.font_size, bold, false, None, ui.frame); | |
| 78 | .styled(name, ui.theme.font_size, false, false, None, ui.frame); | |
| 61 | 79 | let width = TAB_PAD + label.size[0] + 4.0 + lean(height); |
| 62 | 80 | left += width; |
| 63 | 81 | (left - width, width) |
| ... | ... | @@ -144,7 +162,7 @@ pub fn section_tabs( |
| 144 | 162 | .chain((active < tabs.len() && Some(active) != lifted).then_some(active)) |
| 145 | 163 | .chain(lifted); |
| 146 | 164 | for index in order { |
| 147 | let (name, color, bold) = tabs[index]; | |
| 165 | let (name, color, unread) = tabs[index]; | |
| 148 | 166 | let (x, width) = placed[index]; |
| 149 | 167 | // Keyed by name, so a tab eases from where it stood when the tabs are reordered. |
| 150 | 168 | let key = ui.id(("x", name)); |
| ... | ... | @@ -179,7 +197,6 @@ pub fn section_tabs( |
| 179 | 197 | size: [px(width), px(tall)], |
| 180 | 198 | position: [x - offset, height - tall], |
| 181 | 199 | text: Some(name), |
| 182 | bold, | |
| 183 | 200 | color: Some(mix(mix(theme.ink, fill, 0.2), theme.ink, open)), |
| 184 | 201 | fill: Some(if lit == Some(index) { |
| 185 | 202 | mix(colors.frame[0], [1.0; 4], 0.08) |
| ... | ... | @@ -198,6 +215,13 @@ pub fn section_tabs( |
| 198 | 215 | ..Spec::default() |
| 199 | 216 | }, |
| 200 | 217 | ); |
| 218 | if unread { | |
| 219 | let position = [ | |
| 220 | x - offset + (TAB_PAD - DOT) / 2.0, | |
| 221 | height - tall + (tall - DOT) / 2.0, | |
| 222 | ]; | |
| 223 | unread_dot(ui, ("unread", index), position); | |
| 224 | } | |
| 201 | 225 | if let Some(node) = ui.access(tab_id(row, index)) { |
| 202 | 226 | node.set_selected(index == active); |
| 203 | 227 | } |
tools/canvas/README.md+1-1| ... | ... | @@ -52,7 +52,7 @@ snowbound title bar · toolbar · section frame · page tabs · the page as a |
| 52 | 52 | |
| 53 | 53 | Launched without a file, the app reopens the notebooks open when it last closed, at the one shown last, or shows "No notebooks open" with New Notebook and Open Notebook. Settings (the open notebooks, the sidebar, recent fonts and the user name edits are stored under, which Settings (⌘,, Ctrl+, on Linux, or Options in the sidebar) changes and which starts as the account's full name) are JSON in `~/Library/Application Support/Snowbound/settings.json` or `$XDG_CONFIG_HOME/snowbound`; `--settings FILE` uses another file, `--notebook FOLDER` adds a notebook to them, and `--screenshot` never writes them. What draws the window is the settings' `"renderer"`: `"default"` (the platform's first that starts), or `"metal"` or `"gl"` on macOS, `"vulkan"` or `"gl"` on Linux, `"d3d12"`, `"d3d11"` or `"gl"` on Windows, and `"webgpu"`, `"webgl2"` or `"canvas2d"` in a browser. `SNOWBOUND_RENDERER=NAME` overrides it and `--renderer NAME` overrides both, without changing the settings; in a browser, `index.html?renderer=NAME` does. A running app switches when Options' Renderer changes, or when the file's `renderer` was edited while the window was in the background. A backend that fails to start falls back to the default and says so. Open Notebook takes a notebook folder, its `Open Notebook.onetoc2` or a section file, which opens in the notebook its folders make up. A notebook on a mounted SMB share opens through the embedded SMB client, signed in with the password the system keychain holds, and through the mount only when that fails. The notebook button left of the section tabs opens OneNote 2010's navigation bar: the open notebooks with their sections and section groups, the open section marked; dragging a row reorders a folder or, onto a group, moves into it, and right-clicking a row, a section tab or a page tab offers OneNote's commands (Rename, Delete to the notebook's recycle bin, New Section, New Section Group, Close This Notebook; Delete, New Page, Make Subpage, Promote Subpage). Page tabs drag to reorder. The `+` above the page tabs adds a page at the end of the section, titled with its date and time as OneNote titles one, and puts the caret in its title; until its body is typed in, drawn on or clicked, templates offer our recreations of OneNote 2010's art (Ivy first), its page colours and Informal Meeting Notes' content, written as ops (`corpus/notebook-management`). |
| 54 | 54 | |
| 55 | As in OneNote 2010, Notebook Recycle Bin (File menu, a notebook's context menu, or the palette) shows the notebook's recycle bin as its own row of tabs, read-only under a bar: a page's context menu restores it to a section, keeping its identity, copies it out, or deletes it for good, a section's restores it to a folder, and Empty Recycle Bin deletes what it holds (`corpus/recycle-bin-view`). Pages another author changed since they were viewed are bold in the page list, and so are their section's tab and row and the groups and notebook holding them, until left or marked read (Mark as Read, Ctrl+Q off macOS; Mark Notebook as Read; Next Unread; Show Unread Changes in This Notebook); what was read is kept in the cache folder's `read.json`, as OneNote keeps it in its cache. Save As (⇧⌘S) writes the page or section as a OneNote section, the notebook as a OneNote package, or any of them as a PDF; opening a `.onepkg` asks, as Unpack Notebook does, for its name, colour and folder (`corpus/notebook-package`). | |
| 55 | As in OneNote 2010, Notebook Recycle Bin (File menu, a notebook's context menu, or the palette) shows the notebook's recycle bin as its own row of tabs, read-only under a bar: a page's context menu restores it to a section, keeping its identity, copies it out, or deletes it for good, a section's restores it to a folder, and Empty Recycle Bin deletes what it holds (`corpus/recycle-bin-view`). Pages another author changed since they were viewed carry an accent dot in the page list, where OneNote sets them bold, and so do their section's tab and row and the groups and notebook holding them, until left or marked read (Mark as Read, Ctrl+Q off macOS; Mark Notebook as Read; Next Unread; Show Unread Changes in This Notebook); what was read is kept in the cache folder's `read.json`, as OneNote keeps it in its cache. Save As (⇧⌘S) writes the page or section as a OneNote section, the notebook as a OneNote package, or any of them as a PDF; opening a `.onepkg` asks, as Unpack Notebook does, for its name, colour and folder (`corpus/notebook-package`). | |
| 56 | 56 | |
| 57 | 57 | The `ui` crate builds every frame from the application's state. A cache keyed by stable box ids keeps hover, press, focus, scroll and animation values, and the previous frame's layout routes the frame's input before building, so a frame answers input one layout late and paints with its own. Sizes are solved per axis after building: fixed, label-sized, a fraction of an ancestor, or the sum of children, with overflow shared out by each box's strictness. The page is a custom box: `ui` routes it the pointer, wheel, key and input-method events that land on it, in order, and the host hands them to `PageView` and paints the page in a clipped layer of the same frame. Page scrollbars are `ui` widgets over that box; the canvas reports only its scroll bounds. Text fields edit through `draw::edit`, as the page does, so keys, clicks and drags select and move the same way in both. Work the frame asks for (page requests, saving) runs after the frame is painted and requests the frame that shows it. Opening a section or page reads and lays it out on a thread of its own while the current page stays live; the newest replaces the page once the pictures it shows first are drawn, or after 200 ms. Its tab is selected at once, and an open that takes over 80 ms shows a page's outline in place of the page leaving, which takes no input. The pages beside the open one, the sections beside the open section and a hovered page or section tab are read ahead on a thread of their own. A notebook keeps the last three sections left or read ahead open, which `Library::open` hands out, and the last 32 pages shown or read ahead keep their scenes, with up to 128 MiB of decoded pictures, so a page shown again is laid out afresh around pictures already drawn. |
| 58 | 58 |