diff --git a/ROADMAP.md b/ROADMAP.md index d3ef17e47..e9c2b6fa3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -349,6 +349,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. - **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** · 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** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. - **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change. diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 7049c2f39..5c662834c 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -403,6 +403,15 @@ fn table_specs() -> Vec { }), 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), ] } @@ -1537,6 +1546,33 @@ pub(crate) fn step_cell(s: &mut Session, forward: bool) -> Result { Ok(json!({"row": row, "col": col})) } +/// 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 { + 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.ok_or_else(|| bad(ID, "not in a cell"))?; + let cells = match st.selection.cells { + Some(cs) => { + let cell = CellAddr { table: cs.table, row: cs.range.r0, col: cs.range.c0 }; + 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 { let g = target(s, p, "table.get")?; let st = s.doc()?; diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index 59d2f267b..3933bd0d0 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -127,6 +127,32 @@ fn table_edit_after_anchor_removal_floors_parent_caret() { } } +/// 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 })); + // 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(); diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index ee3b362f1..dce3629b6 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -1452,6 +1452,29 @@ 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!({}))); + let s = Selection::text(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"}))); + assert!(matches!(t.key(&cx, ToolKey::Escape, Mods::default()).first(), Some(Action::SwitchTool(_)))); +} + #[test] fn type_tool_drags_a_table_column_border() { for degrees in [0.0, 90.0] { diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 56658f41e..79d0e9e09 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -359,6 +359,7 @@ 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}))] }; + let in_cell = cx.selection.text.is_some_and(|t| t.cell.is_some()); match key { ToolKey::Left if mods.alt && !mods.shift => kern(-1.0), ToolKey::Right if mods.alt && !mods.shift => kern(1.0), @@ -371,7 +372,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![], }