authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 21:09:05-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 07:05:30-07:00
logc5f2777d27e890082216a8d8eb1dacee4d4a104f
tree2107307d3317eeb448ab24311182fdd1d7d15fd6
parent30328408481c8d7949dcfabb9d032a380c6f93b5
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

fix: unread changes are a dot, and never the reader's own

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.5

8 files changed, 193 insertions(+), 41 deletions(-)

crates/notebook/README.md+2-1
...@@ -352,7 +352,8 @@ targets are `None`....@@ -352,7 +352,8 @@ targets are `None`.
352352
353`Notebook::read_section(path)` reads a section file as stored, without opening a353`Notebook::read_section(path)` reads a section file as stored, without opening a
354replica, and `session::stored_pages(image)` builds its pages with each page's354replica, 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
356sections it has not opened this way, and unread changes leave out the reader's own.
356357
357A password-protected section lists as `Locked`. `Notebook::unlock(path, password)` opens358A password-protected section lists as `Locked`. `Notebook::unlock(path, password)` opens
358its key (a wrong password is `Error::Protected(PasswordMismatch)`), and359its key (a wrong password is `Error::Protected(PasswordMismatch)`), and
crates/notebook/src/session.rs+12
...@@ -1719,6 +1719,8 @@ pub struct StoredPage {...@@ -1719,6 +1719,8 @@ pub struct StoredPage {
1719 pub page: Page,1719 pub page: Page,
1720 /// The page's `LastModifiedTime`, Time32 seconds since 1980.1720 /// The page's `LastModifiedTime`, Time32 seconds since 1980.
1721 pub modified: Option<u32>,1721 pub modified: Option<u32>,
1722 /// Who changed it last: the `AuthorMostRecent` of its latest modified object.
1723 pub author: Option<String>,
1722}1724}
17231725
1724/// The pages of the section file `image` holds, in section order; pages the model cannot1726/// 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,10 +1745,20 @@ fn pages_of(document: &Document<'_>) -> Result<Vec<StoredPage>> {
1743 .into_iter()1745 .into_iter()
1744 .filter_map(|(space, id)| {1746 .filter_map(|(space, id)| {
1745 let revision = document.active(space).ok()?;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 Some(StoredPage {1757 Some(StoredPage {
1747 space,1758 space,
1748 page: Page::from_revision(revision, id).ok()?,1759 page: Page::from_revision(revision, id).ok()?,
1749 modified: revision.nodes.get(&id).and_then(|node| node.modified),1760 modified: revision.nodes.get(&id).and_then(|node| node.modified),
1761 author,
1750 })1762 })
1751 })1763 })
1752 .collect())1764 .collect())
crates/notebook/tests/session.rs+32
...@@ -1393,3 +1393,35 @@ fn walkdir(dir: &std::path::Path) -> Vec<std::path::PathBuf> {...@@ -1393,3 +1393,35 @@ fn walkdir(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
1393 }1393 }
1394 files1394 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]
1400fn 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,7 +5211,7 @@ struct PageTab<'a> {
5211 conflicted: bool,5211 conflicted: bool,
5212 /// A search found the page, which OneNote marks yellow.5212 /// A search found the page, which OneNote marks yellow.
5213 found: bool,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 unread: bool,5215 unread: bool,
5216 /// The colours of the others who have it open.5216 /// The colours of the others who have it open.
5217 peers: &'a [[f32; 4]],5217 peers: &'a [[f32; 4]],
...@@ -5235,8 +5235,8 @@ fn page_tab(...@@ -5235,8 +5235,8 @@ fn page_tab(
5235 selected: bool,5235 selected: bool,
5236 (rounding, left): (f32, bool),5236 (rounding, left): (f32, bool),
5237) -> (ui::Signal, Option<bool>) {5237) -> (ui::Signal, Option<bool>) {
5238 // A rename field's text stands where the label did.5238 // A rename field's text stands where the label did, past room for the unread dot.
5239 let pad = 10.0 + 16.0 * tab.indent as f325239 let pad = 16.0 + 16.0 * tab.indent as f32
5240 - if tab.renaming.is_some() {5240 - if tab.renaming.is_some() {
5241 rename::PAD5241 rename::PAD
5242 } else {5242 } else {
...@@ -5290,11 +5290,20 @@ fn page_tab(...@@ -5290,11 +5290,20 @@ fn page_tab(
5290 };5290 };
5291 (spec, color)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 let row = ui.open(id, spec);5299 let row = ui.open(id, spec);
5294 if let Some(node) = ui.access(row) {5300 if let Some(node) = ui.access(row) {
5295 node.set_selected(selected);5301 node.set_selected(selected);
5296 node.set_level(tab.indent as usize + 1);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 let kept = match tab.renaming {5307 let kept = match tab.renaming {
5299 Some(name) => rename::edit(ui, theme, name, ROW - ROW_GAP),5308 Some(name) => rename::edit(ui, theme, name, ROW - ROW_GAP),
5300 None => {5309 None => {
...@@ -5303,7 +5312,6 @@ fn page_tab(...@@ -5303,7 +5312,6 @@ fn page_tab(
5303 Spec {5312 Spec {
5304 size: [fill(), px(ROW - ROW_GAP)],5313 size: [fill(), px(ROW - ROW_GAP)],
5305 text: Some(tab.label),5314 text: Some(tab.label),
5306 bold: tab.unread,
5307 overflow: ui::Overflow::Ellipsis,5315 overflow: ui::Overflow::Ellipsis,
5308 color: Some(color),5316 color: Some(color),
5309 ..Spec::default()5317 ..Spec::default()
crates/snowbound/src/sidebar.rs+16-11
...@@ -98,7 +98,7 @@ struct Ghost {...@@ -98,7 +98,7 @@ struct Ghost {
98 depth: u32,98 depth: u32,
99 dim: bool,99 dim: bool,
100 selected: Option<[f32; 4]>,100 selected: Option<[f32; 4]>,
101 bold: bool,101 unread: bool,
102}102}
103103
104impl Ghost {104impl Ghost {
...@@ -109,7 +109,7 @@ impl Ghost {...@@ -109,7 +109,7 @@ impl Ghost {
109 depth: row.depth,109 depth: row.depth,
110 dim: row.dim,110 dim: row.dim,
111 selected: row.selected,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,7 +255,7 @@ fn sidebar(
255 fold: Some(unfolded),255 fold: Some(unfolded),
256 selected: None,256 selected: None,
257 renamed,257 renamed,
258 bold: tree.unread.contains(&key),258 unread: tree.unread.contains(&key),
259 };259 };
260 if let Some((lifted, slot)) = tree.lifted_notebook {260 if let Some((lifted, slot)) = tree.lifted_notebook {
261 if lifted == index {261 if lifted == index {
...@@ -362,7 +362,7 @@ fn sidebar(...@@ -362,7 +362,7 @@ fn sidebar(
362 fold: None,362 fold: None,
363 selected: None,363 selected: None,
364 renamed: false,364 renamed: false,
365 bold: false,365 unread: false,
366 },366 },
367 );367 );
368 if row.clicked {368 if row.clicked {
...@@ -420,7 +420,7 @@ fn folder(...@@ -420,7 +420,7 @@ fn folder(
420 fold: None,420 fold: None,
421 selected: (open == Some(section.path.as_str())).then_some(section_color(color)),421 selected: (open == Some(section.path.as_str())).then_some(section_color(color)),
422 renamed: renaming,422 renamed: renaming,
423 bold: tree.unread.contains(&library.key(&section.path)),423 unread: tree.unread.contains(&library.key(&section.path)),
424 };424 };
425 if lifts(tree, notebook, &section.path, &row) {425 if lifts(tree, notebook, &section.path, &row) {
426 continue;426 continue;
...@@ -467,7 +467,7 @@ fn folder(...@@ -467,7 +467,7 @@ fn folder(
467 fold: Some(unfolded),467 fold: Some(unfolded),
468 selected: None,468 selected: None,
469 renamed: renaming,469 renamed: renaming,
470 bold: tree.unread.contains(&key),470 unread: tree.unread.contains(&key),
471 };471 };
472 // A dragged group's rows fold away beneath it.472 // A dragged group's rows fold away beneath it.
473 let lifted = lifts(tree, notebook, &group.path, &row);473 let lifted = lifts(tree, notebook, &group.path, &row);
...@@ -529,7 +529,7 @@ fn folder(...@@ -529,7 +529,7 @@ fn folder(
529 fold: None,529 fold: None,
530 selected: None,530 selected: None,
531 renamed: false,531 renamed: false,
532 bold: false,532 unread: false,
533 },533 },
534 );534 );
535 if row.clicked {535 if row.clicked {
...@@ -726,8 +726,8 @@ struct Row<'a> {...@@ -726,8 +726,8 @@ struct Row<'a> {
726 selected: Option<[f32; 4]>,726 selected: Option<[f32; 4]>,
727 /// The row is being renamed: its label is the tree's rename field.727 /// The row is being renamed: its label is the tree's rename field.
728 renamed: bool,728 renamed: bool,
729 /// It holds unread pages, which OneNote sets bold.729 /// It holds unread pages, which its dot marks.
730 bold: bool,730 unread: bool,
731}731}
732732
733/// One row of the tree: its signal, and whether its fold arrow was clicked.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,6 +755,12 @@ fn tree_row(ui: &mut Ui, tree: &mut Tree, id: Id, row: Row) -> (Signal, bool) {
755 ..Spec::default()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 if let Some(node) = ui.access(id) {764 if let Some(node) = ui.access(id) {
759 node.set_label(row.label);765 node.set_label(row.label);
760 node.set_level(row.depth as usize + 1);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,7 +794,6 @@ fn tree_row(ui: &mut Ui, tree: &mut Tree, id: Id, row: Row) -> (Signal, bool) {
788 Spec {794 Spec {
789 size: [fill(), px(ROW)],795 size: [fill(), px(ROW)],
790 text: Some(row.label),796 text: Some(row.label),
791 bold: row.bold,
792 color: Some(color),797 color: Some(color),
793 ..Spec::default()798 ..Spec::default()
794 },799 },
...@@ -1093,7 +1098,7 @@ impl crate::State {...@@ -1093,7 +1098,7 @@ impl crate::State {
1093 fold: None,1098 fold: None,
1094 selected: ghost.selected,1099 selected: ghost.selected,
1095 renamed: false,1100 renamed: false,
1096 bold: ghost.bold,1101 unread: ghost.unread,
1097 },1102 },
1098 );1103 );
1099 self.ui.close();1104 self.ui.close();
crates/snowbound/src/unread.rs+89-19
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1//! Unread changes, as OneNote 2010 shows them: a page another author changed since it was1//! Unread changes, as OneNote 2010 keeps 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 groups2//! last viewed has a dot in the page list, where OneNote sets it bold, and so do its section's
3//! and notebook holding it, until the page is viewed and left or marked as read. OneNote keeps3//! tab and row and the groups and notebook holding it, until the page is viewed and left or marked as read. OneNote keeps
4//! this, and whether a notebook shows it, per user and machine in its cache, never in the4//! this, and whether a notebook shows it, per user and machine in its cache, never in the
5//! notebook's files; this keeps it in the cache folder's `read.json`.5//! notebook's files; this keeps it in the cache folder's `read.json`.
66
...@@ -121,20 +121,23 @@ impl Reads {...@@ -121,20 +121,23 @@ impl Reads {
121 self.save();121 self.save();
122 }122 }
123123
124 /// Takes in a section as its file stands, its pages with their `LastModifiedTime`: those124 /// Takes in a section as its file stands, its pages with their `LastModifiedTime` and
125 /// changed since it was last known are unread.125 /// whether the reader changed them last: those another author changed since it was last
126 /// known are unread.
126 fn arrived(127 fn arrived(
127 &mut self,128 &mut self,
128 notebook: &str,129 notebook: &str,
129 section: [u8; 16],130 section: [u8; 16],
130 pages: impl IntoIterator<Item = (ExGuid, Option<u32>)>,131 pages: impl IntoIterator<Item = (ExGuid, Option<u32>, bool)>,
131 ) {132 ) {
132 let now = now();133 let now = now();
133 let kept = self.section_mut(notebook, section);134 let kept = self.section_mut(notebook, section);
134 let read = kept.read;135 let read = kept.read;
135 let mut newest = read.max(now);136 let mut newest = read.max(now);
136 for (space, modified) in pages {137 for (space, modified, mine) in pages {
137 if modified.is_some_and(|modified| modified > read) {138 if mine {
139 kept.unread.remove(&space);
140 } else if modified.is_some_and(|modified| modified > read) {
138 kept.unread.insert(space);141 kept.unread.insert(space);
139 }142 }
140 newest = newest.max(modified.unwrap_or(0));143 newest = newest.max(modified.unwrap_or(0));
...@@ -246,22 +249,56 @@ impl State {...@@ -246,22 +249,56 @@ impl State {
246 viewing.map(|(notebook, section, space)| (notebook, section, space, false));249 viewing.map(|(notebook, section, space)| (notebook, section, space, false));
247 }250 }
248251
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 pub(crate) fn changed_elsewhere(&mut self, spaces: &[ExGuid]) {254 pub(crate) fn changed_elsewhere(&mut self, spaces: &[ExGuid]) {
251 if let Some(session) = &self.session255 let Some(session) = &self.session else {
252 && let Some(section) = session256 return;
253 .library257 };
254 .section_identity(&session.tabs[session.tab].path)258 let Some(section) = session
255 {259 .library
256 self.reads260 .section_identity(&session.tabs[session.tab].path)
257 .changed(&session.library.location, section, spaces.iter().copied());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 }
260297
261 /// Sections of `library` at catalog `paths` whose files another client changed: their298 /// Sections of `library` at catalog `paths` whose files another client changed: their
262 /// pages are read on a thread of their own, those changed since unread.299 /// pages are read on a thread of their own, those changed since unread.
263 pub(crate) fn sections_changed(&self, library: &Arc<Library>, paths: Vec<String>) {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 crate::spawn(move || {302 crate::spawn(move || {
266 let Ok(Some(notebook)) = &library.notebook else {303 let Ok(Some(notebook)) = &library.notebook else {
267 return;304 return;
...@@ -272,13 +309,15 @@ impl State {...@@ -272,13 +309,15 @@ impl State {
272 let section = library.section_identity(path)?;309 let section = library.section_identity(path)?;
273 let image = notebook.read_section(path).ok()?;310 let image = notebook.read_section(path).ok()?;
274 let pages = notebook::session::stored_pages(&image).ok()?;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 Some((section, pages))315 Some((section, pages))
276 })316 })
277 .collect();317 .collect();
278 let location = library.location.clone();318 let location = library.location.clone();
279 let _ = proxy.send_event(UserEvent::Then(Box::new(move |state: &mut State| {319 let _ = proxy.send_event(UserEvent::Then(Box::new(move |state: &mut State| {
280 for (section, pages) in read {320 for (section, pages) in read {
281 let pages = pages.iter().map(|page| (page.space, page.modified));
282 state.reads.arrived(&location, section, pages);321 state.reads.arrived(&location, section, pages);
283 }322 }
284 Ok(())323 Ok(())
...@@ -380,13 +419,14 @@ impl State {...@@ -380,13 +419,14 @@ impl State {
380mod tests {419mod tests {
381 use super::*;420 use super::*;
382421
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 ExGuid {424 ExGuid {
386 guid: [n as u8; 16],425 guid: [n as u8; 16],
387 n,426 n,
388 },427 },
389 modified,428 modified,
429 false,
390 )430 )
391 }431 }
392432
...@@ -436,4 +476,34 @@ mod tests {...@@ -436,4 +476,34 @@ mod tests {
436 assert!(reads.pages(notebook, section).is_none() && !reads.shown(notebook));476 assert!(reads.pages(notebook, section).is_none() && !reads.shown(notebook));
437 notebook::fs::remove_dir_all(&folder).unwrap();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,6 +22,24 @@ const ARROW: f32 = 10.0;
22const MORE: f32 = 14.0;22const MORE: f32 = 14.0;
23/// Where a section tab's label starts inside it.23/// Where a section tab's label starts inside it.
24pub const TAB_PAD: f32 = 10.0;24pub const TAB_PAD: f32 = 10.0;
25/// Side of the dot marking something unread, as Mail marks unread mail.
26pub 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.
30pub 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/// How far section tabs fade out at an end of their row they are cut at, and how near it a43/// How far section tabs fade out at an end of their row they are cut at, and how near it a
26/// dragged tab scrolls the row on.44/// dragged tab scrolls the row on.
27const EDGE: f32 = 20.0;45const EDGE: f32 = 20.0;
...@@ -36,7 +54,7 @@ const EDGE: f32 = 20.0;...@@ -36,7 +54,7 @@ const EDGE: f32 = 20.0;
36/// The row is as wide as the tabs, and gives up all of that where its own row lacks room.54/// The row is as wide as the tabs, and gives up all of that where its own row lacks room.
37/// Then the tabs scroll sideways under the wheel, either way it turns, fading out at the55/// Then the tabs scroll sideways under the wheel, either way it turns, fading out at the
38/// ends they are cut at into the row's `fill`, and the open tab scrolls into view as it56/// 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#[allow(clippy::too_many_arguments)]58#[allow(clippy::too_many_arguments)]
41pub fn section_tabs(59pub fn section_tabs(
42 ui: &mut Ui,60 ui: &mut Ui,
...@@ -54,10 +72,10 @@ pub fn section_tabs(...@@ -54,10 +72,10 @@ pub fn section_tabs(
54 let mut left = crate::SHADOW[0];72 let mut left = crate::SHADOW[0];
55 let placed: Vec<_> = tabs73 let placed: Vec<_> = tabs
56 .iter()74 .iter()
57 .map(|&(name, _, bold)| {75 .map(|&(name, ..)| {
58 let label = ui76 let label = ui
59 .texts77 .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 let width = TAB_PAD + label.size[0] + 4.0 + lean(height);79 let width = TAB_PAD + label.size[0] + 4.0 + lean(height);
62 left += width;80 left += width;
63 (left - width, width)81 (left - width, width)
...@@ -144,7 +162,7 @@ pub fn section_tabs(...@@ -144,7 +162,7 @@ pub fn section_tabs(
144 .chain((active < tabs.len() && Some(active) != lifted).then_some(active))162 .chain((active < tabs.len() && Some(active) != lifted).then_some(active))
145 .chain(lifted);163 .chain(lifted);
146 for index in order {164 for index in order {
147 let (name, color, bold) = tabs[index];165 let (name, color, unread) = tabs[index];
148 let (x, width) = placed[index];166 let (x, width) = placed[index];
149 // Keyed by name, so a tab eases from where it stood when the tabs are reordered.167 // Keyed by name, so a tab eases from where it stood when the tabs are reordered.
150 let key = ui.id(("x", name));168 let key = ui.id(("x", name));
...@@ -179,7 +197,6 @@ pub fn section_tabs(...@@ -179,7 +197,6 @@ pub fn section_tabs(
179 size: [px(width), px(tall)],197 size: [px(width), px(tall)],
180 position: [x - offset, height - tall],198 position: [x - offset, height - tall],
181 text: Some(name),199 text: Some(name),
182 bold,
183 color: Some(mix(mix(theme.ink, fill, 0.2), theme.ink, open)),200 color: Some(mix(mix(theme.ink, fill, 0.2), theme.ink, open)),
184 fill: Some(if lit == Some(index) {201 fill: Some(if lit == Some(index) {
185 mix(colors.frame[0], [1.0; 4], 0.08)202 mix(colors.frame[0], [1.0; 4], 0.08)
...@@ -198,6 +215,13 @@ pub fn section_tabs(...@@ -198,6 +215,13 @@ pub fn section_tabs(
198 ..Spec::default()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 if let Some(node) = ui.access(tab_id(row, index)) {225 if let Some(node) = ui.access(tab_id(row, index)) {
202 node.set_selected(index == active);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,7 +52,7 @@ snowbound title bar · toolbar · section frame · page tabs · the page as a
5252
53Launched 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`).53Launched 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`).
5454
55As 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`).55As 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`).
5656
57The `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.57The `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.
5858