From c20635aacfcbb9c628630a065e5b8e7917aede30 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 12:22:11 +0200 Subject: [PATCH 01/30] Pages panel: spread labels and section markers as InDesign's; it follows the page in view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chris Nielsen's "Numbering Page Sections" reads the Pages panel all along: InDesign labels each spread ("2–3", "2, 1" where a section starts inside it, "8, A", "ii–iii"), puts a small triangle over each section's first page, and the panel scrolls to the page selected ("the pages panel jumps down there", 6:46). DesignCraft named each page under it ("2 1") and drew its triangle over the spread in view. - One label per spread (spread_label): a range with an en dash for pages numbered on in one section, the names apart when a section starts inside it. Positions under absolute numbering. A blue badge marks the spread in view; a click on the label goes to the spread. - A triangle above every page that starts a section (page 1 included). - The panel scrolls to the spread of the page in view when that page changes. - Page thumbnails are named for screen readers ("Page 1"). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/panels/pages.rs | 130 +++++++++++++++++++++++++---- 2 files changed, 117 insertions(+), 14 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c60a04107..4db547830 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · The Pages panel labels spreads as InDesign does ("2–3", "2, 1" across a section start, "8, A"), marks each section's first page with a triangle, and scrolls to the page in view. No readiness percentage change. - **2026-10-12** · Text threads by hand: click a selected text frame's in or out port to load the text cursor, then click a frame to thread it, click empty space for a column-wide frame or drag one (it snaps like frame drawing); Shift-click autoflows, Alt-click stays loaded on the diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index ae323225b..2fa04b9ff 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -51,6 +51,25 @@ enum PageDrag { Parent(Option), } +/// A spread's label under its thumbnails, as InDesign's: one name for a single page, "2–3" for +/// pages numbered on in one section (an en dash), and the names apart ("2, 1", "8, A") when a section starts +/// inside the spread. +pub(crate) fn spread_label(app: &DesignApp, doc: &designcraft_doc::Document, first: usize, n: usize) -> String { + let names: Vec = (first..first + n).map(|abs| app.session.page_label(abs)).collect(); + let starts = |abs: usize| abs == 0 || doc.sections.iter().any(|s| s.start == abs); + match (names.first(), names.last()) { + (Some(a), Some(b)) if names.len() > 1 => { + if !app.session.prefs.absolute_page_numbers && (first + 1..first + n).any(starts) { + names.join(", ") + } else { + format!("{a}\u{2013}{b}") + } + } + (Some(a), _) => a.clone(), + _ => String::new(), + } +} + /// Selected-page tint (multiplied over the thumbnail). const SELECTED_TINT: Color32 = Color32::from_rgb(0x88, 0xc7, 0xfb); /// Page-number badge of the selected page. @@ -112,18 +131,19 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { // ---- Document spreads. let cur = crate::canvas::current_page(app).unwrap_or(0); let cur_spread = doc.page_loc(cur).map(|l| l.0); + let followed_id = egui::Id::new("pages_panel_followed"); let center = ui.available_width() / 2.0; let dragging = egui::DragAndDrop::has_any_payload(ui.ctx()); for (si, sp) in doc.spreads.iter().enumerate() { let first = doc.first_page_of_spread(si); let scale = THUMB_H / doc.settings.page_height.max(1.0) as f32; let spine = sp.spine_x() as f32; - let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0 + THUMB_H + 22.0), Sense::hover()); + let (row, row_resp) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0 + THUMB_H + 22.0), Sense::hover()); let top = row.min.y + 8.0; - // Marker above the current spread. - if cur_spread == Some(si) && !editing_parents { - let c = egui::pos2(row.min.x + center, row.min.y + 3.5); - ui.painter().add(egui::Shape::convex_polygon(vec![c + vec2(-5.0, -3.0), c + vec2(5.0, -3.0), c + vec2(0.0, 3.0)], t.icon, Stroke::NONE)); + // The panel follows the page in view (InDesign scrolls to the selected page). + if cur_spread == Some(si) && !editing_parents && ui.data(|d| d.get_temp::(followed_id)) != Some(cur) { + row_resp.scroll_to_me(None); + ui.data_mut(|d| d.insert_temp(followed_id, cur)); } for (pi, p) in sp.pages.iter().enumerate() { let abs = first + pi; @@ -138,6 +158,22 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.painter().rect_filled(pr, 0.0, tint); } ui.painter().rect_stroke(pr, 0.0, Stroke::new(0.5, Color32::from_gray(0x4a)), StrokeKind::Outside); + // A section starts here: InDesign's small triangle above the page. + if abs == 0 || doc.sections.iter().any(|s| s.start == abs) { + let c = egui::pos2(pr.center().x, row.min.y + 4.0); + ui.painter().add(egui::Shape::convex_polygon( + vec![c + vec2(-4.5, -3.0), c + vec2(4.5, -3.0), c + vec2(0.0, 3.0)], + t.icon, + Stroke::NONE, + )); + } + resp.widget_info(|| { + egui::WidgetInfo::labeled( + egui::WidgetType::Button, + true, + format!("{} {}", crate::i18n::tr(&app.ui.language, "Page"), app.session.page_label(abs)), + ) + }); // Parent prefix in the outer top corner (black on white). if let Some(pid) = p.parent && let Some(pp) = doc.parents.iter().find(|x| x.id == pid) @@ -172,15 +208,20 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { crate::canvas::go_to_page(app, abs); } resp.context_menu(|ui| page_menu(app, ui, &doc, abs, si)); - // Page number under the page: white on a blue badge when selected. - let name = app.session.page_label(abs); - let g = ui.painter().layout_no_wrap(name, crate::theme::semibold(11.0), Color32::WHITE); - let c = egui::pos2(pr.center().x, pr.max.y + 11.0); - if selected { - let br = egui::Rect::from_center_size(c, vec2(g.size().x.max(7.0) + 7.0, 12.5)); - ui.painter().rect_filled(br, 0.0, BADGE); - } - ui.painter().galley(c - g.size() / 2.0, g, Color32::WHITE); + } + // The spread's label under its pages: white on a blue badge when it holds the page in view. + let label = spread_label(app, &doc, first, sp.pages.len()); + let g = ui.painter().layout_no_wrap(label.clone(), crate::theme::semibold(11.0), Color32::WHITE); + let c = egui::pos2(row.min.x + center, top + THUMB_H + 11.0); + let br = egui::Rect::from_center_size(c, vec2(g.size().x.max(7.0) + 7.0, 12.5)); + if cur_spread == Some(si) && !editing_parents { + ui.painter().rect_filled(br, 0.0, BADGE); + } + ui.painter().galley(c - g.size() / 2.0, g, Color32::WHITE); + let lr = ui.interact(br, ui.id().with(("spread_label", si)), Sense::click()); + lr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, &label)); + if lr.clicked() { + crate::canvas::go_to_page(app, first); } } ui.add_space(6.0); @@ -289,3 +330,64 @@ fn drag_ghost(ui: &egui::Ui, label: &str, t: &Tokens) { painter.rect(r, 2.0, t.panel_darker, Stroke::new(1.0, t.field_border), StrokeKind::Inside); painter.galley(r.min + vec2(5.0, 3.0), g, t.text_strong); } + +#[cfg(test)] +mod spread_label_tests { + use egui_kittest::{Harness, kittest::Queryable}; + use serde_json::json; + + use super::*; + + fn app() -> DesignApp { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"facingPages": true, "pages": 9})).unwrap(); + app + } + + fn labels(app: &DesignApp) -> Vec { + let doc = app.session.doc().unwrap().doc.clone(); + (0..doc.spreads.len()).map(|si| spread_label(app, &doc, doc.first_page_of_spread(si), doc.spreads[si].pages.len())).collect() + } + + #[test] + fn spreads_are_labelled_as_indesign_labels_them() { + // Chris Nielsen's "Numbering Page Sections": sections starting at 1 on pages 3 and 7, + // the second in letters, label their spreads "2, 1" and "6, A". + let mut app = app(); + assert_eq!(labels(&app), ["1", "2–3", "4–5", "6–7", "8–9"]); + app.run("layout.section", json!({"page": 3, "startNumber": 1})).unwrap(); + app.run("layout.section", json!({"page": 7, "startNumber": 1, "style": "upperLetters"})).unwrap(); + assert_eq!(labels(&app), ["1", "2, 1", "2–3", "4, A", "B–C"]); + // Absolute numbering: positions, always a range. + app.session.prefs.absolute_page_numbers = true; + assert_eq!(labels(&app), ["1", "2–3", "4–5", "6–7", "8–9"]); + } + + #[test] + fn the_panel_shows_spread_labels_and_a_click_on_one_goes_to_its_spread() { + let mut app = app(); + app.run("layout.section", json!({"page": 3, "startNumber": 1})).unwrap(); + // The canvas the panel's clicks move (not drawn here). + app.canvas_rect = Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), vec2(1000.0, 800.0))); + let mut h = Harness::builder().with_size(vec2(260.0, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + egui::ScrollArea::vertical().show(ui, |ui| show(app, ui)); + }, + app, + ); + h.run_steps(3); + let _ = h.get_by_label("2, 1"); + assert!(h.query_by_label("2 1").is_none()); + // Each page is named for screen readers: two pages are numbered 1. + assert_eq!(h.query_all_by_label("Page 1").count(), 2); + h.get_by_label("2–3").click(); + h.run_steps(3); + assert_eq!(h.state().session.doc().unwrap().doc.page_loc(crate::canvas::current_page(h.state()).unwrap()).unwrap().0, 2); + } +} From 42903d7bd3e94aedc818db69dd11dc06acd1dbbd Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:43:17 +0200 Subject: [PATCH 02/30] TOC-5: open numbering options from section markers --- ROADMAP.md | 2 ++ crates/ui-egui/src/panels/pages.rs | 56 +++++++++++++++++++++++++++++- docs/ui-parity.md | 3 ++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4b47f97a0..3cb6d4d0b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-18** · Double-clicking a section triangle in the Pages panel opens that page’s numbering options, including from parent-page editing. No readiness percentage change. + - **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change. - **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change. - **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change. diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index ef9895373..88f04e362 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -472,7 +472,24 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { t.icon, Stroke::NONE, )); - + let marker = + ui.interact(egui::Rect::from_center_size(c, vec2(12.0, 8.0)), ui.id().with(("section", abs)), Sense::click()); + let label = format!( + "{}: {}", + crate::i18n::tr(&app.ui.language, "Numbering & Section Options…").trim_end_matches('…'), + app.session.page_label(abs) + ); + marker.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &label)); + if marker.double_clicked() { + if editing_parents { + let _ = app.run("layout.parents.edit", json!({"on": false})); + if let Some(view) = app.view_mut() { + view.fitted = false; + } + } + crate::canvas::go_to_page(app, abs); + crate::menus::activate(app, "layout.section", &serde_json::Value::Null); + } } // Parent prefix in the outer top corner (black on white). if let Some(pid) = p.parent @@ -1123,6 +1140,43 @@ mod spread_label_tests { use super::*; + /// Section-marker activation targets its page and leaves parent editing without editing the document. + #[test] + fn double_clicking_a_section_marker_opens_that_pages_numbering_options() { + let mut app = app(); + app.run("layout.section", json!({"page": 1, "startNumber": 1, "style": "lowerRoman"})).unwrap(); + app.run("layout.section", json!({"page": 3, "startNumber": 1, "style": "arabic"})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1000.0, 800.0))); + app.run("layout.parents.edit", json!({"on": true})).unwrap(); + let mut h = Harness::builder().with_size(vec2(280.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("section_marker_test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + let pos = h.get_by_label("Numbering & Section Options: 1").rect().center(); + let before = h.state().session.doc().unwrap().doc.clone(); + for _ in 0..2 { + for pressed in [true, false] { + h.event(egui::Event::PointerMoved(pos)); + h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + } + } + let dialog = h.state().ui.dialog.as_ref().unwrap(); + assert_eq!(dialog.id, "numberingSection"); + assert_eq!(dialog.fields.get("page"), Some(&json!(3))); + assert!(!h.state().session.doc().unwrap().editing_parents); + assert!(std::sync::Arc::ptr_eq(&h.state().session.doc().unwrap().doc, &before), "opening options does not edit the section"); + } + fn app() -> DesignApp { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"facingPages": true, "pages": 9})).unwrap(); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2dcf4687a..5c3f0b8b6 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -126,3 +126,6 @@ covers checking the sampled and redefined settings in the styles tutorial (2:45 | 2026-10-11 | minor | Added held Cmd+Space / Cmd+Alt+Space Zoom navigation and canvas input regression tests | | 2026-10-10 | minor | Threading row corrected to ✗ after checking the code (no out-port interaction, no thread command) | | 2026-10-10 | major | Created: measured menu coverage, 27-row interaction checklist from the code and issues | + +Double-clicking a section triangle in the Pages panel opens Numbering & Section Options for +that section’s first page. It leaves parent-page editing and does not edit section data until confirmed. From 7200e90615e9bfc36c5e16341ed66440be0786a3 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 15:55:23 +0200 Subject: [PATCH 03/30] PR-892: spread labels read on a light panel and are buttons for screen readers Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/pages.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 6221e940d..5f9a7aec6 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -523,15 +523,19 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { resp.context_menu(|ui| page_menu(app, ui, &doc, abs, si)); } let label = spread_label(app, &doc, first, sp.pages.len()); - let g = ui.painter().layout_no_wrap(label.clone(), crate::theme::semibold(11.0), Color32::WHITE); + // White on the badge; the theme's text colour elsewhere, so it reads on a light panel. + let badge = cur_spread == Some(si) && !editing_parents; + let ink = if badge { Color32::WHITE } else { t.text_strong }; + let g = ui.painter().layout_no_wrap(label.clone(), crate::theme::semibold(11.0), ink); let c = egui::pos2(row.min.x + center, top + row_h + 11.0); let br = egui::Rect::from_center_size(c, vec2(g.size().x + 7.0, 12.5)); - if cur_spread == Some(si) && !editing_parents { + if badge { ui.painter().rect_filled(br, 0.0, BADGE); } - ui.painter().galley(c - g.size() / 2.0, g, Color32::WHITE); + ui.painter().galley(c - g.size() / 2.0, g, ink); let lr = ui.interact(br, ui.id().with(("spread_label", si)), Sense::click()); - lr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, &label)); + // A click goes to the spread, so assistive technology hears a button. + lr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &label)); if lr.clicked() { if editing_parents { let _ = app.run("layout.parents.edit", json!({"on": false})); @@ -1211,7 +1215,8 @@ mod spread_label_tests { app, ); h.run_steps(3); - let _ = h.get_by_label("2, 1"); + // A spread label is a button: a click goes to the spread. + let _ = h.get_by(|n| n.role() == egui::accesskit::Role::Button && n.label().as_deref() == Some("2, 1")); assert!(h.query_by_label("2 1").is_none()); // Each page is named for screen readers: two pages are numbered 1. assert_eq!(h.query_all_by_label("Page 1").count(), 2); From fefeb2b5b619fe885369a02f2d963f6de39a2090 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 17:21:43 +0200 Subject: [PATCH 04/30] PR-1287: the keyboard and screen readers open a section marker's options too Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/pages.rs | 32 +++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 778331bff..473c70357 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -491,7 +491,10 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { app.session.page_label(abs) ); marker.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &label)); - if marker.double_clicked() { + // A double click, or the keyboard or a screen reader activating it (a + // single pointer click only selects, as on a page). + let activated = marker.double_clicked() || marker.clicked() && !marker.clicked_by(egui::PointerButton::Primary); + if activated { if editing_parents { let _ = app.run("layout.parents.edit", json!({"on": false})); if let Some(view) = app.view_mut() { @@ -1218,6 +1221,33 @@ mod spread_label_tests { assert!(std::sync::Arc::ptr_eq(&h.state().session.doc().unwrap().doc, &before), "opening options does not edit the section"); } + #[test] + fn a_section_marker_opens_its_options_from_the_keyboard_but_not_on_one_click() { + let mut app = app(); + app.run("layout.section", json!({"page": 3, "startNumber": 5})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1000.0, 800.0))); + let mut h = Harness::builder().with_size(vec2(280.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("section_marker_key_test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Numbering & Section Options: 5").click(); + h.run_steps(2); + assert!(h.state().ui.dialog.is_none(), "one pointer click doesn't open the options"); + h.run_steps(30); + h.get_by_label("Numbering & Section Options: 5").click_accesskit(); + h.run_steps(2); + assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.id.as_str()), Some("numberingSection")); + } + /// Create a facing-page document for section navigation tests. fn app() -> DesignApp { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From 10cd0cac3c22862460d9e1dd024435474d32ed47 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 23 Oct 2026 20:57:03 +0200 Subject: [PATCH 05/30] Table menu in InDesign's order; Insert Table dialog laid out as InDesign's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Table menu: Insert Table… (Cmd+Alt+Shift+T), Convert Text to Table, Convert Table to Text | Cell Options | Insert, Delete, Select | Merge, Unmerge, Split Horizontally/Vertically, Convert Cell Type, Convert Rows | Distribute Rows Evenly, Distribute Columns Evenly. The Table panel item left the menu (it is in Window › Type & Tables, Shift+F9). - Cmd+Alt+Shift+T opens the Insert Table dialog, as InDesign's; it ran table.insert with its defaults, so the shortcut and the menu did different things. - The Insert Table dialog: InDesign's title, a narrow window, Body Rows and Columns then Header and Footer Rows as numbers with spinners and right-aligned labels, and a Table Style applied to the new table. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/table.rs | 3 +- crates/ui-egui/src/dialogs.rs | 109 +++++++++++++++++++++++++-------- crates/ui-egui/src/menus.rs | 60 +++++++++++++++--- docs/target-app-parity.md | 2 +- 5 files changed, 139 insertions(+), 36 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d3ef17e47..03691beec 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: the Table menu follows InDesign's order (Cell Options before Insert, Delete and Select; Merge, Unmerge and Split before Convert Cell Type and Convert Rows; Distribute Rows Evenly before Distribute Columns Evenly), and its first item is Insert Table… with InDesign's Cmd+Alt+Shift+T, which opens the dialog (the shortcut ran `table.insert` with its defaults, behind the dialog). The Table panel stays in Window › Type & Tables (Shift+F9). The Insert Table dialog is laid out as InDesign's: Body Rows and Columns, then Header and Footer Rows, as numbers with spinners and right-aligned labels, and a Table Style applied to the new table. 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..09f433723 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -88,7 +88,8 @@ fn table_specs() -> Vec { "table.insert", "Create Table", [], - Some("Cmd+Alt+Shift+T"), + // InDesign's Cmd+Alt+Shift+T opens the Insert Table dialog (`app.insertTableDialog`). + None, "{rows?: body rows (4), cols? | columns? (4), headerRows? (0), footerRows? (0), width?} — at the text insertion point", has_text, insert diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 40787954f..de490a157 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -137,6 +137,49 @@ fn text_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, w: f32) { } } +/// Table › Insert Table, laid out as InDesign's: Table Dimensions (body rows and columns, then +/// header and footer rows, each a number with a spinner, labels on the right edge of their +/// column) and the Table Style to apply. +fn insert_table_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let lang = &app.ui.language; + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "Table Dimensions")).font(semibold(12.0))); + let rows = [ + ("bodyRows", crate::i18n::tr(lang, "Body Rows"), 1.0), + ("columns", crate::i18n::tr_context(lang, "Columns", "table"), 1.0), + ("headerRows", crate::i18n::tr(lang, "Header Rows"), 0.0), + ("footerRows", crate::i18n::tr(lang, "Footer Rows"), 0.0), + ]; + // Body rows and columns, then (after a gap) header and footer rows; one label width for both. + for (g, pair) in rows.chunks(2).enumerate() { + if g > 0 { + ui.add_space(8.0); + } + egui::Grid::new(("ins_table", g)).num_columns(2).min_col_width(100.0).spacing([8.0, 6.0]).show(ui, |ui| { + for (key, label, min) in pair { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| crate::rtl::label(ui, format!("{label}:"))); + let field = crate::widgets::NumField::number(key, d.n(key), "", 0).width(70.0).range(*min, 10_000.0).spinner(); + if let Some(v) = field.show(ui) { + d.fields.insert((*key).into(), json!(v.round())); + } + ui.end_row(); + } + }); + } + ui.add_space(8.0); + let names: Vec = app.session.active().map(|st| st.doc.styles.table.iter().map(|s| s.name.clone()).collect()).unwrap_or_default(); + let cur = Some(d.s("tableStyle")).filter(|s| !s.is_empty()).unwrap_or_else(|| designcraft_doc::BASIC_TABLE.to_string()); + ui.horizontal(|ui| { + crate::rtl::label(ui, format!("{}:", crate::i18n::tr(lang, "Table Style"))); + egui::ComboBox::from_id_salt("ins_table_style").selected_text(&cur).width(160.0).show_ui(ui, |ui| { + for n in &names { + if ui.selectable_label(*n == cur, n).clicked() { + d.fields.insert("tableStyle".into(), json!(n)); + } + } + }); + }); +} + /// Extract once when opened. Cancel is read-only; confirmation adds exactly these colours. pub(crate) fn open_color_theme(app: &mut DesignApp, params: Value) { match app.run("color.extractTheme", params) { @@ -2044,7 +2087,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "newDocument" => crate::i18n::tr(&app.ui.language, "New Document"), "frameSize" => crate::i18n::tr(&app.ui.language, "Rectangle"), "goToPage" => crate::i18n::tr(&app.ui.language, "Go to Page"), - "insertTable" => crate::i18n::tr(&app.ui.language, "Create Table"), + "insertTable" => crate::i18n::tr(&app.ui.language, "Insert Table…").trim_end_matches('…').trim_end(), "cellOptions" => crate::i18n::tr(&app.ui.language, "Cell Options"), "createGuides" => crate::i18n::tr(&app.ui.language, "Create Guides"), "marginsAndColumns" => crate::i18n::tr(&app.ui.language, "Margins and Columns"), @@ -2107,7 +2150,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let available = (ctx.content_rect().size() - frame_margin - egui::vec2(16.0, 16.0)).max(egui::Vec2::splat(1.0)); let preferred_width: f32 = match d.id.as_str() { "alert" => 440.0, - "closeDocument" | "updateLink" | "deletePages" | "newWorkspace" | "pagesPanelOptions" => 380.0, + "closeDocument" | "updateLink" | "deletePages" | "newWorkspace" | "pagesPanelOptions" | "insertTable" => 380.0, "newDocument" => 960.0, "numberingSection" => 560.0, crate::effects_dialog::ID => 720.0, @@ -2773,21 +2816,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }); } "ruby" => style_cjk::ruby_dialog(app, ui, &mut d), - "insertTable" => { - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Table Dimensions")).font(semibold(12.0))); - egui::Grid::new("ins_table").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Body Rows")); - text_field(ui, &mut d, "bodyRows", 60.0); - crate::rtl::label(ui, crate::i18n::tr_context(&app.ui.language, "Columns", "table")); - text_field(ui, &mut d, "columns", 60.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Header Rows")); - text_field(ui, &mut d, "headerRows", 60.0); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Footer Rows")); - text_field(ui, &mut d, "footerRows", 60.0); - ui.end_row(); - }); - } + "insertTable" => insert_table_dialog(app, ui, &mut d), "cellOptions" => cell_options(app, ui, &mut d), "createGuides" => create_guides_dialog(app, ui, &mut d), "marginsAndColumns" => margins_and_columns_dialog(app, ui, &mut d), @@ -3099,11 +3128,20 @@ pub fn confirm(app: &mut DesignApp) -> Result { None => Err(format!("no page \"{typed}\"")), } } - "insertTable" => app.run( - "table.insert", - json!({"rows": d.n("bodyRows").unwrap_or(4.0).max(1.0) as u64, "cols": d.n("columns").unwrap_or(4.0).max(1.0) as u64, - "headerRows": d.n("headerRows").unwrap_or(0.0).max(0.0) as u64, "footerRows": d.n("footerRows").unwrap_or(0.0).max(0.0) as u64}), - ), + "insertTable" => app + .run( + "table.insert", + json!({"rows": d.n("bodyRows").unwrap_or(4.0).max(1.0) as u64, "cols": d.n("columns").unwrap_or(4.0).max(1.0) as u64, + "headerRows": d.n("headerRows").unwrap_or(0.0).max(0.0) as u64, "footerRows": d.n("footerRows").unwrap_or(0.0).max(0.0) as u64}), + ) + .and_then(|r| { + // A table style other than [Basic Table] (which a new table has) is applied after. + let style = d.s("tableStyle"); + if style.is_empty() || style == designcraft_doc::BASIC_TABLE { + return Ok(r); + } + app.run("style.table.apply", json!({"name": style, "story": r["story"], "table": r["table"]})).map(|_| r) + }), "numberingSection" => { // On an error the dialog stays open to be corrected. let r = crate::section_options::confirm(app, &d); @@ -9608,6 +9646,27 @@ mod tests { app.session.active().and_then(|st| st.doc.styles.char_style(name).cloned()) } + /// Insert Table: the dimensions as typed, and the Table Style chosen is applied to the new + /// table; [Basic Table] (the default) leaves it unstyled. + #[test] + fn insert_table_applies_its_dimensions_and_table_style() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("style.table.create", json!({"name": "Grid"})).unwrap(); + for (style, expected) in [("Grid", "Grid"), (designcraft_doc::BASIC_TABLE, "")] { + app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text"})).unwrap(); + assert_eq!(app.run("app.insertTableDialog", json!({})).unwrap(), Value::Null); + for (field, value) in [("bodyRows", json!(5)), ("columns", json!(3)), ("headerRows", json!(1)), ("tableStyle", json!(style))] { + ctl(&mut app, "ui.dialog.set", json!({"field": field, "value": value})); + } + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + let t = app.run("table.get", json!({})).unwrap(); + let len = |k: &str| t[k].as_array().map_or(0, Vec::len); + assert_eq!((len("rows"), len("columns"), t["headerRows"].as_u64()), (6, 3, Some(1)), "{t}"); + assert_eq!(t["style"].as_str().unwrap_or(""), expected, "{t}"); + } + } + #[test] fn character_style_options_set_exactly_the_edited_attributes() { let mut app = styled_app(); @@ -9782,7 +9841,7 @@ mod tests { assert!((centred.center() - egui::pos2(600.0, 400.0)).length() < 1.0, "{centred:?}"); assert!(centred.width() < 1000.0 && centred.height() < 700.0, "the area is the dialog, not the backdrop: {centred:?}"); - let title = h.get_by_label("Create Table").rect().center(); + let title = h.get_by_label("Insert Table").rect().center(); drag(&mut h, title, title + vec2(100.0, 60.0)); let moved = rect(&h); assert!((moved.min - centred.min - vec2(100.0, 60.0)).length() < 2.0, "{centred:?} -> {moved:?}"); @@ -9792,7 +9851,7 @@ mod tests { assert_eq!(rect(&h), moved, "a drag in the body leaves the dialog in place"); // The title row stays inside the top edge. - let title = h.get_by_label("Create Table").rect().center(); + let title = h.get_by_label("Insert Table").rect().center(); drag(&mut h, title, title - vec2(0.0, 1000.0)); assert!(rect(&h).top().abs() < 1.0, "{:?}", rect(&h)); @@ -9804,7 +9863,7 @@ mod tests { assert!((rect(&h).center() - centred.center()).length() < 1.0, "{:?}", rect(&h)); // Past the left edge, about 100 px of the title row stays inside. - let title = h.get_by_label("Create Table").rect().center(); + let title = h.get_by_label("Insert Table").rect().center(); drag(&mut h, title, title - vec2(900.0, 0.0)); assert!((rect(&h).right() - 100.0).abs() < 2.0, "{:?}", rect(&h)); } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 87537abd7..f6c8bd02a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -92,7 +92,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ "{path | text} — a set saved by InDesign's Show Set (.txt): matched commands take its Default shortcuts (or none), other commands lose keys it takes → {set, imported, changed, conflictsCleared, skipped (an earlier item whose keys a later one took), unmatched, unmatchedWithoutShortcut, unreadable}", ), ("app.findChange", "Find/Change…", Some("Cmd+F"), "{}"), - ("app.insertTableDialog", "Create Table…", None, "{} — Insert Table dialog (body/header/footer rows, columns)"), + ("app.insertTableDialog", "Insert Table…", Some("Cmd+Alt+Shift+T"), "{} — Insert Table dialog (body/header/footer rows, columns, table style)"), ( "app.cellOptionsDialog", "Strokes and Fills…", @@ -713,12 +713,17 @@ pub const MENUS: &[(&str, &[&str])] = &[ ], ), ( + // InDesign's order. The Table panel is in Window › Type & Tables (Shift+F9). "Table", &[ "ui:app.insertTableDialog", "cmd:table.convertFromText", "cmd:table.convertToText", "-", + ">Cell Options", + "ui:app.cellOptionsDialog", + "<", + "-", ">Insert", "cmd:table.insertRowAbove", "cmd:table.insertRowBelow", @@ -736,11 +741,10 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:table.selectTable", "<", "-", - ">Cell Options", - "ui:app.cellOptionsDialog", - "<", "cmd:table.merge", "cmd:table.unmerge", + "cmd:table.splitHorizontally", + "cmd:table.splitVertically", ">Convert Cell Type", "ui:app.graphicCell|Convert Cell to Graphic Cell…", "cmd:table.textCell", @@ -750,12 +754,9 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:table.convertRowsToBody", "cmd:table.convertRowsToFooter", "<", - "cmd:table.splitHorizontally", - "cmd:table.splitVertically", - "cmd:table.distributeColumns", - "cmd:table.distributeRows", "-", - "ui:app.tablePanel", + "cmd:table.distributeRows", + "cmd:table.distributeColumns", ], ), ( @@ -4944,6 +4945,47 @@ mod tests { } } + #[test] + fn the_table_menu_follows_indesigns_order() { + // InDesign's Table menu with a text insertion point (Adobe's "How to make a table"). + let table = menu_tree().into_iter().find(|(m, _)| *m == "Table").unwrap().1; + let labels: Vec = table + .iter() + .map(|i| match i { + Item::Sep => "-".to_string(), + Item::Sub(n, _) => format!("{n} >"), + Item::Cmd { label, .. } => label.clone(), + }) + .collect(); + let expected = [ + "Insert Table…", + "Convert Text to Table", + "Convert Table to Text", + "-", + "Cell Options >", + "-", + "Insert >", + "Delete >", + "Select >", + "-", + "Merge Cells", + "Unmerge Cells", + "Split Cell Horizontally", + "Split Cell Vertically", + "Convert Cell Type >", + "Convert Rows >", + "-", + "Distribute Rows Evenly", + "Distribute Columns Evenly", + ]; + assert_eq!(labels, expected); + // Cmd+Alt+Shift+T opens the dialog, as InDesign's; the engine command has no shortcut of + // its own. The Table panel is in Window › Type & Tables. + assert_eq!(ui_label("app.insertTableDialog").and_then(|c| c.1), Some("Cmd+Alt+Shift+T")); + assert_eq!(designcraft_engine::find_command("table.insert").unwrap().shortcut, None); + assert_eq!(ui_label("app.tablePanel").and_then(|c| c.1), Some("Shift+F9")); + } + #[test] fn the_type_menu_follows_indesigns_order() { // InDesign's Type menu, as Chris Nielsen opens it in "Numbering Page Sections" (2:09). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index bb7c5eb80..77bd6d505 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -273,7 +273,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type; Option/Alt+Left/Right kerns at the caret or tracks a selection by the Kerning/Tracking increment, ×5 with Cmd/Ctrl; words move with Cmd+Left/Right on macOS and Ctrl+Left/Right elsewhere), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. - PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions. - IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories; attributes a file leaves unset take InDesign's defaults (Keep Lines Together at start/end of paragraph, 12 pt corners, paragraph rules in Text Color, auto-sizing from the centre, numbered lists restarting in each story, alternating table fills Black 20 % / None; a tint of −1 is the colour's own; frames take the fill, stroke, corners and Text Frame Options they don't write from their object style chain, then [None]) — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`). -- Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu, Table panel, Create Table dialog, caret/typing/Tab navigation and cell selection in cells; Type tool drags of row and column borders (Shift keeps the table size, or scales every row/column on the outer edge; `table.resizeBorder`); `table.*` commands. +- Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu (in InDesign's order), Table panel, Insert Table dialog (with Table Style), caret/typing/Tab navigation and cell selection in cells; Type tool drags of row and column borders (Shift keeps the table size, or scales every row/column on the outer edge; `table.resizeBorder`); `table.*` commands. - Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline. - EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`). - Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid, with a previewing dialog), snippets, object styles, Numbering & Section Options. From cbb2a974d15057b0470aed3969e4880e50c80241 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 23 Oct 2026 21:21:32 +0200 Subject: [PATCH 06/30] Tables: InDesign's Convert Text to Table and Convert Table to Text dialogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Table › Convert Text to Table… and Convert Table to Text… open dialogs laid out as InDesign's instead of converting at once with tabs and paragraphs: - Column and Row Separator: Tab, Comma, Paragraph from the field's menu, or any characters typed. - Number of Columns (Convert Text to Table), available when both separators are the same: the cells fill rows of that many. - Table Style (Convert Text to Table), applied to the new table in the same undo step. table.convertFromText takes rowSeparator, columns and tableStyle, and any characters as separators; table.convertToText takes both separators (Table::text_with). Convert Text to Table is available only with text selected outside a table, as in InDesign. New strings are translated. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/doc/src/table.rs | 9 ++- crates/engine/src/cmd/table.rs | 66 ++++++++++++++--- crates/engine/src/tests_table.rs | 44 +++++++++++ crates/ui-egui/src/dialogs.rs | 121 ++++++++++++++++++++++++++++++- crates/ui-egui/src/i18n.rs | 13 ++++ crates/ui-egui/src/i18n/ar.rs | 6 ++ crates/ui-egui/src/i18n/cs.rs | 6 ++ crates/ui-egui/src/i18n/de.rs | 6 ++ crates/ui-egui/src/i18n/fr.rs | 1 - crates/ui-egui/src/i18n/it.rs | 7 ++ crates/ui-egui/src/i18n/ja.rs | 6 ++ crates/ui-egui/src/i18n/ka.rs | 6 ++ crates/ui-egui/src/i18n/pl.rs | 6 ++ crates/ui-egui/src/i18n/pt_br.rs | 7 ++ crates/ui-egui/src/i18n/ru.rs | 6 ++ crates/ui-egui/src/i18n/uk.rs | 7 ++ crates/ui-egui/src/menus.rs | 38 +++++++++- docs/target-app-parity.md | 2 +- 19 files changed, 340 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 03691beec..28e3c17d8 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: Convert Text to Table… and Convert Table to Text… open InDesign's dialogs (they converted at once, with tabs and paragraphs only). Column and Row Separator take Tab, Comma, Paragraph or any characters typed; Number of Columns, available when both separators are the same, fills rows of that many cells (a list of paragraphs into two columns); Convert Text to Table also applies a Table Style. `table.convertFromText` takes `rowSeparator`, `columns` and `tableStyle`, and `table.convertToText` its separators. Convert Text to Table is available only with text selected outside a table. No readiness percentage change. - **2026-10-23** · Tables: the Table menu follows InDesign's order (Cell Options before Insert, Delete and Select; Merge, Unmerge and Split before Convert Cell Type and Convert Rows; Distribute Rows Evenly before Distribute Columns Evenly), and its first item is Insert Table… with InDesign's Cmd+Alt+Shift+T, which opens the dialog (the shortcut ran `table.insert` with its defaults, behind the dialog). The Table panel stays in Window › Type & Tables (Shift+F9). The Insert Table dialog is laid out as InDesign's: Body Rows and Columns, then Header and Footer Rows, as numbers with spinners and right-aligned labels, and a Table Style applied to the new table. 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. diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index e96aac4b7..bc9c857aa 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -1011,14 +1011,19 @@ impl Table { /// Cell texts, rows separated by `\n`, cells by `\t` (Convert Table to Text, search, agents). pub fn plain_text(&self) -> String { + self.text_with("\t", "\n") + } + + /// The cells' text, columns separated by `col` and rows by `row` (Convert Table to Text). + pub fn text_with(&self, col: &str, row: &str) -> String { let mut out = String::new(); for r in 0..self.nrows() { if r > 0 { - out.push('\n'); + out.push_str(row); } for c in 0..self.ncols() { if c > 0 { - out.push('\t'); + out.push_str(col); } if let Some(cell) = self.cell(r, c) { out.push_str(&cell.text.text.replace('\n', " ")); diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 09f433723..9b6e551a9 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -99,7 +99,7 @@ fn table_specs() -> Vec { "Convert Text to Table", ["Table"], None, - "{columnSeparator?: tab|comma, width?: table width (default: the column)} — selected paragraphs become rows", + "{columnSeparator?: tab|comma|paragraph|text (tab), rowSeparator?: paragraph|tab|comma|text (paragraph), columns?: number of columns when both separators are the same (1), tableStyle?: name, width?: table width (default: the column)} — the selected paragraphs become a table, as InDesign's Convert Text to Table", has_text, convert_from_text ), @@ -108,7 +108,7 @@ fn table_specs() -> Vec { "Convert Table to Text", ["Table"], None, - "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — cells separated by tabs, rows by paragraphs", + "{columnSeparator?: tab|comma|paragraph|text (tab), rowSeparator?: paragraph|tab|comma|text (paragraph), story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the table becomes its cells' text, as InDesign's Convert Table to Text", in_table, convert_to_text ), @@ -594,12 +594,49 @@ fn insert(s: &mut Session, p: &Value) -> Result { }) } -fn convert_from_text(s: &mut Session, p: &Value) -> Result { - let t = s.doc()?.selection.text.ok_or_else(|| bad("table.convertFromText", "no text selected"))?; - if t.cell.is_some() { - return Err(bad("table.convertFromText", "select text outside tables")); +/// A Convert Text to Table / Convert Table to Text separator: `tab`, `comma`, `paragraph` or the +/// separator's own text (InDesign's fields take any characters). +fn separator(p: &Value, key: &str, default: &str, cmd: &str) -> Result { + let sep = match p.get(key) { + None | Some(Value::Null) => return Ok(default.to_string()), + Some(Value::String(s)) => match s.as_str() { + "tab" => "\t", + "comma" => ",", + "paragraph" => "\n", + other => other, + }, + Some(_) => "", + }; + if sep.is_empty() { + return Err(bad(cmd, format!("`{key}` must be tab, comma, paragraph or the separator's characters"))); } - let sep = if str_param(p, "columnSeparator") == Some("comma") { ',' } else { '\t' }; + Ok(sep.to_string()) +} + +fn convert_from_text(s: &mut Session, p: &Value) -> Result { + const ID: &str = "table.convertFromText"; + let t = s.doc()?.selection.text.ok_or_else(|| bad(ID, "no text selected"))?; + if t.cell.is_some() { + return Err(bad(ID, "select text outside tables")); + } + let (col_sep, row_sep) = (separator(p, "columnSeparator", "\t", ID)?, separator(p, "rowSeparator", "\n", ID)?); + // With one separator for both, InDesign fills rows of Number of Columns cells. + let per_row = match p.get("columns") { + None | Some(Value::Null) => 1, + Some(v) => { + v.as_u64().filter(|n| (1..=10_000).contains(n)).ok_or_else(|| bad(ID, "`columns` must be a whole number from 1 to 10000"))? as usize + } + }; + let style = match str_param(p, "tableStyle").filter(|n| *n != designcraft_doc::BASIC_TABLE) { + Some(name) => { + let styles = &s.doc()?.doc.styles; + if !styles.table.iter().any(|x| x.name == name) { + return Err(bad(ID, format!("no table style `{name}`"))); + } + Some((styles.resolve_table_style(name), styles.cell.clone())) + } + None => None, + }; let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t)); s.edit(|d, sel| { let id = d.alloc(); @@ -611,11 +648,20 @@ fn convert_from_text(s: &mut Session, p: &Value) -> Result { return Err(bad("table.convertFromText", "the selection contains a table")); } let (a, b) = (ranges[pa].start, ranges[pb].end); - let data: Vec> = st.text[a..b].split('\n').map(|line| line.split(sep).map(|c| c.trim().to_string()).collect()).collect(); + let text = &st.text[a..b]; + let data: Vec> = if col_sep == row_sep { + let cells: Vec = text.split(col_sep.as_str()).map(|c| c.trim().to_string()).collect(); + cells.chunks(per_row).map(<[String]>::to_vec).collect() + } else { + text.split(row_sep.as_str()).map(|row| row.split(col_sep.as_str()).map(|c| c.trim().to_string()).collect()).collect() + }; let pf = st.paras[pa].clone(); let cf = st.format_after(a).clone(); let mut table = Table::from_strings(id, &data, width, &pf, &cf); table.options.direction = direction; + if let Some((ts, cells)) = &style { + ts.apply_to(&mut table, cells); + } st.delete(a..b); let anchor = st.insert_table(a, table); *sel = Selection::text(TextSel::caret(t.story, anchor)); @@ -625,9 +671,11 @@ fn convert_from_text(s: &mut Session, p: &Value) -> Result { fn convert_to_text(s: &mut Session, p: &Value) -> Result { let g = target(s, p, "table.convertToText")?; + let col_sep = separator(p, "columnSeparator", "\t", "table.convertToText")?; + let row_sep = separator(p, "rowSeparator", "\n", "table.convertToText")?; s.edit(|d, sel| { let st = d.story_mut(g.story).ok_or_else(|| bad("table.convertToText", "no story"))?; - let text = st.tables.get(&g.table).map(|t| t.plain_text()).unwrap_or_default(); + let text = st.tables.get(&g.table).map(|t| t.text_with(&col_sep, &row_sep)).unwrap_or_default(); let a = st.table_anchor(g.table).ok_or_else(|| bad("table.convertToText", "no anchor"))?; st.replace(a..a + designcraft_doc::TABLE_ANCHOR.len_utf8(), &text); *sel = Selection::text(TextSel::caret(g.story, a + text.len())); diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index 59d2f267b..8c5c98e76 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -444,6 +444,50 @@ fn convert_text_to_table_and_back() { assert_eq!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text, "Ink\tCoverage\nPlum\t80%\nSunset\t45%\nAfter"); } +/// InDesign's Convert Text to Table and Convert Table to Text separators: tab, comma, paragraph or +/// any characters; with one separator for both, rows of Number of Columns cells; a Table Style. +#[test] +fn convert_text_to_table_with_indesigns_separators() { + let (mut s, sid, _) = session_with_frame(); + let convert = |s: &mut Session, text: &str, p: Value| -> Result { + s.execute("story.setText", &json!({"story": sid, "text": format!("{text}\nAfter")})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": text.len()})).unwrap(); + s.execute("table.convertFromText", &p) + }; + let cells = |s: &mut Session, r: &Value| -> (usize, Vec) { + let t = s.execute("table.get", &json!({"table": r["table"]})).unwrap(); + let texts = t["cells"].as_array().unwrap().iter().map(|c| c["text"].as_str().unwrap().to_string()).collect(); + (t["columns"].as_array().unwrap().len(), texts) + }; + // Comma-separated columns, paragraphs for rows. + let r = convert(&mut s, "Ink, Coverage\nPlum, 80%", json!({"columnSeparator": "comma"})).unwrap(); + assert_eq!(cells(&mut s, &r), (2, vec!["Ink".into(), "Coverage".into(), "Plum".into(), "80%".into()])); + check(&s); + // The lesson's list: one paragraph per cell; with two columns, two cells a row. + let list = "Name:\nAddress:\nCity:\nState:"; + let r = convert(&mut s, list, json!({"columnSeparator": "paragraph", "rowSeparator": "paragraph", "columns": 2})).unwrap(); + assert_eq!(r["rows"], 2); + assert_eq!(cells(&mut s, &r).0, 2); + let r = convert(&mut s, list, json!({"columnSeparator": "tab"})).unwrap(); + assert_eq!((r["rows"].as_u64(), cells(&mut s, &r).0), (Some(4), 1), "a column of paragraphs"); + // Any characters separate: here " | " between columns and ";" between rows. + let r = convert(&mut s, "a | b;c | d", json!({"columnSeparator": " | ", "rowSeparator": ";"})).unwrap(); + assert_eq!(cells(&mut s, &r), (2, vec!["a".into(), "b".into(), "c".into(), "d".into()])); + // A table style is applied to the new table. + s.execute("style.table.create", &json!({"name": "Grid"})).unwrap(); + let r = convert(&mut s, "x\ty", json!({"tableStyle": "Grid"})).unwrap(); + assert_eq!(s.execute("table.get", &json!({"table": r["table"]})).unwrap()["style"], "Grid"); + for bad in [json!({"columnSeparator": ""}), json!({"rowSeparator": 3}), json!({"columns": 0}), json!({"tableStyle": "Missing"})] { + assert!(convert(&mut s, "p\tq", bad.clone()).is_err(), "{bad}"); + } + check(&s); + // Back to text with a comma between columns and a tab between rows. + let r = convert(&mut s, "Plum\t80%\nSunset\t45%", json!({})).unwrap(); + s.execute("table.convertToText", &json!({"table": r["table"], "columnSeparator": "comma", "rowSeparator": "tab"})).unwrap(); + check(&s); + assert_eq!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text, "Plum,80%\tSunset,45%\nAfter"); +} + #[test] fn caret_placement_in_cells_and_cell_formatting() { let (mut s, sid, fid) = session_with_frame(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index de490a157..ee462ea48 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -34,6 +34,8 @@ impl Dialog { "goToPage" => json!({"page": "1"}), "qrCode" => json!({"type": "url", "content": "https://", "color": "[Black]"}), "insertTable" => json!({"bodyRows": 4, "columns": 4, "headerRows": 0, "footerRows": 0}), + "convertTextToTable" => json!({"columnSeparator": "tab", "rowSeparator": "paragraph", "columns": 1}), + "convertTableToText" => json!({"columnSeparator": "tab", "rowSeparator": "paragraph"}), "insertXref" => json!({"linkTo": "paragraph", "style": "", "target": "", "format": ""}), "findChange" => json!({"find": "", "change": "", "grep": false, "caseSensitive": false, "wholeWord": false, "scope": "document"}), "documentSetup" => json!({}), @@ -137,6 +139,75 @@ fn text_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, w: f32) { } } +/// Separators the Convert dialogs offer by name; anything else typed in the field is the +/// separator's own characters. +const SEPARATORS: [(&str, &str); 3] = [("tab", "Tab"), ("comma", "Comma"), ("paragraph", "Paragraph")]; + +/// A separator field, as InDesign's: type any characters, or pick Tab, Comma or Paragraph from +/// its menu. Stored as `tab`/`comma`/`paragraph` or the typed text. +fn separator_field(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, key: &str) { + let cur = d.s(key); + let name = |v: &str| SEPARATORS.iter().find(|s| s.0 == v).map(|s| crate::i18n::tr(lang, s.1).to_string()); + let mut shown = name(&cur).unwrap_or_else(|| cur.clone()); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + if ui.add(egui::TextEdit::singleline(&mut shown).id_salt(("sep", key)).desired_width(150.0)).changed() { + let v = SEPARATORS.iter().find(|s| crate::i18n::tr(lang, s.1) == shown).map_or(shown.clone(), |s| s.0.to_string()); + d.fields.insert(key.into(), json!(v)); + } + ui.menu_button("▾", |ui| { + for (v, label) in SEPARATORS { + if ui.selectable_label(cur == v, crate::i18n::tr(lang, label)).clicked() { + d.fields.insert(key.into(), json!(v)); + ui.close(); + } + } + }); + }); +} + +/// Table › Convert Text to Table and Convert Table to Text, laid out as InDesign's: Column and +/// Row Separator; converting text to a table also has Number of Columns (only when both +/// separators are the same: the cells then fill rows of that many) and Table Style. +fn convert_table_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let lang = &app.ui.language; + let to_table = d.id == "convertTextToTable"; + let label = |ui: &mut egui::Ui, text: &str, on: bool| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_enabled(on, egui::Label::new(crate::rtl::widget(ui, format!("{}:", crate::i18n::tr(lang, text))))); + }); + }; + egui::Grid::new(("convert_table", to_table)).num_columns(2).min_col_width(120.0).spacing([8.0, 6.0]).show(ui, |ui| { + label(ui, "Column Separator", true); + separator_field(lang, ui, d, "columnSeparator"); + ui.end_row(); + label(ui, "Row Separator", true); + separator_field(lang, ui, d, "rowSeparator"); + ui.end_row(); + if !to_table { + return; + } + let same = d.s("columnSeparator") == d.s("rowSeparator"); + label(ui, "Number of Columns", same); + let field = crate::widgets::NumField::number("columns", d.n("columns"), "", 0).width(70.0).range(1.0, 10_000.0).spinner().enabled(same); + if let Some(v) = field.show(ui) { + d.fields.insert("columns".into(), json!(v.round())); + } + ui.end_row(); + label(ui, "Table Style", true); + let names: Vec = app.session.active().map(|st| st.doc.styles.table.iter().map(|s| s.name.clone()).collect()).unwrap_or_default(); + let cur = Some(d.s("tableStyle")).filter(|s| !s.is_empty()).unwrap_or_else(|| designcraft_doc::BASIC_TABLE.to_string()); + egui::ComboBox::from_id_salt("convert_table_style").selected_text(&cur).width(170.0).show_ui(ui, |ui| { + for n in &names { + if ui.selectable_label(*n == cur, n).clicked() { + d.fields.insert("tableStyle".into(), json!(n)); + } + } + }); + ui.end_row(); + }); +} + /// Table › Insert Table, laid out as InDesign's: Table Dimensions (body rows and columns, then /// header and footer rows, each a number with a spinner, labels on the right edge of their /// column) and the Table Style to apply. @@ -2088,6 +2159,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "frameSize" => crate::i18n::tr(&app.ui.language, "Rectangle"), "goToPage" => crate::i18n::tr(&app.ui.language, "Go to Page"), "insertTable" => crate::i18n::tr(&app.ui.language, "Insert Table…").trim_end_matches('…').trim_end(), + "convertTextToTable" => crate::i18n::tr(&app.ui.language, "Convert Text to Table"), + "convertTableToText" => crate::i18n::tr(&app.ui.language, "Convert Table to Text"), "cellOptions" => crate::i18n::tr(&app.ui.language, "Cell Options"), "createGuides" => crate::i18n::tr(&app.ui.language, "Create Guides"), "marginsAndColumns" => crate::i18n::tr(&app.ui.language, "Margins and Columns"), @@ -2150,7 +2223,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let available = (ctx.content_rect().size() - frame_margin - egui::vec2(16.0, 16.0)).max(egui::Vec2::splat(1.0)); let preferred_width: f32 = match d.id.as_str() { "alert" => 440.0, - "closeDocument" | "updateLink" | "deletePages" | "newWorkspace" | "pagesPanelOptions" | "insertTable" => 380.0, + "closeDocument" | "updateLink" | "deletePages" | "newWorkspace" | "pagesPanelOptions" | "insertTable" | "convertTableToText" => 380.0, + "convertTextToTable" => 420.0, "newDocument" => 960.0, "numberingSection" => 560.0, crate::effects_dialog::ID => 720.0, @@ -2817,6 +2891,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } "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), "cellOptions" => cell_options(app, ui, &mut d), "createGuides" => create_guides_dialog(app, ui, &mut d), "marginsAndColumns" => margins_and_columns_dialog(app, ui, &mut d), @@ -3128,6 +3203,18 @@ pub fn confirm(app: &mut DesignApp) -> Result { None => Err(format!("no page \"{typed}\"")), } } + "convertTextToTable" => { + let same = d.s("columnSeparator") == d.s("rowSeparator"); + let style = Some(d.s("tableStyle")).filter(|s| !s.is_empty() && s != designcraft_doc::BASIC_TABLE); + app.run( + "table.convertFromText", + json!({"columnSeparator": d.s("columnSeparator"), "rowSeparator": d.s("rowSeparator"), + "columns": if same { json!(d.n("columns").unwrap_or(1.0).max(1.0) as u64) } else { Value::Null }, "tableStyle": style}), + ) + } + "convertTableToText" => { + app.run("table.convertToText", json!({"columnSeparator": d.s("columnSeparator"), "rowSeparator": d.s("rowSeparator")})) + } "insertTable" => app .run( "table.insert", @@ -9667,6 +9754,38 @@ mod tests { } } + /// Convert Text to Table: available with text selected (not with a caret alone); its + /// separators and Number of Columns shape the table. Convert Table to Text takes it back with + /// the separators chosen there. + #[test] + fn convert_dialogs_use_their_separators() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let text = "Name:\nAddress:\nCity:\nState:"; + let sid = app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text", "text": text})).unwrap()["story"].clone(); + app.run("text.select", json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + assert!(!crate::menus::menu_enabled(&app, "app.convertTextToTableDialog"), "a caret alone"); + assert!(!crate::menus::menu_enabled(&app, "app.convertTableToTextDialog"), "no table"); + app.run("text.select", json!({"story": sid, "anchor": 0, "focus": text.len()})).unwrap(); + assert!(crate::menus::menu_enabled(&app, "app.convertTextToTableDialog")); + app.run("app.convertTextToTableDialog", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("convertTextToTable")); + for (field, value) in [("columnSeparator", json!("paragraph")), ("rowSeparator", json!("paragraph")), ("columns", json!(2))] { + ctl(&mut app, "ui.dialog.set", json!({"field": field, "value": value})); + } + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + let t = app.run("table.get", json!({})).unwrap(); + let len = |k: &str| t[k].as_array().map_or(0, Vec::len); + assert_eq!((len("rows"), len("columns")), (2, 2), "{t}"); + assert!(crate::menus::menu_enabled(&app, "app.convertTableToTextDialog")); + app.run("app.convertTableToTextDialog", json!({})).unwrap(); + ctl(&mut app, "ui.dialog.set", json!({"field": "columnSeparator", "value": ", "})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + let story = app.session.active().unwrap().doc.story(designcraft_doc::StoryId(sid.as_u64().unwrap())).unwrap().text.clone(); + // (The empty paragraph after a table that ends its story stays after the text.) + assert_eq!(story.trim_end(), "Name:, Address:\nCity:, State:"); + } + #[test] fn character_style_options_set_exactly_the_edited_attributes() { let mut app = styled_app(); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 6057abc97..8fab7218a 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1000,6 +1000,19 @@ const TABLE: &[(&str, [&str; 5])] = &[ ], ), ("Load Styles", ["Formate laden", "Charger les styles", "Cargar estilos", "スタイルを読み込み", "加载样式"]), + ("Tab", ["Tabulator", "Tabulation", "Tabulación", "タブ", "制表符"]), + ( + "Convert Text to Table…", + ["Text in Tabelle umwandeln …", "Convertir le texte en tableau…", "Convertir texto en tabla…", "テキストを表に変換…", "将文本转换为表格…"], + ), + ( + "Convert Table to Text…", + ["Tabelle in Text umwandeln …", "Convertir le tableau en texte…", "Convertir tabla en texto…", "表をテキストに変換…", "将表格转换为文本…"], + ), + ("Column Separator", ["Spaltentrennzeichen", "Séparateur de colonnes", "Separador de columnas", "列分解文字", "列分隔符"]), + ("Row Separator", ["Zeilentrennzeichen", "Séparateur de rangs", "Separador de filas", "行分解文字", "行分隔符"]), + ("Number of Columns", ["Spaltenanzahl", "Nombre de colonnes", "Número de columnas", "列数", "列数"]), + ("Comma", ["Komma", "Virgule", "Coma", "カンマ", "逗号"]), ( "Differs from this document", ["Weicht von diesem Dokument ab", "Diffère de ce document", "Difiere de este documento", "このドキュメントと異なります", "与此文档不同"], diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 31f7fb70d..fc4b05688 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Convert Text to Table…", "تحويل النص إلى جدول…"), + ("Convert Table to Text…", "تحويل الجدول إلى نص…"), + ("Column Separator", "فاصل الأعمدة"), + ("Row Separator", "فاصل الصفوف"), + ("Number of Columns", "عدد الأعمدة"), + ("Comma", "فاصلة"), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), ("Fixed Number", "عدد ثابت"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 4d41c30dc..f9066bee1 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Convert Text to Table…", "Převést text na tabulku…"), + ("Convert Table to Text…", "Převést tabulku na text…"), + ("Column Separator", "Oddělovač sloupců"), + ("Row Separator", "Oddělovač řádků"), + ("Number of Columns", "Počet sloupců"), + ("Comma", "Čárka"), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 4f4177cbd..0200516a9 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -31,6 +31,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Ausschneiden"), ("Copy", "Kopieren"), ("Paste", "Einfügen"), + ("Convert Text to Table…", "Text in Tabelle umwandeln …"), + ("Convert Table to Text…", "Tabelle in Text umwandeln …"), + ("Column Separator", "Spaltentrennzeichen"), + ("Row Separator", "Zeilentrennzeichen"), + ("Number of Columns", "Spaltenanzahl"), + ("Comma", "Komma"), ("Inches Decimal", "Zoll dezimal"), ("Back", "Zurück"), ("Warning", "Warnung"), diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 681230d84..b2d9f6dd4 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -351,7 +351,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Subscript:", "Indice :"), ("Superscript:", "Exposant :"), ("Swap Text Fill and Stroke", "Permuter le fond et le contour du texte"), - ("Tab", "Tabulation"), ("Table of Contents", "Table des matières"), ("Table/Convert Cell Type", "Tableau/Convertir le type de cellule"), ("Table/Delete", "Tableau/Supprimer"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 755272fe5..8471dbd5a 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Tab", "Tabulazione"), + ("Convert Text to Table…", "Converti testo in tabella…"), + ("Convert Table to Text…", "Converti tabella in testo…"), + ("Column Separator", "Separatore colonna"), + ("Row Separator", "Separatore riga"), + ("Number of Columns", "Numero di colonne"), + ("Comma", "Virgola"), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index e23ef15c7..3e8499fa7 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Convert Text to Table…", "テキストを表に変換…"), + ("Convert Table to Text…", "表をテキストに変換…"), + ("Column Separator", "列分解文字"), + ("Row Separator", "行分解文字"), + ("Number of Columns", "列数"), + ("Comma", "カンマ"), ("Paste Vector Art", "ベクトルアートをペースト"), ("Baseline Options", "ベースラインオプション"), ("Column Rules", "段落境界線"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 0300b62d7..0734bbc3e 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Convert Text to Table…", "ტექსტის ცხრილად გარდაქმნა…"), + ("Convert Table to Text…", "ცხრილის ტექსტად გარდაქმნა…"), + ("Column Separator", "სვეტების გამყოფი"), + ("Row Separator", "სტრიქონების გამყოფი"), + ("Number of Columns", "სვეტების რაოდენობა"), + ("Comma", "მძიმე"), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 7d1d12c67..03a6e5aa8 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Convert Text to Table…", "Zamień tekst na tabelę…"), + ("Convert Table to Text…", "Zamień tabelę na tekst…"), + ("Column Separator", "Separator kolumn"), + ("Row Separator", "Separator wierszy"), + ("Number of Columns", "Liczba kolumn"), + ("Comma", "Przecinek"), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index f552e9a63..1f7159e1c 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Tab", "Tabulação"), + ("Convert Text to Table…", "Converter texto em tabela…"), + ("Convert Table to Text…", "Converter tabela em texto…"), + ("Column Separator", "Separador de coluna"), + ("Row Separator", "Separador de linha"), + ("Number of Columns", "Número de colunas"), + ("Comma", "Vírgula"), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), ("Fixed Number", "Número fixo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 3f6ef1be0..a2bb57854 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Convert Text to Table…", "Преобразовать текст в таблицу…"), + ("Convert Table to Text…", "Преобразовать таблицу в текст…"), + ("Column Separator", "Разделитель столбцов"), + ("Row Separator", "Разделитель строк"), + ("Number of Columns", "Число столбцов"), + ("Comma", "Запятая"), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 9c7cc855a..2dce9d48b 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Tab", "Табуляція"), + ("Convert Text to Table…", "Перетворити текст на таблицю…"), + ("Convert Table to Text…", "Перетворити таблицю на текст…"), + ("Column Separator", "Роздільник стовпців"), + ("Row Separator", "Роздільник рядків"), + ("Number of Columns", "Кількість стовпців"), + ("Comma", "Кома"), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index f6c8bd02a..d50603c4c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -93,6 +93,13 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ), ("app.findChange", "Find/Change…", Some("Cmd+F"), "{}"), ("app.insertTableDialog", "Insert Table…", Some("Cmd+Alt+Shift+T"), "{} — Insert Table dialog (body/header/footer rows, columns, table style)"), + ( + "app.convertTextToTableDialog", + "Convert Text to Table…", + None, + "{} — Convert Text to Table dialog (column and row separators, number of columns, table style)", + ), + ("app.convertTableToTextDialog", "Convert Table to Text…", None, "{} — Convert Table to Text dialog (column and row separators)"), ( "app.cellOptionsDialog", "Strokes and Fills…", @@ -717,8 +724,8 @@ pub const MENUS: &[(&str, &[&str])] = &[ "Table", &[ "ui:app.insertTableDialog", - "cmd:table.convertFromText", - "cmd:table.convertToText", + "ui:app.convertTextToTableDialog", + "ui:app.convertTableToTextDialog", "-", ">Cell Options", "ui:app.cellOptionsDialog", @@ -1254,6 +1261,20 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { + if !menu_enabled(app, id) { + return Some(Err("select the text to convert to a table".into())); + } + app.ui.dialog = Some(crate::dialogs::Dialog::new("convertTextToTable", p.clone())); + Ok(Value::Null) + } + "app.convertTableToTextDialog" => { + if !menu_enabled(app, id) { + return Some(Err("place the insertion point in a table to convert it to text".into())); + } + app.ui.dialog = Some(crate::dialogs::Dialog::new("convertTableToText", p.clone())); + Ok(Value::Null) + } "app.insertTableDialog" => { if app.session.active().is_none_or(|d| d.selection.text.is_none()) { return Some(Err("place the insertion point in a text frame to create a table".into())); @@ -2465,6 +2486,15 @@ pub fn menu_enabled(app: &DesignApp, id: &str) -> bool { if id == "guide.selectAll" && (!app.ui.guides || app.ui.guides_locked) { return false; } + // As InDesign's: Convert Text to Table wants selected text outside a table, Convert Table to + // Text a table. + if id == "app.convertTextToTableDialog" { + let selected = app.session.active().and_then(|d| d.selection.text).is_some_and(|t| t.cell.is_none() && !t.is_caret()); + return selected && enabled(app, "table.convertFromText"); + } + if id == "app.convertTableToTextDialog" { + return enabled(app, "table.convertToText"); + } if ui_label(id).is_some() { return app.session.active().is_some() || id.starts_with("app.") || id.starts_with("window.") || id.starts_with("help."); } @@ -4959,8 +4989,8 @@ mod tests { .collect(); let expected = [ "Insert Table…", - "Convert Text to Table", - "Convert Table to Text", + "Convert Text to Table…", + "Convert Table to Text…", "-", "Cell Options >", "-", diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 77bd6d505..e273fe286 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -273,7 +273,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type; Option/Alt+Left/Right kerns at the caret or tracks a selection by the Kerning/Tracking increment, ×5 with Cmd/Ctrl; words move with Cmd+Left/Right on macOS and Ctrl+Left/Right elsewhere), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. - PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions. - IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories; attributes a file leaves unset take InDesign's defaults (Keep Lines Together at start/end of paragraph, 12 pt corners, paragraph rules in Text Color, auto-sizing from the centre, numbered lists restarting in each story, alternating table fills Black 20 % / None; a tint of −1 is the colour's own; frames take the fill, stroke, corners and Text Frame Options they don't write from their object style chain, then [None]) — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`). -- Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu (in InDesign's order), Table panel, Insert Table dialog (with Table Style), caret/typing/Tab navigation and cell selection in cells; Type tool drags of row and column borders (Shift keeps the table size, or scales every row/column on the outer edge; `table.resizeBorder`); `table.*` commands. +- Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu (in InDesign's order), Table panel, Insert Table dialog (with Table Style), Convert Text to Table and Convert Table to Text dialogs (any separators, Number of Columns, Table Style), caret/typing/Tab navigation and cell selection in cells; Type tool drags of row and column borders (Shift keeps the table size, or scales every row/column on the outer edge; `table.resizeBorder`); `table.*` commands. - Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline. - EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`). - Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid, with a previewing dialog), snippets, object styles, Numbering & Section Options. From dd2d7e6949e371b3bed6246db56b1b714de7d50c Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 23 Oct 2026 22:04:42 +0200 Subject: [PATCH 07/30] Insert Table: the style in the same step, and the engine's limits in the dialog - table.insert takes tableStyle and applies it in its own edit, so one Insert Table is one undo, and a style that is gone (or not a name) changes nothing. The dialog passes it instead of running style.table.apply afterwards. - The dialog's fields stop at table.insert's limits (500 body rows, 200 columns, 100 header or footer rows; now public constants), so the table made is the one shown. Found by CodeRabbit. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/table.rs | 36 +++++++++++++++++++++++---- crates/engine/src/tests_table.rs | 20 +++++++++++++++ crates/ui-egui/src/dialogs.rs | 42 +++++++++++++++++++------------- 3 files changed, 76 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 09f433723..be6dab3bb 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -90,7 +90,7 @@ fn table_specs() -> Vec { [], // InDesign's Cmd+Alt+Shift+T opens the Insert Table dialog (`app.insertTableDialog`). None, - "{rows?: body rows (4), cols? | columns? (4), headerRows? (0), footerRows? (0), width?} — at the text insertion point", + "{rows?: body rows (4, at most 500), cols? | columns? (4, at most 200), headerRows? (0), footerRows? (0, each at most 100), tableStyle?: name, width?} — at the text insertion point, styled in the same step", has_text, insert ), @@ -564,15 +564,38 @@ fn para_direction(d: &Document, story: StoryId, offset: usize) -> designcraft_do .map_or(designcraft_doc::TextDirection::LeftToRight, |pf| d.styles.resolve_para(pf).0.direction) } +/// The most body rows, columns, and header or footer rows `table.insert` makes (the Insert Table +/// dialog's limits too). +pub const MAX_BODY_ROWS: u64 = 500; +pub const MAX_COLUMNS: u64 = 200; +pub const MAX_HEADER_FOOTER_ROWS: u64 = 100; + +/// A command's `tableStyle`: none for an absent one or [Basic Table] (a new table's look), else the +/// resolved style and the cell styles it names. Anything but an existing style's name is an error. +fn table_style_param(s: &Session, p: &Value, cmd: &str) -> Result)>> { + let name = match p.get("tableStyle") { + None | Some(Value::Null) => return Ok(None), + Some(Value::String(n)) if n == designcraft_doc::BASIC_TABLE => return Ok(None), + Some(Value::String(n)) => n, + Some(_) => return Err(bad(cmd, "`tableStyle` must be a table style's name")), + }; + let styles = &s.doc()?.doc.styles; + if !styles.table.iter().any(|x| x.name == *name) { + return Err(bad(cmd, format!("no table style `{name}`"))); + } + Ok(Some((styles.resolve_table_style(name), styles.cell.clone()))) +} + fn insert(s: &mut Session, p: &Value) -> Result { let t = s.doc()?.selection.text.ok_or_else(|| bad("table.insert", "no insertion point"))?; if t.cell.is_some() { return Err(bad("table.insert", "tables can't be nested in cells")); } - let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(4).clamp(1, 500) as usize; - let cols = p.get("cols").or_else(|| p.get("columns")).and_then(Value::as_u64).unwrap_or(4).clamp(1, 200) as usize; - let header = p.get("headerRows").and_then(Value::as_u64).unwrap_or(0).min(100) as usize; - let footer = p.get("footerRows").and_then(Value::as_u64).unwrap_or(0).min(100) as usize; + let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(4).clamp(1, MAX_BODY_ROWS) as usize; + let cols = p.get("cols").or_else(|| p.get("columns")).and_then(Value::as_u64).unwrap_or(4).clamp(1, MAX_COLUMNS) as usize; + let header = p.get("headerRows").and_then(Value::as_u64).unwrap_or(0).min(MAX_HEADER_FOOTER_ROWS) as usize; + let footer = p.get("footerRows").and_then(Value::as_u64).unwrap_or(0).min(MAX_HEADER_FOOTER_ROWS) as usize; + let style = table_style_param(s, p, "table.insert")?; let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t)); s.edit(|d, sel| { let id = d.alloc(); @@ -586,6 +609,9 @@ fn insert(s: &mut Session, p: &Value) -> Result { c.text.paras[0] = designcraft_doc::ParaFormat { table: None, ..pf.clone() }; c.text.chars[0].format = cf.clone(); } + if let Some((ts, cells)) = &style { + ts.apply_to(&mut table, cells); + } let r = t.range(); st.delete(r.clone()); st.insert_table(r.start, table); diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index 59d2f267b..a2a9cd824 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -426,6 +426,26 @@ fn rows_columns_merge_and_options() { assert_eq!(st.doc.story(sid).unwrap().text, "Intro"); } +/// `table.insert` styles the table in the same step; a style that isn't there, or isn't a name, +/// leaves the story alone. Its dimensions stop at the documented limits. +#[test] +fn insert_table_with_a_style_is_one_step() { + let (mut s, sid, _) = session_with_frame(); + s.execute("style.table.create", &json!({"name": "Grid"})).unwrap(); + for bad in [json!("Missing"), json!(7)] { + assert!(s.execute("table.insert", &json!({"tableStyle": bad})).is_err(), "{bad}"); + } + assert!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().tables.is_empty()); + let r = s.execute("table.insert", &json!({"rows": 900, "cols": 300, "headerRows": 150, "tableStyle": "Grid"})).unwrap(); + let t = s.execute("table.get", &json!({"table": r["table"]})).unwrap(); + assert_eq!(t["style"], "Grid"); + let n = |k: &str| t[k].as_array().unwrap().len() as u64; + use crate::cmd::table::{MAX_BODY_ROWS, MAX_COLUMNS, MAX_HEADER_FOOTER_ROWS}; + assert_eq!((n("rows"), n("columns")), (MAX_BODY_ROWS + MAX_HEADER_FOOTER_ROWS, MAX_COLUMNS)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().tables.is_empty(), "one undo"); +} + #[test] fn convert_text_to_table_and_back() { let (mut s, sid, _) = session_with_frame(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index de490a157..b23287579 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -143,11 +143,13 @@ fn text_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, w: f32) { fn insert_table_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let lang = &app.ui.language; crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "Table Dimensions")).font(semibold(12.0))); + use designcraft_engine::cmd::table::{MAX_BODY_ROWS, MAX_COLUMNS, MAX_HEADER_FOOTER_ROWS}; + // `table.insert`'s limits, so the table made is the one the fields show. let rows = [ - ("bodyRows", crate::i18n::tr(lang, "Body Rows"), 1.0), - ("columns", crate::i18n::tr_context(lang, "Columns", "table"), 1.0), - ("headerRows", crate::i18n::tr(lang, "Header Rows"), 0.0), - ("footerRows", crate::i18n::tr(lang, "Footer Rows"), 0.0), + ("bodyRows", crate::i18n::tr(lang, "Body Rows"), 1.0, MAX_BODY_ROWS), + ("columns", crate::i18n::tr_context(lang, "Columns", "table"), 1.0, MAX_COLUMNS), + ("headerRows", crate::i18n::tr(lang, "Header Rows"), 0.0, MAX_HEADER_FOOTER_ROWS), + ("footerRows", crate::i18n::tr(lang, "Footer Rows"), 0.0, MAX_HEADER_FOOTER_ROWS), ]; // Body rows and columns, then (after a gap) header and footer rows; one label width for both. for (g, pair) in rows.chunks(2).enumerate() { @@ -155,9 +157,9 @@ fn insert_table_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.add_space(8.0); } egui::Grid::new(("ins_table", g)).num_columns(2).min_col_width(100.0).spacing([8.0, 6.0]).show(ui, |ui| { - for (key, label, min) in pair { + for (key, label, min, max) in pair { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| crate::rtl::label(ui, format!("{label}:"))); - let field = crate::widgets::NumField::number(key, d.n(key), "", 0).width(70.0).range(*min, 10_000.0).spinner(); + let field = crate::widgets::NumField::number(key, d.n(key), "", 0).width(70.0).range(*min, *max as f64).spinner(); if let Some(v) = field.show(ui) { d.fields.insert((*key).into(), json!(v.round())); } @@ -3128,20 +3130,16 @@ pub fn confirm(app: &mut DesignApp) -> Result { None => Err(format!("no page \"{typed}\"")), } } - "insertTable" => app - .run( + "insertTable" => { + // The table and its style in one step; a style that is gone leaves the document alone. + let style = Some(d.s("tableStyle")).filter(|s| !s.is_empty()); + app.run( "table.insert", json!({"rows": d.n("bodyRows").unwrap_or(4.0).max(1.0) as u64, "cols": d.n("columns").unwrap_or(4.0).max(1.0) as u64, - "headerRows": d.n("headerRows").unwrap_or(0.0).max(0.0) as u64, "footerRows": d.n("footerRows").unwrap_or(0.0).max(0.0) as u64}), + "headerRows": d.n("headerRows").unwrap_or(0.0).max(0.0) as u64, "footerRows": d.n("footerRows").unwrap_or(0.0).max(0.0) as u64, + "tableStyle": style}), ) - .and_then(|r| { - // A table style other than [Basic Table] (which a new table has) is applied after. - let style = d.s("tableStyle"); - if style.is_empty() || style == designcraft_doc::BASIC_TABLE { - return Ok(r); - } - app.run("style.table.apply", json!({"name": style, "story": r["story"], "table": r["table"]})).map(|_| r) - }), + } "numberingSection" => { // On an error the dialog stays open to be corrected. let r = crate::section_options::confirm(app, &d); @@ -9664,7 +9662,17 @@ mod tests { let len = |k: &str| t[k].as_array().map_or(0, Vec::len); assert_eq!((len("rows"), len("columns"), t["headerRows"].as_u64()), (6, 3, Some(1)), "{t}"); assert_eq!(t["style"].as_str().unwrap_or(""), expected, "{t}"); + // One Insert Table, one undo. + app.run("edit.undo", json!({})).unwrap(); + assert!(app.run("table.get", json!({})).is_err(), "the table is gone"); } + // A style that no longer exists changes nothing. + let tables = |app: &DesignApp| app.session.active().unwrap().doc.stories.values().map(|s| s.tables.len()).sum::(); + app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text"})).unwrap(); + app.run("app.insertTableDialog", json!({})).unwrap(); + ctl(&mut app, "ui.dialog.set", json!({"field": "tableStyle", "value": "Gone"})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false); + assert_eq!(tables(&app), 0); } #[test] From eaef6e94f59e116b66fadc3498d00c64cafd9d62 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 23 Oct 2026 22:11:30 +0200 Subject: [PATCH 08/30] Format the conversion test Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests_table.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index 844d47298..8e9735337 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -497,7 +497,13 @@ fn convert_text_to_table_with_indesigns_separators() { s.execute("style.table.create", &json!({"name": "Grid"})).unwrap(); let r = convert(&mut s, "x\ty", json!({"tableStyle": "Grid"})).unwrap(); assert_eq!(s.execute("table.get", &json!({"table": r["table"]})).unwrap()["style"], "Grid"); - for bad in [json!({"columnSeparator": ""}), json!({"rowSeparator": 3}), json!({"columns": 0}), json!({"tableStyle": "Missing"}), json!({"tableStyle": 123})] { + for bad in [ + json!({"columnSeparator": ""}), + json!({"rowSeparator": 3}), + json!({"columns": 0}), + json!({"tableStyle": "Missing"}), + json!({"tableStyle": 123}), + ] { assert!(convert(&mut s, "p\tq", bad.clone()).is_err(), "{bad}"); } check(&s); From 394f4152fa3521a0694b1e1968f1bab9fdfad8ab Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sat, 24 Oct 2026 11:13:04 +0200 Subject: [PATCH 09/30] Insert Table: the dialog stays open when the table can't be made As the other dialogs do (Cell Options, Text Frame Options): on an error the message goes to the status bar and the dialog comes back with what was typed. Found by CodeRabbit on #1679. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2f8e33bc7..26be062f5 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3135,14 +3135,20 @@ pub fn confirm(app: &mut DesignApp) -> Result { } } "insertTable" => { - // The table and its style in one step; a style that is gone leaves the document alone. + // The table and its style in one step; a style that is gone leaves the document alone, + // and the dialog stays open to be corrected. let style = Some(d.s("tableStyle")).filter(|s| !s.is_empty()); - app.run( + let r = app.run( "table.insert", json!({"rows": d.n("bodyRows").unwrap_or(4.0).max(1.0) as u64, "cols": d.n("columns").unwrap_or(4.0).max(1.0) as u64, "headerRows": d.n("headerRows").unwrap_or(0.0).max(0.0) as u64, "footerRows": d.n("footerRows").unwrap_or(0.0).max(0.0) as u64, "tableStyle": style}), - ) + ); + if let Err(e) = &r { + app.status(e.clone()); + app.ui.dialog = Some(d); + } + r } "numberingSection" => { // On an error the dialog stays open to be corrected. @@ -9747,6 +9753,8 @@ mod tests { ctl(&mut app, "ui.dialog.set", json!({"field": "tableStyle", "value": "Gone"})); assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false); assert_eq!(tables(&app), 0); + let d = app.ui.dialog.as_ref().expect("the dialog stays open to be corrected"); + assert_eq!((d.id.as_str(), d.s("tableStyle")), ("insertTable", "Gone".to_string())); } #[test] From ed63b8f4ab47d12238bc2dca49436f957fac3a38 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sat, 24 Oct 2026 11:17:28 +0200 Subject: [PATCH 10/30] Convert dialogs stay open when the conversion fails An emptied separator field reached table.convertFromText / convertToText, which refuse it; the dialog closed and lost what was typed. On an error the message goes to the status bar and the dialog comes back, as the other dialogs do (Insert Table included, from #1676). Found by CodeRabbit. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0a2962991..b2e3bd714 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3210,17 +3210,24 @@ pub fn confirm(app: &mut DesignApp) -> Result { None => Err(format!("no page \"{typed}\"")), } } - "convertTextToTable" => { - let same = d.s("columnSeparator") == d.s("rowSeparator"); - let style = Some(d.s("tableStyle")).filter(|s| !s.is_empty() && s != designcraft_doc::BASIC_TABLE); - app.run( - "table.convertFromText", - json!({"columnSeparator": d.s("columnSeparator"), "rowSeparator": d.s("rowSeparator"), - "columns": if same { json!(d.n("columns").unwrap_or(1.0).max(1.0) as u64) } else { Value::Null }, "tableStyle": style}), - ) - } - "convertTableToText" => { - app.run("table.convertToText", json!({"columnSeparator": d.s("columnSeparator"), "rowSeparator": d.s("rowSeparator")})) + "convertTextToTable" | "convertTableToText" => { + let r = if d.id == "convertTextToTable" { + let same = d.s("columnSeparator") == d.s("rowSeparator"); + let style = Some(d.s("tableStyle")).filter(|s| !s.is_empty() && s != designcraft_doc::BASIC_TABLE); + app.run( + "table.convertFromText", + json!({"columnSeparator": d.s("columnSeparator"), "rowSeparator": d.s("rowSeparator"), + "columns": if same { json!(d.n("columns").unwrap_or(1.0).max(1.0) as u64) } else { Value::Null }, "tableStyle": style}), + ) + } else { + app.run("table.convertToText", json!({"columnSeparator": d.s("columnSeparator"), "rowSeparator": d.s("rowSeparator")})) + }; + // On an error (an emptied separator field) the dialog stays open to be corrected. + if let Err(e) = &r { + app.status(e.clone()); + app.ui.dialog = Some(d); + } + r } "insertTable" => { // The table and its style in one step; a style that is gone leaves the document alone, @@ -9861,6 +9868,10 @@ mod tests { assert!(crate::menus::menu_enabled(&app, "app.convertTextToTableDialog")); app.run("app.convertTextToTableDialog", json!({})).unwrap(); assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("convertTextToTable")); + // An emptied separator: the command refuses it and the dialog stays open, as typed. + ctl(&mut app, "ui.dialog.set", json!({"field": "rowSeparator", "value": ""})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false); + assert_eq!(app.ui.dialog.as_ref().map(|d| (d.id.as_str(), d.s("rowSeparator"))), Some(("convertTextToTable", String::new()))); for (field, value) in [("columnSeparator", json!("paragraph")), ("rowSeparator", json!("paragraph")), ("columns", json!(2))] { ctl(&mut app, "ui.dialog.set", json!({"field": field, "value": value})); } From 5fe0fe6ff6e4b8d0905da3d2fa7b1e657c2b5bb7 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sat, 24 Oct 2026 13:07:49 +0200 Subject: [PATCH 11/30] Insert Table: a null table style is no style Dialog::s turned JSON null into the string "null", which table.insert refused as an unknown style. The field is read as text, and null or empty means [Basic Table]. Found by CodeRabbit. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 26be062f5..0d201619d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -137,6 +137,11 @@ fn text_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, w: f32) { } } +/// The dialog's chosen table style: none when unset, null or empty (a new table's [Basic Table]). +fn table_style_field(d: &Dialog) -> Option { + d.fields.get("tableStyle").and_then(Value::as_str).filter(|s| !s.is_empty()).map(str::to_string) +} + /// Table › Insert Table, laid out as InDesign's: Table Dimensions (body rows and columns, then /// header and footer rows, each a number with a spinner, labels on the right edge of their /// column) and the Table Style to apply. @@ -169,7 +174,7 @@ fn insert_table_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } ui.add_space(8.0); let names: Vec = app.session.active().map(|st| st.doc.styles.table.iter().map(|s| s.name.clone()).collect()).unwrap_or_default(); - let cur = Some(d.s("tableStyle")).filter(|s| !s.is_empty()).unwrap_or_else(|| designcraft_doc::BASIC_TABLE.to_string()); + let cur = table_style_field(d).unwrap_or_else(|| designcraft_doc::BASIC_TABLE.to_string()); ui.horizontal(|ui| { crate::rtl::label(ui, format!("{}:", crate::i18n::tr(lang, "Table Style"))); egui::ComboBox::from_id_salt("ins_table_style").selected_text(&cur).width(160.0).show_ui(ui, |ui| { @@ -3137,7 +3142,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { "insertTable" => { // The table and its style in one step; a style that is gone leaves the document alone, // and the dialog stays open to be corrected. - let style = Some(d.s("tableStyle")).filter(|s| !s.is_empty()); + let style = table_style_field(&d); let r = app.run( "table.insert", json!({"rows": d.n("bodyRows").unwrap_or(4.0).max(1.0) as u64, "cols": d.n("columns").unwrap_or(4.0).max(1.0) as u64, @@ -9746,6 +9751,13 @@ mod tests { app.run("edit.undo", json!({})).unwrap(); assert!(app.run("table.get", json!({})).is_err(), "the table is gone"); } + // A null style is no style: the table is made, unstyled. + app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text"})).unwrap(); + app.run("app.insertTableDialog", json!({})).unwrap(); + ctl(&mut app, "ui.dialog.set", json!({"field": "tableStyle", "value": null})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + assert_eq!(app.run("table.get", json!({})).unwrap()["style"].as_str().unwrap_or(""), ""); + app.run("edit.undo", json!({})).unwrap(); // A style that no longer exists changes nothing. let tables = |app: &DesignApp| app.session.active().unwrap().doc.stories.values().map(|s| s.tables.len()).sum::(); app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text"})).unwrap(); From b30f4edb5b93f25c264eab49dfa44e3615e1aec6 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sun, 25 Oct 2026 17:11:29 +0100 Subject: [PATCH 12/30] Convert Text to Table: refuse text too wide or too big for a table A row wider than MAX_COLUMNS, or more cells than the largest table table.insert makes (MAX_BODY_ROWS x MAX_COLUMNS), is an error before the table is built; the text stays as it was. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/table.rs | 11 +++++++++++ crates/engine/src/tests_table.rs | 9 +++++++++ 2 files changed, 20 insertions(+) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 827384713..58a31e863 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -569,6 +569,9 @@ fn para_direction(d: &Document, story: StoryId, offset: usize) -> designcraft_do pub const MAX_BODY_ROWS: u64 = 500; pub const MAX_COLUMNS: u64 = 200; pub const MAX_HEADER_FOOTER_ROWS: u64 = 100; +/// The most cells `table.convertFromText` makes: as many as the largest table `table.insert` +/// makes, however the rows and columns divide them (a long list can make a long table). +pub const MAX_CONVERTED_CELLS: u64 = MAX_BODY_ROWS * MAX_COLUMNS; /// A command's `tableStyle`: none for an absent one or [Basic Table] (a new table's look), else the /// resolved style and the cell styles it names. Anything but an existing style's name is an error. @@ -673,6 +676,14 @@ fn convert_from_text(s: &mut Session, p: &Value) -> Result { } else { text.split(row_sep.as_str()).map(|row| row.split(col_sep.as_str()).map(|c| c.trim().to_string()).collect()).collect() }; + // The table is as wide as its widest row; refuse what no table here could hold. + let cols = data.iter().map(Vec::len).max().unwrap_or(1) as u64; + if cols > MAX_COLUMNS { + return Err(bad(ID, format!("the text makes {cols} columns; a table has at most {MAX_COLUMNS}"))); + } + if cols * data.len() as u64 > MAX_CONVERTED_CELLS { + return Err(bad(ID, format!("the text makes more than {MAX_CONVERTED_CELLS} cells"))); + } let pf = st.paras[pa].clone(); let cf = st.format_after(a).clone(); let mut table = Table::from_strings(id, &data, width, &pf, &cf); diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index cb2519e1e..072e91f9a 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -512,6 +512,15 @@ fn convert_text_to_table_with_indesigns_separators() { ] { assert!(convert(&mut s, "p\tq", bad.clone()).is_err(), "{bad}"); } + // Text that would make too wide or too big a table is refused, and stays as it was. + use crate::cmd::table::{MAX_COLUMNS, MAX_CONVERTED_CELLS}; + let wide = vec!["x"; MAX_COLUMNS as usize + 1].join("\t"); + assert!(convert(&mut s, &wide, json!({})).is_err(), "a row wider than a table can be"); + let long = vec!["a\tb"; MAX_CONVERTED_CELLS as usize / 2 + 1].join("\n"); + assert!(convert(&mut s, &long, json!({})).is_err(), "more cells than a table can have"); + assert_eq!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text, format!("{long}\nAfter")); + let at_most = vec!["x"; MAX_COLUMNS as usize].join("\t"); + assert!(convert(&mut s, &at_most, json!({})).is_ok(), "a row as wide as a table can be"); check(&s); // Back to text with a comma between columns and a tab between rows. let r = convert(&mut s, "Plum\t80%\nSunset\t45%", json!({})).unwrap(); From c27ef351e1d5585645954b0ad07090cba13eee97 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sun, 25 Oct 2026 17:12:01 +0100 Subject: [PATCH 13/30] Insert Table: a tableStyle that isn't a name goes to table.insert as it is So the command refuses it (and the dialog stays open) instead of the table being made unstyled. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7489398d2..93fa0a44c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -142,6 +142,15 @@ fn table_style_field(d: &Dialog) -> Option { d.fields.get("tableStyle").and_then(Value::as_str).filter(|s| !s.is_empty()).map(str::to_string) } +/// The dialog's `tableStyle` as the table commands take it: null for none, the style's name, or +/// a value that is neither (set through `ui.dialog.set`) as it is, for the command to refuse. +fn table_style_value(d: &Dialog) -> Value { + match d.fields.get("tableStyle") { + None | Some(Value::String(_)) => table_style_field(d).map_or(Value::Null, Value::String), + Some(v) => v.clone(), + } +} + /// Table › Insert Table, laid out as InDesign's: Table Dimensions (body rows and columns, then /// header and footer rows, each a number with a spinner, labels on the right edge of their /// column) and the Table Style to apply. @@ -3162,7 +3171,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { "insertTable" => { // The table and its style in one step; a style that is gone leaves the document alone, // and the dialog stays open to be corrected. - let style = table_style_field(&d); + let style = table_style_value(&d); let r = app.run( "table.insert", json!({"rows": d.n("bodyRows").unwrap_or(4.0).max(1.0) as u64, "cols": d.n("columns").unwrap_or(4.0).max(1.0) as u64, @@ -10072,6 +10081,12 @@ mod tests { assert_eq!(tables(&app), 0); let d = app.ui.dialog.as_ref().expect("the dialog stays open to be corrected"); assert_eq!((d.id.as_str(), d.s("tableStyle")), ("insertTable", "Gone".to_string())); + // So does a style that isn't a name: the command refuses it, rather than making the table + // unstyled. + ctl(&mut app, "ui.dialog.set", json!({"field": "tableStyle", "value": 42})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false); + assert_eq!(tables(&app), 0); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("insertTable"), "the dialog stays open"); } #[test] From 7ffb4874daeb3579b3224b11230908bff4e34244 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sun, 25 Oct 2026 17:12:01 +0100 Subject: [PATCH 14/30] Insert Table: a tableStyle that isn't a name goes to table.insert as it is So the command refuses it (and the dialog stays open) instead of the table being made unstyled. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ee1d6f2b4..50b403358 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -231,6 +231,15 @@ fn table_style_field(d: &Dialog) -> Option { d.fields.get("tableStyle").and_then(Value::as_str).filter(|s| !s.is_empty()).map(str::to_string) } +/// The dialog's `tableStyle` as the table commands take it: null for none, the style's name, or +/// a value that is neither (set through `ui.dialog.set`) as it is, for the command to refuse. +fn table_style_value(d: &Dialog) -> Value { + match d.fields.get("tableStyle") { + None | Some(Value::String(_)) => table_style_field(d).map_or(Value::Null, Value::String), + Some(v) => v.clone(), + } +} + /// Table › Insert Table, laid out as InDesign's: Table Dimensions (body rows and columns, then /// header and footer rows, each a number with a spinner, labels on the right edge of their /// column) and the Table Style to apply. @@ -3402,7 +3411,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { "insertTable" => { // The table and its style in one step; a style that is gone leaves the document alone, // and the dialog stays open to be corrected. - let style = table_style_field(&d); + let style = table_style_value(&d); let r = app.run( "table.insert", json!({"rows": d.n("bodyRows").unwrap_or(4.0).max(1.0) as u64, "cols": d.n("columns").unwrap_or(4.0).max(1.0) as u64, @@ -10567,6 +10576,12 @@ mod tests { assert_eq!(tables(&app), 0); let d = app.ui.dialog.as_ref().expect("the dialog stays open to be corrected"); assert_eq!((d.id.as_str(), d.s("tableStyle")), ("insertTable", "Gone".to_string())); + // So does a style that isn't a name: the command refuses it, rather than making the table + // unstyled. + ctl(&mut app, "ui.dialog.set", json!({"field": "tableStyle", "value": 42})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false); + assert_eq!(tables(&app), 0); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("insertTable"), "the dialog stays open"); } /// Convert Text to Table: available with text selected (not with a caret alone); its From fd02125c68bb3b19b13e3f5fc06a8b4ac63d0f86 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sun, 25 Oct 2026 17:12:20 +0100 Subject: [PATCH 15/30] Convert Text to Table: a tableStyle that isn't a name goes to the command as it is As Insert Table's: the command refuses it, and the dialog stays open. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 50b403358..46915dce9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3392,7 +3392,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { "convertTextToTable" | "convertTableToText" => { let r = if d.id == "convertTextToTable" { let same = same_separators(&d); - let style = table_style_field(&d).filter(|s| s != designcraft_doc::BASIC_TABLE); + let style = table_style_value(&d); app.run( "table.convertFromText", json!({"columnSeparator": d.s("columnSeparator"), "rowSeparator": d.s("rowSeparator"), From 888b8a2d901ebaab75d5218828918aca77b20f02 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 14:32:08 +0100 Subject: [PATCH 16/30] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/pages.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 128e51496..6834e56a2 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -484,7 +484,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { Stroke::NONE, )); let marker = - ui.interact(egui::Rect::from_center_size(c, vec2(12.0, 8.0)), ui.id().with(("section", abs)), Sense::click()); + ui.interact(egui::Rect::from_center_size(c, vec2(12.0, 8.0)), ui.scope_id().with(("section", abs)), Sense::click()); let label = format!( "{}: {}", crate::i18n::tr(&app.ui.language, "Numbering & Section Options…").trim_end_matches('…'), @@ -551,7 +551,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.painter().rect_filled(br, 0.0, BADGE); } ui.painter().galley(c - g.size() / 2.0, g, Color32::WHITE); - let lr = ui.interact(br, ui.id().with(("spread_label", si)), Sense::click()); + let lr = ui.interact(br, ui.scope_id().with(("spread_label", si)), Sense::click()); lr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, &label)); if lr.clicked() { if editing_parents { From 5f96d2b3ffa3410695a0fff0bbe0d3029cdc4345 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:14:53 +0100 Subject: [PATCH 17/30] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/pages.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index ae951787c..10a53e175 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -533,7 +533,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.painter().rect_filled(br, 0.0, BADGE); } ui.painter().galley(c - g.size() / 2.0, g, ink); - let lr = ui.interact(br, ui.id().with(("spread_label", si)), Sense::click()); + let lr = ui.interact(br, ui.scope_id().with(("spread_label", si)), Sense::click()); // A click goes to the spread, so assistive technology hears a button. lr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &label)); if lr.clicked() { From d40bb87def4615e0be94002939182a28ca54489a Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 17:53:41 +0100 Subject: [PATCH 18/30] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/pages.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 6834e56a2..4fe7fea8a 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -427,7 +427,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { // ---- Document spreads. let cur = crate::canvas::current_page(app).unwrap_or(0); let cur_spread = doc.page_loc(cur).map(|l| l.0); - let followed_id = egui::Id::new("pages_panel_followed"); + let followed_id = egui::Id::unique("pages_panel_followed"); let dragging = egui::DragAndDrop::has_any_payload(ui.ctx()); let tallest = doc.spreads.iter().flat_map(|sp| sp.pages.iter().map(|p| p.height)).fold(doc.settings.page_height, f64::max).max(1.0); let th = opts.page_size.page_h(); @@ -490,7 +490,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { crate::i18n::tr(&app.ui.language, "Numbering & Section Options…").trim_end_matches('…'), app.session.page_label(abs) ); - marker.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &label)); + marker.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label)); // A double click, or the keyboard or a screen reader activating it (a // single pointer click only selects, as on a page). let activated = marker.double_clicked() || marker.clicked() && !marker.clicked_by(egui::PointerButton::Primary); @@ -552,7 +552,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } ui.painter().galley(c - g.size() / 2.0, g, Color32::WHITE); let lr = ui.interact(br, ui.scope_id().with(("spread_label", si)), Sense::click()); - lr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, &label)); + lr.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, &label)); if lr.clicked() { if editing_parents { let _ = app.run("layout.parents.edit", json!({"on": false})); @@ -831,7 +831,7 @@ mod spread_label_tests { app.run("layout.parents.edit", json!({"on": true})).unwrap(); let mut h = Harness::builder().with_size(vec2(280.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( |ui, app: &mut DesignApp| { - let key = egui::Id::new("section_marker_test_fonts"); + let key = egui::Id::unique("section_marker_test_fonts"); if ui.data(|d| d.get_temp::(key)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(key, true)); @@ -865,7 +865,7 @@ mod spread_label_tests { app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1000.0, 800.0))); let mut h = Harness::builder().with_size(vec2(280.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( |ui, app: &mut DesignApp| { - let key = egui::Id::new("section_marker_key_test_fonts"); + let key = egui::Id::unique("section_marker_key_test_fonts"); if ui.data(|d| d.get_temp::(key)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(key, true)); @@ -922,7 +922,7 @@ mod spread_label_tests { app.canvas_rect = Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), vec2(1000.0, 800.0))); let mut h = Harness::builder().with_size(vec2(260.0, 900.0)).build_ui_state( |ui, app: &mut DesignApp| { - let key = egui::Id::new("test_fonts"); + let key = egui::Id::unique("test_fonts"); if ui.data(|d| d.get_temp::(key)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(key, true)); From b348eb4efb9545e8ead9026c6e1ca0a41ef77d72 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 18:06:18 +0100 Subject: [PATCH 19/30] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/pages.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 10a53e175..176daaa00 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -427,7 +427,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { // ---- Document spreads. let cur = crate::canvas::current_page(app).unwrap_or(0); let cur_spread = doc.page_loc(cur).map(|l| l.0); - let followed_id = egui::Id::new("pages_panel_followed"); + let followed_id = egui::Id::unique("pages_panel_followed"); let dragging = egui::DragAndDrop::has_any_payload(ui.ctx()); let tallest = doc.spreads.iter().flat_map(|sp| sp.pages.iter().map(|p| p.height)).fold(doc.settings.page_height, f64::max).max(1.0); let th = opts.page_size.page_h(); @@ -535,7 +535,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.painter().galley(c - g.size() / 2.0, g, ink); let lr = ui.interact(br, ui.scope_id().with(("spread_label", si)), Sense::click()); // A click goes to the spread, so assistive technology hears a button. - lr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &label)); + lr.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label)); if lr.clicked() { if editing_parents { let _ = app.run("layout.parents.edit", json!({"on": false})); @@ -841,7 +841,7 @@ mod spread_label_tests { app.canvas_rect = Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), vec2(1000.0, 800.0))); let mut h = Harness::builder().with_size(vec2(260.0, 900.0)).build_ui_state( |ui, app: &mut DesignApp| { - let key = egui::Id::new("test_fonts"); + let key = egui::Id::unique("test_fonts"); if ui.data(|d| d.get_temp::(key)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(key, true)); From 4321999720af5f226acbff08cf80edf78ee7634a Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 19:39:04 +0100 Subject: [PATCH 20/30] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/table.rs | 2 +- crates/ui-egui/src/dialogs/tests.rs | 6 +-- crates/ui-egui/src/menus/tests.rs | 80 ++++++++++++++--------------- 3 files changed, 44 insertions(+), 44 deletions(-) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index e7e9cc3e7..ffa8687c1 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -28,5 +28,5 @@ mod styles; mod target; pub use registry::specs; -pub use structure::{MAX_BODY_ROWS, MAX_COLUMNS, MAX_HEADER_FOOTER_ROWS}; pub(crate) use selection::step_cell; +pub use structure::{MAX_BODY_ROWS, MAX_COLUMNS, MAX_HEADER_FOOTER_ROWS}; diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index f6b689e67..577b9a3ae 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3369,7 +3369,7 @@ fn dragging_the_title_moves_the_dialog_and_dragging_the_body_does_not() { assert!((centred.center() - egui::pos2(600.0, 400.0)).length() < 1.0, "{centred:?}"); assert!(centred.width() < 1000.0 && centred.height() < 700.0, "the area is the dialog, not the backdrop: {centred:?}"); - let title = h.get_by_label("Insert Table").rect().center(); + let title = h.get_by_label("Insert Table").rect().center(); drag(&mut h, title, title + vec2(100.0, 60.0)); let moved = rect(&h); assert!((moved.min - centred.min - vec2(100.0, 60.0)).length() < 2.0, "{centred:?} -> {moved:?}"); @@ -3379,7 +3379,7 @@ fn dragging_the_title_moves_the_dialog_and_dragging_the_body_does_not() { assert_eq!(rect(&h), moved, "a drag in the body leaves the dialog in place"); // The title row stays inside the top edge. - let title = h.get_by_label("Insert Table").rect().center(); + let title = h.get_by_label("Insert Table").rect().center(); drag(&mut h, title, title - vec2(0.0, 1000.0)); assert!(rect(&h).top().abs() < 1.0, "{:?}", rect(&h)); @@ -3391,7 +3391,7 @@ fn dragging_the_title_moves_the_dialog_and_dragging_the_body_does_not() { assert!((rect(&h).center() - centred.center()).length() < 1.0, "{:?}", rect(&h)); // Past the left edge, about 100 px of the title row stays inside. - let title = h.get_by_label("Insert Table").rect().center(); + let title = h.get_by_label("Insert Table").rect().center(); drag(&mut h, title, title - vec2(900.0, 0.0)); assert!((rect(&h).right() - 100.0).abs() < 2.0, "{:?}", rect(&h)); } diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 57ce99cd6..88e826607 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -2107,46 +2107,46 @@ fn context_menus_hold_commands_and_menu_bar_submenus() { } } - #[test] - fn the_table_menu_follows_indesigns_order() { - // InDesign's Table menu with a text insertion point (Adobe's "How to make a table"). - let table = menu_tree().into_iter().find(|(m, _)| *m == "Table").unwrap().1; - let labels: Vec = table - .iter() - .map(|i| match i { - Item::Sep => "-".to_string(), - Item::Sub(n, _) => format!("{n} >"), - Item::Cmd { label, .. } => label.clone(), - }) - .collect(); - let expected = [ - "Insert Table…", - "Convert Text to Table", - "Convert Table to Text", - "-", - "Cell Options >", - "-", - "Insert >", - "Delete >", - "Select >", - "-", - "Merge Cells", - "Unmerge Cells", - "Split Cell Horizontally", - "Split Cell Vertically", - "Convert Cell Type >", - "Convert Rows >", - "-", - "Distribute Rows Evenly", - "Distribute Columns Evenly", - ]; - assert_eq!(labels, expected); - // Cmd+Alt+Shift+T opens the dialog, as InDesign's; the engine command has no shortcut of - // its own. The Table panel is in Window › Type & Tables. - assert_eq!(ui_label("app.insertTableDialog").and_then(|c| c.1), Some("Cmd+Alt+Shift+T")); - assert_eq!(designcraft_engine::find_command("table.insert").unwrap().shortcut, None); - assert_eq!(ui_label("app.tablePanel").and_then(|c| c.1), Some("Shift+F9")); - } +#[test] +fn the_table_menu_follows_indesigns_order() { + // InDesign's Table menu with a text insertion point (Adobe's "How to make a table"). + let table = menu_tree().into_iter().find(|(m, _)| *m == "Table").unwrap().1; + let labels: Vec = table + .iter() + .map(|i| match i { + Item::Sep => "-".to_string(), + Item::Sub(n, _) => format!("{n} >"), + Item::Cmd { label, .. } => label.clone(), + }) + .collect(); + let expected = [ + "Insert Table…", + "Convert Text to Table", + "Convert Table to Text", + "-", + "Cell Options >", + "-", + "Insert >", + "Delete >", + "Select >", + "-", + "Merge Cells", + "Unmerge Cells", + "Split Cell Horizontally", + "Split Cell Vertically", + "Convert Cell Type >", + "Convert Rows >", + "-", + "Distribute Rows Evenly", + "Distribute Columns Evenly", + ]; + assert_eq!(labels, expected); + // Cmd+Alt+Shift+T opens the dialog, as InDesign's; the engine command has no shortcut of + // its own. The Table panel is in Window › Type & Tables. + assert_eq!(ui_label("app.insertTableDialog").and_then(|c| c.1), Some("Cmd+Alt+Shift+T")); + assert_eq!(designcraft_engine::find_command("table.insert").unwrap().shortcut, None); + assert_eq!(ui_label("app.tablePanel").and_then(|c| c.1), Some("Shift+F9")); +} #[test] fn the_type_menu_follows_indesigns_order() { From ae28681b2cc65ccdb1a63995a50f9f339e9b00fd Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 19:43:55 +0100 Subject: [PATCH 21/30] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/table.rs | 2 +- crates/ui-egui/src/dialogs/tests.rs | 6 +-- crates/ui-egui/src/menus/tests.rs | 80 ++++++++++++++--------------- 3 files changed, 44 insertions(+), 44 deletions(-) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 7ad499635..b304edada 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -28,5 +28,5 @@ mod styles; mod target; pub use registry::specs; -pub use structure::{MAX_BODY_ROWS, MAX_COLUMNS, MAX_CONVERTED_CELLS, MAX_HEADER_FOOTER_ROWS}; pub(crate) use selection::step_cell; +pub use structure::{MAX_BODY_ROWS, MAX_COLUMNS, MAX_CONVERTED_CELLS, MAX_HEADER_FOOTER_ROWS}; diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index f6b689e67..577b9a3ae 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3369,7 +3369,7 @@ fn dragging_the_title_moves_the_dialog_and_dragging_the_body_does_not() { assert!((centred.center() - egui::pos2(600.0, 400.0)).length() < 1.0, "{centred:?}"); assert!(centred.width() < 1000.0 && centred.height() < 700.0, "the area is the dialog, not the backdrop: {centred:?}"); - let title = h.get_by_label("Insert Table").rect().center(); + let title = h.get_by_label("Insert Table").rect().center(); drag(&mut h, title, title + vec2(100.0, 60.0)); let moved = rect(&h); assert!((moved.min - centred.min - vec2(100.0, 60.0)).length() < 2.0, "{centred:?} -> {moved:?}"); @@ -3379,7 +3379,7 @@ fn dragging_the_title_moves_the_dialog_and_dragging_the_body_does_not() { assert_eq!(rect(&h), moved, "a drag in the body leaves the dialog in place"); // The title row stays inside the top edge. - let title = h.get_by_label("Insert Table").rect().center(); + let title = h.get_by_label("Insert Table").rect().center(); drag(&mut h, title, title - vec2(0.0, 1000.0)); assert!(rect(&h).top().abs() < 1.0, "{:?}", rect(&h)); @@ -3391,7 +3391,7 @@ fn dragging_the_title_moves_the_dialog_and_dragging_the_body_does_not() { assert!((rect(&h).center() - centred.center()).length() < 1.0, "{:?}", rect(&h)); // Past the left edge, about 100 px of the title row stays inside. - let title = h.get_by_label("Insert Table").rect().center(); + let title = h.get_by_label("Insert Table").rect().center(); drag(&mut h, title, title - vec2(900.0, 0.0)); assert!((rect(&h).right() - 100.0).abs() < 2.0, "{:?}", rect(&h)); } diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 0fc54a059..69e72e47e 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -2107,46 +2107,46 @@ fn context_menus_hold_commands_and_menu_bar_submenus() { } } - #[test] - fn the_table_menu_follows_indesigns_order() { - // InDesign's Table menu with a text insertion point (Adobe's "How to make a table"). - let table = menu_tree().into_iter().find(|(m, _)| *m == "Table").unwrap().1; - let labels: Vec = table - .iter() - .map(|i| match i { - Item::Sep => "-".to_string(), - Item::Sub(n, _) => format!("{n} >"), - Item::Cmd { label, .. } => label.clone(), - }) - .collect(); - let expected = [ - "Insert Table…", - "Convert Text to Table…", - "Convert Table to Text…", - "-", - "Cell Options >", - "-", - "Insert >", - "Delete >", - "Select >", - "-", - "Merge Cells", - "Unmerge Cells", - "Split Cell Horizontally", - "Split Cell Vertically", - "Convert Cell Type >", - "Convert Rows >", - "-", - "Distribute Rows Evenly", - "Distribute Columns Evenly", - ]; - assert_eq!(labels, expected); - // Cmd+Alt+Shift+T opens the dialog, as InDesign's; the engine command has no shortcut of - // its own. The Table panel is in Window › Type & Tables. - assert_eq!(ui_label("app.insertTableDialog").and_then(|c| c.1), Some("Cmd+Alt+Shift+T")); - assert_eq!(designcraft_engine::find_command("table.insert").unwrap().shortcut, None); - assert_eq!(ui_label("app.tablePanel").and_then(|c| c.1), Some("Shift+F9")); - } +#[test] +fn the_table_menu_follows_indesigns_order() { + // InDesign's Table menu with a text insertion point (Adobe's "How to make a table"). + let table = menu_tree().into_iter().find(|(m, _)| *m == "Table").unwrap().1; + let labels: Vec = table + .iter() + .map(|i| match i { + Item::Sep => "-".to_string(), + Item::Sub(n, _) => format!("{n} >"), + Item::Cmd { label, .. } => label.clone(), + }) + .collect(); + let expected = [ + "Insert Table…", + "Convert Text to Table…", + "Convert Table to Text…", + "-", + "Cell Options >", + "-", + "Insert >", + "Delete >", + "Select >", + "-", + "Merge Cells", + "Unmerge Cells", + "Split Cell Horizontally", + "Split Cell Vertically", + "Convert Cell Type >", + "Convert Rows >", + "-", + "Distribute Rows Evenly", + "Distribute Columns Evenly", + ]; + assert_eq!(labels, expected); + // Cmd+Alt+Shift+T opens the dialog, as InDesign's; the engine command has no shortcut of + // its own. The Table panel is in Window › Type & Tables. + assert_eq!(ui_label("app.insertTableDialog").and_then(|c| c.1), Some("Cmd+Alt+Shift+T")); + assert_eq!(designcraft_engine::find_command("table.insert").unwrap().shortcut, None); + assert_eq!(ui_label("app.tablePanel").and_then(|c| c.1), Some("Shift+F9")); +} #[test] fn the_type_menu_follows_indesigns_order() { From 21f21045af3520d43a5b36ba4a7bf0ab61a6c570 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 19:44:05 +0100 Subject: [PATCH 22/30] Tests: the branch's dialog tests beside main's dialog test helpers (dialogs/tests.rs) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 46 ----------------------------- crates/ui-egui/src/dialogs/tests.rs | 46 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 46 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6de67e638..6db142dcd 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -245,52 +245,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - /// Insert Table: the dimensions as typed, and the Table Style chosen is applied to the new - /// table; [Basic Table] (the default) leaves it unstyled. - #[test] - fn insert_table_applies_its_dimensions_and_table_style() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - app.run("style.table.create", json!({"name": "Grid"})).unwrap(); - for (style, expected) in [("Grid", "Grid"), (designcraft_doc::BASIC_TABLE, "")] { - app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text"})).unwrap(); - assert_eq!(app.run("app.insertTableDialog", json!({})).unwrap(), Value::Null); - for (field, value) in [("bodyRows", json!(5)), ("columns", json!(3)), ("headerRows", json!(1)), ("tableStyle", json!(style))] { - ctl(&mut app, "ui.dialog.set", json!({"field": field, "value": value})); - } - assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); - let t = app.run("table.get", json!({})).unwrap(); - let len = |k: &str| t[k].as_array().map_or(0, Vec::len); - assert_eq!((len("rows"), len("columns"), t["headerRows"].as_u64()), (6, 3, Some(1)), "{t}"); - assert_eq!(t["style"].as_str().unwrap_or(""), expected, "{t}"); - // One Insert Table, one undo. - app.run("edit.undo", json!({})).unwrap(); - assert!(app.run("table.get", json!({})).is_err(), "the table is gone"); - } - // A null style is no style: the table is made, unstyled. - app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text"})).unwrap(); - app.run("app.insertTableDialog", json!({})).unwrap(); - ctl(&mut app, "ui.dialog.set", json!({"field": "tableStyle", "value": null})); - assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); - assert_eq!(app.run("table.get", json!({})).unwrap()["style"].as_str().unwrap_or(""), ""); - app.run("edit.undo", json!({})).unwrap(); - // A style that no longer exists changes nothing. - let tables = |app: &DesignApp| app.session.active().unwrap().doc.stories.values().map(|s| s.tables.len()).sum::(); - app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text"})).unwrap(); - app.run("app.insertTableDialog", json!({})).unwrap(); - ctl(&mut app, "ui.dialog.set", json!({"field": "tableStyle", "value": "Gone"})); - assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false); - assert_eq!(tables(&app), 0); - let d = app.ui.dialog.as_ref().expect("the dialog stays open to be corrected"); - assert_eq!((d.id.as_str(), d.s("tableStyle")), ("insertTable", "Gone".to_string())); - // So does a style that isn't a name: the command refuses it, rather than making the table - // unstyled. - ctl(&mut app, "ui.dialog.set", json!({"field": "tableStyle", "value": 42})); - assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false); - assert_eq!(tables(&app), 0); - assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("insertTable"), "the dialog stays open"); - } - #[test] fn color_theme_cancel_is_read_only_and_confirm_adds_the_preview() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 577b9a3ae..4f5c825ba 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3521,3 +3521,49 @@ fn character_style_options_set_the_digits() { assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); assert_eq!(char_style(&app, "Emphasis").unwrap().chars.digits, Some(designcraft_doc::Digits::Farsi)); } + +/// Insert Table: the dimensions as typed, and the Table Style chosen is applied to the new +/// table; [Basic Table] (the default) leaves it unstyled. +#[test] +fn insert_table_applies_its_dimensions_and_table_style() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("style.table.create", json!({"name": "Grid"})).unwrap(); + for (style, expected) in [("Grid", "Grid"), (designcraft_doc::BASIC_TABLE, "")] { + app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text"})).unwrap(); + assert_eq!(app.run("app.insertTableDialog", json!({})).unwrap(), Value::Null); + for (field, value) in [("bodyRows", json!(5)), ("columns", json!(3)), ("headerRows", json!(1)), ("tableStyle", json!(style))] { + ctl(&mut app, "ui.dialog.set", json!({"field": field, "value": value})); + } + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + let t = app.run("table.get", json!({})).unwrap(); + let len = |k: &str| t[k].as_array().map_or(0, Vec::len); + assert_eq!((len("rows"), len("columns"), t["headerRows"].as_u64()), (6, 3, Some(1)), "{t}"); + assert_eq!(t["style"].as_str().unwrap_or(""), expected, "{t}"); + // One Insert Table, one undo. + app.run("edit.undo", json!({})).unwrap(); + assert!(app.run("table.get", json!({})).is_err(), "the table is gone"); + } + // A null style is no style: the table is made, unstyled. + app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text"})).unwrap(); + app.run("app.insertTableDialog", json!({})).unwrap(); + ctl(&mut app, "ui.dialog.set", json!({"field": "tableStyle", "value": null})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + assert_eq!(app.run("table.get", json!({})).unwrap()["style"].as_str().unwrap_or(""), ""); + app.run("edit.undo", json!({})).unwrap(); + // A style that no longer exists changes nothing. + let tables = |app: &DesignApp| app.session.active().unwrap().doc.stories.values().map(|s| s.tables.len()).sum::(); + app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text"})).unwrap(); + app.run("app.insertTableDialog", json!({})).unwrap(); + ctl(&mut app, "ui.dialog.set", json!({"field": "tableStyle", "value": "Gone"})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false); + assert_eq!(tables(&app), 0); + let d = app.ui.dialog.as_ref().expect("the dialog stays open to be corrected"); + assert_eq!((d.id.as_str(), d.s("tableStyle")), ("insertTable", "Gone".to_string())); + // So does a style that isn't a name: the command refuses it, rather than making the table + // unstyled. + ctl(&mut app, "ui.dialog.set", json!({"field": "tableStyle", "value": 42})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false); + assert_eq!(tables(&app), 0); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("insertTable"), "the dialog stays open"); +} From 5335e0a92fc8c976bdfa2fb7f03c3a53432b2f75 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 19:51:12 +0100 Subject: [PATCH 23/30] Tests: the branch's table tests beside main's moved test helpers and imports Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests/table.rs | 3 +- crates/ui-egui/src/dialogs.rs | 90 ----------------------------- crates/ui-egui/src/dialogs/tests.rs | 90 +++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 91 deletions(-) diff --git a/crates/engine/src/tests/table.rs b/crates/engine/src/tests/table.rs index a837647e6..79ea51c7d 100644 --- a/crates/engine/src/tests/table.rs +++ b/crates/engine/src/tests/table.rs @@ -1,6 +1,7 @@ -use serde_json::json; +use serde_json::{Value, json}; use super::{Session, doc}; +use crate::Result; fn session_with_frame() -> (Session, u64, u64) { let mut s = Session::new(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1468470a3..17f9129f1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -333,96 +333,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - /// Insert Table: the dimensions as typed, and the Table Style chosen is applied to the new - /// table; [Basic Table] (the default) leaves it unstyled. - #[test] - fn insert_table_applies_its_dimensions_and_table_style() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - app.run("style.table.create", json!({"name": "Grid"})).unwrap(); - for (style, expected) in [("Grid", "Grid"), (designcraft_doc::BASIC_TABLE, "")] { - app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text"})).unwrap(); - assert_eq!(app.run("app.insertTableDialog", json!({})).unwrap(), Value::Null); - for (field, value) in [("bodyRows", json!(5)), ("columns", json!(3)), ("headerRows", json!(1)), ("tableStyle", json!(style))] { - ctl(&mut app, "ui.dialog.set", json!({"field": field, "value": value})); - } - assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); - let t = app.run("table.get", json!({})).unwrap(); - let len = |k: &str| t[k].as_array().map_or(0, Vec::len); - assert_eq!((len("rows"), len("columns"), t["headerRows"].as_u64()), (6, 3, Some(1)), "{t}"); - assert_eq!(t["style"].as_str().unwrap_or(""), expected, "{t}"); - // One Insert Table, one undo. - app.run("edit.undo", json!({})).unwrap(); - assert!(app.run("table.get", json!({})).is_err(), "the table is gone"); - } - // A null style is no style: the table is made, unstyled. - app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text"})).unwrap(); - app.run("app.insertTableDialog", json!({})).unwrap(); - ctl(&mut app, "ui.dialog.set", json!({"field": "tableStyle", "value": null})); - assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); - assert_eq!(app.run("table.get", json!({})).unwrap()["style"].as_str().unwrap_or(""), ""); - app.run("edit.undo", json!({})).unwrap(); - // A style that no longer exists changes nothing. - let tables = |app: &DesignApp| app.session.active().unwrap().doc.stories.values().map(|s| s.tables.len()).sum::(); - app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text"})).unwrap(); - app.run("app.insertTableDialog", json!({})).unwrap(); - ctl(&mut app, "ui.dialog.set", json!({"field": "tableStyle", "value": "Gone"})); - assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false); - assert_eq!(tables(&app), 0); - let d = app.ui.dialog.as_ref().expect("the dialog stays open to be corrected"); - assert_eq!((d.id.as_str(), d.s("tableStyle")), ("insertTable", "Gone".to_string())); - // So does a style that isn't a name: the command refuses it, rather than making the table - // unstyled. - ctl(&mut app, "ui.dialog.set", json!({"field": "tableStyle", "value": 42})); - assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false); - assert_eq!(tables(&app), 0); - assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("insertTable"), "the dialog stays open"); - } - - /// Convert Text to Table: available with text selected (not with a caret alone); its - /// separators and Number of Columns shape the table. Convert Table to Text takes it back with - /// the separators chosen there. - #[test] - fn convert_dialogs_use_their_separators() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let text = "Name:\nAddress:\nCity:\nState:"; - let sid = app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text", "text": text})).unwrap()["story"].clone(); - app.run("text.select", json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); - assert!(!crate::menus::menu_enabled(&app, "app.convertTextToTableDialog"), "a caret alone"); - assert!(!crate::menus::menu_enabled(&app, "app.convertTableToTextDialog"), "no table"); - app.run("text.select", json!({"story": sid, "anchor": 0, "focus": text.len()})).unwrap(); - assert!(crate::menus::menu_enabled(&app, "app.convertTextToTableDialog")); - app.run("app.convertTextToTableDialog", json!({})).unwrap(); - assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("convertTextToTable")); - // "comma" and a typed "," are the same separator, so Number of Columns applies; a count of - // 0 is refused, not taken as 1, and the dialog stays open. - ctl(&mut app, "ui.dialog.set", json!({"field": "columnSeparator", "value": "comma"})); - ctl(&mut app, "ui.dialog.set", json!({"field": "rowSeparator", "value": ","})); - assert!(same_separators(app.ui.dialog.as_ref().unwrap())); - ctl(&mut app, "ui.dialog.set", json!({"field": "columns", "value": 0})); - assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false); - assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("convertTextToTable")); - // An emptied separator: the command refuses it and the dialog stays open, as typed. - ctl(&mut app, "ui.dialog.set", json!({"field": "rowSeparator", "value": ""})); - assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false); - assert_eq!(app.ui.dialog.as_ref().map(|d| (d.id.as_str(), d.s("rowSeparator"))), Some(("convertTextToTable", String::new()))); - for (field, value) in [("columnSeparator", json!("paragraph")), ("rowSeparator", json!("paragraph")), ("columns", json!(2))] { - ctl(&mut app, "ui.dialog.set", json!({"field": field, "value": value})); - } - assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); - let t = app.run("table.get", json!({})).unwrap(); - let len = |k: &str| t[k].as_array().map_or(0, Vec::len); - assert_eq!((len("rows"), len("columns")), (2, 2), "{t}"); - assert!(crate::menus::menu_enabled(&app, "app.convertTableToTextDialog")); - app.run("app.convertTableToTextDialog", json!({})).unwrap(); - ctl(&mut app, "ui.dialog.set", json!({"field": "columnSeparator", "value": ", "})); - assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); - let story = app.session.active().unwrap().doc.story(designcraft_doc::StoryId(sid.as_u64().unwrap())).unwrap().text.clone(); - // (The empty paragraph after a table that ends its story stays after the text.) - assert_eq!(story.trim_end(), "Name:, Address:\nCity:, State:"); - } - #[test] fn color_theme_cancel_is_read_only_and_confirm_adds_the_preview() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 577b9a3ae..c6d494f9e 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3521,3 +3521,93 @@ fn character_style_options_set_the_digits() { assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); assert_eq!(char_style(&app, "Emphasis").unwrap().chars.digits, Some(designcraft_doc::Digits::Farsi)); } + +/// Insert Table: the dimensions as typed, and the Table Style chosen is applied to the new +/// table; [Basic Table] (the default) leaves it unstyled. +#[test] +fn insert_table_applies_its_dimensions_and_table_style() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("style.table.create", json!({"name": "Grid"})).unwrap(); + for (style, expected) in [("Grid", "Grid"), (designcraft_doc::BASIC_TABLE, "")] { + app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text"})).unwrap(); + assert_eq!(app.run("app.insertTableDialog", json!({})).unwrap(), Value::Null); + for (field, value) in [("bodyRows", json!(5)), ("columns", json!(3)), ("headerRows", json!(1)), ("tableStyle", json!(style))] { + ctl(&mut app, "ui.dialog.set", json!({"field": field, "value": value})); + } + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + let t = app.run("table.get", json!({})).unwrap(); + let len = |k: &str| t[k].as_array().map_or(0, Vec::len); + assert_eq!((len("rows"), len("columns"), t["headerRows"].as_u64()), (6, 3, Some(1)), "{t}"); + assert_eq!(t["style"].as_str().unwrap_or(""), expected, "{t}"); + // One Insert Table, one undo. + app.run("edit.undo", json!({})).unwrap(); + assert!(app.run("table.get", json!({})).is_err(), "the table is gone"); + } + // A null style is no style: the table is made, unstyled. + app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text"})).unwrap(); + app.run("app.insertTableDialog", json!({})).unwrap(); + ctl(&mut app, "ui.dialog.set", json!({"field": "tableStyle", "value": null})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + assert_eq!(app.run("table.get", json!({})).unwrap()["style"].as_str().unwrap_or(""), ""); + app.run("edit.undo", json!({})).unwrap(); + // A style that no longer exists changes nothing. + let tables = |app: &DesignApp| app.session.active().unwrap().doc.stories.values().map(|s| s.tables.len()).sum::(); + app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text"})).unwrap(); + app.run("app.insertTableDialog", json!({})).unwrap(); + ctl(&mut app, "ui.dialog.set", json!({"field": "tableStyle", "value": "Gone"})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false); + assert_eq!(tables(&app), 0); + let d = app.ui.dialog.as_ref().expect("the dialog stays open to be corrected"); + assert_eq!((d.id.as_str(), d.s("tableStyle")), ("insertTable", "Gone".to_string())); + // So does a style that isn't a name: the command refuses it, rather than making the table + // unstyled. + ctl(&mut app, "ui.dialog.set", json!({"field": "tableStyle", "value": 42})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false); + assert_eq!(tables(&app), 0); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("insertTable"), "the dialog stays open"); +} + +/// Convert Text to Table: available with text selected (not with a caret alone); its +/// separators and Number of Columns shape the table. Convert Table to Text takes it back with +/// the separators chosen there. +#[test] +fn convert_dialogs_use_their_separators() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let text = "Name:\nAddress:\nCity:\nState:"; + let sid = app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text", "text": text})).unwrap()["story"].clone(); + app.run("text.select", json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + assert!(!crate::menus::menu_enabled(&app, "app.convertTextToTableDialog"), "a caret alone"); + assert!(!crate::menus::menu_enabled(&app, "app.convertTableToTextDialog"), "no table"); + app.run("text.select", json!({"story": sid, "anchor": 0, "focus": text.len()})).unwrap(); + assert!(crate::menus::menu_enabled(&app, "app.convertTextToTableDialog")); + app.run("app.convertTextToTableDialog", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("convertTextToTable")); + // "comma" and a typed "," are the same separator, so Number of Columns applies; a count of + // 0 is refused, not taken as 1, and the dialog stays open. + ctl(&mut app, "ui.dialog.set", json!({"field": "columnSeparator", "value": "comma"})); + ctl(&mut app, "ui.dialog.set", json!({"field": "rowSeparator", "value": ","})); + assert!(same_separators(app.ui.dialog.as_ref().unwrap())); + ctl(&mut app, "ui.dialog.set", json!({"field": "columns", "value": 0})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("convertTextToTable")); + // An emptied separator: the command refuses it and the dialog stays open, as typed. + ctl(&mut app, "ui.dialog.set", json!({"field": "rowSeparator", "value": ""})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false); + assert_eq!(app.ui.dialog.as_ref().map(|d| (d.id.as_str(), d.s("rowSeparator"))), Some(("convertTextToTable", String::new()))); + for (field, value) in [("columnSeparator", json!("paragraph")), ("rowSeparator", json!("paragraph")), ("columns", json!(2))] { + ctl(&mut app, "ui.dialog.set", json!({"field": field, "value": value})); + } + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + let t = app.run("table.get", json!({})).unwrap(); + let len = |k: &str| t[k].as_array().map_or(0, Vec::len); + assert_eq!((len("rows"), len("columns")), (2, 2), "{t}"); + assert!(crate::menus::menu_enabled(&app, "app.convertTableToTextDialog")); + app.run("app.convertTableToTextDialog", json!({})).unwrap(); + ctl(&mut app, "ui.dialog.set", json!({"field": "columnSeparator", "value": ", "})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + let story = app.session.active().unwrap().doc.story(designcraft_doc::StoryId(sid.as_u64().unwrap())).unwrap().text.clone(); + // (The empty paragraph after a table that ends its story stays after the text.) + assert_eq!(story.trim_end(), "Name:, Address:\nCity:, State:"); +} From 693e9b13a888f1664c3d6ba342eca7744d2abb86 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 00:01:11 +0100 Subject: [PATCH 24/30] Dropdowns: craft_ui::ComboBox, as main's clippy configuration asks Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 17f9129f1..3beaa0964 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -226,7 +226,7 @@ fn convert_table_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { label(ui, "Table Style", true); let names: Vec = app.session.active().map(|st| st.doc.styles.table.iter().map(|s| s.name.clone()).collect()).unwrap_or_default(); let cur = table_style_field(d).unwrap_or_else(|| designcraft_doc::BASIC_TABLE.to_string()); - egui::ComboBox::from_id_salt("convert_table_style").selected_text(&cur).width(170.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("convert_table_style").selected_text(&cur).width(170.0).show_ui(ui, |ui| { for n in &names { if ui.selectable_label(*n == cur, n).clicked() { d.fields.insert("tableStyle".into(), json!(n)); @@ -286,7 +286,7 @@ fn insert_table_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let cur = table_style_field(d).unwrap_or_else(|| designcraft_doc::BASIC_TABLE.to_string()); ui.horizontal(|ui| { crate::rtl::label(ui, format!("{}:", crate::i18n::tr(lang, "Table Style"))); - egui::ComboBox::from_id_salt("ins_table_style").selected_text(&cur).width(160.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("ins_table_style").selected_text(&cur).width(160.0).show_ui(ui, |ui| { for n in &names { if ui.selectable_label(*n == cur, n).clicked() { d.fields.insert("tableStyle".into(), json!(n)); From 4814096392a75001630b50b98ca9a89dba07267d Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 00:01:12 +0100 Subject: [PATCH 25/30] Dropdowns: craft_ui::ComboBox, as main's clippy configuration asks Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6db142dcd..d7e67d2fc 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -198,7 +198,7 @@ fn insert_table_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let cur = table_style_field(d).unwrap_or_else(|| designcraft_doc::BASIC_TABLE.to_string()); ui.horizontal(|ui| { crate::rtl::label(ui, format!("{}:", crate::i18n::tr(lang, "Table Style"))); - egui::ComboBox::from_id_salt("ins_table_style").selected_text(&cur).width(160.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("ins_table_style").selected_text(&cur).width(160.0).show_ui(ui, |ui| { for n in &names { if ui.selectable_label(*n == cur, n).clicked() { d.fields.insert("tableStyle".into(), json!(n)); From d4b7e6659242b77150ad9596cbf494095b715fd8 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 12:33:53 +0100 Subject: [PATCH 26/30] Table convert dialogs: menu_enabled from main's split menu state module Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menus/run.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index b88dfb4a1..c4b99e474 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -1,6 +1,6 @@ use super::export::{download_document, export_bytes, export_epub, export_idml, export_pdf, export_png, separations_view}; use super::invoke::guide_spread; -use super::state::{is_window_control, window_control_available}; +use super::state::{is_window_control, menu_enabled, window_control_available}; use super::tables::{CLASSIC_GROUPS, TYPOGRAPHY_GROUPS, workspace_open_panel}; use super::tree::recent_label; use super::{DesignApp, ScreenMode, Value, json}; From 69e8ab0d3afa464a87454915a81cfd8ac0de6af4 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 18:51:44 +0100 Subject: [PATCH 27/30] Searched UI commands test: a text selected context, where Convert Text to Table runs Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menus/tests.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 69e72e47e..45bf6a794 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -1953,7 +1953,7 @@ fn searched_ui_commands_run_without_parameters_and_keep_preferences() { let mut scratch = designcraft_engine::Session::new(); scratch.execute("file.new", &json!({})).unwrap(); let pdf = scratch.execute("file.exportPdf", &json!({})).unwrap()["base64"].clone(); - let contexts = ["nothing selected", "text insertion point", "text frame selected", "table cell", "placed PDF selected"]; + let contexts = ["nothing selected", "text insertion point", "text frame selected", "table cell", "placed PDF selected", "text selected"]; // A two-page document, a saved workspace and non-default preferences, a host with window // controls, and file dialogs that are dismissed. let setup = |context: usize| { @@ -1987,6 +1987,11 @@ fn searched_ui_commands_run_without_parameters_and_keep_preferences() { app.run("file.place", json!({"base64": pdf, "name": "a.pdf", "frame": f})).unwrap(); app.run("selection.set", json!({"ids": [f]})).unwrap(); } + // Convert Text to Table needs text selected. + 5 => { + let f = app.run("frame.create", json!({"rect": [72, 72, 400, 400], "content": "text", "text": "One Two"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 7})).unwrap(); + } _ => {} } app.ui.dialog = None; From f3549fb2d82614e9c3415a38cc6e76ba0e2e3fde Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 30 Oct 2026 23:24:16 +0000 Subject: [PATCH 28/30] Integration: #892 spread label test comment --- crates/ui-egui/src/panels/pages.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 559d11963..5488996ee 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -821,7 +821,7 @@ mod spread_label_tests { #[test] fn spreads_are_labelled_as_indesign_labels_them() { // Chris Nielsen's "Numbering Page Sections": sections starting at 1 on pages 3 and 7, - // the second in letters, label their spreads "2, 1" and "6, A". + // the second in letters, label their spreads "2, 1" and "4, A". let mut app = app(); assert_eq!(labels(&app), ["1", "2–3", "4–5", "6–7", "8–9"]); app.run("layout.section", json!({"page": 3, "startNumber": 1})).unwrap(); From 4f8cc475c50edd247d5a524b82cbcfb134adf8e9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 30 Oct 2026 23:25:19 +0000 Subject: [PATCH 29/30] Integration: #1676 flat Convert Cell items --- crates/engine/src/cmd/table/registry.rs | 4 ++-- crates/ui-egui/src/menus/tables.rs | 2 -- crates/ui-egui/src/menus/tests.rs | 3 ++- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/table/registry.rs b/crates/engine/src/cmd/table/registry.rs index c6b0dda17..0374b71df 100644 --- a/crates/engine/src/cmd/table/registry.rs +++ b/crates/engine/src/cmd/table/registry.rs @@ -208,7 +208,7 @@ fn table_specs() -> Vec { cmd!( "table.placeGraphic", "Convert Cell to Graphic Cell", - ["Table", "Convert Cell Type"], + ["Table"], None, "{path? | base64?, name?: with base64, fit?: proportional|fill (default proportional), story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — an image in the target cell (its text is kept but hidden)", in_table, @@ -217,7 +217,7 @@ fn table_specs() -> Vec { cmd!( "table.textCell", "Convert Cell to Text Cell", - ["Table", "Convert Cell Type"], + ["Table"], None, "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — drop the target cells' graphics", in_table, diff --git a/crates/ui-egui/src/menus/tables.rs b/crates/ui-egui/src/menus/tables.rs index b361927cd..d654a02c7 100644 --- a/crates/ui-egui/src/menus/tables.rs +++ b/crates/ui-egui/src/menus/tables.rs @@ -453,10 +453,8 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:table.unmerge", "cmd:table.splitHorizontally", "cmd:table.splitVertically", - ">Convert Cell Type", "ui:app.graphicCell|Convert Cell to Graphic Cell…", "cmd:table.textCell", - "<", ">Convert Rows", "cmd:table.convertRowsToHeader", "cmd:table.convertRowsToBody", diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index abf3552d4..d7b6fc0ed 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -2233,7 +2233,8 @@ fn the_table_menu_follows_indesigns_order() { "Unmerge Cells", "Split Cell Horizontally", "Split Cell Vertically", - "Convert Cell Type >", + "Convert Cell to Graphic Cell…", + "Convert Cell to Text Cell", "Convert Rows >", "-", "Distribute Rows Evenly", From 664740f0afd7d50fc419bbce41a000686c720bd6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 30 Oct 2026 23:36:13 +0000 Subject: [PATCH 30/30] Integration: drop duplicate #892 progress line --- ROADMAP.md | 1 - 1 file changed, 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 172c7a496..13b10d99b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -494,7 +494,6 @@ Newest first. - **2026-10-20** · Measures in centimetres show four decimals, as InDesign's fields do (a 1p gutter reads 0.4233 cm, not 0.423 cm). No readiness percentage change. - **2026-10-14** · The Pages panel labels spreads as InDesign does ("2–3", "2, 1" across a section start, "8, A"), marks each section's first page with a triangle, and scrolls to the page in view. No readiness percentage change. - **2026-10-18** · Double-clicking a section triangle in the Pages panel opens that page’s numbering options, including from parent-page editing. No readiness percentage change. -- **2026-10-14** · The Pages panel labels spreads as ranges or separate names across a section start, marks each section’s first page with a triangle, and scrolls to the page in view (#892). No readiness percentage change. - **2026-10-21** · Properties › Character and Paragraph: ••• (More Options) expands the section in place, as InDesign's: Character adds vertical and horizontal scale, baseline shift, skew, language and OpenType, and All Caps, Small Caps, Superscript, Subscript, Underline and Strikethrough; Paragraph adds the left, right, first line and last line indents, space before and after, the drop cap's lines and characters, Align to Baseline Grid and Hyphenate. It used to open the Character or Paragraph panel; the language field moved under ••• as in InDesign. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change. - **2026-10-21** · The text Control panel has InDesign's two modes: A (Character Formatting Controls: font and style, size and leading, All Caps/Superscript/Underline and Small Caps/Subscript/Strikethrough, kerning and tracking, vertical and horizontal scale, baseline shift and skew, character style and language) and ¶ (Paragraph Formatting Controls: the nine alignments, left/first line and right/last line indents, space before and after, the drop cap's lines and characters, paragraph style, Hyphenate and Align to Baseline Grid). It used to show a fixed mix of both. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change. - **2026-10-20** · With the Type tool, the Control panel's font size, leading and tracking fields have a spinner and a list of common values, as in InDesign (auto leading in parentheses). No readiness percentage change.