From c20635aacfcbb9c628630a065e5b8e7917aede30 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 12:22:11 +0200 Subject: [PATCH 1/4] Pages panel: spread labels and section markers as InDesign's; it follows the page in view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chris Nielsen's "Numbering Page Sections" reads the Pages panel all along: InDesign labels each spread ("2–3", "2, 1" where a section starts inside it, "8, A", "ii–iii"), puts a small triangle over each section's first page, and the panel scrolls to the page selected ("the pages panel jumps down there", 6:46). DesignCraft named each page under it ("2 1") and drew its triangle over the spread in view. - One label per spread (spread_label): a range with an en dash for pages numbered on in one section, the names apart when a section starts inside it. Positions under absolute numbering. A blue badge marks the spread in view; a click on the label goes to the spread. - A triangle above every page that starts a section (page 1 included). - The panel scrolls to the spread of the page in view when that page changes. - Page thumbnails are named for screen readers ("Page 1"). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/panels/pages.rs | 130 +++++++++++++++++++++++++---- 2 files changed, 117 insertions(+), 14 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c60a04107..4db547830 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · The Pages panel labels spreads as InDesign does ("2–3", "2, 1" across a section start, "8, A"), marks each section's first page with a triangle, and scrolls to the page in view. No readiness percentage change. - **2026-10-12** · Text threads by hand: click a selected text frame's in or out port to load the text cursor, then click a frame to thread it, click empty space for a column-wide frame or drag one (it snaps like frame drawing); Shift-click autoflows, Alt-click stays loaded on the diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index ae323225b..2fa04b9ff 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -51,6 +51,25 @@ enum PageDrag { Parent(Option), } +/// A spread's label under its thumbnails, as InDesign's: one name for a single page, "2–3" for +/// pages numbered on in one section (an en dash), and the names apart ("2, 1", "8, A") when a section starts +/// inside the spread. +pub(crate) fn spread_label(app: &DesignApp, doc: &designcraft_doc::Document, first: usize, n: usize) -> String { + let names: Vec = (first..first + n).map(|abs| app.session.page_label(abs)).collect(); + let starts = |abs: usize| abs == 0 || doc.sections.iter().any(|s| s.start == abs); + match (names.first(), names.last()) { + (Some(a), Some(b)) if names.len() > 1 => { + if !app.session.prefs.absolute_page_numbers && (first + 1..first + n).any(starts) { + names.join(", ") + } else { + format!("{a}\u{2013}{b}") + } + } + (Some(a), _) => a.clone(), + _ => String::new(), + } +} + /// Selected-page tint (multiplied over the thumbnail). const SELECTED_TINT: Color32 = Color32::from_rgb(0x88, 0xc7, 0xfb); /// Page-number badge of the selected page. @@ -112,18 +131,19 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { // ---- Document spreads. let cur = crate::canvas::current_page(app).unwrap_or(0); let cur_spread = doc.page_loc(cur).map(|l| l.0); + let followed_id = egui::Id::new("pages_panel_followed"); let center = ui.available_width() / 2.0; let dragging = egui::DragAndDrop::has_any_payload(ui.ctx()); for (si, sp) in doc.spreads.iter().enumerate() { let first = doc.first_page_of_spread(si); let scale = THUMB_H / doc.settings.page_height.max(1.0) as f32; let spine = sp.spine_x() as f32; - let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0 + THUMB_H + 22.0), Sense::hover()); + let (row, row_resp) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0 + THUMB_H + 22.0), Sense::hover()); let top = row.min.y + 8.0; - // Marker above the current spread. - if cur_spread == Some(si) && !editing_parents { - let c = egui::pos2(row.min.x + center, row.min.y + 3.5); - ui.painter().add(egui::Shape::convex_polygon(vec![c + vec2(-5.0, -3.0), c + vec2(5.0, -3.0), c + vec2(0.0, 3.0)], t.icon, Stroke::NONE)); + // The panel follows the page in view (InDesign scrolls to the selected page). + if cur_spread == Some(si) && !editing_parents && ui.data(|d| d.get_temp::(followed_id)) != Some(cur) { + row_resp.scroll_to_me(None); + ui.data_mut(|d| d.insert_temp(followed_id, cur)); } for (pi, p) in sp.pages.iter().enumerate() { let abs = first + pi; @@ -138,6 +158,22 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.painter().rect_filled(pr, 0.0, tint); } ui.painter().rect_stroke(pr, 0.0, Stroke::new(0.5, Color32::from_gray(0x4a)), StrokeKind::Outside); + // A section starts here: InDesign's small triangle above the page. + if abs == 0 || doc.sections.iter().any(|s| s.start == abs) { + let c = egui::pos2(pr.center().x, row.min.y + 4.0); + ui.painter().add(egui::Shape::convex_polygon( + vec![c + vec2(-4.5, -3.0), c + vec2(4.5, -3.0), c + vec2(0.0, 3.0)], + t.icon, + Stroke::NONE, + )); + } + resp.widget_info(|| { + egui::WidgetInfo::labeled( + egui::WidgetType::Button, + true, + format!("{} {}", crate::i18n::tr(&app.ui.language, "Page"), app.session.page_label(abs)), + ) + }); // Parent prefix in the outer top corner (black on white). if let Some(pid) = p.parent && let Some(pp) = doc.parents.iter().find(|x| x.id == pid) @@ -172,15 +208,20 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { crate::canvas::go_to_page(app, abs); } resp.context_menu(|ui| page_menu(app, ui, &doc, abs, si)); - // Page number under the page: white on a blue badge when selected. - let name = app.session.page_label(abs); - let g = ui.painter().layout_no_wrap(name, crate::theme::semibold(11.0), Color32::WHITE); - let c = egui::pos2(pr.center().x, pr.max.y + 11.0); - if selected { - let br = egui::Rect::from_center_size(c, vec2(g.size().x.max(7.0) + 7.0, 12.5)); - ui.painter().rect_filled(br, 0.0, BADGE); - } - ui.painter().galley(c - g.size() / 2.0, g, Color32::WHITE); + } + // The spread's label under its pages: white on a blue badge when it holds the page in view. + let label = spread_label(app, &doc, first, sp.pages.len()); + let g = ui.painter().layout_no_wrap(label.clone(), crate::theme::semibold(11.0), Color32::WHITE); + let c = egui::pos2(row.min.x + center, top + THUMB_H + 11.0); + let br = egui::Rect::from_center_size(c, vec2(g.size().x.max(7.0) + 7.0, 12.5)); + if cur_spread == Some(si) && !editing_parents { + ui.painter().rect_filled(br, 0.0, BADGE); + } + ui.painter().galley(c - g.size() / 2.0, g, Color32::WHITE); + let lr = ui.interact(br, ui.id().with(("spread_label", si)), Sense::click()); + lr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, &label)); + if lr.clicked() { + crate::canvas::go_to_page(app, first); } } ui.add_space(6.0); @@ -289,3 +330,64 @@ fn drag_ghost(ui: &egui::Ui, label: &str, t: &Tokens) { painter.rect(r, 2.0, t.panel_darker, Stroke::new(1.0, t.field_border), StrokeKind::Inside); painter.galley(r.min + vec2(5.0, 3.0), g, t.text_strong); } + +#[cfg(test)] +mod spread_label_tests { + use egui_kittest::{Harness, kittest::Queryable}; + use serde_json::json; + + use super::*; + + fn app() -> DesignApp { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"facingPages": true, "pages": 9})).unwrap(); + app + } + + fn labels(app: &DesignApp) -> Vec { + let doc = app.session.doc().unwrap().doc.clone(); + (0..doc.spreads.len()).map(|si| spread_label(app, &doc, doc.first_page_of_spread(si), doc.spreads[si].pages.len())).collect() + } + + #[test] + fn spreads_are_labelled_as_indesign_labels_them() { + // Chris Nielsen's "Numbering Page Sections": sections starting at 1 on pages 3 and 7, + // the second in letters, label their spreads "2, 1" and "6, A". + let mut app = app(); + assert_eq!(labels(&app), ["1", "2–3", "4–5", "6–7", "8–9"]); + app.run("layout.section", json!({"page": 3, "startNumber": 1})).unwrap(); + app.run("layout.section", json!({"page": 7, "startNumber": 1, "style": "upperLetters"})).unwrap(); + assert_eq!(labels(&app), ["1", "2, 1", "2–3", "4, A", "B–C"]); + // Absolute numbering: positions, always a range. + app.session.prefs.absolute_page_numbers = true; + assert_eq!(labels(&app), ["1", "2–3", "4–5", "6–7", "8–9"]); + } + + #[test] + fn the_panel_shows_spread_labels_and_a_click_on_one_goes_to_its_spread() { + let mut app = app(); + app.run("layout.section", json!({"page": 3, "startNumber": 1})).unwrap(); + // The canvas the panel's clicks move (not drawn here). + app.canvas_rect = Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), vec2(1000.0, 800.0))); + let mut h = Harness::builder().with_size(vec2(260.0, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + egui::ScrollArea::vertical().show(ui, |ui| show(app, ui)); + }, + app, + ); + h.run_steps(3); + let _ = h.get_by_label("2, 1"); + assert!(h.query_by_label("2 1").is_none()); + // Each page is named for screen readers: two pages are numbered 1. + assert_eq!(h.query_all_by_label("Page 1").count(), 2); + h.get_by_label("2–3").click(); + h.run_steps(3); + assert_eq!(h.state().session.doc().unwrap().doc.page_loc(crate::canvas::current_page(h.state()).unwrap()).unwrap().0, 2); + } +} From 7200e90615e9bfc36c5e16341ed66440be0786a3 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 15:55:23 +0200 Subject: [PATCH 2/4] PR-892: spread labels read on a light panel and are buttons for screen readers Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/pages.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 6221e940d..5f9a7aec6 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -523,15 +523,19 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { resp.context_menu(|ui| page_menu(app, ui, &doc, abs, si)); } let label = spread_label(app, &doc, first, sp.pages.len()); - let g = ui.painter().layout_no_wrap(label.clone(), crate::theme::semibold(11.0), Color32::WHITE); + // White on the badge; the theme's text colour elsewhere, so it reads on a light panel. + let badge = cur_spread == Some(si) && !editing_parents; + let ink = if badge { Color32::WHITE } else { t.text_strong }; + let g = ui.painter().layout_no_wrap(label.clone(), crate::theme::semibold(11.0), ink); let c = egui::pos2(row.min.x + center, top + row_h + 11.0); let br = egui::Rect::from_center_size(c, vec2(g.size().x + 7.0, 12.5)); - if cur_spread == Some(si) && !editing_parents { + if badge { ui.painter().rect_filled(br, 0.0, BADGE); } - ui.painter().galley(c - g.size() / 2.0, g, Color32::WHITE); + ui.painter().galley(c - g.size() / 2.0, g, ink); let lr = ui.interact(br, ui.id().with(("spread_label", si)), Sense::click()); - lr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, &label)); + // A click goes to the spread, so assistive technology hears a button. + lr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &label)); if lr.clicked() { if editing_parents { let _ = app.run("layout.parents.edit", json!({"on": false})); @@ -1211,7 +1215,8 @@ mod spread_label_tests { app, ); h.run_steps(3); - let _ = h.get_by_label("2, 1"); + // A spread label is a button: a click goes to the spread. + let _ = h.get_by(|n| n.role() == egui::accesskit::Role::Button && n.label().as_deref() == Some("2, 1")); assert!(h.query_by_label("2 1").is_none()); // Each page is named for screen readers: two pages are numbered 1. assert_eq!(h.query_all_by_label("Page 1").count(), 2); From 5f96d2b3ffa3410695a0fff0bbe0d3029cdc4345 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:14:53 +0100 Subject: [PATCH 3/4] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/pages.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index ae951787c..10a53e175 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -533,7 +533,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.painter().rect_filled(br, 0.0, BADGE); } ui.painter().galley(c - g.size() / 2.0, g, ink); - let lr = ui.interact(br, ui.id().with(("spread_label", si)), Sense::click()); + let lr = ui.interact(br, ui.scope_id().with(("spread_label", si)), Sense::click()); // A click goes to the spread, so assistive technology hears a button. lr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &label)); if lr.clicked() { From b348eb4efb9545e8ead9026c6e1ca0a41ef77d72 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 18:06:18 +0100 Subject: [PATCH 4/4] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/pages.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 10a53e175..176daaa00 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -427,7 +427,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { // ---- Document spreads. let cur = crate::canvas::current_page(app).unwrap_or(0); let cur_spread = doc.page_loc(cur).map(|l| l.0); - let followed_id = egui::Id::new("pages_panel_followed"); + let followed_id = egui::Id::unique("pages_panel_followed"); let dragging = egui::DragAndDrop::has_any_payload(ui.ctx()); let tallest = doc.spreads.iter().flat_map(|sp| sp.pages.iter().map(|p| p.height)).fold(doc.settings.page_height, f64::max).max(1.0); let th = opts.page_size.page_h(); @@ -535,7 +535,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.painter().galley(c - g.size() / 2.0, g, ink); let lr = ui.interact(br, ui.scope_id().with(("spread_label", si)), Sense::click()); // A click goes to the spread, so assistive technology hears a button. - lr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &label)); + lr.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label)); if lr.clicked() { if editing_parents { let _ = app.run("layout.parents.edit", json!({"on": false})); @@ -841,7 +841,7 @@ mod spread_label_tests { app.canvas_rect = Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), vec2(1000.0, 800.0))); let mut h = Harness::builder().with_size(vec2(260.0, 900.0)).build_ui_state( |ui, app: &mut DesignApp| { - let key = egui::Id::new("test_fonts"); + let key = egui::Id::unique("test_fonts"); if ui.data(|d| d.get_temp::(key)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(key, true));