TOC-5: integrate PR #892 section markers on current main

This commit is contained in:
moppeeer
2026-10-18 23:42:50 +02:00
2 changed files with 109 additions and 40 deletions
+1
View File
@@ -410,6 +410,7 @@ Newest first.
- **2026-10-15** · Flex Layout (InDesign 2026): Object › Flex Layout (Add to, Convert to, Remove) and a Flex Layout panel laid out as InDesign's, also in Properties. A container lays out its objects by direction, wrap, justification, the alignment grid, Fixed/Auto width and height, padding and spacing, after every edit; Add detects aligned objects (direction, Auto size, Space Between at their spacing); resizing a container reflows its objects instead of scaling them; dragging an object in one reorders it. Montilla Design's tutorial replays step for step. No readiness percentage change.
- **2026-10-15** · IDML: the CJK leading model InDesign writes on [No paragraph style] of every document (Aki Below) only applies when the document uses a Japanese composer, as in InDesign; a Western document's paragraphs after a size change no longer sit lower than in InDesign (Montilla Design's Flex Layout file: its captions came out overset). No readiness percentage change.
- **2026-10-15** · IDML: linked images in a subfolder beside the package load when the package is opened on another machine (a link to `/images/logo.png` finds `images/logo.png` next to the .idml); they showed as missing. No readiness percentage change.
- **2026-10-14** · The Pages panel labels spreads as ranges or separate names across a section start, marks each section’s first page with a triangle, and scrolls to the page in view (#892). No readiness percentage change.
- **2026-10-14** · Overset text is marked on every frame that ends an overset story, selected or not and inside groups too (the red mark at the out port, with frame edges shown); a selected group shows the in/out ports of the text frames inside it. InDesign shows it only on the selected frame, which hides overset labels in grouped objects until Preflight. No readiness percentage change.
- **2026-10-14** · Load Swatches adds the colors of an `.ase` file that repeat a name or have none: a repeated name gets a number (`Red 2`), and a color without a name is named by its value (`C=0 M=0 Y=0 K=55`). Before, a repeated name kept one of its colors, and only the first unnamed color was added, with an empty name. No readiness percentage change.
- **2026-10-14** · Objects snap to the bleed guide's four sides while moving, resizing or drawing; snapping and the canvas share one bleed rectangle (`Spread::bleed_rect`). No readiness percentage change.
+108 -40
View File
@@ -204,6 +204,25 @@ const THUMB_H: f32 = 37.0;
/// Parent rows.
const ROW_H: f32 = 29.0;
/// 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(),
}
}
/// A row of the Pages panel's parents list: [None] (`parent` = `None`) or a parent.
struct ParentRow {
label: String,
@@ -263,6 +282,7 @@ fn parent_icon(app: &mut DesignApp, ui: &egui::Ui, right: egui::Pos2, row: &Pare
pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let Some(st) = app.session.active() else { return };
let uid = st.uid;
let doc = st.doc.clone();
let editing_parents = st.editing_parents;
let aspect = (doc.settings.page_width / doc.settings.page_height.max(1.0)) as f32;
@@ -396,6 +416,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 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();
@@ -417,22 +438,13 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
let spine = sp.spine_x() as f32;
let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0 + row_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,
));
let key = (uid, cur);
if ui.data(|d| d.get_temp::<(u64, usize)>(followed_id)) != Some(key) {
ui.scroll_to_rect(row, None);
ui.data_mut(|d| d.insert_temp(followed_id, key));
}
}
// Pages narrower than their numbers ("10" under an Extra Small page) are labelled
// once for the spread.
let narrow = sp.pages.len() > 1
&& (first..first + sp.pages.len()).zip(&sp.pages).any(|(abs, p)| {
let w = ui.painter().layout_no_wrap(app.session.page_label(abs), crate::theme::semibold(11.0), Color32::WHITE).size().x;
(p.width as f32 * scale) < w + 6.0
});
for (pi, p) in sp.pages.iter().enumerate() {
let abs = first + pi;
let x0 = row.min.x + center + (p.x as f32 - spine) * scale;
@@ -453,6 +465,15 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
}
}
ui.painter().rect_stroke(pr, 0.0, Stroke::new(0.5, Color32::from_gray(0x4a)), StrokeKind::Outside);
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,
));
}
// 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)
@@ -490,33 +511,19 @@ 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. Pages too
// narrow for their numbers share one label under the spread (below).
if narrow {
continue;
}
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);
}
if narrow {
let names: Vec<String> = (first..first + sp.pages.len()).map(|abs| app.session.page_label(abs)).collect();
let label = match (names.first(), names.last()) {
(Some(a), Some(b)) if names.len() > 1 => format!("{a}\u{2013}{b}"),
(Some(a), _) => a.clone(),
_ => String::new(),
};
let g = ui.painter().layout_no_wrap(label, crate::theme::semibold(11.0), Color32::WHITE);
let c = egui::pos2(row.min.x + center, top + row_h + 11.0);
if cur_spread == Some(si) && !editing_parents {
ui.painter().rect_filled(egui::Rect::from_center_size(c, vec2(g.size().x + 7.0, 12.5)), 0.0, BADGE);
}
ui.painter().galley(c - g.size() / 2.0, g, Color32::WHITE);
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 + 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 {
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);
}
}
// Right-click on the empty part of the panel: the short menu.
@@ -1108,3 +1115,64 @@ mod tests {
let _ = h.get_by_label("Edit page size");
}
}
#[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);
}
}