Browse / github.com/storytold/designcraft / patch
Tables: Table › Go to Row… selects a row by its number, as in InDesign
Tables: Table › Go to Row… selects a row by its number, as in InDesign ## What changes for users DesignCraft's Table menu had no Go to Row. InDesign's Table › Go to Row… jumps to a row given its number, and when the table has header or footer rows, the user chooses Header or Footer from a menu ([Select and edit tables, "Jump to a specific row in a table"](https://web.archive.org/web/20260513072511/https://helpx.adobe.com/indesign/using/selecting-editing-tables.html)). - `table.goToRow {row, section?: header|body|footer}` selects the row, counted from 1 within its section (body by default), and returns the page that shows it. A row that is not a whole number fro
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- dotCooCoo (imported from pull request #1845, open)
- Base
18246dddd2c9af3bd062b0e69781a040b34ff9b2- Size
- +344 −7 in 16 files
- Review
- pending
- Shared
- 2026-10-28
- Id
c4bd78564bcea09ac2f2047a
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/engine/src/cmd/table.rs crates/ui-egui/src/dialogs.rs crates/ui-egui/src/i18n.rs crates/ui-egui/src/i18n/ar.rs crates/ui-egui/src/i18n/cs.rs crates/ui-egui/src/i18n/de.rs crates/ui-egui/src/i18n/it.rs crates/ui-egui/src/i18n/ja.rs crates/ui-egui/src/i18n/ka.rs crates/ui-egui/src/i18n/pl.rs crates/ui-egui/src/i18n/pt_br.rs crates/ui-egui/src/i18n/ru.rs crates/ui-egui/src/i18n/uk.rs crates/ui-egui/src/menus.rs docs/gaps.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.@@ -401,6 +401,15 @@ fn table_specs() -> Vec<CommandSpec> { 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_or_ids,+ 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<Value> { 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<Value> {+ 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<Value> {@@ -2321,6 +2378,95 @@ 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_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();+ 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::*;@@ -1005,6 +1005,60 @@ 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!({+ "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+ }),+ ));+}++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 +2320,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 +3082,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 +3398,36 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> { ) } "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::<u64>().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 +11016,70 @@ 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");+ }++ /// 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);@@ -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", "编组透明度"]),@@ -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", "الصفحة الأولى"),@@ -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"),@@ -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"),@@ -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"),@@ -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", "グループの透明"),@@ -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", "ჯგუფის გამჭვირვალობა"),@@ -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"),@@ -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"),@@ -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", "Прозрачность группы"),@@ -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", "Прозорість групи"),@@ -818,6 +818,8 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:table.distributeColumns", "cmd:table.distributeRows", "-",+ "cmd:table.goToRow",+ "-", "ui:app.tablePanel", ], ),@@ -2574,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; }@@ -2662,6 +2668,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");@@ -3745,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.@@ -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,