From c5f2777d27e890082216a8d8eb1dacee4d4a104f Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 2 Oct 2026 21:09:05 -0700 Subject: [PATCH] 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 --- crates/notebook/README.md | 3 +- crates/notebook/src/session.rs | 12 ++++ crates/notebook/tests/session.rs | 32 +++++++++ crates/snowbound/src/main.rs | 16 +++-- crates/snowbound/src/sidebar.rs | 27 ++++---- crates/snowbound/src/unread.rs | 108 +++++++++++++++++++++++++------ crates/ui/src/shell.rs | 34 ++++++++-- tools/canvas/README.md | 2 +- 8 files changed, 193 insertions(+), 41 deletions(-) diff --git a/crates/notebook/README.md b/crates/notebook/README.md index fadb051de72b984fd933127b7a90ce6ff7d8753f..2045ad344ae63810a9523e076444be0097138b03 100644 --- a/crates/notebook/README.md +++ b/crates/notebook/README.md @@ -352,7 +352,8 @@ targets are `None`. `Notebook::read_section(path)` reads a section file as stored, without opening a replica, and `session::stored_pages(image)` builds its pages with each page's -`LastModifiedTime`: the app's search indexes the sections it has not opened this way. +`LastModifiedTime` and the author of its latest change: the app's search indexes the +sections it has not opened this way, and unread changes leave out the reader's own. A password-protected section lists as `Locked`. `Notebook::unlock(path, password)` opens its key (a wrong password is `Error::Protected(PasswordMismatch)`), and diff --git a/crates/notebook/src/session.rs b/crates/notebook/src/session.rs index f2fd32b69e4ae671a659f2a840109af9c5c0dfa1..1eac026e7c60bb5cb7f99bfcc523012297ebc9ae 100644 --- a/crates/notebook/src/session.rs +++ b/crates/notebook/src/session.rs @@ -1719,6 +1719,8 @@ pub struct StoredPage { pub page: Page, /// The page's `LastModifiedTime`, Time32 seconds since 1980. pub modified: Option, + /// Who changed it last: the `AuthorMostRecent` of its latest modified object. + pub author: Option, } /// 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> { .into_iter() .filter_map(|(space, id)| { let revision = document.active(space).ok()?; + let latest = (revision.nodes.values()) + .filter(|node| node.latest_author.is_some()) + .max_by_key(|node| node.modified); + let author = latest + .and_then(|node| revision.nodes.get(&node.latest_author?)) + .and_then(|node| match &node.kind { + onestore::document::Kind::Author { name } => name.clone(), + _ => None, + }); Some(StoredPage { space, page: Page::from_revision(revision, id).ok()?, modified: revision.nodes.get(&id).and_then(|node| node.modified), + author, }) }) .collect()) diff --git a/crates/notebook/tests/session.rs b/crates/notebook/tests/session.rs index 4217a8484aaae6c73f44fa18655eb47bc7e59357..8a20e57391d92fc9d7e631f92ab057c8a08075f5 100644 --- a/crates/notebook/tests/session.rs +++ b/crates/notebook/tests/session.rs @@ -1393,3 +1393,35 @@ fn walkdir(dir: &std::path::Path) -> Vec { } files } + +/// A stored page names who changed it last, which tells another author's changes from +/// one's own. +#[test] +fn a_stored_page_names_who_changed_it_last() { + let image = onestore::create_section("Authors.one", "Body", "Alice").unwrap(); + let first = ¬ebook::session::stored_pages(&image).unwrap()[0]; + assert_eq!(first.author.as_deref(), Some("Alice")); + let arena = onestore::Arena::default(); + let mut section = onestore::Section::open(&arena, image).unwrap(); + // Enter, which stamps both paragraphs with who pressed it. + let new_id = || onestore::page::text::new_id().unwrap(); + let op = PageOp::Split { + text: first_text(&first.page), + at: 0, + paragraph: new_id(), + right: new_id(), + lists: Vec::new(), + }; + let edit = Edit { + // A minute on, so the edit is the page's latest change. + at: model_ops::now() + 600_000_000, + ops: vec![Op::Page { + space: first.space, + op, + }], + }; + section.apply("Bob", &edit).unwrap(); + section.seal().unwrap(); + let edited = ¬ebook::session::stored_pages(§ion.image()).unwrap()[0]; + assert_eq!(edited.author.as_deref(), Some("Bob")); +} diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 0d4660183a0449757c39577ff92a952ce1bcd8bd..e99d7e7936ea953c58d4f922901d22ccd1d9fd13 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -5211,7 +5211,7 @@ struct PageTab<'a> { conflicted: bool, /// A search found the page, which OneNote marks yellow. found: bool, - /// Another author changed it since it was last viewed, which OneNote sets bold. + /// Another author changed it since it was last viewed, which its dot marks. unread: bool, /// The colours of the others who have it open. peers: &'a [[f32; 4]], @@ -5235,8 +5235,8 @@ fn page_tab( selected: bool, (rounding, left): (f32, bool), ) -> (ui::Signal, Option) { - // A rename field's text stands where the label did. - let pad = 10.0 + 16.0 * tab.indent as f32 + // A rename field's text stands where the label did, past room for the unread dot. + let pad = 16.0 + 16.0 * tab.indent as f32 - if tab.renaming.is_some() { rename::PAD } else { @@ -5290,11 +5290,20 @@ fn page_tab( }; (spec, color) }; + // Just before the label, in the tab's leading pad. + let dot = ui::shell::DOT; + let unread = [ + (spec.pad[0] - dot - 2.0).max(spec.inset[0]), + (ROW - ROW_GAP - dot) / 2.0, + ]; let row = ui.open(id, spec); if let Some(node) = ui.access(row) { node.set_selected(selected); node.set_level(tab.indent as usize + 1); } + if tab.unread { + ui::shell::unread_dot(ui, "unread", unread); + } let kept = match tab.renaming { Some(name) => rename::edit(ui, theme, name, ROW - ROW_GAP), None => { @@ -5303,7 +5312,6 @@ fn page_tab( Spec { size: [fill(), px(ROW - ROW_GAP)], text: Some(tab.label), - bold: tab.unread, overflow: ui::Overflow::Ellipsis, color: Some(color), ..Spec::default() diff --git a/crates/snowbound/src/sidebar.rs b/crates/snowbound/src/sidebar.rs index c23409bf9c2c6c8d7c7d9f47034ac9a66525993e..43ecc29b3c0d091638772ea5e7329472b8e1684e 100644 --- a/crates/snowbound/src/sidebar.rs +++ b/crates/snowbound/src/sidebar.rs @@ -98,7 +98,7 @@ struct Ghost { depth: u32, dim: bool, selected: Option<[f32; 4]>, - bold: bool, + unread: bool, } impl Ghost { @@ -109,7 +109,7 @@ impl Ghost { depth: row.depth, dim: row.dim, selected: row.selected, - bold: row.bold, + unread: row.unread, } } } @@ -255,7 +255,7 @@ fn sidebar( fold: Some(unfolded), selected: None, renamed, - bold: tree.unread.contains(&key), + unread: tree.unread.contains(&key), }; if let Some((lifted, slot)) = tree.lifted_notebook { if lifted == index { @@ -362,7 +362,7 @@ fn sidebar( fold: None, selected: None, renamed: false, - bold: false, + unread: false, }, ); if row.clicked { @@ -420,7 +420,7 @@ fn folder( fold: None, selected: (open == Some(section.path.as_str())).then_some(section_color(color)), renamed: renaming, - bold: tree.unread.contains(&library.key(§ion.path)), + unread: tree.unread.contains(&library.key(§ion.path)), }; if lifts(tree, notebook, §ion.path, &row) { continue; @@ -467,7 +467,7 @@ fn folder( fold: Some(unfolded), selected: None, renamed: renaming, - bold: tree.unread.contains(&key), + unread: tree.unread.contains(&key), }; // A dragged group's rows fold away beneath it. let lifted = lifts(tree, notebook, &group.path, &row); @@ -529,7 +529,7 @@ fn folder( fold: None, selected: None, renamed: false, - bold: false, + unread: false, }, ); if row.clicked { @@ -726,8 +726,8 @@ struct Row<'a> { selected: Option<[f32; 4]>, /// The row is being renamed: its label is the tree's rename field. renamed: bool, - /// It holds unread pages, which OneNote sets bold. - bold: bool, + /// It holds unread pages, which its dot marks. + unread: bool, } /// 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) { ..Spec::default() }, ); + // Just before the row's icon, a top row's reaching into the tree's margin. + if row.unread { + let (icon, dot) = (ROW_PAD + INDENT * row.depth as f32, ui::shell::DOT); + let at = [icon - dot - 2.0, (ROW - dot) / 2.0]; + ui::shell::unread_dot(ui, "unread", at); + } if let Some(node) = ui.access(id) { node.set_label(row.label); 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) { Spec { size: [fill(), px(ROW)], text: Some(row.label), - bold: row.bold, color: Some(color), ..Spec::default() }, @@ -1093,7 +1098,7 @@ impl crate::State { fold: None, selected: ghost.selected, renamed: false, - bold: ghost.bold, + unread: ghost.unread, }, ); self.ui.close(); diff --git a/crates/snowbound/src/unread.rs b/crates/snowbound/src/unread.rs index 0f207e96e5212b02b918cdf039686681ee414995..14fba330d7d4e6397b7f646c0548d841d4713798 100644 --- a/crates/snowbound/src/unread.rs +++ b/crates/snowbound/src/unread.rs @@ -1,6 +1,6 @@ -//! Unread changes, as OneNote 2010 shows them: a page another author changed since it was -//! last viewed is bold in the page list, and so are its section's tab and row and the groups -//! and notebook holding it, until the page is viewed and left or marked as read. OneNote keeps +//! Unread changes, as OneNote 2010 keeps them: a page another author changed since it was +//! last viewed has a dot in the page list, where OneNote sets it bold, and so do its section's +//! tab and row and the groups and notebook holding it, until the page is viewed and left or marked as read. OneNote keeps //! this, and whether a notebook shows it, per user and machine in its cache, never in the //! notebook's files; this keeps it in the cache folder's `read.json`. @@ -121,20 +121,23 @@ impl Reads { self.save(); } - /// Takes in a section as its file stands, its pages with their `LastModifiedTime`: those - /// changed since it was last known are unread. + /// Takes in a section as its file stands, its pages with their `LastModifiedTime` and + /// whether the reader changed them last: those another author changed since it was last + /// known are unread. fn arrived( &mut self, notebook: &str, section: [u8; 16], - pages: impl IntoIterator)>, + pages: impl IntoIterator, bool)>, ) { let now = now(); let kept = self.section_mut(notebook, section); let read = kept.read; let mut newest = read.max(now); - for (space, modified) in pages { - if modified.is_some_and(|modified| modified > read) { + for (space, modified, mine) in pages { + if mine { + kept.unread.remove(&space); + } else if modified.is_some_and(|modified| modified > read) { kept.unread.insert(space); } newest = newest.max(modified.unwrap_or(0)); @@ -246,22 +249,56 @@ impl State { viewing.map(|(notebook, section, space)| (notebook, section, space, false)); } - /// Pages of the open section another author changed, as its sync reports them. + /// Pages of the open section another client changed, as its sync reports them: those + /// another author changed last are unread, once read on a thread of their own. pub(crate) fn changed_elsewhere(&mut self, spaces: &[ExGuid]) { - if let Some(session) = &self.session - && let Some(section) = session - .library - .section_identity(&session.tabs[session.tab].path) - { - self.reads - .changed(&session.library.location, section, spaces.iter().copied()); + let Some(session) = &self.session else { + return; + }; + let Some(section) = session + .library + .section_identity(&session.tabs[session.tab].path) + else { + return; + }; + let (location, spaces) = (session.library.location.clone(), spaces.to_vec()); + let (replica, proxy, me) = ( + Arc::clone(session.section.replica()), + self.proxy.clone(), + self.me(), + ); + crate::spawn(move || { + let pages = (replica.snapshot().ok()) + .and_then(|image| notebook::session::stored_pages(&image).ok()) + .unwrap_or_default(); + let others: Vec = (spaces.into_iter()) + .filter(|space| { + !(pages.iter()).any(|page| page.space == *space && me(page.author.as_deref())) + }) + .collect(); + let _ = proxy.send_event(UserEvent::Then(Box::new(move |state: &mut State| { + state.reads.changed(&location, section, others); + Ok(()) + }))); + }); + } + + /// Whether an author's name is the reader's: the user name edits are stored under, or the + /// account's, as OneNote on another of the reader's computers names them. + fn me(&self) -> impl Fn(Option<&str>) -> bool + Send + 'static { + let names = [self.author.clone(), crate::platform::user_name()]; + move |author| { + author.map(str::trim).is_some_and(|author| { + !author.is_empty() + && (names.iter()).any(|name| name.trim().eq_ignore_ascii_case(author)) + }) } } /// Sections of `library` at catalog `paths` whose files another client changed: their /// pages are read on a thread of their own, those changed since unread. pub(crate) fn sections_changed(&self, library: &Arc, paths: Vec) { - let (library, proxy) = (Arc::clone(library), self.proxy.clone()); + let (library, proxy, me) = (Arc::clone(library), self.proxy.clone(), self.me()); crate::spawn(move || { let Ok(Some(notebook)) = &library.notebook else { return; @@ -272,13 +309,15 @@ impl State { let section = library.section_identity(path)?; let image = notebook.read_section(path).ok()?; let pages = notebook::session::stored_pages(&image).ok()?; + let pages: Vec<_> = (pages.into_iter()) + .map(|page| (page.space, page.modified, me(page.author.as_deref()))) + .collect(); Some((section, pages)) }) .collect(); let location = library.location.clone(); let _ = proxy.send_event(UserEvent::Then(Box::new(move |state: &mut State| { for (section, pages) in read { - let pages = pages.iter().map(|page| (page.space, page.modified)); state.reads.arrived(&location, section, pages); } Ok(()) @@ -380,13 +419,14 @@ impl State { mod tests { use super::*; - fn page(n: u32, modified: Option) -> (ExGuid, Option) { + fn page(n: u32, modified: Option) -> (ExGuid, Option, bool) { ( ExGuid { guid: [n as u8; 16], n, }, modified, + false, ) } @@ -436,4 +476,34 @@ mod tests { assert!(reads.pages(notebook, section).is_none() && !reads.shown(notebook)); notebook::fs::remove_dir_all(&folder).unwrap(); } + + /// A page the reader changed last, on this computer or another, is never unread, and + /// stops being so once the reader changes it. + #[test] + fn the_readers_own_changes_are_never_unread() { + let folder = + std::env::temp_dir().join(format!("snowbound-unread-own-{}", std::process::id())); + notebook::fs::create_dir_all(&folder).unwrap(); + let (notebook, section) = ("/notebook", [3; 16]); + let mut reads = Reads::load(&folder); + let known = reads.section_mut(notebook, section).read; + let mine = |n, modified| { + let (space, modified, _) = page(n, Some(modified)); + (space, modified, true) + }; + reads.arrived( + notebook, + section, + [mine(1, known + 5), page(2, Some(known + 5))], + ); + let unread = |reads: &Reads| -> Vec { + (reads.pages(notebook, section).into_iter().flatten()) + .map(|space| space.n) + .collect() + }; + assert_eq!(unread(&reads), [2]); + reads.arrived(notebook, section, [mine(2, known + 9)]); + assert!(unread(&reads).is_empty()); + notebook::fs::remove_dir_all(&folder).unwrap(); + } } diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index baf8c87c4f12bd65d724e4f87378441f9d100ed4..275aa37464230870aa6d5468cada121036823745 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -22,6 +22,24 @@ const ARROW: f32 = 10.0; const MORE: f32 = 14.0; /// Where a section tab's label starts inside it. pub const TAB_PAD: f32 = 10.0; +/// Side of the dot marking something unread, as Mail marks unread mail. +pub const DOT: f32 = 6.0; + +/// The dot marking something unread, in the accent, floating in the box being built at +/// `position`, its top left corner. +pub fn unread_dot(ui: &mut Ui, part: impl std::hash::Hash, position: [f32; 2]) { + ui.leaf( + part, + Spec { + flags: Flags::FLOAT, + size: [px(DOT), px(DOT)], + position, + fill: Some(ui.theme.accent), + radius: DOT / 2.0, + ..Spec::default() + }, + ); +} /// How far section tabs fade out at an end of their row they are cut at, and how near it a /// dragged tab scrolls the row on. const EDGE: f32 = 20.0; @@ -36,7 +54,7 @@ const EDGE: f32 = 20.0; /// The row is as wide as the tabs, and gives up all of that where its own row lacks room. /// Then the tabs scroll sideways under the wheel, either way it turns, fading out at the /// ends they are cut at into the row's `fill`, and the open tab scrolls into view as it -/// opens. A tab given `true` after its colour reads bold, as an unread section's does. +/// opens. A tab given `true` after its colour shows the unread dot in its leading pad. #[allow(clippy::too_many_arguments)] pub fn section_tabs( ui: &mut Ui, @@ -54,10 +72,10 @@ pub fn section_tabs( let mut left = crate::SHADOW[0]; let placed: Vec<_> = tabs .iter() - .map(|&(name, _, bold)| { + .map(|&(name, ..)| { let label = ui .texts - .styled(name, ui.theme.font_size, bold, false, None, ui.frame); + .styled(name, ui.theme.font_size, false, false, None, ui.frame); let width = TAB_PAD + label.size[0] + 4.0 + lean(height); left += width; (left - width, width) @@ -144,7 +162,7 @@ pub fn section_tabs( .chain((active < tabs.len() && Some(active) != lifted).then_some(active)) .chain(lifted); for index in order { - let (name, color, bold) = tabs[index]; + let (name, color, unread) = tabs[index]; let (x, width) = placed[index]; // Keyed by name, so a tab eases from where it stood when the tabs are reordered. let key = ui.id(("x", name)); @@ -179,7 +197,6 @@ pub fn section_tabs( size: [px(width), px(tall)], position: [x - offset, height - tall], text: Some(name), - bold, color: Some(mix(mix(theme.ink, fill, 0.2), theme.ink, open)), fill: Some(if lit == Some(index) { mix(colors.frame[0], [1.0; 4], 0.08) @@ -198,6 +215,13 @@ pub fn section_tabs( ..Spec::default() }, ); + if unread { + let position = [ + x - offset + (TAB_PAD - DOT) / 2.0, + height - tall + (tall - DOT) / 2.0, + ]; + unread_dot(ui, ("unread", index), position); + } if let Some(node) = ui.access(tab_id(row, index)) { node.set_selected(index == active); } diff --git a/tools/canvas/README.md b/tools/canvas/README.md index 3505c127f11c449d47a408d95ac566745e087b76..b60fc7997d88127950ceb950f794ee250a5eb56b 100644 --- a/tools/canvas/README.md +++ b/tools/canvas/README.md @@ -52,7 +52,7 @@ snowbound title bar · toolbar · section frame · page tabs · the page as a 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`). -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`). +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`). 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. -- 2.54.0