collective

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

diff --git a/ROADMAP.md b/ROADMAP.md--- 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--- a/crates/engine/src/cmd/table.rs+++ b/crates/engine/src/cmd/table.rs@@ -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::*;diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs--- a/crates/ui-egui/src/dialogs.rs+++ b/crates/ui-egui/src/dialogs.rs@@ -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);diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs--- 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--- 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--- 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--- 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--- 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--- 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--- 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--- 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--- 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--- 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--- 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--- 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",         ],     ),@@ -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.diff --git a/docs/gaps.md b/docs/gaps.md--- 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,