Browse / github.com/storytold/wordcraft / patch
Navigation pane: the Pages tab shows page thumbnails, as Word's does
Navigation pane: the Pages tab shows page thumbnails, as Word's does ### What The Navigation pane's Pages tab listed "Page 1", "Page 2"… as text buttons. In Word the Pages tab (Word for Windows) and the Thumbnails view of the Navigation pane (Word for Mac) show a small picture of each page that you scroll through and click to go to. ### Change - The Pages tab draws each page as a thumbnail the pane's width (60–220 pt), its number below; a click goes to the page (`edit.goto`, as before), and the page with the caret is outlined. Each thumbnail is a button named "Page N" for screen readers, selected when it's the caret's page. - `canvas::page_thumbnail` renders
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- jpc7k1 (imported from pull request #4513, open)
- Base
d859c673c1e93bccaa6536fa3653b902ea877cea- Size
- +128 −9 in 2 files
- Review
- pending
- Shared
- 2026-10-28
- Id
39e93e988d8708f70b733815
How it went
No other operator has reported using it yet.
Diff
raw · crates/ui-egui/src/canvas.rs crates/ui-egui/src/panes.rs
@@ -37,6 +37,8 @@ const TEXTURE_KEEP_SCREENS: f32 = 3.0; pub struct CanvasState { textures: HashMap<usize, (u64, TextureHandle)>,+ /// The Navigation pane's page thumbnails, by page ([`page_thumbnail`]).+ thumbnails: HashMap<usize, (u64, TextureHandle)>, /// Visible full-resolution tiles of pages too tall for one GPU texture. tiles: HashMap<(usize, usize), (u64, TextureHandle)>, /// Page content fingerprints of the current layout, for the texture cache (#811).@@ -273,6 +275,14 @@ impl CanvasState { crate::objects::dragged_off_page(self.obj_drag.as_ref()) } + /// The pages the Navigation pane has a thumbnail of.+ #[cfg(test)]+ pub(crate) fn thumbnail_pages(&self) -> Vec<usize> {+ let mut pages: Vec<usize> = self.thumbnails.keys().copied().collect();+ pages.sort_unstable();+ pages+ }+ /// How many pages have a cached rendering. #[cfg(test)] pub(crate) fn texture_count(&self) -> usize {@@ -286,6 +296,7 @@ impl Default for CanvasState { fn default() -> Self { CanvasState { textures: HashMap::new(),+ thumbnails: HashMap::new(), tiles: HashMap::new(), page_keys: PageKeys::default(), markup_facts: std::cell::Cell::new(None),@@ -909,6 +920,46 @@ fn page_key(app: &mut WordApp, layout: &std::sync::Arc<DocLayout>, i: usize, vie h.finish() } +/// Page `i` of `layout` drawn `width` points wide, for the Navigation pane's Pages tab, from a+/// cache of its own: the page's texture is kept only near the view, at the page's zoom. A+/// thumbnail out of date (an edit, a change of view) shows until `budget` lets it be drawn again,+/// one page off the budget each, so typing in a long document waits for no thumbnails.+pub(crate) fn page_thumbnail(+ app: &mut WordApp,+ ctx: &egui::Context,+ layout: &std::sync::Arc<DocLayout>,+ i: usize,+ width: f32,+ budget: &mut usize,+) -> Option<TextureHandle> {+ let page = layout.pages.get(i)?;+ let scale_px = (width * ctx.pixels_per_point() / page.w.max(1.0)).clamp(0.02, 1.0);+ let view = view_key(app, false, None);+ let key = page_key(app, layout, i, view, scale_px);+ let fresh = app.canvas.thumbnails.get(&i).is_some_and(|(k, _)| *k == key);+ if !fresh && *budget > 0 {+ *budget -= 1;+ let img = wordcraft_render::render_page(&app.session.doc, page, scale_px, &page_render_options(app, false));+ let ci = page_image(&img);+ match app.canvas.thumbnails.get_mut(&i) {+ Some((k, tex)) => {+ tex.set(ci, egui::TextureOptions::LINEAR);+ *k = key;+ }+ None => {+ let tex = ctx.load_texture(format!("thumbnail{i}"), ci, egui::TextureOptions::LINEAR);+ app.canvas.thumbnails.insert(i, (key, tex));+ }+ }+ }+ app.canvas.thumbnails.get(&i).map(|(_, tex)| tex.clone())+}++/// Drop the thumbnails of pages past the last of `pages`.+pub(crate) fn keep_thumbnails(app: &mut WordApp, pages: usize) {+ app.canvas.thumbnails.retain(|i, _| *i < pages);+}+ fn to_screen(origin: Pos2, page_rect: Rect, scale: f32, x: f32, y: f32) -> Pos2 { pos2(origin.x + page_rect.min.x + x * scale, origin.y + page_rect.min.y + y * scale) }@@ -486,15 +486,7 @@ fn nav(app: &mut WordApp, ui: &mut Ui) { }); ui.separator(); egui::ScrollArea::vertical().show(ui, |ui| match app.ui.nav_tab.as_str() {- "pages" => {- let l = app.session.layout();- for (i, p) in l.pages.iter().enumerate() {- let label = crate::i18n::fmt(tl!("Page {page}"), &[("page", &p.number.to_string())]);- if ui.selectable_label(app.session.page_hint == i, label).clicked() {- let _ = app.run("edit.goto", json!({"page": i + 1}));- }- }- }+ "pages" => page_thumbnails(app, ui), "results" => { let results = app.session.find.results.clone(); ui.label(egui::RichText::new(format!("{} results", results.len())).small().weak());@@ -554,6 +546,51 @@ fn nav(app: &mut WordApp, ui: &mut Ui) { }); } +/// The Pages tab: a picture of each page, as Word's Pages and Thumbnails show; a click goes to+/// the page, and the page with the caret is outlined. Only thumbnails scrolled into view are+/// drawn, two a frame at most ([`crate::canvas::page_thumbnail`]).+fn page_thumbnails(app: &mut WordApp, ui: &mut egui::Ui) {+ let layout = app.session.layout();+ crate::canvas::keep_thumbnails(app, layout.pages.len());+ let width = (ui.available_width() - 32.0).clamp(60.0, 220.0);+ let mut budget = 2;+ for (i, page) in layout.pages.iter().enumerate() {+ let label = crate::i18n::fmt(tl!("Page {page}"), &[("page", &page.number.to_string())]);+ let current = app.session.page_hint == i;+ let size = egui::vec2(width, width * page.h / page.w.max(1.0));+ ui.vertical_centered(|ui| {+ let (rect, r) = ui.allocate_exact_size(size, egui::Sense::click());+ if ui.is_rect_visible(rect) {+ let painter = ui.painter();+ match crate::canvas::page_thumbnail(app, ui.ctx(), &layout, i, width, &mut budget) {+ Some(tex) => {+ painter.image(tex.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE);+ }+ None => {+ painter.rect_filled(rect, 0.0, egui::Color32::WHITE);+ }+ }+ let stroke = if current || r.hovered() {+ egui::Stroke::new(if current { 2.5 } else { 1.5 }, ui.visuals().selection.stroke.color)+ } else {+ egui::Stroke::new(1.0, ui.visuals().widgets.noninteractive.bg_stroke.color)+ };+ painter.rect_stroke(rect, 0.0, stroke, egui::StrokeKind::Outside);+ }+ r.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, current, &label));+ if r.clicked() {+ let _ = app.run("edit.goto", json!({"page": i + 1}));+ }+ ui.label(egui::RichText::new(page.number.to_string()).small().weak());+ });+ ui.add_space(6.0);+ }+ // Thumbnails left out of date wait for the next frame.+ if budget == 0 {+ ui.ctx().request_repaint();+ }+}+ /// Which headings the pane shows: those under a collapsed heading (every following heading /// of a deeper level) are hidden (#1997). fn visible_headings(levels: &[u8], collapsed: &[bool]) -> Vec<bool> {@@ -1293,6 +1330,37 @@ mod tests { assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), "typed"); } + /// The Pages tab shows each page as a picture, the caret's page outlined; a click on one goes+ /// to that page. Thumbnails are drawn a couple a frame, and only for pages in the pane's view.+ #[test]+ #[cfg(not(target_arch = "wasm32"))]+ fn pages_tab_shows_page_thumbnails_that_go_to_their_page() {+ use egui_kittest::kittest::Queryable;+ let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("One")), crate::Services::default());+ for text in ["Two", "Three"] {+ app.run("insert.pageBreak", serde_json::json!({})).unwrap();+ app.run("text.insert", serde_json::json!({"text": text})).unwrap();+ }+ app.run("view.navigationPane", serde_json::json!({"value": true})).unwrap();+ app.ui.nav_tab = "pages".into();+ let mut h = pane_harness(app);+ h.run_steps(4);+ assert_eq!(h.state_mut().session.layout().pages.len(), 3);+ let drawn = h.state().canvas.thumbnail_pages();+ assert!(drawn.starts_with(&[0, 1]), "the pages in the pane's view have their pictures: {drawn:?}");+ assert_eq!(h.state().session.page_hint, 2, "the caret is on the last page");+ assert!(h.query_by_label("Page 3").is_some());++ h.get_by_label("Page 1").click();+ h.run_steps(4);+ assert_eq!(h.state().session.page_hint, 0, "a click goes to its page");++ // An edit leaves the pictures in place until they are drawn again.+ h.state_mut().run("text.insert", serde_json::json!({"text": "Edited "})).unwrap();+ h.run_steps(4);+ assert_eq!(h.state().canvas.thumbnail_pages(), drawn);+ }+ fn pane_harness(app: crate::WordApp) -> egui_kittest::Harness<'static, crate::WordApp> { let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( |ui, app: &mut crate::WordApp| {