Merge pull request #1845 from dotCooCoo/feat/table-go-to-row

Tables: Table › Go to Row… selects a row by its number
This commit is contained in:
FredApps authored and GitHub committed 2026-11-12 18:58:57 +01:00
commit f5f7084fc3
27 files changed
+631 -20

No files matched your search

+1
View File
@@ -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.
+8
View File
@@ -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<crate::Placement> {
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<ComposedStory> {
+8
View File
@@ -53,6 +53,9 @@ pub struct TableFrag {
pub column: u32,
pub rect: Rect,
pub cells: Vec<PlacedCell>,
/// 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<StrokeSeg>,
/// 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,
+17 -13
View File
@@ -70,19 +70,23 @@ pub(super) fn get(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> = (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<Value> = (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)
}
+11 -2
View File
@@ -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<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, &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<CommandSpec> {
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),
]
}
+318 -2
View File
@@ -24,16 +24,113 @@ pub(super) fn select(s: &mut Session, p: &Value) -> Result<Value> {
_ => {}
}
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<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);
// 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<designcraft_geom::Rect> =
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<Value> {
@@ -138,3 +235,222 @@ pub(super) fn move_rc(s: &mut Session, p: &Value, rows: bool) -> Result<Value> {
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<f64> = 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<f64> {
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);
}
}
+2 -1
View File
@@ -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;
+21
View File
@@ -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<designcraft_doc::ids::PageId> {
doc.spreads.iter().flat_map(|s| s.pages.iter().map(|p| p.id)).collect()
+89
View File
@@ -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<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 scrolls the row to its middle, at the same zoom, on the row's page or another.
let spread = serde_json::from_value::<designcraft_doc::SpreadRef>(v["spread"].clone()).ok();
let bounds = v["rowBounds"].as_array().and_then(|a| {
let n: Vec<f64> = 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.
+107
View File
@@ -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<f64> = 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);
+2
View File
@@ -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", "الصفحة الأولى"),
+2
View File
@@ -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"),
+2
View File
@@ -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"),
+2
View File
@@ -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"),
+2
View File
@@ -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", "グループの透明"),
+2
View File
@@ -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", "ჯგუფის გამჭვირვალობა"),
+2
View File
@@ -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"),
+2
View File
@@ -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"),
+2
View File
@@ -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", "Прозрачность группы"),
+2
View File
@@ -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"),
+2
View File
@@ -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", "编组透明度"]),
+2
View File
@@ -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", "Прозорість групи"),
+4
View File
@@ -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");
+4
View File
@@ -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;
}
+2
View File
@@ -469,6 +469,8 @@ pub const MENUS: &[(&str, &[&str])] = &[
"-",
"cmd:table.distributeRows",
"cmd:table.distributeColumns",
"-",
"cmd:table.goToRow",
],
),
(
+13
View File
@@ -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
+2 -2
View File
@@ -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)