From 4bc6f8a730f1f12321f5a442b94f30513d7a3f93 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 25 Oct 2026 17:37:35 -0700 Subject: [PATCH 1/9] =?UTF-8?q?Tables:=20Table=20=E2=80=BA=20Go=20to=20Row?= =?UTF-8?q?=E2=80=A6=20selects=20a=20row=20by=20its=20number,=20as=20in=20?= =?UTF-8?q?InDesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InDesign's Table menu has Go to Row…, which jumps to a row given its number. When the table has header or footer rows, the user chooses Header or Footer from a menu. DesignCraft had no such command. table.goToRow {row, section?} selects the row, counted from 1 within its section, and reports the page that shows it. Table › Go to Row… opens a dialog on the selected row; OK selects the row typed and moves the view to its page when that page is not the one at the center of the view. A row past the section's last row, or a section the table does not have, is refused and the dialog stays open with the reason. --- ROADMAP.md | 1 + crates/engine/src/cmd/table.rs | 135 +++++++++++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 104 ++++++++++++++++++++++++ crates/ui-egui/src/i18n.rs | 2 + crates/ui-egui/src/i18n/ar.rs | 2 + crates/ui-egui/src/i18n/cs.rs | 2 + crates/ui-egui/src/i18n/de.rs | 2 + crates/ui-egui/src/i18n/it.rs | 2 + crates/ui-egui/src/i18n/ja.rs | 2 + crates/ui-egui/src/i18n/ka.rs | 2 + crates/ui-egui/src/i18n/pl.rs | 2 + crates/ui-egui/src/i18n/pt_br.rs | 2 + crates/ui-egui/src/i18n/ru.rs | 2 + crates/ui-egui/src/i18n/uk.rs | 2 + crates/ui-egui/src/menus.rs | 6 ++ docs/gaps.md | 11 +-- 16 files changed, 272 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6a3696361..8f466b9bc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -403,6 +403,7 @@ Newest first. - **2026-10-24** · Paragraph Style Options › Indents and Spacing has an Align to Grid menu: None, All Lines or First Line Only. It shows the style's current setting. A First Line Only style could be set only in the CJK Grid Settings section, which shows only with CJK features on, or through commands. No readiness percentage change. - **2026-10-24** · Anchored text frames, such as marginal notes, compose where their host story places them: they align to the baseline grid at that position, and page-number markers and spine-relative alignment inside them read the host's page. They composed against a grid measured from their stored position and on no page, so a note set to Only Align First Line to Grid could sit a fraction of a line off the body text's grid line. No readiness percentage change. - **2026-10-24** · Tables: a gradient swatch in a cell fill draws as a gradient on the canvas and in PDF, in one gradient space for the whole table, so it runs across cells without restarting. Cells showed the gradient's first stop as a solid colour. No readiness percentage change. +- **2026-10-25** · Table › Go to Row… (`table.goToRow {row, section?}`): as in InDesign, a dialog takes a row number and, when the table has header or footer rows, the section is chosen from a menu. The menu lists Body Rows and those of Header Rows and Footer Rows that the table has. DesignCraft counts the row from 1 within its section and selects it, and the view moves to the row's page when that page is not the one at the center of the view. A row number past the section's last row is refused. No readiness percentage change. - **2026-10-24** · New documents start with Hyphenate Capitalized Words, Hyphenate Last Word and Hyphenate Across Column off in [Basic Paragraph], as in InDesign. Opened and imported files keep the values they carry. No readiness percentage change. - **2026-10-24** · The baseline grid's Relative To setting applies: with Top Margin, the grid starts at each page's own top margin in text composition, on the canvas and for snapping. It started at the page top, so grid-aligned text sat on the wrong lines and a page could fit one line fewer. No readiness percentage change. - **2026-10-24** · Custom-positioned anchored objects honour Relative to Spine (`anchored.options {spineRelative}`, IDML `SpineRelative`): the settings describe a left-hand page and mirror on a right-hand page, so marginal notes stay in the outside margin on both pages of a spread. On right-hand pages they took the left-hand placement and landed in the inside margin, across the spine or over the body text. No readiness percentage change. diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 54d8e5a41..881487629 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -401,6 +401,15 @@ fn table_specs() -> Vec { cmd!(noundo "table.selectColumn", "Select Column", ["Table", "Select"], None, "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", in_table, |s, p| { select(s, &super::with_param(p, "what", json!("column"))) }), + cmd!( + noundo "table.goToRow", + "Go to Row…", + ["Table"], + None, + "{row: number (from 1, counted within the section), section?: header|body|footer (default: body), story?, table?}: selects the row → {story, table, range, page: the page showing the row (from 1), or null when the row is overset}", + in_table, + go_to_row + ), cmd!(noundo "table.nextCell", "Next Cell", [], None, "{}", in_cell, |s, _| step_cell(s, true)), cmd!(noundo "table.prevCell", "Previous Cell", [], None, "{}", in_cell, |s, _| step_cell(s, false)), cmd!(query "table.get", "Get Table", [], None, "{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?} → rows, columns, cells (text, spans, fill), options", in_table_or_ids, get), @@ -1531,6 +1540,54 @@ fn select(s: &mut Session, p: &Value) -> Result { Ok(json!({"story": g.story.0, "table": g.table, "range": r})) } +/// Table › Go to Row…: select row `row` (from 1) of the header, body or footer rows. +fn go_to_row(s: &mut Session, p: &Value) -> Result { + const ID: &str = "table.goToRow"; + // Only the table comes from `p`: `row` here counts within a section, not from the table's top. + let mut at = serde_json::Map::new(); + for key in ["story", "table"] { + if let Some(v) = p.get(key) { + at.insert(key.into(), v.clone()); + } + } + let g = target(s, &Value::Object(at), ID)?; + let n = p.get("row").and_then(Value::as_u64).filter(|&n| n >= 1).ok_or_else(|| bad(ID, "row must be a whole number from 1"))?; + let (index, owners, nc) = { + let st = s.doc()?; + let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?; + let (h, f, nr) = (t.header_rows(), t.footer_rows(), t.nrows()); + let (section, first, count) = match str_param(p, "section").unwrap_or("body") { + "header" => ("header", 0, h), + "body" => ("body", h, nr.saturating_sub(h + f)), + "footer" => ("footer", nr.saturating_sub(f), f), + other => return Err(bad(ID, format!("unknown section `{other}` (header, body or footer)"))), + }; + if count == 0 { + return Err(bad(ID, format!("the table has no {section} rows"))); + } + let n = usize::try_from(n).unwrap_or(usize::MAX); + if n > count { + return Err(bad(ID, format!("row {n} is past the last of the table's {count} {section} rows"))); + } + (first + n - 1, t.owners(), t.ncols()) + }; + let r = select(s, &json!({"story": g.story.0, "table": g.table, "rows": [index, index], "what": "row"}))?; + // The page of the frame that shows the row: the cells that start in it or reach it from above. + let start = index.saturating_mul(nc); + let cells: Vec<(usize, usize)> = owners.get(start..start.saturating_add(nc)).map(<[_]>::to_vec).unwrap_or_default(); + let st = s.doc()?; + let cs = s.cache.get(&st.doc, g.story, None); + let frame = cs.frames.iter().find_map(|ft| { + ft.tables + .iter() + .filter(|t| t.table == g.table) + .any(|t| t.cells.iter().any(|c| !c.repeated && cells.contains(&(c.row, c.col)))) + .then_some(ft.frame) + }); + let page = frame.and_then(|f| st.doc.page_of_item(f)).map(|p| p + 1); + Ok(json!({"story": r["story"], "table": r["table"], "range": r["range"], "page": page})) +} + /// Move the caret to the next/previous cell (Tab / Shift-Tab), selecting its text. Tab in the last /// cell adds a row. pub(crate) fn step_cell(s: &mut Session, forward: bool) -> Result { @@ -2321,6 +2378,84 @@ mod sort_tests { } } +#[cfg(test)] +mod go_to_row_tests { + use designcraft_doc::CellRange; + use serde_json::json; + + use crate::Session; + + /// A table of 1 header, 3 body and 1 footer rows, 2 columns wide, with the caret in its first cell. + fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 700], "content": "text", "text": ""})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 3, "cols": 2, "headerRows": 1, "footerRows": 1})).unwrap(); + s + } + + fn selected(s: &Session) -> CellRange { + s.doc().unwrap().selection.cells.unwrap().range + } + + #[test] + fn go_to_row_selects_a_row_counted_within_its_section() { + let mut s = session(); + let r = s.execute("table.goToRow", &json!({"row": 2})).unwrap(); + assert_eq!(selected(&s), CellRange { r0: 2, c0: 0, r1: 2, c1: 1 }, "the second body row, under the header row"); + assert_eq!(r["page"], 1); + s.execute("table.goToRow", &json!({"row": 1, "section": "header"})).unwrap(); + assert_eq!(selected(&s), CellRange { r0: 0, c0: 0, r1: 0, c1: 1 }); + s.execute("table.goToRow", &json!({"row": 1, "section": "footer"})).unwrap(); + assert_eq!(selected(&s), CellRange { r0: 4, c0: 0, r1: 4, c1: 1 }); + s.execute("table.goToRow", &json!({"row": 3, "section": "body"})).unwrap(); + assert_eq!(selected(&s), CellRange { r0: 3, c0: 0, r1: 3, c1: 1 }); + // The caret goes to the row's first cell. + assert_eq!(s.doc().unwrap().selection.text.and_then(|t| t.cell).map(|c| (c.row, c.col)), Some((3, 0))); + } + + #[test] + fn go_to_row_refuses_rows_outside_the_section() { + let mut s = session(); + s.execute("table.goToRow", &json!({"row": 1})).unwrap(); + let before = selected(&s); + for p in [ + json!({"row": 4}), + json!({"row": 0}), + json!({"row": -1}), + json!({"row": "2"}), + json!({}), + json!({"row": 2, "section": "footer"}), + json!({"row": 1, "section": "side"}), + ] { + assert!(s.execute("table.goToRow", &p).is_err(), "{p}"); + assert_eq!(selected(&s), before, "{p} leaves the selection"); + } + s.execute("table.options", &json!({"headerRows": 0, "footerRows": 0})).unwrap(); + assert!(s.execute("table.goToRow", &json!({"row": 1, "section": "header"})).is_err()); + assert!(s.execute("table.goToRow", &json!({"row": 1, "section": "footer"})).is_err()); + } + + #[test] + fn go_to_row_names_the_page_that_shows_the_row() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": ""})).unwrap(); + let b = s.execute("frame.create", &json!({"spread": 1, "rect": [72, 72, 400, 600], "content": "unassigned"})).unwrap(); + s.execute("text.thread", &json!({"from": a["id"], "to": b["id"]})).unwrap(); + s.execute("text.select", &json!({"story": a["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 20, "cols": 2, "headerRows": 1})).unwrap(); + for row in 0..21 { + s.execute("table.setRowHeight", &json!({"row": row, "height": 20, "mode": "exactly"})).unwrap(); + } + assert_eq!(s.execute("table.goToRow", &json!({"row": 1})).unwrap()["page"], 1); + assert_eq!(s.execute("table.goToRow", &json!({"row": 20})).unwrap()["page"], 2); + // The header row repeats on page 2; the row itself is the one on page 1. + assert_eq!(s.execute("table.goToRow", &json!({"row": 1, "section": "header"})).unwrap()["page"], 1); + } +} + #[cfg(test)] mod arabic_tests { use super::*; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e93e64679..cf384faf6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1005,6 +1005,52 @@ fn doc_unit(app: &DesignApp) -> Unit { app.session.active().map(|s| s.doc.settings.horizontal_units).unwrap_or(Unit::Picas) } +/// Table › Go to Row…: opens on the target table's first selected row, numbered within its header, +/// body or footer rows. +pub fn open_go_to_row(app: &mut DesignApp) { + let Ok(t) = app.run("table.get", json!({})) else { return }; + let (h, f, r) = (count_at(&t, "headerRows"), count_at(&t, "footerRows"), count_at(&t["range"], "r0")); + let nr = t["rows"].as_array().map_or(0, Vec::len); + let (section, n) = if r < h { + ("header", r + 1) + } else if r >= nr.saturating_sub(f) { + ("footer", r + 1 - nr.saturating_sub(f)) + } else { + ("body", r + 1 - h) + }; + app.ui.dialog = Some(Dialog::new( + "goToRow", + json!({"story": t["story"], "table": t["table"], "section": section, "row": n.to_string(), "hasHeader": h > 0, "hasFooter": f > 0}), + )); +} + +fn count_at(v: &Value, k: &str) -> usize { + v[k].as_u64().and_then(|n| usize::try_from(n).ok()).unwrap_or(0) +} + +/// The Go to Row dialog: the section menu shows when the table has header or footer rows. +fn go_to_row_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let tr = |s: &'static str| crate::i18n::tr(&app.ui.language, s); + ui.horizontal(|ui| { + if d.b("hasHeader") || d.b("hasFooter") { + let mut opts = Vec::new(); + if d.b("hasHeader") { + opts.push(("header", tr("Header Rows"))); + } + opts.push(("body", tr("Body Rows"))); + if d.b("hasFooter") { + opts.push(("footer", tr("Footer Rows"))); + } + combo(ui, d, "section", &opts); + } + crate::rtl::label(ui, tr("Row")); + text_field_named(ui, d, "row", 60.0, tr("Row")); + }); + if let Some(st) = d.fields.get("status").and_then(Value::as_str) { + ui.label(egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); + } +} + /// Layout › Create Guides…: guides go on the page in view (or the parent page being edited), not /// on the first page, and the gutters are typed in the document's ruler unit. pub fn open_create_guides(app: &mut DesignApp) { @@ -2266,6 +2312,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "ruby" => crate::i18n::tr(&app.ui.language, "Ruby"), "newDocument" => crate::i18n::tr(&app.ui.language, "New Document"), "frameSize" => crate::i18n::tr(&app.ui.language, "Rectangle"), + "goToRow" => crate::i18n::tr(&app.ui.language, "Go to Row"), "goToPage" => crate::i18n::tr(&app.ui.language, "Go to Page"), "insertTable" => crate::i18n::tr(&app.ui.language, "Create Table"), "cellOptions" => crate::i18n::tr(&app.ui.language, "Cell Options"), @@ -3027,6 +3074,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { text_field(ui, &mut d, "page", 80.0); }); } + "goToRow" => go_to_row_dialog(app, ui, &mut d), "ruby" => style_cjk::ruby_dialog(app, ui, &mut d), "insertTable" => { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Table Dimensions")).font(semibold(12.0))); @@ -3342,6 +3390,29 @@ pub fn confirm(app: &mut DesignApp) -> Result { ) } "ruby" => app.run("type.ruby", style_cjk::ruby_params(&d)), + "goToRow" => { + // Anything but a whole number goes as typed, and the command's message names the fault. + let typed = d.s("row").trim().to_string(); + let row = typed.parse::().map_or_else(|_| json!(typed), |n| json!(n)); + let p = json!({"story": d.fields.get("story"), "table": d.fields.get("table"), "section": d.s("section"), "row": row}); + let r = app.run("table.goToRow", p); + match &r { + Ok(v) => { + // The view moves when the row's page is not the page at the center of the view. + if let Some(page) = v["page"].as_u64().and_then(|p| usize::try_from(p).ok()).and_then(|p| p.checked_sub(1)) + && crate::canvas::current_page(app) != Some(page) + { + crate::canvas::go_to_page(app, page); + } + } + Err(e) => { + let mut d = d.clone(); + d.fields.insert("status".into(), json!(e)); + app.ui.dialog = Some(d); + } + } + r + } "goToPage" => { // A page name ("iv", "A-3"), a number, or "+n" for an absolute position. let typed = match d.fields.get("page") { @@ -10930,6 +11001,39 @@ mod tests { assert_eq!(guide_counts(&app), vec![0, 2, 2, 0, 4, 0]); } + #[test] + fn go_to_row_dialog_selects_the_row_and_shows_its_page() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 2, "facingPages": false})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))); + let a = app.run("frame.create", json!({"rect": [72, 72, 400, 200], "content": "text", "text": ""})).unwrap(); + let b = app.run("frame.create", json!({"spread": 1, "rect": [72, 72, 400, 600], "content": "unassigned"})).unwrap(); + app.run("text.thread", json!({"from": a["id"], "to": b["id"]})).unwrap(); + app.run("text.select", json!({"story": a["story"], "anchor": 0, "focus": 0})).unwrap(); + app.run("table.insert", json!({"rows": 20, "cols": 2, "headerRows": 1})).unwrap(); + for row in 0..21 { + app.run("table.setRowHeight", json!({"row": row, "height": 20, "mode": "exactly"})).unwrap(); + } + app.run("table.select", json!({"rows": [2, 2]})).unwrap(); + crate::canvas::go_to_page(&mut app, 0); + crate::menus::activate(&mut app, "table.goToRow", &Value::Null); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!(d.id, "goToRow"); + assert_eq!((d.s("section"), d.s("row")), ("body".to_string(), "2".to_string()), "it opens on the selected row"); + assert!(d.b("hasHeader") && !d.b("hasFooter")); + // A row that is not a whole number keeps the dialog open with the reason, and the view stays. + ctl(&mut app, "ui.dialog.set", json!({"field": "row", "value": "x"})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false); + assert!(app.ui.dialog.as_ref().is_some_and(|d| d.id == "goToRow" && d.fields.contains_key("status"))); + assert_eq!(crate::canvas::current_page(&app), Some(0)); + ctl(&mut app, "ui.dialog.set", json!({"field": "row", "value": "20"})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + assert!(app.ui.dialog.is_none()); + let range = app.session.active().unwrap().selection.cells.unwrap().range; + assert_eq!((range.r0, range.r1, range.c0, range.c1), (20, 20, 0, 1)); + assert_eq!(crate::canvas::current_page(&app), Some(1), "the view goes to the page that shows the row"); + } + #[test] fn parent_dialogs_cover_both_pages_of_the_parent_spread_by_default() { let mut app = facing_app(4); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index d1b462ab5..b1c001457 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1104,6 +1104,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Generate QR Code", ["Generate QR Code", "Générer un code QR", "Generate QR Code", "Generate QR Code", "生成二维码"]), ("Go To", ["Go To", "Atteindre", "Go To", "Go To", "转到"]), ("Go to Page", ["Go to Page", "Atteindre la page", "Go to Page", "Go to Page", "转到页面"]), + ("Go to Row", ["Go to Row", "Atteindre la ligne", "Go to Row", "Go to Row", "转到行"]), ("Gutter", ["Gutter", "Gouttière", "Gutter", "Gutter", "栏间距"]), ("Header Rows", ["Header Rows", "Lignes d'en-tête", "Header Rows", "Header Rows", "表头行"]), ("Height", ["Height", "Hauteur", "Height", "Height", "高度"]), @@ -2082,6 +2083,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ], ), ("Go to Page…", ["Go to Page…", "Atteindre la page…", "Go to Page…", "Go to Page…", "转到页面…"]), + ("Go to Row…", ["Go to Row…", "Atteindre la ligne…", "Go to Row…", "Go to Row…", "转到行…"]), ("Gradient Feather", ["Gradient Feather", "Contour progressif dégradé", "Gradient Feather", "Gradient Feather", "渐变羽化"]), ("Gridify", ["Gridify", "Mettre en grille", "Gridify", "Gridify", "网格化"]), ("Group Transparency", ["Group Transparency", "Transparence du groupe", "Group Transparency", "Group Transparency", "编组透明度"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index f1dbe0acd..ec9d19fb2 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -203,6 +203,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Numbering & Section Options…", "خيارات الترقيم والأقسام…"), ("Go to Page…", "انتقال إلى صفحة…"), ("Go to Page", "انتقال إلى صفحة"), + ("Go to Row…", "انتقال إلى صف…"), + ("Go to Row", "انتقال إلى صف"), ("Next Page", "الصفحة التالية"), ("Previous Page", "الصفحة السابقة"), ("First Page", "الصفحة الأولى"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 2969d4cff..a33caff35 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -578,6 +578,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Go To Source", "Přejít na zdroj"), ("Go to Footnote Reference", "Přejít na odkaz poznámky pod čarou"), ("Go to Page…", "Přejít na stránku…"), + ("Go to Row…", "Přejít na řádek…"), + ("Go to Row", "Přejít na řádek"), ("Gradient Feather", "Přechodové zjemnění okraje"), ("Gridify", "Rozmístit do mřížky"), ("Group Transparency", "Průhlednost skupiny"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index aaf49bdb3..62905e04c 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -278,6 +278,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Generate QR Code", "QR-Code erzeugen"), ("Go To", "Gehe zu"), ("Go to Page", "Gehe zu Seite"), + ("Go to Row", "Gehe zu Zeile"), ("Gutter", "Spaltenabstand"), ("Header Rows", "Tabellenkopfzeilen"), ("Height", "Höhe"), @@ -615,6 +616,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Go To Source", "Gehe zu Quelle"), ("Go to Footnote Reference", "Gehe zu Fußnotenverweis"), ("Go to Page…", "Gehe zu Seite …"), + ("Go to Row…", "Gehe zu Zeile …"), ("Gradient Feather", "Weiche Verlaufskante"), ("Gridify", "Rastern"), ("Group Transparency", "Gruppentransparenz"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index db671bc83..170cef352 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -706,6 +706,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Go To Source", "Vai all'origine"), ("Go to Footnote Reference", "Vai al riferimento della nota a piè di pagina"), ("Go to Page…", "Vai alla pagina…"), + ("Go to Row…", "Vai alla riga…"), + ("Go to Row", "Vai alla riga"), ("Gradient Feather", "Bordi sfocati con sfumatura"), ("Gridify", "Disponi a griglia"), ("Group Transparency", "Trasparenza gruppo"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 01814f271..c5c988f94 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -463,6 +463,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Generate QR Code", "QR コードを生成"), ("Go To", "移動"), ("Go to Page", "ページへ移動"), + ("Go to Row", "行へ移動"), ("Gutter", "段間"), ("Header Rows", "ヘッダー行"), ("Height", "高さ"), @@ -807,6 +808,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Go To Source", "リンク元へ移動"), ("Go to Footnote Reference", "脚注参照番号へ移動"), ("Go to Page…", "ページへ移動…"), + ("Go to Row…", "行へ移動…"), ("Gradient Feather", "グラデーションぼかし"), ("Gridify", "グリッド化"), ("Group Transparency", "グループの透明"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index e4136880b..be140a61a 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -634,6 +634,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Go To Source", "წყაროზე გადასვლა"), ("Go to Footnote Reference", "სქოლიოს მითითებაზე გადასვლა"), ("Go to Page…", "გვერდზე გადასვლა…"), + ("Go to Row…", "სტრიქონზე გადასვლა…"), + ("Go to Row", "სტრიქონზე გადასვლა"), ("Gradient Feather", "კიდეების გრადიენტული შერბილება"), ("Gridify", "ბადედ განლაგება"), ("Group Transparency", "ჯგუფის გამჭვირვალობა"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index bd3d96b9a..36a56e169 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -585,6 +585,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Go To Source", "Przejdź do źródła"), ("Go to Footnote Reference", "Przejdź do odwołania przypisu"), ("Go to Page…", "Przejdź do strony…"), + ("Go to Row…", "Przejdź do wiersza…"), + ("Go to Row", "Przejdź do wiersza"), ("Gradient Feather", "Wtapianie gradientowe"), ("Gridify", "Rozmieść w siatce"), ("Group Transparency", "Przezroczystość grupy"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index d8125cfe4..367022022 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -208,6 +208,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Create Guides…", "Criar guias…"), ("Numbering & Section Options…", "Opções de numeração e de seção…"), ("Go to Page…", "Ir para página…"), + ("Go to Row…", "Ir para linha…"), + ("Go to Row", "Ir para linha"), ("Go to Page", "Ir para página"), ("Next Page", "Próxima página"), ("Previous Page", "Página anterior"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 5e8b58806..c13694de9 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -565,6 +565,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Go To Source", "Перейти к источнику"), ("Go to Footnote Reference", "Перейти к ссылке на сноску"), ("Go to Page…", "Перейти к странице…"), + ("Go to Row…", "Перейти к строке…"), + ("Go to Row", "Перейти к строке"), ("Gradient Feather", "Градиентная растушевка"), ("Gridify", "Разместить в виде сетки"), ("Group Transparency", "Прозрачность группы"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index f0cf41017..237067ed3 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -709,6 +709,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Go To Source", "Перейти до джерела"), ("Go to Footnote Reference", "Перейти до посилання на виноску"), ("Go to Page…", "Перейти до сторінки…"), + ("Go to Row…", "Перейти до рядка…"), + ("Go to Row", "Перейти до рядка"), ("Gradient Feather", "Градієнтне розтушовування"), ("Gridify", "Розмістити сіткою"), ("Group Transparency", "Прозорість групи"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e3226044b..58c9a24a0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -818,6 +818,8 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:table.distributeColumns", "cmd:table.distributeRows", "-", + "cmd:table.goToRow", + "-", "ui:app.tablePanel", ], ), @@ -2662,6 +2664,10 @@ fn activate_inner(app: &mut DesignApp, id: &str, params: &Value) { app.ui.dialog = Some(crate::dialogs::Dialog::new("textFrameOptions", json!({}))); return; } + if params.is_null() && id == "table.goToRow" { + crate::dialogs::open_go_to_row(app); + return; + } // New Paragraph/Character Style…: the style options for the new style. if params.is_null() && matches!(id, "style.paragraph.create" | "style.character.create") { crate::dialogs::open_new_style(app, id == "style.paragraph.create"); diff --git a/docs/gaps.md b/docs/gaps.md index ea5b70840..85274ecd4 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -150,8 +150,8 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · ### 12. Tables in depth (F) - **Missing:** anchored objects in cells not drawn (#163); Table ▸ Table Options and Cell Options - submenus as InDesign lays them out, Diagonal Lines, Edit Header/Footer, - Go to Row; general reliability (#20). + submenus as InDesign lays them out, Diagonal Lines, Edit Header/Footer; general reliability + (#20). - **Estimate:** 25–40 h. ### 13. Hyphenation and spelling languages (T, L) @@ -177,13 +177,10 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · - **Estimate:** 30–50 h. ### 16. Menu items still missing (U) -- **Missing:** 149 of 482 in-scope items of InDesign 21.6's menu dump have no match (measured 2026-10-10; some exist under other labels): Type ▸ Notes -- **Missing:** 150 of 482 in-scope items of InDesign 21.6's menu dump have no match (measured 2026-10-10; some exist under other labels): Type ▸ Notes +- **Missing:** 147 of 482 in-scope items of InDesign 21.6's menu dump have no match (measured 2026-10-10; some exist under other labels): Type ▸ Notes and Track Changes submenus (the panels and commands exist), hyperlink and cross-reference - update commands, Convert Bullets/Numbering to Text, Convert Variable to Text, Object ▸ Clear - Effects / Clear All Transparency / Rotate 180° / Reverse Path, update commands, Convert Bullets/Numbering to Text, Object ▸ Clear - Effects / Clear All Transparency / Rotate 180° / Reverse Path, Layout ▸ Go Back/Forward, + Effects / Clear All Transparency / Rotate 180°, Window ▸ Arrange (2-up … 6-up), Trap Presets, Overlays, Edit ▸ Assign Profiles / Convert to Profile / Edit Original, File ▸ Open PDF / Document and Print Presets / User Settings, Type ▸ Size presets, Table ▸ Table Options / Cell Options submenus, Paste Before/After, From 3b2373bf8d8a09c4d210d943d452280ea6f26f88 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 25 Oct 2026 21:10:04 -0700 Subject: [PATCH 2/9] Tables: Go to Row runs on a named table and stays with its document table.goToRow given a story and table now selects the row when the selection is outside a table, as table.select does. The Table menu item still needs the selection in a table. The Go to Row dialog keeps the document it was opened for. If another document became active before OK, that document becomes active again and the row is selected there; if it has closed, OK selects nothing. --- crates/engine/src/cmd/table.rs | 13 ++++++++- crates/ui-egui/src/dialogs.rs | 48 +++++++++++++++++++++++++++++++++- crates/ui-egui/src/menus.rs | 15 +++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 881487629..c84d370c8 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -407,7 +407,7 @@ fn table_specs() -> Vec { ["Table"], None, "{row: number (from 1, counted within the section), section?: header|body|footer (default: body), story?, table?}: selects the row → {story, table, range, page: the page showing the row (from 1), or null when the row is overset}", - in_table, + in_table_or_ids, go_to_row ), cmd!(noundo "table.nextCell", "Next Cell", [], None, "{}", in_cell, |s, _| step_cell(s, true)), @@ -2437,6 +2437,17 @@ mod go_to_row_tests { assert!(s.execute("table.goToRow", &json!({"row": 1, "section": "footer"})).is_err()); } + #[test] + fn go_to_row_selects_a_row_of_a_named_table_outside_the_selection() { + let mut s = session(); + let t = s.execute("table.get", &json!({})).unwrap(); + let other = s.execute("frame.create", &json!({"rect": [72, 720, 500, 760], "content": "text", "text": "Notes"})).unwrap(); + s.execute("text.select", &json!({"story": other["story"], "anchor": 0, "focus": 0})).unwrap(); + assert!(s.execute("table.goToRow", &json!({"row": 2})).is_err(), "no table named and none selected"); + s.execute("table.goToRow", &json!({"story": t["story"], "table": t["table"], "row": 2})).unwrap(); + assert_eq!(selected(&s), CellRange { r0: 2, c0: 0, r1: 2, c1: 1 }); + } + #[test] fn go_to_row_names_the_page_that_shows_the_row() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index cf384faf6..e62249b26 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1020,7 +1020,15 @@ pub fn open_go_to_row(app: &mut DesignApp) { }; app.ui.dialog = Some(Dialog::new( "goToRow", - json!({"story": t["story"], "table": t["table"], "section": section, "row": n.to_string(), "hasHeader": h > 0, "hasFooter": f > 0}), + json!({ + "uid": app.session.active().map(|doc| doc.uid), + "story": t["story"], + "table": t["table"], + "section": section, + "row": n.to_string(), + "hasHeader": h > 0, + "hasFooter": f > 0 + }), )); } @@ -3391,6 +3399,13 @@ pub fn confirm(app: &mut DesignApp) -> Result { } "ruby" => app.run("type.ruby", style_cjk::ruby_params(&d)), "goToRow" => { + // The story and table belong to the document the dialog was opened for: that document + // becomes active first, and when it has closed, nothing is selected. + let uid = d.fields.get("uid").and_then(Value::as_u64); + if uid.is_some() && uid != app.session.active().map(|doc| doc.uid) { + let Some(i) = app.session.documents().iter().position(|doc| Some(doc.uid) == uid) else { return Ok(Value::Null) }; + app.run("file.activate", json!({"index": i}))?; + } // Anything but a whole number goes as typed, and the command's message names the fault. let typed = d.s("row").trim().to_string(); let row = typed.parse::().map_or_else(|_| json!(typed), |n| json!(n)); @@ -11034,6 +11049,37 @@ mod tests { assert_eq!(crate::canvas::current_page(&app), Some(1), "the view goes to the page that shows the row"); } + /// Go to Row's OK goes to the row of the table in the document the dialog was opened for, even + /// when another document became active meanwhile; when that document has closed, nothing is selected. + #[test] + fn go_to_row_dialog_goes_to_the_document_it_was_opened_for() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let table_doc = |app: &mut DesignApp, title: &str| { + app.run("file.new", json!({"title": title})).unwrap(); + let r = app.run("frame.create", json!({"rect": [72, 72, 500, 700], "content": "text", "text": ""})).unwrap(); + app.run("text.select", json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + app.run("table.insert", json!({"rows": 3, "cols": 2})).unwrap(); + }; + table_doc(&mut app, "first"); + crate::menus::activate(&mut app, "table.goToRow", &Value::Null); + table_doc(&mut app, "second"); + setup_fields(&mut app, json!({"row": "3"})); + confirm(&mut app).unwrap(); + let active = app.session.active().unwrap(); + assert_eq!(active.doc.title, "first", "the dialog's document becomes active"); + assert_eq!(active.selection.cells.map(|c| (c.range.r0, c.range.r1)), Some((2, 2))); + let second = app.session.documents().iter().find(|d| d.doc.title == "second").unwrap(); + assert!(second.selection.cells.is_none(), "the other document's selection stays"); + + crate::menus::activate(&mut app, "table.goToRow", &Value::Null); + app.run("file.close", json!({})).unwrap(); + let before = app.session.active().unwrap().selection.clone(); + setup_fields(&mut app, json!({"row": "1"})); + assert_eq!(confirm(&mut app), Ok(Value::Null)); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.active().unwrap().selection, before, "the dialog's document closed"); + } + #[test] fn parent_dialogs_cover_both_pages_of_the_parent_spread_by_default() { let mut app = facing_app(4); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 58c9a24a0..7f970b798 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2576,6 +2576,10 @@ pub fn menu_enabled(app: &DesignApp, id: &str) -> bool { if id.starts_with("fit.") { return enabled(app, "object.fit"); } + // Go to Row runs on a named table from anywhere; the menu item needs the selection in a table. + if id == "table.goToRow" { + return enabled(app, "table.selectColumn"); + } if id == "guide.selectAll" && (!app.ui.guides || app.ui.guides_locked) { return false; } @@ -3751,6 +3755,17 @@ mod tests { } } + #[test] + fn go_to_row_is_enabled_in_a_table() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let r = app.run("frame.create", json!({"rect": [72, 72, 500, 700], "content": "text", "text": "Notes"})).unwrap(); + app.run("text.select", json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + assert!(!menu_enabled(&app, "table.goToRow"), "the insertion point is not in a table"); + app.run("table.insert", json!({"rows": 2, "cols": 2})).unwrap(); + assert!(menu_enabled(&app, "table.goToRow")); + } + #[test] fn pdf_export_menu_item_opens_the_dialog_or_exports() { // No params: the menu item opens the options dialog. From c2b4514ee6ed434c822ac81ecc52fa53b154dac7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 21:25:42 -0700 Subject: [PATCH 3/9] Tables: Go to Row shows the caret in the frame that shows the row table.goToRow and table.select naming a table in another story kept the previous caret's frame, so frame commands that follow acted on the other story's frame. The caret's frame is now kept only within the same story, and Go to Row puts it in the frame that shows the row. The Table menu order test lists Go to Row after Distribute Columns Evenly. --- crates/engine/src/cmd/table/selection.rs | 22 ++++++++++++++++++++-- crates/ui-egui/src/menus/tests.rs | 2 ++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/table/selection.rs b/crates/engine/src/cmd/table/selection.rs index 833a84e22..f29b3ca58 100644 --- a/crates/engine/src/cmd/table/selection.rs +++ b/crates/engine/src/cmd/table/selection.rs @@ -24,7 +24,8 @@ pub(super) fn select(s: &mut Session, p: &Value) -> Result { _ => {} } let st = s.doc_mut()?; - let frame = st.selection.text.and_then(|t| t.frame); + // The caret's frame is kept only within the same story. + let frame = st.selection.text.filter(|t| t.story == g.story).and_then(|t| t.frame); st.selection = Selection { cells: Some(TableSel { story: g.story, table: g.table, range: r }), text: Some(TextSel { story: g.story, anchor: 0, focus: 0, frame, cell: Some(CellAddr { table: g.table, row: r.r0, col: r.c0 }) }), @@ -79,6 +80,12 @@ pub(super) fn go_to_row(s: &mut Session, p: &Value) -> Result { .then_some(ft.frame) }); let page = frame.and_then(|f| st.doc.page_of_item(f)).map(|p| p + 1); + // The caret is shown in the frame that shows the row. + if let Some(f) = frame + && let Some(t) = s.doc_mut()?.selection.text.as_mut() + { + t.frame = Some(f); + } Ok(json!({"story": r["story"], "table": r["table"], "range": r["range"], "page": page})) } @@ -251,10 +258,19 @@ mod go_to_row_tests { let mut s = session(); let t = s.execute("table.get", &json!({})).unwrap(); let other = s.execute("frame.create", &json!({"rect": [72, 720, 500, 760], "content": "text", "text": "Notes"})).unwrap(); - s.execute("text.select", &json!({"story": other["story"], "anchor": 0, "focus": 0})).unwrap(); + // A click in the other frame: the caret is shown in that frame. + s.execute("text.placeCaret", &json!({"frame": other["id"], "point": [80, 730]})).unwrap(); + assert_eq!(s.doc().unwrap().selection.text.and_then(|t| t.frame).map(|f| f.0), other["id"].as_u64()); assert!(s.execute("table.goToRow", &json!({"row": 2})).is_err(), "no table named and none selected"); s.execute("table.goToRow", &json!({"story": t["story"], "table": t["table"], "row": 2})).unwrap(); assert_eq!(selected(&s), CellRange { r0: 2, c0: 0, r1: 2, c1: 1 }); + let frame = s.doc().unwrap().selection.text.and_then(|t| t.frame).map(|f| f.0); + assert_ne!(frame, other["id"].as_u64(), "the caret is not left in the other story's frame"); + // table.select naming the table from another story does not keep that story's frame either. + s.execute("text.placeCaret", &json!({"frame": other["id"], "point": [80, 730]})).unwrap(); + s.execute("table.select", &json!({"story": t["story"], "table": t["table"], "what": "table"})).unwrap(); + let frame = s.doc().unwrap().selection.text.and_then(|t| t.frame).map(|f| f.0); + assert_ne!(frame, other["id"].as_u64()); } #[test] @@ -271,6 +287,8 @@ mod go_to_row_tests { } assert_eq!(s.execute("table.goToRow", &json!({"row": 1})).unwrap()["page"], 1); assert_eq!(s.execute("table.goToRow", &json!({"row": 20})).unwrap()["page"], 2); + let frame = s.doc().unwrap().selection.text.and_then(|t| t.frame).map(|f| f.0); + assert_eq!(frame, b["id"].as_u64(), "the caret is in the frame that shows the row"); // The header row repeats on page 2; the row itself is the one on page 1. assert_eq!(s.execute("table.goToRow", &json!({"row": 1, "section": "header"})).unwrap()["page"], 1); } diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index e4a111a8b..352eb3a58 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -2255,6 +2255,8 @@ fn the_table_menu_follows_indesigns_order() { "-", "Distribute Rows Evenly", "Distribute Columns Evenly", + "-", + "Go to Row…", ]; assert_eq!(labels, expected); // Cmd+Alt+Shift+T opens the dialog, as InDesign's; the engine command has no shortcut of From cc4939569636cb2dc1fd2d40c8898d27231f0309 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 21:41:12 -0700 Subject: [PATCH 4/9] Tables: Go to Row names the page under the row and reads no cell text table.goToRow took the page from the frame's center, so a row in a frame that spans two pages of a spread could name the other page, and the view moved there. It now takes the page under the row's first cell. table.get takes cells: false to leave out the cells, and the Go to Row dialog uses it, so opening the dialog on a large table does not copy every cell's text. --- crates/engine/src/cmd/table/options.rs | 30 +++++++++------- crates/engine/src/cmd/table/registry.rs | 2 +- crates/engine/src/cmd/table/selection.rs | 44 +++++++++++++++++++++--- crates/ui-egui/src/dialogs.rs | 2 +- 4 files changed, 59 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/cmd/table/options.rs b/crates/engine/src/cmd/table/options.rs index 7f9ed7786..867c7b87d 100644 --- a/crates/engine/src/cmd/table/options.rs +++ b/crates/engine/src/cmd/table/options.rs @@ -70,19 +70,23 @@ pub(super) fn get(s: &mut Session, p: &Value) -> Result { let g = target(s, p, "table.get")?; let st = s.doc()?; let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad("table.get", "no table"))?; - let owners = t.owners(); - let cells: Vec = (0..t.nrows()) - .flat_map(|r| (0..t.ncols()).map(move |c| (r, c))) - .filter(|&(r, c)| owners[r * t.ncols() + c] == (r, c)) - .filter_map(|(r, c)| { - let cell = t.cell(r, c)?; - Some(json!({"row": r, "col": c, "rowSpan": cell.row_span, "colSpan": cell.col_span, "text": cell.text.text, "fill": cell.fill, - "insets": cell.insets, "vj": cell.vj, "firstBaseline": cell.first_baseline, "firstBaselineMin": cell.first_baseline_min, "rotation": cell.rotation, "style": cell.style})) - }) - .collect(); - Ok(json!({ + let mut out = json!({ "story": g.story.0, "table": g.table, "anchor": st.doc.story(g.story).and_then(|x| x.table_anchor(g.table)), "rows": t.rows, "columns": t.columns, "headerRows": t.header_rows(), "footerRows": t.footer_rows(), - "cells": cells, "options": t.options, "range": g.range, "style": t.style, - })) + "options": t.options, "range": g.range, "style": t.style, + }); + if p.get("cells").and_then(Value::as_bool) != Some(false) { + let owners = t.owners(); + let cells: Vec = (0..t.nrows()) + .flat_map(|r| (0..t.ncols()).map(move |c| (r, c))) + .filter(|&(r, c)| owners[r * t.ncols() + c] == (r, c)) + .filter_map(|(r, c)| { + let cell = t.cell(r, c)?; + Some(json!({"row": r, "col": c, "rowSpan": cell.row_span, "colSpan": cell.col_span, "text": cell.text.text, "fill": cell.fill, + "insets": cell.insets, "vj": cell.vj, "firstBaseline": cell.first_baseline, "firstBaselineMin": cell.first_baseline_min, "rotation": cell.rotation, "style": cell.style})) + }) + .collect(); + out["cells"] = json!(cells); + } + Ok(out) } diff --git a/crates/engine/src/cmd/table/registry.rs b/crates/engine/src/cmd/table/registry.rs index 43c32f292..c42470681 100644 --- a/crates/engine/src/cmd/table/registry.rs +++ b/crates/engine/src/cmd/table/registry.rs @@ -416,6 +416,6 @@ fn table_specs() -> Vec { in_cell, |s, _| toggle_selection(s) ), - cmd!(query "table.get", "Get Table", [], None, "{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?} → rows, columns, cells (text, spans, fill), options", in_table_or_ids, get), + cmd!(query "table.get", "Get Table", [], None, "{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?, cells?: bool (false leaves out the cells)} → rows, columns, cells (text, spans, fill), options", in_table_or_ids, get), ] } diff --git a/crates/engine/src/cmd/table/selection.rs b/crates/engine/src/cmd/table/selection.rs index f29b3ca58..c6f3d31ae 100644 --- a/crates/engine/src/cmd/table/selection.rs +++ b/crates/engine/src/cmd/table/selection.rs @@ -72,14 +72,17 @@ pub(super) fn go_to_row(s: &mut Session, p: &Value) -> Result { let cells: Vec<(usize, usize)> = owners.get(start..start.saturating_add(nc)).map(<[_]>::to_vec).unwrap_or_default(); let st = s.doc()?; let cs = s.cache.get(&st.doc, g.story, None); - let frame = cs.frames.iter().find_map(|ft| { + let placed = cs.frames.iter().find_map(|ft| { ft.tables .iter() .filter(|t| t.table == g.table) - .any(|t| t.cells.iter().any(|c| !c.repeated && cells.contains(&(c.row, c.col)))) - .then_some(ft.frame) + .flat_map(|t| t.cells.iter()) + .find(|c| !c.repeated && cells.contains(&(c.row, c.col))) + .map(|c| (ft.frame, c.rect.center())) }); - let page = frame.and_then(|f| st.doc.page_of_item(f)).map(|p| p + 1); + let frame = placed.map(|(f, _)| f); + // The page under the row's first cell: a frame can span two pages of a spread. + let page = placed.and_then(|(f, at)| page_under(&st.doc, f, at)).map(|p| p + 1); // The caret is shown in the frame that shows the row. if let Some(f) = frame && let Some(t) = s.doc_mut()?.selection.text.as_mut() @@ -89,6 +92,15 @@ pub(super) fn go_to_row(s: &mut Session, p: &Value) -> Result { Ok(json!({"story": r["story"], "table": r["table"], "range": r["range"], "page": page})) } +/// The page (from 0) under `at`, a point in the inner space of text frame `frame`. +fn page_under(doc: &designcraft_doc::Document, frame: designcraft_doc::ItemId, at: designcraft_geom::Point) -> Option { + let loc = doc.find(frame)?; + let designcraft_doc::SpreadRef::Doc(si) = loc.spread else { return None }; + let it = doc.item_at(&loc)?; + let p = doc.parent_xf(&loc) * doc.text_xf(it) * at; + Some(doc.first_page_of_spread(si) + doc.spread(loc.spread)?.page_at_x(p.x).unwrap_or(0)) +} + /// Move the caret to the next/previous cell (Tab / Shift-Tab), selecting its text. Tab in the last /// cell adds a row. pub(crate) fn step_cell(s: &mut Session, forward: bool) -> Result { @@ -292,4 +304,28 @@ mod go_to_row_tests { // The header row repeats on page 2; the row itself is the one on page 1. assert_eq!(s.execute("table.goToRow", &json!({"row": 1, "section": "header"})).unwrap()["page"], 1); } + + #[test] + fn table_get_without_cells_reads_the_rows_alone() { + // The Go to Row dialog reads the row counts and the selection, not every cell's text. + let mut s = session(); + s.execute("table.goToRow", &json!({"row": 2})).unwrap(); + let t = s.execute("table.get", &json!({"cells": false})).unwrap(); + assert!(t.get("cells").is_none(), "{t}"); + assert_eq!((t["headerRows"].clone(), t["footerRows"].clone(), t["range"]["r0"].clone()), (json!(1), json!(1), json!(2))); + assert_eq!(t["rows"].as_array().map(Vec::len), Some(5)); + assert_eq!(s.execute("table.get", &json!({})).unwrap()["cells"].as_array().map(Vec::len), Some(10)); + } + + #[test] + fn go_to_row_names_the_page_under_the_row_in_a_frame_across_the_spine() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 3, "facingPages": true})).unwrap(); + // Pages 2 and 3 meet at x = 612; the frame's center is on page 3, the narrow table on page 2. + let f = s.execute("frame.create", &json!({"spread": 1, "rect": [400, 72, 900, 400], "content": "text", "text": ""})).unwrap(); + s.execute("text.select", &json!({"story": f["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap(); + s.execute("table.setColumnWidth", &json!({"cols": [0, 1], "width": 50})).unwrap(); + assert_eq!(s.execute("table.goToRow", &json!({"row": 2})).unwrap()["page"], 2); + } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index dae827fb2..79343e9ed 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1506,7 +1506,7 @@ fn doc_unit(app: &DesignApp) -> Unit { /// Table › Go to Row…: opens on the target table's first selected row, numbered within its header, /// body or footer rows. pub fn open_go_to_row(app: &mut DesignApp) { - let Ok(t) = app.run("table.get", json!({})) else { return }; + let Ok(t) = app.run("table.get", json!({"cells": false})) else { return }; let (h, f, r) = (count_at(&t, "headerRows"), count_at(&t, "footerRows"), count_at(&t["range"], "r0")); let nr = t["rows"].as_array().map_or(0, Vec::len); let (section, n) = if r < h { From 327b9cc7a34e9e72264b12f4e84bd756aae18d83 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 21:46:49 -0700 Subject: [PATCH 5/9] Tables: selecting a row puts the caret in the merged cell that covers it table.select and table.goToRow put the caret at the range's first grid position, which has no cell of its own when a merged cell from the row above covers it, so the caret was not drawn. The caret now goes in the cell that covers that position. --- crates/engine/src/cmd/table/selection.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/table/selection.rs b/crates/engine/src/cmd/table/selection.rs index c6f3d31ae..8c23d698a 100644 --- a/crates/engine/src/cmd/table/selection.rs +++ b/crates/engine/src/cmd/table/selection.rs @@ -24,11 +24,13 @@ pub(super) fn select(s: &mut Session, p: &Value) -> Result { _ => {} } let st = s.doc_mut()?; + // The caret goes in the cell that covers the range's first position, a merged one included. + let (row, col) = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).map_or((r.r0, r.c0), |t| t.owner(r.r0, r.c0)); // The caret's frame is kept only within the same story. let frame = st.selection.text.filter(|t| t.story == g.story).and_then(|t| t.frame); st.selection = Selection { cells: Some(TableSel { story: g.story, table: g.table, range: r }), - text: Some(TextSel { story: g.story, anchor: 0, focus: 0, frame, cell: Some(CellAddr { table: g.table, row: r.r0, col: r.c0 }) }), + text: Some(TextSel { story: g.story, anchor: 0, focus: 0, frame, cell: Some(CellAddr { table: g.table, row, col }) }), ..Default::default() }; st.bump_revision(); @@ -305,6 +307,16 @@ mod go_to_row_tests { assert_eq!(s.execute("table.goToRow", &json!({"row": 1, "section": "header"})).unwrap()["page"], 1); } + #[test] + fn go_to_row_puts_the_caret_in_a_merged_cell_that_covers_the_row() { + let mut s = session(); + // Body rows 1 and 2 (table rows 1 and 2) share one merged cell in column 0. + s.execute("table.merge", &json!({"rows": [1, 2], "col": 0})).unwrap(); + s.execute("table.goToRow", &json!({"row": 2})).unwrap(); + assert_eq!(selected(&s), CellRange { r0: 2, c0: 0, r1: 2, c1: 1 }); + assert_eq!(s.doc().unwrap().selection.text.and_then(|t| t.cell).map(|c| (c.row, c.col)), Some((1, 0))); + } + #[test] fn table_get_without_cells_reads_the_rows_alone() { // The Go to Row dialog reads the row counts and the selection, not every cell's text. From 2ba27b840bb1a5342b75b1199050af22f71ec1d5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 22:28:48 -0700 Subject: [PATCH 6/9] Tables: Go to Row scrolls the row to the middle of the view Go to Row's OK moved the view only when the row's page was not the page at the center of the view, and then centered that page. When zoomed in, a row on the page in view stayed off screen, and a row on another page could stay off screen too. The view now scrolls so the row is in the middle of the view from top to bottom, at the same zoom, on the page in view or another. It scrolls sideways only when no part of the row is in view across. table.goToRow also returns the spread that shows the row, a document or parent spread, and the row's bounds on that spread. A composed table fragment lists its rows with their top and bottom, so a row inside cells merged across several rows is bounded by its own height. A row in an anchored text frame is found where the host story places the frame. --- ROADMAP.md | 2 +- crates/compose/src/cache.rs | 7 ++ crates/compose/src/table.rs | 8 ++ crates/engine/src/cmd/table/registry.rs | 2 +- crates/engine/src/cmd/table/selection.rs | 131 ++++++++++++++++++++--- crates/ui-egui/src/canvas.rs | 3 +- crates/ui-egui/src/canvas/view.rs | 21 ++++ crates/ui-egui/src/dialogs.rs | 13 ++- crates/ui-egui/src/dialogs/tests.rs | 43 ++++++++ 9 files changed, 205 insertions(+), 25 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9b9c7d284..ac0849624 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -429,7 +429,7 @@ Newest first. - **2026-10-24** · A guide pulled out of a ruler keeps the resize cursor across it until it is dropped; the tool's cursor took over as soon as the pointer left the ruler, so the drag looked as if it had stopped. No readiness percentage change. - **2026-10-24** · Anchored text frames, such as marginal notes, compose where their host story places them: they align to the baseline grid at that position, and page-number markers and spine-relative alignment inside them read the host's page. They composed against a grid measured from their stored position and on no page, so a note set to Only Align First Line to Grid could sit a fraction of a line off the body text's grid line. No readiness percentage change. - **2026-10-24** · Tables: a gradient swatch in a cell fill draws as a gradient on the canvas and in PDF, in one gradient space for the whole table, so it runs across cells without restarting. Cells showed the gradient's first stop as a solid colour. No readiness percentage change. -- **2026-10-25** · Table › Go to Row… (`table.goToRow {row, section?}`): as in InDesign, a dialog takes a row number and, when the table has header or footer rows, the section is chosen from a menu. The menu lists Body Rows and those of Header Rows and Footer Rows that the table has. DesignCraft counts the row from 1 within its section and selects it, and the view moves to the row's page when that page is not the one at the center of the view. A row number past the section's last row is refused. No readiness percentage change. +- **2026-10-25** · Table › Go to Row… (`table.goToRow {row, section?}`): as in InDesign, a dialog takes a row number and, when the table has header or footer rows, the section is chosen from a menu. The menu lists Body Rows and those of Header Rows and Footer Rows that the table has. DesignCraft counts the row from 1 within its section and selects it, and the view scrolls the row to its middle at the same zoom, on the page in view or another. A row number past the section's last row is refused. No readiness percentage change. - **2026-10-22** · Paragraph Style Options › Paragraph Shading and Paragraph Border are laid out as InDesign's: headed with the page's name; Tint is a spinner with InDesign's presets and has Overprint beside it (shading, border and gap; a new attribute each, kept through IDML; PDF export warns it doesn't overprint yet); Corner Size and Shape is InDesign's two-by-two grid of corner icons, sizes and shape menus showing each shape's glyph, with the link in the middle; offsets and weights have spinners. Found in the Learn Skills Daily course, chapter 41. No readiness percentage change. - **2026-10-24** · New documents start with Hyphenate Capitalized Words, Hyphenate Last Word and Hyphenate Across Column off in [Basic Paragraph], as in InDesign. Opened and imported files keep the values they carry. No readiness percentage change. - **2026-10-24** · The baseline grid's Relative To setting applies: with Top Margin, the grid starts at each page's own top margin in text composition, on the canvas and for snapping. It started at the page top, so grid-aligned text sat on the wrong lines and a page could fit one line fewer. No readiness percentage change. diff --git a/crates/compose/src/cache.rs b/crates/compose/src/cache.rs index 66122ccf4..88421ceb5 100644 --- a/crates/compose/src/cache.rs +++ b/crates/compose/src/cache.rs @@ -103,6 +103,13 @@ impl Cache { self.get_with(doc, sid, on, true, 0) } + /// Where the host story places anchored text frame `fid`, one of story `sid`'s frames; `None` for + /// a frame on a spread. + pub fn placement(&self, doc: &Document, sid: StoryId, fid: designcraft_doc::ItemId) -> Option { + let placed = crate::placed::placements(doc, sid, &mut |host| self.get_with(doc, host, ShownOn::Own, true, 1)); + placed.into_iter().find_map(|(id, p)| (id == fid).then_some(p)) + } + /// [`Cache::get_on`], wrapped by other stories' anchored objects only when `cross` is set, for /// a story `depth` hosts down from the story asked for. fn get_with(&self, doc: &Document, sid: StoryId, on: ShownOn, cross: bool, depth: usize) -> Arc { diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index dc579d8a7..f0c6fb59b 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -52,6 +52,9 @@ pub struct TableFrag { pub column: u32, pub rect: Rect, pub cells: Vec, + /// The table rows in the fragment, each with its top and bottom; repeated header and footer + /// rows are left out. + pub rows: Vec<(usize, f64, f64)>, pub strokes: Vec, /// Index of the fragment's (glyph-less) line in the frame's lines. pub line: usize, @@ -88,6 +91,10 @@ impl TableFrag { c.clip = c.clip + v; c.origin += v; } + for r in &mut self.rows { + r.1 += dy; + r.2 += dy; + } for s in &mut self.strokes { s.a += v; s.b += v; @@ -492,6 +499,7 @@ fn emit( column: cur.col as u32, rect: Rect::new(x0, top, right, bottom), cells, + rows: rows.iter().filter(|r| !r.2).map(|&(r, y, _)| (r, y, y + heights[r])).collect(), strokes, line, first, diff --git a/crates/engine/src/cmd/table/registry.rs b/crates/engine/src/cmd/table/registry.rs index c42470681..04b4dee30 100644 --- a/crates/engine/src/cmd/table/registry.rs +++ b/crates/engine/src/cmd/table/registry.rs @@ -401,7 +401,7 @@ fn table_specs() -> Vec { "Go to Row…", ["Table"], None, - "{row: number (from 1, counted within the section), section?: header|body|footer (default: body), story?, table?}: selects the row → {story, table, range, page: the page showing the row (from 1), or null when the row is overset}", + "{row: number (from 1, counted within the section), section?: header|body|footer (default: body), story?, table?}: selects the row → {story, table, range, page: the document page showing the row (from 1), spread: {kind: doc|parent, index} of the spread showing it, rowBounds: [x0, y0, x1, y1] of the row on that spread; each null when the row is overset, and page on a parent spread}", in_table_or_ids, go_to_row ), diff --git a/crates/engine/src/cmd/table/selection.rs b/crates/engine/src/cmd/table/selection.rs index 8c23d698a..524106373 100644 --- a/crates/engine/src/cmd/table/selection.rs +++ b/crates/engine/src/cmd/table/selection.rs @@ -74,33 +74,63 @@ pub(super) fn go_to_row(s: &mut Session, p: &Value) -> Result { let cells: Vec<(usize, usize)> = owners.get(start..start.saturating_add(nc)).map(<[_]>::to_vec).unwrap_or_default(); let st = s.doc()?; let cs = s.cache.get(&st.doc, g.story, None); + // The part of the table that shows the row, its frame, and the cells that cover the row there. let placed = cs.frames.iter().find_map(|ft| { - ft.tables - .iter() - .filter(|t| t.table == g.table) - .flat_map(|t| t.cells.iter()) - .find(|c| !c.repeated && cells.contains(&(c.row, c.col))) - .map(|c| (ft.frame, c.rect.center())) + ft.tables.iter().filter(|t| t.table == g.table).find_map(|t| { + let covering: Vec = + t.cells.iter().filter(|c| !c.repeated && cells.contains(&(c.row, c.col))).map(|c| c.rect).collect(); + (!covering.is_empty()).then_some((ft.frame, t, covering)) + }) }); - let frame = placed.map(|(f, _)| f); - // The page under the row's first cell: a frame can span two pages of a spread. - let page = placed.and_then(|(f, at)| page_under(&st.doc, f, at)).map(|p| p + 1); + let frame = placed.as_ref().map(|(f, _, _)| *f); + let on_spread = frame.and_then(|f| frame_on_spread(&st.doc, &s.cache, g.story, f)); + // The document page under the row's first cell: a frame can span two pages of a spread. + let page = placed.as_ref().zip(on_spread).and_then(|((_, _, covering), (spread, xf))| { + let designcraft_doc::SpreadRef::Doc(si) = spread else { return None }; + let at = xf * covering.first()?.center(); + Some(st.doc.first_page_of_spread(si) + st.doc.spreads.get(si)?.page_at_x(at.x).unwrap_or(0) + 1) + }); + // The row on its spread: across, the cells that cover it; from top to bottom, the row alone, also + // inside cells merged across several rows. + let bounds = placed.as_ref().zip(on_spread).and_then(|((_, t, covering), (_, xf))| { + let across = covering.iter().copied().reduce(|a, b| a.union(b))?; + let &(_, top, bottom) = t.rows.iter().find(|r| r.0 == index)?; + Some(xf.transform_rect_bbox(designcraft_geom::Rect::new(across.x0, top, across.x1, bottom))) + }); + let spread = on_spread.map(|(si, _)| si); // The caret is shown in the frame that shows the row. if let Some(f) = frame && let Some(t) = s.doc_mut()?.selection.text.as_mut() { t.frame = Some(f); } - Ok(json!({"story": r["story"], "table": r["table"], "range": r["range"], "page": page})) + Ok(json!({ + "story": r["story"], + "table": r["table"], + "range": r["range"], + "page": page, + "spread": spread, + "rowBounds": bounds.map(|b| [b.x0, b.y0, b.x1, b.y1]), + })) } -/// The page (from 0) under `at`, a point in the inner space of text frame `frame`. -fn page_under(doc: &designcraft_doc::Document, frame: designcraft_doc::ItemId, at: designcraft_geom::Point) -> Option { - let loc = doc.find(frame)?; - let designcraft_doc::SpreadRef::Doc(si) = loc.spread else { return None }; - let it = doc.item_at(&loc)?; - let p = doc.parent_xf(&loc) * doc.text_xf(it) * at; - Some(doc.first_page_of_spread(si) + doc.spread(loc.spread)?.page_at_x(p.x).unwrap_or(0)) +/// The spread (a document or parent spread) that text frame `frame` of story `sid` is on, and the +/// transform from the frame's inner space to that spread's coordinates. An anchored frame is where +/// its host story places it. +fn frame_on_spread( + doc: &designcraft_doc::Document, + cache: &designcraft_compose::Cache, + sid: StoryId, + frame: designcraft_doc::ItemId, +) -> Option<(designcraft_doc::SpreadRef, designcraft_geom::Affine)> { + let (spread, parent, it) = match doc.find(frame) { + Some(loc) => (loc.spread, doc.parent_xf(&loc), doc.item_at(&loc)?), + None => { + let p = cache.placement(doc, sid, frame)?; + (p.spread, p.xf, doc.anchored_item(frame)?) + } + }; + Some((spread, parent * doc.text_xf(it))) } /// Move the caret to the next/previous cell (Tab / Shift-Tab), selecting its text. Tab in the last @@ -303,6 +333,11 @@ mod go_to_row_tests { assert_eq!(s.execute("table.goToRow", &json!({"row": 20})).unwrap()["page"], 2); let frame = s.doc().unwrap().selection.text.and_then(|t| t.frame).map(|f| f.0); assert_eq!(frame, b["id"].as_u64(), "the caret is in the frame that shows the row"); + // The row on its spread: one row tall, in the frame on the second spread. + let r = s.execute("table.goToRow", &json!({"row": 20})).unwrap(); + assert_eq!(r["spread"], json!({"kind": "doc", "index": 1})); + let at: Vec = r["rowBounds"].as_array().map(|a| a.iter().filter_map(serde_json::Value::as_f64).collect()).unwrap_or_default(); + assert!(at.len() == 4 && (at[3] - at[1] - 20.0).abs() < 1e-6 && at[0] >= 72.0 && at[2] <= 401.0 && at[1] >= 72.0 && at[3] <= 600.0, "{at:?}"); // The header row repeats on page 2; the row itself is the one on page 1. assert_eq!(s.execute("table.goToRow", &json!({"row": 1, "section": "header"})).unwrap()["page"], 1); } @@ -317,6 +352,68 @@ mod go_to_row_tests { assert_eq!(s.doc().unwrap().selection.text.and_then(|t| t.cell).map(|c| (c.row, c.col)), Some((1, 0))); } + #[test] + fn go_to_row_bounds_a_row_inside_cells_merged_across_rows() { + let mut s = session(); + for (row, height) in [20, 10, 40, 20, 20].into_iter().enumerate() { + s.execute("table.setRowHeight", &json!({"row": row, "height": height, "mode": "exactly"})).unwrap(); + } + // Every column's cell covers body rows 1 to 3 (table rows 1 to 3), so no cell starts in body row 2. + s.execute("table.merge", &json!({"rows": [1, 3], "cols": [0, 0]})).unwrap(); + s.execute("table.merge", &json!({"rows": [1, 3], "cols": [1, 1]})).unwrap(); + let bounds = |s: &mut Session, p: serde_json::Value| -> Vec { + let r = s.execute("table.goToRow", &p).unwrap(); + r["rowBounds"].as_array().map(|a| a.iter().filter_map(serde_json::Value::as_f64).collect()).unwrap_or_default() + }; + let header = bounds(&mut s, json!({"row": 1, "section": "header"})); + let row = bounds(&mut s, json!({"row": 2})); + assert_eq!((header.len(), row.len()), (4, 4)); + assert!((row[1] - (header[3] + 10.0)).abs() < 1e-6 && (row[3] - row[1] - 40.0).abs() < 1e-6, "{header:?} {row:?}"); + assert_eq!((row[0], row[2]), (header[0], header[2]), "the row spans the table across"); + } + + #[test] + fn go_to_row_finds_a_row_in_an_anchored_text_frame() { + use designcraft_doc::{AnchorPosition, AnchoredObject, Content, Item, ItemId, Shape, Story, StoryId, TextFrame}; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let host = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": "Host text"})).unwrap(); + // A text frame anchored in the host's text, its own story holding the table. + let sid = { + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let (fid, sid) = (ItemId(d.alloc()), StoryId(d.alloc())); + let mut story = Story::with_text(sid, "", Default::default()); + story.frames.push(fid); + d.stories.insert(sid, std::sync::Arc::new(story)); + let layer = d.item(ItemId(host["id"].as_u64().unwrap())).unwrap().layer; + let mut it = + Item::new(fid, layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 200.0, 100.0))); + it.content = Content::Text(TextFrame { story: sid, options: Default::default() }); + let host_story = StoryId(host["story"].as_u64().unwrap()); + d.story_mut(host_story).unwrap().insert_object(4, AnchoredObject::new(it, AnchorPosition::default())); + sid + }; + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap(); + let r = s.execute("table.goToRow", &json!({"row": 2})).unwrap(); + assert_eq!((r["page"].clone(), r["spread"].clone()), (json!(1), json!({"kind": "doc", "index": 0})), "{r}"); + assert_eq!(r["rowBounds"].as_array().map(Vec::len), Some(4), "{r}"); + } + + #[test] + fn go_to_row_finds_a_row_on_a_parent_spread() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let f = s + .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 72, 400, 400], "content": "text", "text": ""})) + .unwrap(); + s.execute("text.select", &json!({"story": f["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap(); + let r = s.execute("table.goToRow", &json!({"row": 2})).unwrap(); + assert_eq!((r["page"].clone(), r["spread"].clone()), (json!(null), json!({"kind": "parent", "index": 0})), "{r}"); + assert_eq!(r["rowBounds"].as_array().map(Vec::len), Some(4), "{r}"); + } + #[test] fn table_get_without_cells_reads_the_rows_alone() { // The Go to Row dialog reads the row counts and the selection, not every cell's text. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ed207c34b..565454f91 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -62,7 +62,8 @@ pub(crate) use view::page_in_view; use view::present_fit; pub use view::{ PageBox, apply_view_request, can_go_through_history, current_page, current_parent_page, current_slot, current_spread, current_spread_page, - drop_point, fit, go_through_history, go_to_page, page_box, page_box_go, set_zoom, show_spread, step_spread, view_centre, zoom_at, + drop_point, fit, go_through_history, go_to_page, go_to_spread_rect, page_box, page_box_go, set_zoom, show_spread, step_spread, view_centre, + zoom_at, }; pub const RULER: f32 = 15.0; diff --git a/crates/ui-egui/src/canvas/view.rs b/crates/ui-egui/src/canvas/view.rs index 2ba0876a1..d1fe151d2 100644 --- a/crates/ui-egui/src/canvas/view.rs +++ b/crates/ui-egui/src/canvas/view.rs @@ -218,6 +218,27 @@ fn centre_page(app: &mut DesignApp, abs: usize) { } } +/// Scroll so `r`, a rectangle on spread `spread` as the canvas lays it out now, is in the middle of +/// the view from top to bottom, and from side to side when no part of it is in view across (keeps +/// zoom). A jump Go Back can return from. +pub fn go_to_spread_rect(app: &mut DesignApp, spread: SpreadRef, r: DRect) { + note_jump(app, false); + centre_spread_rect(app, spread, r); + note_jump(app, true); +} + +fn centre_spread_rect(app: &mut DesignApp, spread: SpreadRef, r: DRect) { + let Some(st) = app.session.active() else { return }; + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let Some(b) = layout.slot(spread).map(|s| s.xf.transform_rect_bbox(r)) else { return }; + let Some(rect) = app.canvas_rect else { return }; + if let Some(v) = app.view_mut() { + let (w, h) = (rect.width() as f64 / v.zoom, rect.height() as f64 / v.zoom); + let x = if b.x1 > v.origin.x && b.x0 < v.origin.x + w { v.origin.x } else { b.center().x - w / 2.0 }; + v.origin = Point::new(x, b.center().y - h / 2.0); + } +} + /// The document's pages by id, in page order. fn page_ids(doc: &Document) -> Vec { doc.spreads.iter().flat_map(|s| s.pages.iter().map(|p| p.id)).collect() diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 79343e9ed..b3f18834f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4298,11 +4298,14 @@ fn confirm_part1(app: &mut DesignApp, d: Dialog) -> Result { let r = app.run("table.goToRow", p); match &r { Ok(v) => { - // The view moves when the row's page is not the page at the center of the view. - if let Some(page) = v["page"].as_u64().and_then(|p| usize::try_from(p).ok()).and_then(|p| p.checked_sub(1)) - && crate::canvas::current_page(app) != Some(page) - { - crate::canvas::go_to_page(app, page); + // The view scrolls the row to its middle, at the same zoom, on the row's page or another. + let spread = serde_json::from_value::(v["spread"].clone()).ok(); + let bounds = v["rowBounds"].as_array().and_then(|a| { + let n: Vec = a.iter().filter_map(Value::as_f64).collect(); + (n.len() == 4).then(|| designcraft_geom::Rect::new(n[0], n[1], n[2], n[3])) + }); + if let (Some(spread), Some(b)) = (spread, bounds) { + crate::canvas::go_to_spread_rect(app, spread, b); } } Err(e) => { diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 0b6179060..1a65b9f9d 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3750,6 +3750,49 @@ fn go_to_row_dialog_selects_the_row_and_shows_its_page() { assert_eq!(crate::canvas::current_page(&app), Some(1), "the view goes to the page that shows the row"); } +/// Go to Row's OK scrolls the row to the middle of the view at the same zoom: a row off screen on the +/// page in view, and a row on another page away from that page's center. +#[test] +fn go_to_row_dialog_scrolls_the_row_to_the_middle_of_the_view() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 2, "facingPages": false})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))); + let a = app.run("frame.create", json!({"rect": [72, 72, 400, 200], "content": "text", "text": ""})).unwrap(); + let b = app.run("frame.create", json!({"spread": 1, "rect": [72, 72, 400, 600], "content": "unassigned"})).unwrap(); + app.run("text.thread", json!({"from": a["id"], "to": b["id"]})).unwrap(); + app.run("text.select", json!({"story": a["story"], "anchor": 0, "focus": 0})).unwrap(); + app.run("table.insert", json!({"rows": 20, "cols": 2, "headerRows": 1})).unwrap(); + for row in 0..21 { + app.run("table.setRowHeight", json!({"row": row, "height": 20, "mode": "exactly"})).unwrap(); + } + app.run("table.select", json!({"rows": [1, 1]})).unwrap(); + crate::canvas::go_to_page(&mut app, 0); + crate::canvas::set_zoom(&mut app, 4.0); + // The row's middle and the view's middle, from top to bottom, on the canvas. + let middles = |app: &mut DesignApp, row: u64| { + let r = app.run("table.goToRow", json!({"row": row})).unwrap(); + let spread: designcraft_doc::SpreadRef = serde_json::from_value(r["spread"].clone()).unwrap(); + let n: Vec = r["rowBounds"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + let st = app.session.active().unwrap(); + let slot = designcraft_tools::CanvasLayout::new(&st.doc, false).slot(spread).unwrap().xf; + let row_y = slot.transform_rect_bbox(designcraft_geom::Rect::new(n[0], n[1], n[2], n[3])).center().y; + let (v, rect) = (app.view().unwrap(), app.canvas_rect.unwrap()); + (row_y, v.origin.y + rect.height() as f64 / 2.0 / v.zoom) + }; + let x = app.view().unwrap().origin.x; + for row in ["3", "20"] { + crate::menus::activate(&mut app, "table.goToRow", &Value::Null); + ctl(&mut app, "ui.dialog.set", json!({"field": "row", "value": row})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + let (row_y, view_y) = middles(&mut app, row.parse().unwrap()); + assert!((row_y - view_y).abs() < 0.5, "row {row} is in the middle of the view: {row_y} against {view_y}"); + let v = app.view().unwrap(); + assert!((v.zoom - 4.0).abs() < 1e-9, "the zoom stays"); + assert!((v.origin.x - x).abs() < 1e-9, "the row was in view across, so the view does not scroll sideways"); + } + assert_eq!(crate::canvas::current_page(&app), Some(1)); +} + /// Go to Row's OK goes to the row of the table in the document the dialog was opened for, even /// when another document became active meanwhile; when that document has closed, nothing is selected. #[test] From 5b0e0295b3535411dadcbda1d03bc767b82b9bea Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 2 Nov 2026 08:48:57 -0800 Subject: [PATCH 7/9] =?UTF-8?q?i18n:=20Swedish=20row=20for=20Go=20to=20Row?= =?UTF-8?q?=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swedish became a complete-menu language on main, so the Table menu's Go to Row… item and the dialog title need Swedish rows. --- crates/ui-egui/src/i18n/sv.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/ui-egui/src/i18n/sv.rs b/crates/ui-egui/src/i18n/sv.rs index 1a7550a35..4f7488ff6 100644 --- a/crates/ui-egui/src/i18n/sv.rs +++ b/crates/ui-egui/src/i18n/sv.rs @@ -102,6 +102,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Numbering & Section Options…", "Numrerings- och avsnittsalternativ…"), ("Go to Page…", "Gå till sida…"), ("Go to Page", "Gå till sida"), + ("Go to Row…", "Gå till rad…"), + ("Go to Row", "Gå till rad"), ("Next Page", "Nästa sida"), ("Previous Page", "Föregående sida"), ("First Page", "Första sidan"), From db8e1b63644f226cd7846d960f52cf4fc690b482 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 13:48:13 -0800 Subject: [PATCH 8/9] Docs: leave gap 16 of docs/gaps.md as it is on main The Go to Row change only removes Go to Row from gap 12. --- docs/gaps.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index 7010c807b..398288062 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -178,10 +178,13 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · - **Estimate:** 30–50 h. ### 16. Menu items still missing (U) -- **Missing:** 147 of 482 in-scope items of InDesign 21.6's menu dump have no match (measured 2026-10-10; some exist under other labels): Type ▸ Notes +- **Missing:** 149 of 482 in-scope items of InDesign 21.6's menu dump have no match (measured 2026-10-10; some exist under other labels): Type ▸ Notes +- **Missing:** 150 of 482 in-scope items of InDesign 21.6's menu dump have no match (measured 2026-10-10; some exist under other labels): Type ▸ Notes and Track Changes submenus (the panels and commands exist), hyperlink and cross-reference + update commands, Convert Bullets/Numbering to Text, Convert Variable to Text, Object ▸ Clear + Effects / Clear All Transparency / Rotate 180° / Reverse Path, update commands, Convert Bullets/Numbering to Text, Object ▸ Clear - Effects / Clear All Transparency / Rotate 180°, + Effects / Clear All Transparency / Rotate 180° / Reverse Path, Layout ▸ Go Back/Forward, Window ▸ Arrange (2-up … 6-up), Trap Presets, Overlays, Edit ▸ Assign Profiles / Convert to Profile / Edit Original, File ▸ Open PDF / Document and Print Presets / User Settings, Type ▸ Size presets, Table ▸ Table Options / Cell Options submenus, Paste Before/After, From 240f423392c17a88a4b22c58198cec9817be8cbd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 15:41:31 -0800 Subject: [PATCH 9/9] Tables: Go to Row leaves no caret frame for an overset row When no frame showed the row, the caret kept an earlier frame of the story, so frame commands acted on a frame that does not show the row. --- crates/engine/src/cmd/table/selection.rs | 26 +++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/table/selection.rs b/crates/engine/src/cmd/table/selection.rs index 524106373..320b21350 100644 --- a/crates/engine/src/cmd/table/selection.rs +++ b/crates/engine/src/cmd/table/selection.rs @@ -98,11 +98,9 @@ pub(super) fn go_to_row(s: &mut Session, p: &Value) -> Result { Some(xf.transform_rect_bbox(designcraft_geom::Rect::new(across.x0, top, across.x1, bottom))) }); let spread = on_spread.map(|(si, _)| si); - // The caret is shown in the frame that shows the row. - if let Some(f) = frame - && let Some(t) = s.doc_mut()?.selection.text.as_mut() - { - t.frame = Some(f); + // The caret is shown in the frame that shows the row, and in no frame when the row is overset. + if let Some(t) = s.doc_mut()?.selection.text.as_mut() { + t.frame = frame; } Ok(json!({ "story": r["story"], @@ -342,6 +340,24 @@ mod go_to_row_tests { assert_eq!(s.execute("table.goToRow", &json!({"row": 1, "section": "header"})).unwrap()["page"], 1); } + #[test] + fn go_to_row_leaves_no_caret_frame_for_an_overset_row() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": ""})).unwrap(); + s.execute("text.select", &json!({"story": a["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 20, "cols": 2})).unwrap(); + for row in 0..20 { + s.execute("table.setRowHeight", &json!({"row": row, "height": 20, "mode": "exactly"})).unwrap(); + } + let frame = |s: &Session| s.doc().unwrap().selection.text.and_then(|t| t.frame).map(|f| f.0); + s.execute("table.goToRow", &json!({"row": 1})).unwrap(); + assert_eq!(frame(&s), a["id"].as_u64()); + let r = s.execute("table.goToRow", &json!({"row": 20})).unwrap(); + assert_eq!((r["page"].clone(), r["spread"].clone()), (json!(null), json!(null)), "{r}"); + assert_eq!(frame(&s), None, "no frame shows the overset row"); + } + #[test] fn go_to_row_puts_the_caret_in_a_merged_cell_that_covers_the_row() { let mut s = session();