Pages panel: spread labels and section markers as InDesign's; it follows the page in view

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 <noreply@anthropic.com>
This commit is contained in:
moppeeerandClaude Opus 5.5 committed 2026-10-14 12:22:11 +02:00
1 parent 43a46c04bf
commit c20635aacf
2 files changed
+117 -14

No files matched your search

+1
View File
@@ -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
+116 -14
View File
@@ -51,6 +51,25 @@ enum PageDrag {
Parent(Option<String>),
}
/// 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<String> = (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::<usize>(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<String> {
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::<bool>(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);
}
}