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`.
352352
353353`Notebook::read_section(path)` reads a section file as stored, without opening a
354354replica, 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
357358A password-protected section lists as `Locked`. `Notebook::unlock(path, password)` opens
358359its key (a wrong password is `Error::Protected(PasswordMismatch)`), and
crates/notebook/src/session.rs+12
......@@ -1719,6 +1719,8 @@ pub struct StoredPage {
17191719 pub page: Page,
17201720 /// The page's `LastModifiedTime`, Time32 seconds since 1980.
17211721 pub modified: Option<u32>,
1722 /// Who changed it last: the `AuthorMostRecent` of its latest modified object.
1723 pub author: Option<String>,
17221724}
17231725
17241726/// 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>> {
17431745 .into_iter()
17441746 .filter_map(|(space, id)| {
17451747 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 });
17461757 Some(StoredPage {
17471758 space,
17481759 page: Page::from_revision(revision, id).ok()?,
17491760 modified: revision.nodes.get(&id).and_then(|node| node.modified),
1761 author,
17501762 })
17511763 })
17521764 .collect())
crates/notebook/tests/session.rs+32
......@@ -1393,3 +1393,35 @@ fn walkdir(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
13931393 }
13941394 files
13951395}
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> {
52115211 conflicted: bool,
52125212 /// A search found the page, which OneNote marks yellow.
52135213 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.
52155215 unread: bool,
52165216 /// The colours of the others who have it open.
52175217 peers: &'a [[f32; 4]],
......@@ -5235,8 +5235,8 @@ fn page_tab(
52355235 selected: bool,
52365236 (rounding, left): (f32, bool),
52375237) -> (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
52405240 - if tab.renaming.is_some() {
52415241 rename::PAD
52425242 } else {
......@@ -5290,11 +5290,20 @@ fn page_tab(
52905290 };
52915291 (spec, color)
52925292 };
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 ];
52935299 let row = ui.open(id, spec);
52945300 if let Some(node) = ui.access(row) {
52955301 node.set_selected(selected);
52965302 node.set_level(tab.indent as usize + 1);
52975303 }
5304 if tab.unread {
5305 ui::shell::unread_dot(ui, "unread", unread);
5306 }
52985307 let kept = match tab.renaming {
52995308 Some(name) => rename::edit(ui, theme, name, ROW - ROW_GAP),
53005309 None => {
......@@ -5303,7 +5312,6 @@ fn page_tab(
53035312 Spec {
53045313 size: [fill(), px(ROW - ROW_GAP)],
53055314 text: Some(tab.label),
5306 bold: tab.unread,
53075315 overflow: ui::Overflow::Ellipsis,
53085316 color: Some(color),
53095317 ..Spec::default()
crates/snowbound/src/sidebar.rs+16-11
......@@ -98,7 +98,7 @@ struct Ghost {
9898 depth: u32,
9999 dim: bool,
100100 selected: Option<[f32; 4]>,
101 bold: bool,
101 unread: bool,
102102}
103103
104104impl Ghost {
......@@ -109,7 +109,7 @@ impl Ghost {
109109 depth: row.depth,
110110 dim: row.dim,
111111 selected: row.selected,
112 bold: row.bold,
112 unread: row.unread,
113113 }
114114 }
115115}
......@@ -255,7 +255,7 @@ fn sidebar(
255255 fold: Some(unfolded),
256256 selected: None,
257257 renamed,
258 bold: tree.unread.contains(&key),
258 unread: tree.unread.contains(&key),
259259 };
260260 if let Some((lifted, slot)) = tree.lifted_notebook {
261261 if lifted == index {
......@@ -362,7 +362,7 @@ fn sidebar(
362362 fold: None,
363363 selected: None,
364364 renamed: false,
365 bold: false,
365 unread: false,
366366 },
367367 );
368368 if row.clicked {
......@@ -420,7 +420,7 @@ fn folder(
420420 fold: None,
421421 selected: (open == Some(section.path.as_str())).then_some(section_color(color)),
422422 renamed: renaming,
423 bold: tree.unread.contains(&library.key(&section.path)),
423 unread: tree.unread.contains(&library.key(&section.path)),
424424 };
425425 if lifts(tree, notebook, &section.path, &row) {
426426 continue;
......@@ -467,7 +467,7 @@ fn folder(
467467 fold: Some(unfolded),
468468 selected: None,
469469 renamed: renaming,
470 bold: tree.unread.contains(&key),
470 unread: tree.unread.contains(&key),
471471 };
472472 // A dragged group's rows fold away beneath it.
473473 let lifted = lifts(tree, notebook, &group.path, &row);
......@@ -529,7 +529,7 @@ fn folder(
529529 fold: None,
530530 selected: None,
531531 renamed: false,
532 bold: false,
532 unread: false,
533533 },
534534 );
535535 if row.clicked {
......@@ -726,8 +726,8 @@ struct Row<'a> {
726726 selected: Option<[f32; 4]>,
727727 /// The row is being renamed: its label is the tree's rename field.
728728 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,
731731}
732732
733733/// 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) {
755755 ..Spec::default()
756756 },
757757 );
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 }
758764 if let Some(node) = ui.access(id) {
759765 node.set_label(row.label);
760766 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) {
788794 Spec {
789795 size: [fill(), px(ROW)],
790796 text: Some(row.label),
791 bold: row.bold,
792797 color: Some(color),
793798 ..Spec::default()
794799 },
......@@ -1093,7 +1098,7 @@ impl crate::State {
10931098 fold: None,
10941099 selected: ghost.selected,
10951100 renamed: false,
1096 bold: ghost.bold,
1101 unread: ghost.unread,
10971102 },
10981103 );
10991104 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
44//! this, and whether a notebook shows it, per user and machine in its cache, never in the
55//! notebook's files; this keeps it in the cache folder's `read.json`.
66
......@@ -121,20 +121,23 @@ impl Reads {
121121 self.save();
122122 }
123123
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.
126127 fn arrived(
127128 &mut self,
128129 notebook: &str,
129130 section: [u8; 16],
130 pages: impl IntoIterator<Item = (ExGuid, Option<u32>)>,
131 pages: impl IntoIterator<Item = (ExGuid, Option<u32>, bool)>,
131132 ) {
132133 let now = now();
133134 let kept = self.section_mut(notebook, section);
134135 let read = kept.read;
135136 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) {
138141 kept.unread.insert(space);
139142 }
140143 newest = newest.max(modified.unwrap_or(0));
......@@ -246,22 +249,56 @@ impl State {
246249 viewing.map(|(notebook, section, space)| (notebook, section, space, false));
247250 }
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.
250254 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 })
258295 }
259296 }
260297
261298 /// Sections of `library` at catalog `paths` whose files another client changed: their
262299 /// pages are read on a thread of their own, those changed since unread.
263300 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());
265302 crate::spawn(move || {
266303 let Ok(Some(notebook)) = &library.notebook else {
267304 return;
......@@ -272,13 +309,15 @@ impl State {
272309 let section = library.section_identity(path)?;
273310 let image = notebook.read_section(path).ok()?;
274311 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();
275315 Some((section, pages))
276316 })
277317 .collect();
278318 let location = library.location.clone();
279319 let _ = proxy.send_event(UserEvent::Then(Box::new(move |state: &mut State| {
280320 for (section, pages) in read {
281 let pages = pages.iter().map(|page| (page.space, page.modified));
282321 state.reads.arrived(&location, section, pages);
283322 }
284323 Ok(())
......@@ -380,13 +419,14 @@ impl State {
380419mod tests {
381420 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) {
384423 (
385424 ExGuid {
386425 guid: [n as u8; 16],
387426 n,
388427 },
389428 modified,
429 false,
390430 )
391431 }
392432
......@@ -436,4 +476,34 @@ mod tests {
436476 assert!(reads.pages(notebook, section).is_none() && !reads.shown(notebook));
437477 notebook::fs::remove_dir_all(&folder).unwrap();
438478 }
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 }
439509}
crates/ui/src/shell.rs+29-5
......@@ -22,6 +22,24 @@ const ARROW: f32 = 10.0;
2222const MORE: f32 = 14.0;
2323/// Where a section tab's label starts inside it.
2424pub 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}
2543/// How far section tabs fade out at an end of their row they are cut at, and how near it a
2644/// dragged tab scrolls the row on.
2745const EDGE: f32 = 20.0;
......@@ -36,7 +54,7 @@ const EDGE: f32 = 20.0;
3654/// The row is as wide as the tabs, and gives up all of that where its own row lacks room.
3755/// Then the tabs scroll sideways under the wheel, either way it turns, fading out at the
3856/// 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.
4058#[allow(clippy::too_many_arguments)]
4159pub fn section_tabs(
4260 ui: &mut Ui,
......@@ -54,10 +72,10 @@ pub fn section_tabs(
5472 let mut left = crate::SHADOW[0];
5573 let placed: Vec<_> = tabs
5674 .iter()
57 .map(|&(name, _, bold)| {
75 .map(|&(name, ..)| {
5876 let label = ui
5977 .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);
6179 let width = TAB_PAD + label.size[0] + 4.0 + lean(height);
6280 left += width;
6381 (left - width, width)
......@@ -144,7 +162,7 @@ pub fn section_tabs(
144162 .chain((active < tabs.len() && Some(active) != lifted).then_some(active))
145163 .chain(lifted);
146164 for index in order {
147 let (name, color, bold) = tabs[index];
165 let (name, color, unread) = tabs[index];
148166 let (x, width) = placed[index];
149167 // Keyed by name, so a tab eases from where it stood when the tabs are reordered.
150168 let key = ui.id(("x", name));
......@@ -179,7 +197,6 @@ pub fn section_tabs(
179197 size: [px(width), px(tall)],
180198 position: [x - offset, height - tall],
181199 text: Some(name),
182 bold,
183200 color: Some(mix(mix(theme.ink, fill, 0.2), theme.ink, open)),
184201 fill: Some(if lit == Some(index) {
185202 mix(colors.frame[0], [1.0; 4], 0.08)
......@@ -198,6 +215,13 @@ pub fn section_tabs(
198215 ..Spec::default()
199216 },
200217 );
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 }
201225 if let Some(node) = ui.access(tab_id(row, index)) {
202226 node.set_selected(index == active);
203227 }
tools/canvas/README.md+1-1
......@@ -52,7 +52,7 @@ snowbound title bar · toolbar · section frame · page tabs · the page as a
5252
5353Launched 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
5757The `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