Browse / github.com/storytold/designcraft / patch
Tables: Shift+Tab goes back a cell, and Esc toggles between the text and the cell, as in InDesign
Tables: Shift+Tab goes back a cell, and Esc toggles between the text and the cell, as in InDesign Found while replaying Adobe's tutorial *How to make a table in InDesign* (Matthew Pizzi, Train Simple; adobe.com/learn/indesign/web/indesign-creating-a-table). In the lesson, Tab and Shift+Tab move between cells, and Esc in a cell selects that cell. Two of those keys didn't work in DesignCraft. - **Shift+Tab moved forward.** The Type tool sent a plain Tab whatever the modifiers, so Shift+Tab in a cell went on to the *next* cell instead of the previous one. - **Esc left the table.** In a cell, Esc switched to the Selection tool and selected the text frame. In InDesign, Esc toggles between the
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- moppeeer (imported from pull request #1674, open)
- Base
dcce40fb8731213c1f8e31cdd23444995afc62ce- Size
- +156 −1 in 6 files
- Review
- pending
- Shared
- 2026-10-28
- Id
8aaa6d10763322c058f7b640
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/engine/src/cmd/table.rs crates/engine/src/cmd/text.rs crates/engine/src/tests_table.rs crates/tools/src/tests.rs crates/tools/src/text.rs
@@ -405,6 +405,7 @@ Newest first. - **2026-10-22** · Autoflow (Shift-click with the loaded text cursor, autoflow on Place, `text.thread {autoflow}`) fills the pages that follow before it adds any, as InDesign does: one frame per column on each following page, whatever else the page holds, then new pages at the end of the document. It inserted pages right after the story's last frame, in front of the document's own pages. Frames it set past the end of the text go again; a page the document had stays, and a page autoflow added only for such a frame goes with it. Smart Text Reflow of the primary text frame is unchanged. No readiness percentage change. - **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. - **2026-10-23** · Translations: a missing string shows in English and fails no test; `cargo xtask i18n-report`, run by `cargo xtask ci`, prints the missing strings per language. Tests keep duplicate-key and placeholder checks and the language mechanics (lookup, context captions, plural captions, right-to-left). No readiness percentage change.+- **2026-10-23** · Tables: with the Type tool in a cell, Shift+Tab goes back a cell (it went on to the next one) and Esc toggles between the text and the cell, as in InDesign: the insertion point selects its cell, and selected cells select the text of the first one (`table.toggleSelection`). Esc left the table for the frame. No readiness percentage change. - **2026-10-22** · Hyphenation Settings, as in InDesign: the Paragraph panel menu's Hyphenation… (`app.hyphenationDialog`) sets the selected paragraphs' Hyphenate, Words with at Least, After First, Before Last, Hyphen Limit, Hyphenation Zone, the Better Spacing ↔ Fewer Hyphens slider (InDesign's 0–10 steps), Hyphenate Capitalized Words, Last Word and Across Column, with Preview, as one undo step; Paragraph Style Options › Hyphenation has the same form. The panel menu lists its items in InDesign's order. Replicated from a hyphenation tutorial: the same text in Minion Pro breaks into the same lines as InDesign's with hyphenation on, off, and at Fewer Hyphens. No readiness percentage change. - **2026-10-22** · Hyphenate Across Column (Hyphenation Settings; IDML HyphenateAcrossColumns) is applied: with it off, a paragraph whose line ending in an automatic hyphen would end a column or frame is re-composed without that hyphenation point, so the word moves on whole, as in InDesign. Opened and imported files keep their own setting, so their layouts don't change. No readiness percentage change. - **2026-10-22** · The Paragraph panel is laid out as InDesign's: the nine alignment buttons in groups; indent, spacing and drop cap fields by their icons in two columns, with Space Between Paragraphs Using Same Style (Ignore or a space); Shading and Border with the Paragraph Borders and Shading button and colour; Hyphenate with Do Not Align / Align to Baseline Grid. Its panel menu follows InDesign's order: Paragraph and Single-line Composer, Only Align First Line to Grid, Balance Ragged Lines, Optical Margin Alignment, Justification…, Keep Options…, Span Columns…, Hyphenation…, Drop Caps and Nested Styles…, GREP Styles… (these three now open for the selected paragraphs), Paragraph Rules…, Paragraph Borders and Shading…, Bullets and Numbering…, Restart/Continue Numbering, Define Lists… and the paragraph direction. Convert Bullets and Numbering to Text is listed but not available yet. No readiness percentage change.@@ -403,6 +403,15 @@ fn table_specs() -> Vec<CommandSpec> { }), 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!(+ noundo "table.toggleSelection",+ "Toggle Text and Cell Selection",+ [],+ None,+ "{} — Esc in a table, as InDesign: the insertion point or text in a cell selects that cell; selected cells select the text of the first one",+ in_cell,+ |s, _| toggle_selection(s)+ ), cmd!(query "table.get", "Get Table", [], None, "{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?} → rows, columns, cells (text, spans, fill), options", in_table_or_ids, get), ] }@@ -429,7 +438,13 @@ fn in_table_or_ids(s: &Session) -> std::result::Result<(), String> { fn in_cell(s: &Session) -> std::result::Result<(), String> { has_doc(s)?;- if s.active().is_some_and(|d| d.selection.text.is_some_and(|t| t.cell.is_some())) { Ok(()) } else { Err("not in a table cell".into()) }+ // Footnote text is addressed as a cell too, but isn't in a table.+ let in_table = |c: CellAddr| c.footnote_id().is_none();+ if s.active().is_some_and(|d| d.selection.text.is_some_and(|t| t.cell.is_some_and(in_table))) {+ Ok(())+ } else {+ Err("not in a table cell".into())+ } } /// The table a command acts on and its target cell range.@@ -1563,6 +1578,55 @@ pub(crate) fn step_cell(s: &mut Session, forward: bool) -> Result<Value> { Ok(json!({"row": row, "col": col})) } +/// With one cell selected (a merged one counts as one), select its whole text instead, so what is+/// typed next replaces it, as InDesign does. Other selections are left as they are.+pub(crate) fn one_selected_cell_to_text(s: &mut Session) -> Result<()> {+ let st = s.doc_mut()?;+ let (Some(cs), Some(t)) = (st.selection.cells, st.selection.text) else { return Ok(()) };+ let Some(tb) = st.doc.story(cs.story).and_then(|x| x.tables.get(&cs.table)) else { return Ok(()) };+ let (row, col) = tb.owner(cs.range.r0, cs.range.c0);+ if tb.owner(cs.range.r1, cs.range.c1) != (row, col) {+ return Ok(());+ }+ let cell = CellAddr { table: cs.table, row, col };+ let len = st.doc.text_story(cs.story, Some(cell)).map_or(0, |x| x.len());+ st.selection = Selection::text(TextSel { story: cs.story, anchor: 0, focus: len, cell: Some(cell), ..t });+ Ok(())+}++/// Esc in a table: text in a cell becomes that cell's selection, and selected cells the text of+/// the first one (InDesign toggles between the two).+fn toggle_selection(s: &mut Session) -> Result<Value> {+ const ID: &str = "table.toggleSelection";+ let st = s.doc_mut()?;+ let t = st.selection.text.ok_or_else(|| bad(ID, "no caret"))?;+ let c = t.cell.filter(|c| c.footnote_id().is_none()).ok_or_else(|| bad(ID, "not in a cell"))?;+ let cells = match st.selection.cells {+ Some(cs) => {+ // A merged cell's text belongs to the cell at its top left.+ let (row, col) = st+ .doc+ .story(cs.story)+ .and_then(|x| x.tables.get(&cs.table))+ .map_or((cs.range.r0, cs.range.c0), |tb| tb.owner(cs.range.r0, cs.range.c0));+ let cell = CellAddr { table: cs.table, row, col };+ let len = st.doc.text_story(cs.story, Some(cell)).map_or(0, |x| x.len());+ st.selection = Selection::text(TextSel { story: cs.story, anchor: 0, focus: len, cell: Some(cell), ..t });+ false+ }+ None => {+ st.selection = Selection {+ cells: Some(TableSel { story: t.story, table: c.table, range: CellRange::cell(c.row, c.col) }),+ text: Some(TextSel { anchor: 0, focus: 0, ..t }),+ ..Default::default()+ };+ true+ }+ };+ st.bump_revision();+ Ok(json!({"cells": cells}))+}+ fn get(s: &mut Session, p: &Value) -> Result<Value> { let g = target(s, p, "table.get")?; let st = s.doc()?;@@ -672,6 +672,8 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> { if text == "\t" && s.doc()?.selection.text.is_some_and(|t| t.cell.is_some()) { return super::table::step_cell(s, true); }+ // Typing with one cell selected replaces its text, as InDesign.+ super::table::one_selected_cell_to_text(s)?; let raw = p.get("raw").and_then(Value::as_bool).unwrap_or(false); let text = if s.prefs.typographers_quotes && !raw { smart_quotes(s, &text) } else { text }; let tracking = s.doc()?.doc.settings.track_changes;@@ -127,6 +127,62 @@ fn table_edit_after_anchor_removal_floors_parent_caret() { } } +/// Footnote text is addressed as a cell, but the table commands don't take it for one.+#[test]+fn the_cell_commands_refuse_footnote_text() {+ let (mut s, sid, _) = session_with_frame();+ s.execute("story.setText", &json!({"story": sid, "text": "Hello"})).unwrap();+ s.execute("text.select", &json!({"story": sid, "anchor": 5, "focus": 5})).unwrap();+ s.execute("footnote.insert", &json!({"text": "Note"})).unwrap();+ let before = s.doc().unwrap().selection.clone();+ assert!(before.text.and_then(|t| t.cell).and_then(|c| c.footnote_id()).is_some(), "the caret is in the footnote");+ for cmd in ["table.toggleSelection", "table.prevCell", "table.nextCell"] {+ assert!(s.execute(cmd, &json!({})).is_err(), "{cmd}");+ }+ assert_eq!(s.doc().unwrap().selection, before);+}++/// Esc in a table toggles between the text and the cell (InDesign's Esc): the caret in a cell+/// selects that cell, and selected cells select the first one's text; Shift+Tab goes back a cell.+#[test]+fn escape_toggles_text_and_cell_selection() {+ let (mut s, sid, _) = session_with_frame();+ let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap();+ s.execute("table.nextCell", &json!({})).unwrap();+ s.execute("text.insert", &json!({"text": "Phone"})).unwrap();+ let cell = designcraft_doc::CellAddr { table: tid, row: 0, col: 1 };+ assert_eq!(s.execute("table.toggleSelection", &json!({})).unwrap(), json!({"cells": true}));+ let sel = s.doc().unwrap().selection.clone();+ let cells = sel.cells.unwrap();+ assert_eq!((cells.story.0, cells.table, cells.range), (sid, tid, designcraft_doc::CellRange::cell(0, 1)));+ assert_eq!(sel.text.unwrap().cell, Some(cell));+ assert_eq!(s.execute("table.toggleSelection", &json!({})).unwrap(), json!({"cells": false}));+ let sel = s.doc().unwrap().selection.clone();+ assert!(sel.cells.is_none());+ let t = sel.text.unwrap();+ assert_eq!((t.cell, t.range()), (Some(cell), 0..5), "the cell's text is selected");+ s.execute("table.prevCell", &json!({})).unwrap();+ assert_eq!(s.doc().unwrap().selection.text.unwrap().cell, Some(designcraft_doc::CellAddr { col: 0, ..cell }));+ // With the cell selected, typing replaces its text and leaves the caret after it.+ s.execute("table.nextCell", &json!({})).unwrap();+ s.execute("table.toggleSelection", &json!({})).unwrap();+ s.execute("text.insert", &json!({"text": "Fax"})).unwrap();+ let t = s.execute("table.get", &json!({"table": tid})).unwrap();+ assert_eq!(t["cells"][1]["text"], "Fax");+ let sel = s.doc().unwrap().selection.clone();+ assert!(sel.cells.is_none());+ assert_eq!(sel.text.unwrap().range(), 3..3);+ // A selection that starts inside a merged cell goes back to the merged cell's text.+ s.execute("table.select", &json!({"rows": [0, 1], "cols": [0, 0]})).unwrap();+ s.execute("table.merge", &json!({})).unwrap();+ s.execute("table.select", &json!({"rows": [1, 1], "cols": [0, 1]})).unwrap();+ s.execute("table.toggleSelection", &json!({})).unwrap();+ assert_eq!(s.doc().unwrap().selection.text.unwrap().cell, Some(designcraft_doc::CellAddr { row: 0, col: 0, ..cell }));+ // Outside a table there is nothing to toggle.+ s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap();+ assert!(s.execute("table.toggleSelection", &json!({})).is_err());+}+ fn selected_merged_table(column: bool) -> (Session, u64, u64, u64) { let (mut s, sid, fid) = session_with_frame(); let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap();@@ -1454,6 +1454,32 @@ fn table_frame(degrees: f64) -> (Document, ItemId) { (d, id) } +/// In a table cell Shift+Tab goes back a cell and Esc toggles cell selection, as InDesign; out of+/// a table Tab types a tab and Esc leaves the text.+#[test]+fn type_tool_keys_in_a_table_cell() {+ let (d, id) = table_frame(0.0);+ let sid = d.item(id).unwrap().text_frame().unwrap().story;+ let table = *d.story(sid).unwrap().tables.keys().next().unwrap();+ let (c, l) = (Cache::new(), CanvasLayout::new(&d, false));+ let cell = designcraft_doc::CellAddr { table, row: 1, col: 1 };+ let s = Selection::text(designcraft_doc::TextSel { cell: Some(cell), ..designcraft_doc::TextSel::caret(sid, 0) });+ let cx = ctx(&d, &s, &c, &l);+ let mut t = create("type");+ let shift = Mods { shift: true, ..Default::default() };+ let exec = |c: &str, p: serde_json::Value| vec![Action::Exec(c.into(), p)];+ assert_eq!(t.key(&cx, ToolKey::Tab, shift), exec("table.prevCell", serde_json::json!({})));+ assert_eq!(t.key(&cx, ToolKey::Tab, Mods::default()), exec("text.insert", serde_json::json!({"text": "\t"})));+ assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), exec("table.toggleSelection", serde_json::json!({})));+ // Out of a table, and in footnote text (addressed as a cell too).+ for cell in [None, Some(designcraft_doc::CellAddr::footnote(1))] {+ let s = Selection::text(designcraft_doc::TextSel { cell, ..designcraft_doc::TextSel::caret(sid, 0) });+ let cx = ctx(&d, &s, &c, &l);+ assert_eq!(t.key(&cx, ToolKey::Tab, shift), exec("text.insert", serde_json::json!({"text": "\t"})), "{cell:?}");+ assert!(matches!(t.key(&cx, ToolKey::Escape, Mods::default()).first(), Some(Action::SwitchTool(_))), "{cell:?}");+ }+}+ #[test] fn type_tool_drags_a_table_column_border() { for degrees in [0.0, 90.0] {@@ -364,6 +364,8 @@ impl Tool for TypeTool { let steps = if mods.cmd || mods.ctrl { 5.0 } else { 1.0 }; vec![Action::Exec("type.kernStep".into(), json!({"steps": sign * steps}))] };+ // A table cell's text, not a footnote's (which is addressed as a cell too).+ let in_cell = cx.selection.text.is_some_and(|t| t.cell.is_some_and(|c| c.footnote_id().is_none())); match key { ToolKey::Left if mods.alt && !mods.shift => kern(-1.0), ToolKey::Right if mods.alt && !mods.shift => kern(1.0),@@ -376,7 +378,11 @@ impl Tool for TypeTool { ToolKey::Backspace => vec![Action::Exec("text.delete".into(), json!({"forward": false, "word": mods.alt}))], ToolKey::Delete => vec![Action::Exec("text.delete".into(), json!({"forward": true, "word": mods.alt}))], ToolKey::Enter => vec![Action::Exec("text.insert".into(), json!({"text": if mods.shift { "\u{2028}" } else { "\n" }}))],+ // In a table cell Shift+Tab goes back a cell (Tab goes on through `text.insert`).+ ToolKey::Tab if mods.shift && in_cell => vec![Action::Exec("table.prevCell".into(), json!({}))], ToolKey::Tab => vec![Action::Exec("text.insert".into(), json!({"text": "\t"}))],+ // In a table Esc toggles between the cell's text and the cell, as InDesign.+ ToolKey::Escape if in_cell => vec![Action::Exec("table.toggleSelection".into(), json!({}))], ToolKey::Escape => vec![Action::SwitchTool("selection".into()), Action::Exec("text.exitToFrame".into(), json!({}))], _ => vec![], }