mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-31 21:56:50 -06:00
Merge PR #892: Pages panel spread labels
This commit is contained in:
@@ -490,6 +490,7 @@ Newest first.
|
||||
- **2026-10-20** · Drawing a frame or shape shows its width and height in the document's ruler units, to two decimals and on two lines ("W: 6.08 cm", "H: 6.08 cm"), as InDesign does; it was always whole points. No readiness percentage change.
|
||||
- **2026-10-20** · Type tool: a drag that starts in an empty frame draws a new text frame over it, as in InDesign; a click still turns the frame into a text frame. No readiness percentage change.
|
||||
- **2026-10-20** · Measures in centimetres show four decimals, as InDesign's fields do (a 1p gutter reads 0.4233 cm, not 0.423 cm). No readiness percentage change.
|
||||
- **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-21** · Properties › Character and Paragraph: ••• (More Options) expands the section in place, as InDesign's: Character adds vertical and horizontal scale, baseline shift, skew, language and OpenType, and All Caps, Small Caps, Superscript, Subscript, Underline and Strikethrough; Paragraph adds the left, right, first line and last line indents, space before and after, the drop cap's lines and characters, Align to Baseline Grid and Hyphenate. It used to open the Character or Paragraph panel; the language field moved under ••• as in InDesign. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change.
|
||||
- **2026-10-21** · The text Control panel has InDesign's two modes: A (Character Formatting Controls: font and style, size and leading, All Caps/Superscript/Underline and Small Caps/Subscript/Strikethrough, kerning and tracking, vertical and horizontal scale, baseline shift and skew, character style and language) and ¶ (Paragraph Formatting Controls: the nine alignments, left/first line and right/last line indents, space before and after, the drop cap's lines and characters, paragraph style, Hyphenate and Align to Baseline Grid). It used to show a fixed mix of both. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change.
|
||||
- **2026-10-20** · With the Type tool, the Control panel's font size, leading and tracking fields have a spinner and a list of common values, as in InDesign (auto leading in parentheses). No readiness percentage change.
|
||||
|
||||
@@ -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;
|
||||
@@ -407,6 +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::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();
|
||||
@@ -428,22 +449,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;
|
||||
@@ -464,6 +476,14 @@ 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)
|
||||
@@ -501,33 +521,29 @@ 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);
|
||||
let label = spread_label(app, &doc, first, sp.pages.len());
|
||||
// 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 badge {
|
||||
ui.painter().rect_filled(br, 0.0, BADGE);
|
||||
}
|
||||
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::Role::Button, true, &label));
|
||||
if lr.clicked() {
|
||||
if editing_parents {
|
||||
let _ = app.run("layout.parents.edit", json!({"on": false}));
|
||||
if let Some(view) = app.view_mut() {
|
||||
view.fitted = false;
|
||||
}
|
||||
}
|
||||
ui.painter().galley(c - g.size() / 2.0, g, Color32::WHITE);
|
||||
crate::canvas::go_to_page(app, first);
|
||||
}
|
||||
}
|
||||
// Right-click on the empty part of the panel: the short menu.
|
||||
@@ -780,3 +796,76 @@ pub(crate) fn drag_ghost(ui: &egui::Ui, label: &str, t: &Tokens) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod spread_label_tests {
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Create a facing-page document for section navigation tests.
|
||||
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
|
||||
}
|
||||
|
||||
/// Read the label displayed for each document spread.
|
||||
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()
|
||||
}
|
||||
|
||||
/// Section boundaries split a spread label instead of implying continuous numbering.
|
||||
#[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"]);
|
||||
}
|
||||
|
||||
/// Labels navigate to document spreads, including when parent editing was active.
|
||||
#[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::unique("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);
|
||||
// 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);
|
||||
h.state_mut().run("layout.parents.edit", json!({"on": true})).unwrap();
|
||||
if let Some(view) = h.state_mut().view_mut() {
|
||||
view.fitted = true;
|
||||
}
|
||||
h.run_steps(3);
|
||||
h.get_by_label("2–3").click();
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().session.doc().unwrap().editing_parents);
|
||||
assert!(!h.state().view().unwrap().fitted);
|
||||
assert_eq!(h.state().session.doc().unwrap().doc.page_loc(crate::canvas::current_page(h.state()).unwrap()).unwrap().0, 2);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user