diff --git a/ROADMAP.md b/ROADMAP.md index 9e1a512eb..402c81438 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -462,6 +462,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 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 dd7180915..4e1a401c4 100644 --- a/crates/compose/src/cache.rs +++ b/crates/compose/src/cache.rs @@ -146,6 +146,14 @@ impl Cache { c } + /// 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 mut resolved = Resolved::new(); + let placed = crate::placed::placements(doc, sid, &mut |host| self.resolve(doc, host, true, 1, &mut resolved)); + 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, resolved: &mut Resolved) -> Arc { diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index 5811887ae..0190aad05 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -53,6 +53,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, @@ -89,6 +92,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; @@ -500,6 +507,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/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 aca8b2cd4..df8820dad 100644 --- a/crates/engine/src/cmd/table/registry.rs +++ b/crates/engine/src/cmd/table/registry.rs @@ -1,7 +1,7 @@ use super::cell::{get_cell_stroke, set_cell}; use super::content::{drop_cells, place_graphic, sort_rows}; use super::options::{get, options}; -use super::selection::{move_rc, select, step_cell, toggle_selection}; +use super::selection::{go_to_row, move_rc, select, step_cell, toggle_selection}; use super::sizing::{distribute_cols, distribute_rows, resize_border, set_col_width, set_row_height}; use super::structure::{ can_convert_rows, convert_from_text, convert_rows, convert_to_text, delete_cols, delete_rows, delete_table, insert, insert_col, insert_row, @@ -396,6 +396,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, &crate::cmd::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 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 + ), 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!( @@ -407,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. Supplied `story` and `table` values must be nonnegative integer IDs. When a table ID occurs in multiple stories, supply `story` with `table`.", 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. Supplied `story` and `table` values must be nonnegative integer IDs. When a table ID occurs in multiple stories, supply `story` with `table`.", in_table_or_ids, get), ] } diff --git a/crates/engine/src/cmd/table/selection.rs b/crates/engine/src/cmd/table/selection.rs index 656dfdd78..320b21350 100644 --- a/crates/engine/src/cmd/table/selection.rs +++ b/crates/engine/src/cmd/table/selection.rs @@ -24,16 +24,113 @@ 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 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(); 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. +pub(super) 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); + // 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).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.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, 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"], + "table": r["table"], + "range": r["range"], + "page": page, + "spread": spread, + "rowBounds": bounds.map(|b| [b.x0, b.y0, b.x1, b.y1]), + })) +} + +/// 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 /// cell adds a row. pub(crate) fn step_cell(s: &mut Session, forward: bool) -> Result { @@ -138,3 +235,222 @@ pub(super) fn move_rc(s: &mut Session, p: &Value, rows: bool) -> Result { Ok(json!({"from": from, "to": to})) }) } + +#[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(); + // 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] + 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); + 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); + } + + #[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(); + // 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 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. + 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/canvas.rs b/crates/ui-egui/src/canvas.rs index 50652ffe6..d09344683 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, page_target, 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, page_target, 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 f00a005f5..a583e1bb7 100644 --- a/crates/ui-egui/src/canvas/view.rs +++ b/crates/ui-egui/src/canvas/view.rs @@ -216,6 +216,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 8ebde0b22..2d5b2d6c6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1631,6 +1631,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!({"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 { + ("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)); + } +} + /// The document's vertical ruler unit: what a bare number means in a height field. fn doc_vertical_unit(app: &DesignApp) -> Unit { app.session.active().map(|s| s.doc.settings.vertical_units).unwrap_or(Unit::Picas) @@ -2999,6 +3053,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"), "insertPages" => crate::i18n::tr(&app.ui.language, "Insert Pages"), "insertTable" => crate::i18n::tr(&app.ui.language, "Insert Table…").trim_end_matches('…').trim_end(), @@ -3872,6 +3927,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" => insert_table_dialog(app, ui, &mut d), "convertTextToTable" | "convertTableToText" => convert_table_dialog(app, ui, &mut d), @@ -4460,6 +4516,39 @@ fn confirm_part1(app: &mut DesignApp, d: Dialog) -> 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)); + 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 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) => { + let mut d = d.clone(); + d.fields.insert("status".into(), json!(e)); + app.ui.dialog = Some(d); + } + } + r + } "insertPages" => { let n = app.session.active().map_or(1, |st| st.doc.page_count()); // The page the menu shows: a page past the end is the last. diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 44805fdb4..119d92da0 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -4098,6 +4098,113 @@ fn create_guides_dialog_covers_the_spread_in_view() { 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 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] +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/ar.rs b/crates/ui-egui/src/i18n/ar.rs index d3986a5c2..a27e18854 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -300,6 +300,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 8b4dae1f1..a60c34ee2 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -679,6 +679,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 b3103f0f9..d3545b0a6 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -305,6 +305,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"), @@ -642,6 +643,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 7ef033154..0ff7e481d 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -810,6 +810,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 cc540e5f9..e6dd25efb 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -488,6 +488,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Generate QR Code", "QR コードを生成"), ("Go To", "移動"), ("Go to Page", "ページへ移動"), + ("Go to Row", "行へ移動"), ("Gutter", "段間"), ("Header Rows", "ヘッダー行"), ("Height", "高さ"), @@ -832,6 +833,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 7ae783e13..24a5d150a 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -729,6 +729,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 6cb1b5ae2..537d66f73 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -689,6 +689,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 2040bd49b..338e65d0c 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -311,6 +311,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 047fc5b3e..7fa0f4606 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -669,6 +669,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/sv.rs b/crates/ui-egui/src/i18n/sv.rs index 23d8f4688..bc826031d 100644 --- a/crates/ui-egui/src/i18n/sv.rs +++ b/crates/ui-egui/src/i18n/sv.rs @@ -103,6 +103,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"), diff --git a/crates/ui-egui/src/i18n/table.rs b/crates/ui-egui/src/i18n/table.rs index e791e02a4..370d172d4 100644 --- a/crates/ui-egui/src/i18n/table.rs +++ b/crates/ui-egui/src/i18n/table.rs @@ -1254,6 +1254,7 @@ pub(super) 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", "高度"]), @@ -2255,6 +2256,7 @@ pub(super) 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/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 265387c27..bfbd226c9 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -813,6 +813,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/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index b9d35539d..e6a02d3c6 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -83,6 +83,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/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index 00749603e..1ba25fb2d 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -175,6 +175,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; } diff --git a/crates/ui-egui/src/menus/tables.rs b/crates/ui-egui/src/menus/tables.rs index 2001dc2a2..8d75af4aa 100644 --- a/crates/ui-egui/src/menus/tables.rs +++ b/crates/ui-egui/src/menus/tables.rs @@ -469,6 +469,8 @@ pub const MENUS: &[(&str, &[&str])] = &[ "-", "cmd:table.distributeRows", "cmd:table.distributeColumns", + "-", + "cmd:table.goToRow", ], ), ( diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index e977c6733..0be7a9360 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -323,6 +323,17 @@ fn transform_menu_items_open_their_dialogs() { } } +#[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. @@ -2373,6 +2384,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 diff --git a/docs/gaps.md b/docs/gaps.md index addb0b679..fb1e93a36 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -154,8 +154,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)