From c20635aacfcbb9c628630a065e5b8e7917aede30 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 12:22:11 +0200 Subject: [PATCH 001/628] 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 be0fbb75c047bb3579a90625456aa29919df314b Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 12:32:25 +0200 Subject: [PATCH 002/628] Numbering & Section Options opens as New Section on a page without one, as InDesign's Chris Nielsen's "Numbering Page Sections" (4:43): with page 3 selected, the Pages panel menu's Numbering & Section Options opens InDesign's New Section window: Start Section checked, Automatic Page Numbering chosen and 1 offered in Start Page Numbering at, which stays editable. The window here was titled Numbering & Section Options, Start Section was off and the field showed the page's current number, greyed out. - Opened on a page that doesn't start a section, the window is New Section: Start Section on and 1 offered; on a section's first page it is Numbering & Section Options, as before. - Start Page Numbering at and Start Chapter Numbering at stay editable; typing a number chooses that option. - "New Section" translated. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/dialogs.rs | 50 +++++++++++++++++++++++++++ crates/ui-egui/src/i18n.rs | 1 + crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + crates/ui-egui/src/section_options.rs | 24 ++++++++++--- 9 files changed, 76 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 805dfeb6c..f9dfe361a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · Numbering & Section Options opened on a page that doesn't start a section is New Section, as in InDesign: Start Section on, 1 offered, and the start numbers stay editable (typing one chooses it). No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c56bf267e..26c339e72 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1130,6 +1130,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "insertTable" => crate::i18n::tr(&app.ui.language, "Create Table"), "textFrameOptions" => crate::i18n::tr(&app.ui.language, "Text Frame Options"), "documentSetup" => crate::i18n::tr(&app.ui.language, "Document Setup"), + // On a page that doesn't start a section yet, InDesign titles it New Section. + "numberingSection" if d.b("newSection") => crate::i18n::tr(&app.ui.language, "New Section"), "numberingSection" => crate::i18n::tr(&app.ui.language, "Numbering & Section Options…").trim_end_matches('…'), "findChange" => crate::i18n::tr(&app.ui.language, "Find/Change"), "paragraphStyleOptions" => crate::i18n::tr(&app.ui.language, "Paragraph Style Options"), @@ -4156,6 +4158,54 @@ mod tests { } } + #[test] + fn on_a_page_without_a_section_the_window_is_new_section() { + // Chris Nielsen's "Numbering Page Sections" (4:43): page 3 selected, the Pages panel menu's + // Numbering & Section Options opens New Section with Start Section on and 1 offered. + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 6, "facingPages": true})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 800.0))); + crate::canvas::go_to_page(&mut app, 2); + crate::menus::activate(&mut app, "layout.section", &Value::Null); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!( + (d.n("page"), d.b("newSection"), d.b("startSection"), d.b("automatic"), d.s("startNumber")), + (Some(3.0), true, true, true, "1".into()) + ); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1178.0, 814.0)); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&labels, screen, "New Section"); + // Typing a number in the field chooses Start Page Numbering at; OK: page 3 is page 2. + let field = visible_label(&labels, screen, "1"); + click_dialog(&mut app, &ctx, screen, field.center()); + dialog_frame( + &mut app, + &ctx, + screen, + vec![ + egui::Event::Key { key: egui::Key::End, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }, + egui::Event::Key { key: egui::Key::Backspace, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }, + egui::Event::Text("2".into()), + ], + ); + assert!(!app.ui.dialog.as_ref().unwrap().b("automatic"), "typing chose Start Page Numbering at"); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "OK").center()); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.active().unwrap().doc.page_name(2), "2"); + // Opened again on that page, it is Numbering & Section Options, showing the section. + crate::menus::activate(&mut app, "layout.section", &Value::Null); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.b("newSection"), d.b("startSection"), d.b("automatic"), d.s("startNumber")), (false, true, false, "2".into())); + let labels = (0..4).map(|_| dialog_frame(&mut app, &ctx, screen, vec![])).last().unwrap(); + visible_label(&labels, screen, "Numbering & Section Options"); + assert!(!labels.iter().any(|(t, _, _)| t == "New Section")); + } + #[test] fn preferences_sidebar_scroll_keeps_selected_options_visible() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index bff4e0bd8..2d715ffbe 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -55,6 +55,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ("New Section", ["Neuer Abschnitt", "Nouvelle section", "Nueva sección", "新規セクション", "新建章节"]), ("Reverse Path", ["Pfadrichtung umkehren", "Inverser le tracé", "Invertir trazado", "パスの方向反転", "反转路径"]), ("Set Anchor", ["Ankerpunkt festlegen", "Définir le point d’ancrage", "Definir punto de ancla", "アンカーポイントを設定", "设置锚点"]), ("Clear", ["Löschen", "Effacer", "Borrar", "消去", "清除"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index a182d09ec..f40ed5518 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("New Section", "قسم جديد"), ("Reverse Path", "عكس المسار"), ("Set Anchor", "تعيين نقطة الارتساء"), ("Clear", "مسح"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index aa8963f2c..f96181b3b 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("New Section", "Nuova sezione"), ("Reverse Path", "Inverti tracciato"), ("Set Anchor", "Imposta ancoraggio"), ("Clear", "Cancella"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 642b4a4c0..fdfcf0534 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("New Section", "新規セクション"), ("Reverse Path", "パスの方向反転"), ("Set Anchor", "アンカーポイントを設定"), ("Clear", "消去"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 982dfb20d..f144bbf2a 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("New Section", "Nova seção"), ("Reverse Path", "Inverter demarcador"), ("Set Anchor", "Definir ponto de ancoragem"), ("Clear", "Limpar"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index f3bc8fb35..a133bd753 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("New Section", "Новий розділ"), ("Reverse Path", "Розвернути контур"), ("Set Anchor", "Задати прив’язку"), ("Clear", "Очистити"), diff --git a/crates/ui-egui/src/section_options.rs b/crates/ui-egui/src/section_options.rs index 899b9fbed..428f60828 100644 --- a/crates/ui-egui/src/section_options.rs +++ b/crates/ui-egui/src/section_options.rs @@ -36,13 +36,17 @@ pub(crate) fn open(app: &mut DesignApp) { let doc = &st.doc; let page = current.min(doc.page_count().saturating_sub(1)); let own = doc.sections.iter().find(|s| s.start == page); - // A new section starts from the one the page is in. + // A new section starts from the one the page is in. As in InDesign, the window opened on a + // page that doesn't start a section is New Section: Start Section checked, Start Page + // Numbering at offering 1, Automatic Page Numbering chosen. let base = own.or_else(|| doc.section_of(page)); + let new = page != 0 && own.is_none(); let fields = json!({ "page": page + 1, - "startSection": page == 0 || own.is_some(), + "newSection": new, + "startSection": true, "automatic": own.is_none_or(|s| s.start_number.is_none()), - "startNumber": doc.page_number(page).to_string(), + "startNumber": if new { "1".to_string() } else { doc.page_number(page).to_string() }, "prefix": base.map(|s| s.prefix.clone()).unwrap_or_default(), "style": base.map(|s| s.style).unwrap_or_default(), "marker": base.map(|s| s.marker.clone()).unwrap_or_default(), @@ -160,7 +164,12 @@ pub(crate) fn body(app: &DesignApp, ui: &mut Ui, d: &mut Dialog) { if ui.radio(!automatic, crate::rtl::widget(ui, tr("Start Page Numbering at:"))).clicked() { d.fields.insert("automatic".into(), json!(false)); } - ui.add_enabled_ui(!automatic, |ui| text(ui, d, "startNumber", FIELD_W)); + // As in InDesign, the number stays editable; typing one chooses Start Page Numbering at. + let mut n = d.s("startNumber"); + if ui.add(egui::TextEdit::singleline(&mut n).desired_width(FIELD_W)).changed() { + d.fields.insert("startNumber".into(), Value::String(n)); + d.fields.insert("automatic".into(), json!(false)); + } }); ui.add_space(4.0); group(ui, rtl, tr("Page Numbering"), |ui| { @@ -200,7 +209,12 @@ pub(crate) fn body(app: &DesignApp, ui: &mut Ui, d: &mut Dialog) { } }); if value == ChapterSource::UserDefined { - ui.add_enabled_ui(source == value, |ui| text(ui, d, "chapterStart", FIELD_W)); + // Editable whichever is chosen, as in InDesign; typing a number chooses it. + let mut n = d.s("chapterStart"); + if ui.add(egui::TextEdit::singleline(&mut n).desired_width(FIELD_W)).changed() { + d.fields.insert("chapterStart".into(), Value::String(n)); + d.fields.insert("chapterSource".into(), json!(value)); + } } }); } From d70093244692d55e57cecbb46dddf9835dfb5162 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 14:07:17 +0200 Subject: [PATCH 003/628] New Section test: start page 3 at 5, an odd number, so it holds with #889's facing-page sides Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 26c339e72..a289d4005 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4179,7 +4179,8 @@ mod tests { } let labels = dialog_frame(&mut app, &ctx, screen, vec![]); visible_label(&labels, screen, "New Section"); - // Typing a number in the field chooses Start Page Numbering at; OK: page 3 is page 2. + // Typing a number in the field chooses Start Page Numbering at; OK: page 3 is page 5 (odd, + // so a facing page stays on the right). let field = visible_label(&labels, screen, "1"); click_dialog(&mut app, &ctx, screen, field.center()); dialog_frame( @@ -4189,18 +4190,18 @@ mod tests { vec![ egui::Event::Key { key: egui::Key::End, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }, egui::Event::Key { key: egui::Key::Backspace, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }, - egui::Event::Text("2".into()), + egui::Event::Text("5".into()), ], ); assert!(!app.ui.dialog.as_ref().unwrap().b("automatic"), "typing chose Start Page Numbering at"); let labels = dialog_frame(&mut app, &ctx, screen, vec![]); click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "OK").center()); assert!(app.ui.dialog.is_none()); - assert_eq!(app.session.active().unwrap().doc.page_name(2), "2"); + assert_eq!(app.session.active().unwrap().doc.page_name(2), "5"); // Opened again on that page, it is Numbering & Section Options, showing the section. crate::menus::activate(&mut app, "layout.section", &Value::Null); let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!((d.b("newSection"), d.b("startSection"), d.b("automatic"), d.s("startNumber")), (false, true, false, "2".into())); + assert_eq!((d.b("newSection"), d.b("startSection"), d.b("automatic"), d.s("startNumber")), (false, true, false, "5".into())); let labels = (0..4).map(|_| dialog_frame(&mut app, &ctx, screen, vec![])).last().unwrap(); visible_label(&labels, screen, "Numbering & Section Options"); assert!(!labels.iter().any(|(t, _, _)| t == "New Section")); From bf2cf8842bc28e4fcef146bcd9f7a73b77afbbdb Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 14:53:40 +0200 Subject: [PATCH 004/628] Direct Selection: pressing on a corner of an unselected object pulls just that corner Chris Nielsen's "The Adobe InDesign Toolbox" (10:10): with the White Arrow he clicks outside, then presses on a frame's corner and pulls it up, turning the rectangle into a lopsided triangle. DesignCraft only looked for anchors on objects already selected, so the press fell through to moving the whole frame. The Direct Selection tool now also looks at the anchors of the object under the pointer: on one, it selects the object and drags that point, in one undo step. Elsewhere on the object it still drags it whole; Shift- click still toggles the selection. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/tests.rs | 42 ++++++++++++++++++++++++++++++++++++++ crates/tools/src/select.rs | 13 ++++++++++++ 3 files changed, 56 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index c60a04107..0473076a4 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 Direct Selection tool pressed on a corner or anchor of an object that isn't selected selects it and pulls just that point, as in InDesign (it moved the whole object). 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/engine/src/tests.rs b/crates/engine/src/tests.rs index 9d6308983..e40174c48 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -669,3 +669,45 @@ fn type_tool_frames_snap_to_the_margins() { assert!((r.x0 - margin.x0).abs() < 1e-6 && (r.y0 - margin.y0).abs() < 1e-6, "press point {r:?} vs {margin:?}"); assert!((r.x1 - margin.x1).abs() < 1e-6, "right edge {} vs {}", r.x1, margin.x1); } + +#[test] +fn direct_selection_pulls_one_corner_of_an_unselected_frame() { + // Chris Nielsen's "The Adobe InDesign Toolbox" (10:10): with the White Arrow, click outside, + // then press on a frame's corner and pull it: only that corner moves (a triangle, nearly). + use designcraft_tools::{PointerEvent, PointerKind, SnapView}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 612, "height": 792, "facingPages": false})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [156, 219, 455, 569], "content": "graphic"})).unwrap()["id"].as_u64().unwrap(); + s.execute("edit.deselectAll", &json!({})).unwrap(); + let corners = |s: &Session| { + let d = &s.doc().unwrap().doc; + let it = d.item(designcraft_doc::ItemId(id)).unwrap(); + it.path.subpaths[0].anchors.iter().map(|a| it.xf * a.p).collect::>() + }; + let before = corners(&s); + s.set_tool("directSelection"); + let v = ViewInfo { snap: SnapView::OFF, ..ViewInfo::at_zoom(1.0) }; + let layout = s.layout(); + let at = |x: f64, y: f64| layout.to_canvas(designcraft_doc::SpreadRef::Doc(0), designcraft_geom::Point::new(x, y)); + let (a, b) = (at(455.0, 219.0), at(455.0, 150.0)); + s.pointer(&PointerEvent::new(PointerKind::Down, a.x, a.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, a.x, a.y - 30.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, b.x, b.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, b.x, b.y), v).unwrap(); + let after = corners(&s); + let moved: Vec<_> = before.iter().zip(&after).filter(|(p, q)| (**p - **q).hypot() > 1e-6).collect(); + assert_eq!(moved.len(), 1, "one corner moved: {before:?} → {after:?}"); + assert!((*moved[0].1 - designcraft_geom::Point::new(455.0, 150.0)).hypot() < 1e-6, "{after:?}"); + assert_eq!(s.doc().unwrap().selection.items, [designcraft_doc::ItemId(id)], "the frame is selected"); + // One undo step puts the corner back. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(corners(&s), before); + // Away from its corners, the White Arrow still drags the whole unselected frame. + s.execute("edit.deselectAll", &json!({})).unwrap(); + let (c, d) = (at(300.0, 219.0), at(300.0, 269.0)); + s.pointer(&PointerEvent::new(PointerKind::Down, c.x, c.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, c.x, c.y + 20.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, d.x, d.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, d.x, d.y), v).unwrap(); + assert!(corners(&s).iter().zip(&before).all(|(p, q)| (p.y - q.y - 50.0).abs() < 1e-6), "{:?}", corners(&s)); +} diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index b2ac97731..5dab5c128 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -507,6 +507,19 @@ impl Tool for SelectionTool { self.drag = Drag::Anchor { id, si, ai, handle, start: p, at }; return vec![Action::Begin(if handle.is_some() { "Move Direction Handle".into() } else { "Move Anchor".into() })]; } + // As in InDesign, the Direct Selection tool pressed on a corner or anchor of an object + // that isn't selected selects it and moves just that point (Chris Nielsen's "The + // InDesign Toolbox", 10:10: click outside, then pull a frame's corner). + if self.direct + && !ev.mods.shift + && let Some((_, hit)) = cx.hit(p) + && !cx.selection.contains(hit) + && let Some((id, si, ai, None)) = anchor_at_in(cx, &[hit], p) + { + let at = anchor_spread_point(cx, id, si, ai); + self.drag = Drag::Anchor { id, si, ai, handle: None, start: p, at }; + return vec![Action::Exec("selection.set".into(), json!({"ids": [id], "content": true})), Action::Begin("Move Anchor".into())]; + } if !self.direct && handle_at(cx, p).is_none() && let Some(center) = rotate_zone(cx, p) From b642cec8cbcff5cf51869aca728d915e4499606c Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 15 Oct 2026 15:32:19 +0200 Subject: [PATCH 005/628] Anchored objects wrap their own story as their anchor lines are set #563 composed the story again with its objects where the previous pass put them, up to four times. In a long story whose objects move between frames from one pass to the next (Learn Skills Daily's 03_HP_LongDoc_Class, 79 pages), that never settled: stale wrap areas pushed headings into the next frame. Now the composer places a custom or above-line object when its anchor line is set and the lines after it wrap it in the same pass, as in InDesign. Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/lib.rs | 131 +++++++++++++++++++++++++------------ crates/compose/src/wrap.rs | 41 ++---------- 2 files changed, 94 insertions(+), 78 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 5689ced9f..7bebaf9a5 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -514,6 +514,9 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let mut out = ComposedStory { story: story.id, rev: story.rev, text_len: story.text.len(), ..Default::default() }; let mut styles_tab: Vec = Vec::new(); let mut missing_fonts: HashMap = HashMap::new(); + // The wrap of the story's own custom and above-line anchored objects, per frame, added as + // their anchor lines are set (see `own_wrap`). + let mut own_ex: Vec> = vec![Vec::new(); frames.len()]; let cols: Vec> = frames .iter() .map(|f| { @@ -986,7 +989,16 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C para_box_decos(&mut out, pi, &pp); break 'paras; } - let f = &frames[cur.fi]; + let merged; + let f = match own_ex.get(cur.fi).filter(|e| !e.is_empty()) { + Some(extra) => { + let mut spec = frames[cur.fi].clone(); + spec.exclusions.extend(extra.iter().cloned()); + merged = spec; + &merged + } + None => &frames[cur.fi], + }; let base = cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)]; let col = cur.clip(base); // A spanning paragraph isn't held to the balanced bottom of the columns above it. @@ -1232,16 +1244,32 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C continue 'paras; } } + let mut wraps_below = false; if !sub_objects.is_empty() { let ft = &mut out.frames[cur.fi]; let li = ft.lines.len() - 1; let text_ascent = line_glyphs.iter().filter(|g| g.ch != designcraft_doc::OBJECT_MARK).map(|g| g.ascent).fold(0.0, f64::max); let text_ascent = if text_ascent > 0.0 { text_ascent } else { base_chars.size * 0.75 }; + let first = ft.objects.len(); for g in ft.lines[li].glyphs.iter().filter(|g| g.len > 0) { if let Some(o) = sub_objects.get(&g.byte) { ft.objects.push(PlacedObject { index: o.index, origin: Point::ZERO, size: (o.w, o.h), line: li, x: g.x, text_ascent }); } } + // Custom and above-line objects anchored here wrap the lines after this one. + for o in ft.objects.get(first..).unwrap_or_default() { + if let (Some(l), Some(obj)) = (ft.lines.get(li), story.objects.get(o.index)) + && let Some(ex) = own_wrap(story, obj, o, l, &ft.columns, f) + { + for e in &mut own_ex { + e.retain(|x| x.after != ex.after); + } + wraps_below |= ex.rect.x0 < col.x1 && ex.rect.x1 > col.x0 && ex.rect.y1 > baseline && ex.rect.y0 < col.y1; + if let Some(e) = own_ex.get_mut(cur.fi) { + e.push(ex); + } + } + } } cur.last_baseline = Some(baseline); cur.last_reference = reference; @@ -1263,6 +1291,11 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C } // An own-story anchored object anchored in this line wraps the lines after it, // which were broken without it: break them again. + if k + 1 < breaks.len() && wraps_below { + g0 += b.next; + moved = true; + break; + } if k + 1 < breaks.len() && f.exclusions.iter().any(|ex| { ex.after.is_some_and(|a| (range_start..range_end).contains(&a)) @@ -1404,47 +1437,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C for o in &mut ft.objects { let Some(l) = ft.lines.get(o.line) else { continue }; let Some(obj) = story.objects.get(o.index) else { continue }; - let (w, h) = o.size; - o.origin = match &obj.position { - designcraft_doc::AnchorPosition::Inline { y_offset } => Point::new(o.x, l.baseline - y_offset - h), - designcraft_doc::AnchorPosition::AboveLine { align, space_after, .. } => { - let x = match align { - designcraft_doc::anchored::AnchorAlign::Left => l.x0, - designcraft_doc::anchored::AnchorAlign::Center => (l.x0 + l.x1 - w) / 2.0, - designcraft_doc::anchored::AnchorAlign::Right => l.x1 - w, - }; - Point::new(x, l.baseline - o.text_ascent - space_after - h) - } - designcraft_doc::AnchorPosition::Custom { - x_relative, - y_relative, - x_offset, - y_offset, - object_point, - ref_point, - keep_within_column, - } => { - use designcraft_doc::anchored::AnchorRelative as R; - let col = ft.columns.get(l.column as usize).copied().unwrap_or(f.area); - let area = |r: R| match r { - R::Anchor => None, - R::TextFrame => Some(f.area), - R::ColumnEdge => Some(col), - R::PageMargin => Some(f.page_rect.map_or(f.area, |p| p.1)), - R::PageEdge => Some(f.page_rect.map_or(f.area, |p| p.0)), - }; - let frac = |i: u8| ([0.0, 0.5, 1.0][(i % 3) as usize], [0.0, 0.5, 1.0][(i / 3).min(2) as usize]); - let (rfx, rfy) = frac(*ref_point); - let (ofx, ofy) = frac(*object_point); - let rx = area(*x_relative).map_or(o.x, |a| a.x0 + a.width() * rfx); - let ry = area(*y_relative).map_or(l.baseline, |a| a.y0 + a.height() * rfy); - let mut y = ry + y_offset - h * ofy; - if *keep_within_column { - y = y.clamp(col.y0, (col.y1 - h).max(col.y0)); - } - Point::new(rx + x_offset - w * ofx, y) - } - }; + o.origin = object_origin(obj, o, l, &ft.columns, f); } } } @@ -1471,6 +1464,60 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C out } +/// The wrap of a custom or above-line anchored object `obj` of `story`, placed as `o` on line `l` +/// of a frame (`columns`, spec `f`), in the frame's inner space: it wraps the lines of the story +/// that start after its anchor. None for inline objects (they sit in their line) and objects +/// without text wrap. +fn own_wrap(story: &Story, obj: &designcraft_doc::AnchoredObject, o: &PlacedObject, l: &Line, columns: &[Rect], f: &FrameSpec) -> Option { + if obj.item.wrap.mode == WrapMode::None || obj.item.hidden || matches!(obj.position, designcraft_doc::AnchorPosition::Inline { .. }) { + return None; + } + let anchor = story.text.match_indices(designcraft_doc::OBJECT_MARK).nth(o.index).map(|(i, _)| i)?; + let origin = object_origin(obj, o, l, columns, f); + let w = obj.item.wrap.offsets; + let b = obj.item.bounds(); + let rect = Rect::new(b.x0 - w[1], b.y0 - w[0], b.x1 + w[3], b.y1 + w[2]) + origin.to_vec2(); + rect.is_finite().then_some(Exclusion { rect, mode: obj.item.wrap.mode, after: Some(anchor) }) +} + +/// Where anchored object `obj`, placed as `o` on line `l` of a frame (`columns`, spec `f`), has its +/// top-left, in the frame's inner space. +fn object_origin(obj: &designcraft_doc::AnchoredObject, o: &PlacedObject, l: &Line, columns: &[Rect], f: &FrameSpec) -> Point { + let (w, h) = o.size; + match &obj.position { + designcraft_doc::AnchorPosition::Inline { y_offset } => Point::new(o.x, l.baseline - y_offset - h), + designcraft_doc::AnchorPosition::AboveLine { align, space_after, .. } => { + let x = match align { + designcraft_doc::anchored::AnchorAlign::Left => l.x0, + designcraft_doc::anchored::AnchorAlign::Center => (l.x0 + l.x1 - w) / 2.0, + designcraft_doc::anchored::AnchorAlign::Right => l.x1 - w, + }; + Point::new(x, l.baseline - o.text_ascent - space_after - h) + } + designcraft_doc::AnchorPosition::Custom { x_relative, y_relative, x_offset, y_offset, object_point, ref_point, keep_within_column } => { + use designcraft_doc::anchored::AnchorRelative as R; + let col = columns.get(l.column as usize).copied().unwrap_or(f.area); + let area = |r: R| match r { + R::Anchor => None, + R::TextFrame => Some(f.area), + R::ColumnEdge => Some(col), + R::PageMargin => Some(f.page_rect.map_or(f.area, |p| p.1)), + R::PageEdge => Some(f.page_rect.map_or(f.area, |p| p.0)), + }; + let frac = |i: u8| ([0.0, 0.5, 1.0][(i % 3) as usize], [0.0, 0.5, 1.0][(i / 3).min(2) as usize]); + let (rfx, rfy) = frac(*ref_point); + let (ofx, ofy) = frac(*object_point); + let rx = area(*x_relative).map_or(o.x, |a| a.x0 + a.width() * rfx); + let ry = area(*y_relative).map_or(l.baseline, |a| a.y0 + a.height() * rfy); + let mut y = ry + y_offset - h * ofy; + if *keep_within_column { + y = y.clamp(col.y0, (col.y1 - h).max(col.y0)); + } + Point::new(rx + x_offset - w * ofx, y) + } + } +} + /// The columns a spanning paragraph is set across, from `col`'s top to its bottom. fn span_rect(cols: &[Rect], col: Rect, span: SpanColumns) -> Rect { let SpanColumns::Span(n) = span else { return col }; diff --git a/crates/compose/src/wrap.rs b/crates/compose/src/wrap.rs index 31ec719d6..9962e1a70 100644 --- a/crates/compose/src/wrap.rs +++ b/crates/compose/src/wrap.rs @@ -10,22 +10,16 @@ //! other's text don't depend on each other in a cycle. //! - **Its own story.** A custom or above-line anchored object wraps the lines of its own story //! that come after the line holding its anchor; that line and the lines before it are never -//! wrapped by it (an inline object sits in its line and wraps none of its own story). The story -//! is composed again with the objects where the previous pass placed them until they stop -//! moving, at most [`MAX_PASSES`] more times. +//! wrapped by it (an inline object sits in its line and wraps none of its own story). As in +//! InDesign, the object is placed when its anchor line is set and wraps the text that follows +//! in the same composition (`own_wrap` in the composer), so the layout doesn't depend on where +//! an earlier composition put the objects. use designcraft_doc::{AnchorPosition, AnchoredObject, Document, OBJECT_MARK, SpreadRef, Story, StoryId, WrapMode}; use designcraft_geom::{Affine, Rect}; use crate::{ComposeOptions, ComposedStory, Exclusion, FrameSpec}; -/// Extra compositions of a story with its own anchored objects' wrap; a layout that keeps moving -/// its objects (keep options, vertical justification) stops here. -pub(crate) const MAX_PASSES: usize = 4; - -/// How far an obstacle may move between passes and still count as settled (points). -const SETTLED: f64 = 0.01; - /// A wrapping anchored object where its story's composition placed it. #[derive(Clone, Debug)] pub(crate) struct Obstacle { @@ -161,18 +155,6 @@ fn with_exclusions(specs: &[FrameSpec], added: &[Vec]) -> Vec], b: &[Vec]) -> bool { - let near = |x: &Exclusion, y: &Exclusion| { - x.mode == y.mode - && x.after == y.after - && (x.rect.x0 - y.rect.x0).abs() <= SETTLED - && (x.rect.y0 - y.rect.y0).abs() <= SETTLED - && (x.rect.x1 - y.rect.x1).abs() <= SETTLED - && (x.rect.y1 - y.rect.y1).abs() <= SETTLED - }; - a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x.len() == y.len() && x.iter().zip(y).all(|(p, q)| near(p, q))) -} - /// Compose story `sid` with the wrapping anchored objects of `others` (compositions of its /// [`neighbours`]) and of its own. pub(crate) fn compose_among(doc: &Document, sid: StoryId, opts: &ComposeOptions, others: &[&ComposedStory]) -> ComposedStory { @@ -190,18 +172,5 @@ pub(crate) fn compose_among(doc: &Document, sid: StoryId, opts: &ComposeOptions, specs = with_exclusions(&specs, &added); } } - let mut out = crate::compose(doc, st, &specs, opts); - if !has_wrapping_objects(st) { - return out; - } - let mut prev = vec![Vec::new(); specs.len()]; - for _ in 0..MAX_PASSES { - let added = exclusions(doc, &specs, &obstacles(doc, st, &out, true)); - if settled(&added, &prev) { - break; - } - out = crate::compose(doc, st, &with_exclusions(&specs, &added), opts); - prev = added; - } - out + crate::compose(doc, st, &specs, opts) } From 56cef8aeeeb1eeea2927c17af840e90c9d43f87f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 15 Oct 2026 16:07:17 +0200 Subject: [PATCH 006/628] Test and ROADMAP: an object wraps the frame its anchor lands in Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/compose/src/tests.rs | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 8f8523f0b..4b7faf5eb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -347,6 +347,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-15** · Anchored objects wrap their own story as their anchor lines are set, as in InDesign: the layout no longer depends on where an earlier composition put them (a long story whose pictures moved between frames from pass to pass never settled). No readiness percentage change. - **2026-10-12** · Anchored objects with text wrap push text aside: other stories' text under them and the lines of their own story after the anchor's line (#557). No readiness percentage change. - **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change. - **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change. diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 1f01498f9..2dc339697 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -4569,3 +4569,40 @@ fn anchored_wrap_that_moves_its_own_anchor_terminates() { assert!(cs.line_count() > 0); assert_eq!(cs.frames[0].objects.len(), 1); } + +/// An object's wrap pushes the text so that a later object's anchor goes on into the next frame +/// (Learn Skills Daily's long-document exercise: pictures anchored in headings through a 79-page +/// story). Each object is placed when its anchor line is set and wraps only the lines after it, +/// in the frame its anchor lands in: the layout doesn't depend on where an earlier composition put +/// the objects, and composing again gives the same lines. +#[test] +fn an_object_wraps_the_frame_its_anchor_lands_in_after_an_earlier_object_pushed_it_on() { + let text = [LOREM; 6].join(" "); + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (a, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 300.0, 300.0), lid, &text, ParaFormat::default()).unwrap(); + let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(320.0, 0.0, 620.0, 300.0), lid, "", ParaFormat::default()).unwrap(); + d.thread(a, b).unwrap(); + let plain = compose_story(&d, sid, &ComposeOptions::default()); + // The second object's anchor sits near the end of the first frame without wrap. + let late = plain.frames[0].lines.iter().rev().nth(2).unwrap().range.start + 2; + anchor_box(&mut d, sid, late, 0.0, 0.0, true, WrapMode::BoundingBox); + // The first, early in the first frame: 60 pt over its whole width pushes the later anchor on. + let early = plain.frames[0].lines[1].range.start + 2; + anchor_box(&mut d, sid, early, 0.0, 0.0, true, WrapMode::JumpObject); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let late = late + OBJECT_LEN; + let anchor_frame = cs.frames.iter().position(|f| f.range.contains(&late) || f.range.end == late).unwrap(); + assert_eq!(anchor_frame, 1, "the later anchor went on into the second frame"); + // The first frame's lines under the first object's band keep the full measure (the second + // object is in the other frame); in the second frame, the lines under its object move right. + let left = cs.frames[0].lines[0].x0; + assert!(cs.frames[0].lines.iter().all(|l| (l.x0 - left).abs() < 1e-6)); + let f1 = &cs.frames[1]; + let (li, line) = f1.lines.iter().enumerate().find(|(_, l)| l.range.contains(&late)).unwrap(); + let under: Vec<_> = f1.lines[li + 1..].iter().filter(|l| l.baseline - l.ascent < line.baseline + 60.0).collect(); + assert!(!under.is_empty() && under.iter().all(|l| l.x0 >= f1.lines[0].x0 + 100.0 - 1e-6)); + assert_eq!(f1.objects.len(), 1); + // Deterministic: the same lines again. + assert_eq!(line_layout(&compose_story(&d, sid, &ComposeOptions::default())), line_layout(&cs)); +} From 2a7f4d95059006d20f559b841ba4fae8cd276dcb Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 15 Oct 2026 21:58:30 +0200 Subject: [PATCH 007/628] Color Picker: laid out as InDesign's, with a component field and slider, every space's values and Add CMYK Swatch Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/dialogs.rs | 431 ++++++++++++++++++++++++++++--- crates/ui-egui/src/i18n.rs | 22 ++ crates/ui-egui/src/i18n/ar.rs | 4 + crates/ui-egui/src/i18n/de.rs | 4 + crates/ui-egui/src/i18n/it.rs | 4 + crates/ui-egui/src/i18n/ja.rs | 4 + crates/ui-egui/src/i18n/ka.rs | 4 + crates/ui-egui/src/i18n/pt_br.rs | 4 + crates/ui-egui/src/i18n/uk.rs | 4 + 10 files changed, 450 insertions(+), 32 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf49..87ad24136 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -347,6 +347,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- 2026-10-15: The Color Picker is laid out as InDesign's: a field of two components and a slider for the third (H, S, B, L, a, b, R, G or B by radio button; RGB or Lab Color Space View), the new colour over the old one, H/S/B, L/a/b, R/G/B and C/M/Y/K values, the hex code, and Add CMYK Swatch. - **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change. - **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change. - **2026-10-14** · Pathfinder keeps the frontmost object, as InDesign does: a photo placed over shapes and intersected with them shows through the shapes (it was dropped, and the shapes kept their black fill); Add, Exclude Overlap and Minus Back take the front object's fill too, Subtract still keeps the back one. No readiness percentage change. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7d02776a2..262a48b60 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1828,7 +1828,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } "insertXref" => insert_xref(app, ui, &mut d), "findFont" => find_font(app, ui, &mut d), - "colorPicker" => color_picker(ui, &mut d), + "colorPicker" => color_picker(app, ui, &mut d), "preferences" => preferences(app, ui, &mut d, body_height), "print" => print_dialog(app, ui, &mut d), "pdfImport" => { @@ -4710,44 +4710,356 @@ fn insert_xref(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } } -/// Color Picker: saturation/brightness field and hue slider, with RGB, CMYK and hex fields. -fn color_picker(ui: &mut egui::Ui, d: &mut Dialog) { - let hex = d.s("hex"); - let mut c = designcraft_color::Color::from_hex(&hex).map(|c| c.to_rgb()).map_or(egui::Color32::BLACK, |[r, g, b]| { - egui::Color32::from_rgb((r * 255.0).round() as u8, (g * 255.0).round() as u8, (b * 255.0).round() as u8) - }); - let before = c; +/// The Color Picker's components: the one picked runs along the slider, the other two of its +/// space across the field (InDesign's, as Photoshop's: R puts B across and G up). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum PickComp { + H, + S, + Br, + L, + A, + Lb, + R, + G, + Bl, +} + +impl PickComp { + const ALL: [PickComp; 9] = + [PickComp::H, PickComp::S, PickComp::Br, PickComp::L, PickComp::A, PickComp::Lb, PickComp::R, PickComp::G, PickComp::Bl]; + + fn key(self) -> &'static str { + match self { + PickComp::H => "h", + PickComp::S => "s", + PickComp::Br => "b", + PickComp::L => "l", + PickComp::A => "la", + PickComp::Lb => "lb", + PickComp::R => "r", + PickComp::G => "g", + PickComp::Bl => "bl", + } + } + + fn from_key(k: &str) -> PickComp { + PickComp::ALL.into_iter().find(|c| c.key() == k).unwrap_or(PickComp::R) + } + + fn lab(self) -> bool { + matches!(self, PickComp::L | PickComp::A | PickComp::Lb) + } +} + +/// The colour the picker shows, in each of its spaces (HSB keeps its hue through greys). +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Picked { + pub rgb: [f32; 3], + pub hsb: [f32; 3], + pub lab: [f32; 3], +} + +impl Picked { + pub fn from_rgb(rgb: [f32; 3], hue: f32) -> Picked { + let rgb = rgb.map(|v| v.clamp(0.0, 1.0)); + let c = designcraft_color::Color::Rgb { r: rgb[0], g: rgb[1], b: rgb[2] }; + let mut hsb = c.to_hsb(); + if hsb[1] <= 1e-4 || hsb[2] <= 1e-4 { + hsb[0] = hue; + } + let l = designcraft_color::cms::lab::srgb_to_lab(rgb); + Picked { rgb, hsb, lab: [l.l, l.a, l.b] } + } + + fn from_hsb(hsb: [f32; 3]) -> Picked { + let hsb = [hsb[0].rem_euclid(360.0), hsb[1].clamp(0.0, 1.0), hsb[2].clamp(0.0, 1.0)]; + let mut p = Picked::from_rgb(designcraft_color::Color::from_hsb(hsb[0], hsb[1], hsb[2]).to_rgb_uncalibrated(), hsb[0]); + p.hsb = hsb; + p + } + + fn from_lab(lab: [f32; 3], hue: f32) -> Picked { + let lab = [lab[0].clamp(0.0, 100.0), lab[1].clamp(-128.0, 127.0), lab[2].clamp(-128.0, 127.0)]; + let mut p = Picked::from_rgb(designcraft_color::cms::lab::lab_to_srgb(designcraft_color::cms::Lab::new(lab[0], lab[1], lab[2])), hue); + p.lab = lab; + p + } + + /// (slider, across, up), each 0..1, for component `c`. + pub fn coords(&self, c: PickComp) -> (f32, f32, f32) { + let [r, g, b] = self.rgb; + let [h, s, v] = self.hsb; + let [l, la, lb] = self.lab; + let (a, bb) = ((la + 128.0) / 255.0, (lb + 128.0) / 255.0); + match c { + PickComp::R => (r, b, g), + PickComp::G => (g, b, r), + PickComp::Bl => (b, r, g), + PickComp::H => (h / 360.0, s, v), + PickComp::S => (s, h / 360.0, v), + PickComp::Br => (v, h / 360.0, s), + PickComp::L => (l / 100.0, a, bb), + PickComp::A => (a, bb, l / 100.0), + PickComp::Lb => (bb, a, l / 100.0), + } + } + + /// The colour at (slider `t`, across `u`, up `v`) for component `c`. + pub fn at(&self, c: PickComp, t: f32, u: f32, v: f32) -> Picked { + let hue = self.hsb[0]; + let ab = |x: f32| x * 255.0 - 128.0; + match c { + PickComp::R => Picked::from_rgb([t, v, u], hue), + PickComp::G => Picked::from_rgb([v, t, u], hue), + PickComp::Bl => Picked::from_rgb([u, v, t], hue), + PickComp::H => Picked::from_hsb([t * 360.0, u, v]), + PickComp::S => Picked::from_hsb([u * 360.0, t, v]), + PickComp::Br => Picked::from_hsb([u * 360.0, v, t]), + PickComp::L => Picked::from_lab([t * 100.0, ab(u), ab(v)], hue), + PickComp::A => Picked::from_lab([v * 100.0, ab(t), ab(u)], hue), + PickComp::Lb => Picked::from_lab([v * 100.0, ab(u), ab(t)], hue), + } + } + + /// The slider's colour at `t`: the hue slider shows pure hues, the others this colour with + /// the component at `t`. + fn slider_at(&self, c: PickComp, t: f32) -> [f32; 3] { + if c == PickComp::H { + return designcraft_color::Color::from_hsb(t * 360.0, 1.0, 1.0).to_rgb_uncalibrated(); + } + let (_, u, v) = self.coords(c); + self.at(c, t, u, v).rgb + } + + pub fn hex(&self) -> String { + let q = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8; + format!("#{:02x}{:02x}{:02x}", q(self.rgb[0]), q(self.rgb[1]), q(self.rgb[2])) + } +} + +fn color32(rgb: [f32; 3]) -> egui::Color32 { + let q = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8; + egui::Color32::from_rgb(q(rgb[0]), q(rgb[1]), q(rgb[2])) +} + +/// A `cols` × `rows` grid of quads over `r`, coloured at each vertex by `f(x, y)` (0..1, y up). +fn gradient_mesh(painter: &egui::Painter, r: egui::Rect, cols: usize, rows: usize, f: impl Fn(f32, f32) -> [f32; 3]) { + let mut mesh = egui::Mesh::default(); + for j in 0..=rows { + for i in 0..=cols { + let (x, y) = (i as f32 / cols as f32, j as f32 / rows as f32); + mesh.colored_vertex(egui::pos2(r.left() + x * r.width(), r.bottom() - y * r.height()), color32(f(x, y))); + } + } + let w = (cols + 1) as u32; + for j in 0..rows as u32 { + for i in 0..cols as u32 { + let a = j * w + i; + mesh.add_triangle(a, a + 1, a + w); + mesh.add_triangle(a + 1, a + w + 1, a + w); + } + } + painter.add(egui::Shape::mesh(mesh)); +} + +/// `n` numbers from an array field. +fn nums(d: &Dialog, key: &str) -> Option<[f64; N]> { + let a = d.fields.get(key)?.as_array()?; + let mut out = [0.0; N]; + for (o, v) in out.iter_mut().zip(a) { + *o = v.as_f64().filter(|v| v.is_finite())?; + } + (a.len() == N).then_some(out) +} + +/// The colour in the dialog's fields: `hex`, with the HSB hue and the Lab values kept while +/// they round-trip (`_hsb`, `_lab`). +fn picked_of(d: &Dialog) -> Picked { + let rgb = designcraft_color::Color::from_hex(&d.s("hex")).map_or([0.0; 3], |c| c.to_rgb_uncalibrated()); + let hue = nums::<3>(d, "_hsb").map_or(0.0, |h| h[0] as f32); + let mut p = Picked::from_rgb(rgb, hue); + // The exact values the picker last set, while they still give this colour. + if let Some(h) = nums::<3>(d, "_hsb").map(|h| h.map(|v| v as f32)) + && Picked::from_hsb(h).hex() == p.hex() + { + p.hsb = h; + } + if let Some(l) = nums::<3>(d, "_lab").map(|l| l.map(|v| v as f32)) + && Picked::from_lab(l, p.hsb[0]).hex() == p.hex() + { + p.lab = l; + } + p +} + +fn store_picked(d: &mut Dialog, p: &Picked) { + d.fields.insert("hex".into(), json!(p.hex())); + d.fields.insert("_hsb".into(), json!(p.hsb)); + d.fields.insert("_lab".into(), json!(p.lab)); +} + +/// The Color Picker, laid out as InDesign's: the field of two components and the slider of the +/// third (the one whose radio button is on, R at first), the new colour over the one it replaces, +/// H/S/B, L/a/b, R/G/B and C/M/Y/K values, the hex code, and Add CMYK Swatch. +fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); + if !d.fields.contains_key("old") { + let old = d.s("hex"); + d.fields.insert("old".into(), json!(old)); + } + let comp = PickComp::from_key(&d.s("component")); + let mut p = picked_of(d); + let before = p; + let t = crate::theme::Tokens::get(ui.ctx()); ui.horizontal_top(|ui| { - egui::color_picker::color_picker_color32(ui, &mut c, egui::color_picker::Alpha::Opaque); + // The field and the slider. ui.vertical(|ui| { - let mut rgb = [c.r() as f32, c.g() as f32, c.b() as f32]; - egui::Grid::new("cp_rgb").num_columns(2).spacing([6.0, 4.0]).show(ui, |ui| { - for (k, l) in ["R", "G", "B"].iter().enumerate() { - ui.label(*l); - ui.add(egui::DragValue::new(&mut rgb[k]).range(0.0..=255.0)); - ui.end_row(); + crate::rtl::label(ui, egui::RichText::new(tr(if comp.lab() { "Lab Color Space View" } else { "RGB Color Space View" })).size(11.5)); + ui.add_space(4.0); + ui.horizontal_top(|ui| { + let (fr, fresp) = ui.allocate_exact_size(egui::vec2(190.0, 200.0), egui::Sense::click_and_drag()); + let (tv, _, _) = p.coords(comp); + let cells = if matches!(comp, PickComp::R | PickComp::G | PickComp::Bl | PickComp::H) { 2 } else { 24 }; + gradient_mesh(ui.painter(), fr, cells, cells, |x, y| p.at(comp, tv, x, y).rgb); + ui.painter().rect_stroke(fr, 0.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Outside); + fresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, "Color field")); + if let Some(pos) = fresp.interact_pointer_pos().filter(|_| fresp.is_pointer_button_down_on() || fresp.clicked()) { + let u = ((pos.x - fr.left()) / fr.width()).clamp(0.0, 1.0); + let v = ((fr.bottom() - pos.y) / fr.height()).clamp(0.0, 1.0); + p = p.at(comp, tv, u, v); } - }); - c = egui::Color32::from_rgb(rgb[0] as u8, rgb[1] as u8, rgb[2] as u8); - let col = designcraft_color::Color::rgb8(c.r(), c.g(), c.b()); - let [cc, m, y, k] = col.to_cmyk(); - ui.add_space(6.0); - ui.label(format!("C {:.0}% M {:.0}% Y {:.0}% K {:.0}%", cc * 100.0, m * 100.0, y * 100.0, k * 100.0)); - ui.add_space(6.0); - ui.horizontal(|ui| { - ui.label("#"); - let mut h = format!("{:02x}{:02x}{:02x}", c.r(), c.g(), c.b()); - if ui.add(egui::TextEdit::singleline(&mut h).desired_width(70.0)).changed() - && let Some(n) = designcraft_color::Color::from_hex(&h) - { - let [r, g, b] = n.to_rgb(); - c = egui::Color32::from_rgb((r * 255.0).round() as u8, (g * 255.0).round() as u8, (b * 255.0).round() as u8); + let (_, u, v) = p.coords(comp); + let mark = egui::pos2(fr.left() + u * fr.width(), fr.bottom() - v * fr.height()); + ui.painter().circle_stroke(mark, 5.0, egui::Stroke::new(1.5, egui::Color32::WHITE)); + ui.painter().circle_stroke(mark, 6.0, egui::Stroke::new(1.0, egui::Color32::BLACK)); + ui.add_space(6.0); + let (sr, sresp) = ui.allocate_exact_size(egui::vec2(14.0, 200.0), egui::Sense::click_and_drag()); + gradient_mesh(ui.painter(), sr, 1, 48, |_, y| p.slider_at(comp, y)); + ui.painter().rect_stroke(sr, 0.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Outside); + sresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, true, "Color slider")); + if let Some(pos) = sresp.interact_pointer_pos().filter(|_| sresp.is_pointer_button_down_on() || sresp.clicked()) { + let tn = ((sr.bottom() - pos.y) / sr.height()).clamp(0.0, 1.0); + let (_, u, v) = p.coords(comp); + p = p.at(comp, tn, u, v); + } + let (tn, _, _) = p.coords(comp); + let y = sr.bottom() - tn * sr.height(); + for (x, dir) in [(sr.left() - 1.0, 1.0), (sr.right() + 1.0, -1.0)] { + ui.painter().add(egui::Shape::convex_polygon( + vec![egui::pos2(x, y), egui::pos2(x - 5.0 * dir, y - 4.0), egui::pos2(x - 5.0 * dir, y + 4.0)], + t.text_strong, + egui::Stroke::NONE, + )); } }); }); + ui.add_space(10.0); + ui.vertical(|ui| { + // The new colour over the one it replaces. + let (pr, _) = ui.allocate_exact_size(egui::vec2(60.0, 60.0), egui::Sense::hover()); + let old = designcraft_color::Color::from_hex(&d.s("old")).map_or([0.0; 3], |c| c.to_rgb_uncalibrated()); + ui.painter().rect_filled(egui::Rect::from_min_max(pr.min, egui::pos2(pr.max.x, pr.center().y)), 0.0, color32(p.rgb)); + ui.painter().rect_filled(egui::Rect::from_min_max(egui::pos2(pr.min.x, pr.center().y), pr.max), 0.0, color32(old)); + ui.painter().rect_stroke(pr, 0.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Outside); + ui.add_space(10.0); + let mut comp_now = comp; + let mut edited: Option = None; + let [cc, cm, cy, ck] = designcraft_color::Color::Rgb { r: p.rgb[0], g: p.rgb[1], b: p.rgb[2] } + .to_cmyk_managed(designcraft_color::cms::active_settings().intent) + .map(|v| v * 100.0); + let mut cmyk = [cc, cm, cy, ck]; + egui::Grid::new("cp_values").num_columns(6).spacing([4.0, 4.0]).show(ui, |ui| { + let mut field = + |ui: &mut egui::Ui, c: Option, label: &str, v: &mut f32, range: std::ops::RangeInclusive, suffix: &str| -> bool { + match c { + Some(c) => { + ui.radio_value(&mut comp_now, c, ""); + } + None => { + ui.label(""); + } + } + ui.label(label); + ui.add(egui::DragValue::new(v).range(range).max_decimals(0).suffix(suffix)).changed() + }; + let (mut h, mut s, mut b) = (p.hsb[0], p.hsb[1] * 100.0, p.hsb[2] * 100.0); + let (mut l, mut la, mut lb) = (p.lab[0], p.lab[1], p.lab[2]); + let (mut r, mut g, mut bl) = (p.rgb[0] * 255.0, p.rgb[1] * 255.0, p.rgb[2] * 255.0); + // Rows as InDesign's: H S B beside L a b, R G B beside C M Y K. + let hsb_changed = + field(ui, Some(PickComp::H), "H:", &mut h, 0.0..=360.0, "°") | field(ui, Some(PickComp::L), "L:", &mut l, 0.0..=100.0, ""); + ui.end_row(); + let hsb_changed2 = + field(ui, Some(PickComp::S), "S:", &mut s, 0.0..=100.0, "%") | field(ui, Some(PickComp::A), "a:", &mut la, -128.0..=127.0, ""); + ui.end_row(); + let hsb_changed3 = + field(ui, Some(PickComp::Br), "B:", &mut b, 0.0..=100.0, "%") | field(ui, Some(PickComp::Lb), "b:", &mut lb, -128.0..=127.0, ""); + ui.end_row(); + if hsb_changed || hsb_changed2 || hsb_changed3 { + // Whichever space changed wins: HSB if its values moved, else Lab. + edited = Some(if (h, s, b) != (p.hsb[0], p.hsb[1] * 100.0, p.hsb[2] * 100.0) { + Picked::from_hsb([h, s / 100.0, b / 100.0]) + } else { + Picked::from_lab([l, la, lb], p.hsb[0]) + }); + } + let rgb_changed = field(ui, Some(PickComp::R), "R:", &mut r, 0.0..=255.0, ""); + let c1 = field(ui, None, "C:", &mut cmyk[0], 0.0..=100.0, "%"); + ui.end_row(); + let rgb_changed2 = field(ui, Some(PickComp::G), "G:", &mut g, 0.0..=255.0, ""); + let c2 = field(ui, None, "M:", &mut cmyk[1], 0.0..=100.0, "%"); + ui.end_row(); + let rgb_changed3 = field(ui, Some(PickComp::Bl), "B:", &mut bl, 0.0..=255.0, ""); + let c3 = field(ui, None, "Y:", &mut cmyk[2], 0.0..=100.0, "%"); + ui.end_row(); + ui.label(""); + ui.label(""); + ui.label(""); + let c4 = field(ui, None, "K:", &mut cmyk[3], 0.0..=100.0, "%"); + ui.end_row(); + if rgb_changed || rgb_changed2 || rgb_changed3 { + edited = Some(Picked::from_rgb([r / 255.0, g / 255.0, bl / 255.0], p.hsb[0])); + } + if c1 || c2 || c3 || c4 { + let [c, m, y, k] = cmyk.map(|v| v / 100.0); + edited = Some(Picked::from_rgb(designcraft_color::Color::Cmyk { c, m, y, k }.to_rgb(), p.hsb[0])); + } + }); + if let Some(e) = edited { + p = e; + } + if comp_now != comp { + d.fields.insert("component".into(), json!(comp_now.key())); + } + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label("#"); + let id = egui::Id::new("color_picker_hex"); + let editing = ui.memory(|m| m.has_focus(id)); + let mut h = if editing { d.s("_hexEdit") } else { p.hex().trim_start_matches('#').to_string() }; + if ui.add(egui::TextEdit::singleline(&mut h).id(id).desired_width(70.0)).changed() + && let Some(c) = designcraft_color::Color::from_hex(&h) + { + p = Picked::from_rgb(c.to_rgb_uncalibrated(), p.hsb[0]); + } + d.fields.insert("_hexEdit".into(), json!(h)); + }); + ui.add_space(8.0); + if ui.button(crate::rtl::widget(ui, tr("Add CMYK Swatch"))).clicked() { + let [c, m, y, k] = designcraft_color::Color::Rgb { r: p.rgb[0], g: p.rgb[1], b: p.rgb[2] } + .to_cmyk_managed(designcraft_color::cms::active_settings().intent) + .map(|v| (v * 100.0).round()); + match app.run("swatch.create", json!({"color": {"c": c, "m": m, "y": y, "k": k}})) { + Ok(r) => app.status(format!("{} {}", tr("Added swatch"), r["name"].as_str().unwrap_or(""))), + Err(e) => app.status(e), + } + } + }); }); - if c != before { - d.fields.insert("hex".into(), json!(format!("#{:02x}{:02x}{:02x}", c.r(), c.g(), c.b()))); + if p != before { + store_picked(d, &p); } } @@ -6794,6 +7106,61 @@ mod tests { (0..doc.page_count()).map(|abs| doc.page(abs).unwrap().guides.len()).collect() } + /// The Color Picker laid out as InDesign's: a field and a slider for the component whose radio + /// is on, the new colour over the old one, and the values in every space. + #[test] + fn the_color_picker_picks_on_its_field_and_slider_and_adds_a_cmyk_swatch() { + use egui_kittest::kittest::Queryable; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [72, 72, 200, 200], "shape": "rectangle"})).unwrap(); + app.ui.dialog = Some(Dialog::new("colorPicker", json!({"target": "fill", "hex": "#7c1315", "text": false}))); + let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); + h.run_steps(3); + // R at first: R up the slider, B across and G up the field. + let _ = h.get_by_label("RGB Color Space View"); + let field = h.get_by_label("Color field").rect(); + crate::test_window::click_at(&mut h, egui::pos2(field.right() - 1.0, field.top() + 1.0)); + h.run_steps(2); + let p = picked_of(h.state().app.ui.dialog.as_ref().unwrap()); + assert!((p.rgb[0] - 124.0 / 255.0).abs() < 0.01 && p.rgb[1] > 0.97 && p.rgb[2] > 0.97, "{p:?}: R kept, G and B full"); + // The slider sets R alone. + let slider = h.get_by_label("Color slider").rect(); + crate::test_window::click_at(&mut h, egui::pos2(slider.center().x, slider.bottom() - 1.0)); + h.run_steps(2); + let p = picked_of(h.state().app.ui.dialog.as_ref().unwrap()); + assert!(p.rgb[0] < 0.02 && p.rgb[1] > 0.97, "{p:?}"); + // Hue on the slider: the field is saturation across, brightness up. + h.state_mut().app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("#7c1315")); + h.state_mut().app.ui.dialog.as_mut().unwrap().fields.insert("component".into(), json!("h")); + h.run_steps(2); + crate::test_window::click_at(&mut h, egui::pos2(field.left() + 1.0, field.top() + 1.0)); + h.run_steps(2); + let p = picked_of(h.state().app.ui.dialog.as_ref().unwrap()); + assert!(p.hsb[1] < 0.02 && p.hsb[2] > 0.98 && (p.hsb[0] - 359.0).abs() < 2.0, "{p:?}: no saturation, full brightness, the hue kept"); + // Lab shows its own view. + h.state_mut().app.ui.dialog.as_mut().unwrap().fields.insert("component".into(), json!("l")); + h.run_steps(2); + let _ = h.get_by_label("Lab Color Space View"); + // Add CMYK Swatch adds the colour as a CMYK swatch; OK applies it. + h.state_mut().app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("#7c1315")); + h.run_steps(2); + let before = h.state().app.session.active().unwrap().doc.swatches.len(); + h.get_by_label("Add CMYK Swatch").click(); + h.run_steps(3); + let doc = &h.state().app.session.active().unwrap().doc; + assert_eq!(doc.swatches.len(), before + 1); + assert!(matches!( + doc.swatches.last().unwrap().value, + designcraft_color::swatch::SwatchValue::Color { color: designcraft_color::Color::Cmyk { .. }, .. } + )); + confirm(&mut h.state_mut().app).unwrap(); + let st = h.state().app.session.active().unwrap(); + let id = st.selection.items[0]; + let fill = st.doc.item(id).unwrap().fill.swatch.clone(); + assert_eq!(st.doc.resolve_color(&fill, 1.0).unwrap().to_hex(), "#7c1315"); + } + #[test] fn create_guides_dialog_targets_the_page_in_view() { let mut app = facing_app(6); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 245a6e287..87a3eda7b 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -58,6 +58,28 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ( + "RGB Color Space View", + [ + "RGB-Farbraumansicht", + "Affichage de l’espace colorimétrique RVB", + "Vista de espacio de color RGB", + "RGB カラースペース表示", + "RGB 色彩空间视图", + ], + ), + ( + "Lab Color Space View", + [ + "Lab-Farbraumansicht", + "Affichage de l’espace colorimétrique Lab", + "Vista de espacio de color Lab", + "Lab カラースペース表示", + "Lab 色彩空间视图", + ], + ), + ("Add CMYK Swatch", ["CMYK-Farbfeld hinzufügen", "Ajouter une nuance CMJN", "Añadir muestra CMYK", "CMYK スウォッチを追加", "添加 CMYK 色板"]), + ("Added swatch", ["Farbfeld hinzugefügt", "Nuance ajoutée", "Muestra añadida", "スウォッチを追加しました", "已添加色板"]), ( "Bulleted & Numbered Lists", [ diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index d1dcb7acf..29a703d35 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("RGB Color Space View", "عرض مساحة ألوان RGB"), + ("Lab Color Space View", "عرض مساحة ألوان Lab"), + ("Add CMYK Swatch", "إضافة حامل ألوان CMYK"), + ("Added swatch", "تمت إضافة حامل الألوان"), ("Bulleted & Numbered Lists", "القوائم ذات التعداد النقطي والرقمي"), ("Current Page Number", "رقم الصفحة الحالية"), ("Apply Color (,)", "تطبيق اللون (,)"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 0f55578b5..d9eb9b272 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -30,6 +30,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Ausschneiden"), ("Copy", "Kopieren"), ("Paste", "Einfügen"), + ("RGB Color Space View", "RGB-Farbraumansicht"), + ("Lab Color Space View", "Lab-Farbraumansicht"), + ("Add CMYK Swatch", "CMYK-Farbfeld hinzufügen"), + ("Added swatch", "Farbfeld hinzugefügt"), ("Clear", "Löschen"), ("Duplicate", "Duplizieren"), ("Select All", "Alles auswählen"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 00328ac9f..1da1642d3 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("RGB Color Space View", "Vista spazio colore RGB"), + ("Lab Color Space View", "Vista spazio colore Lab"), + ("Add CMYK Swatch", "Aggiungi campione CMYK"), + ("Added swatch", "Campione aggiunto"), ("Bulleted & Numbered Lists", "Elenchi puntati e numerati"), ("Current Page Number", "Numero di pagina corrente"), ("Apply Color (,)", "Applica colore (,)"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index fe9e213e7..dd848f64b 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("RGB Color Space View", "RGB カラースペース表示"), + ("Lab Color Space View", "Lab カラースペース表示"), + ("Add CMYK Swatch", "CMYK スウォッチを追加"), + ("Added swatch", "スウォッチを追加しました"), ("Bulleted & Numbered Lists", "箇条書き"), ("Apply Color (,)", "カラーを適用 (,)"), ("Apply Gradient (.)", "グラデーションを適用 (.)"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index b3379c6d7..12d4b0b8a 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("RGB Color Space View", "RGB ფერთა სივრცის ხედი"), + ("Lab Color Space View", "Lab ფერთა სივრცის ხედი"), + ("Add CMYK Swatch", "CMYK ნიმუშის დამატება"), + ("Added swatch", "ნიმუში დაემატა"), ("Reverse Path", "ტრაექტორიის შებრუნება"), ("Set Anchor", "საყრდენი წერტილის დაყენება"), ("Clear", "გასუფთავება"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 0675a2f66..76ca36985 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("RGB Color Space View", "Exibição do espaço de cores RGB"), + ("Lab Color Space View", "Exibição do espaço de cores Lab"), + ("Add CMYK Swatch", "Adicionar amostra CMYK"), + ("Added swatch", "Amostra adicionada"), ("Bulleted & Numbered Lists", "Listas com marcadores e numeradas"), ("Show Hidden Characters", "Mostrar caracteres ocultos"), ("Current Page Number", "Número da página atual"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 8f1d27f84..32d637087 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -31,6 +31,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("RGB Color Space View", "Подання колірного простору RGB"), + ("Lab Color Space View", "Подання колірного простору Lab"), + ("Add CMYK Swatch", "Додати зразок CMYK"), + ("Added swatch", "Додано зразок"), ("Bulleted & Numbered Lists", "Марковані й нумеровані списки"), ("Current Page Number", "Номер поточної сторінки"), ("Apply Color (,)", "Застосувати колір (,)"), From 7cf1e30acb7beed0127899549902191c41e0601d Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 15 Oct 2026 22:57:35 +0200 Subject: [PATCH 008/628] Color Picker: InDesign's layout at the app's density, its own OK / Cancel / Add CMYK Swatch at the top right Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 401 ++++++++++++++++++++++------------ 1 file changed, 256 insertions(+), 145 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 262a48b60..6e8fe34b5 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1538,6 +1538,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let preferred_width: f32 = match d.id.as_str() { "alert" => 440.0, "closeDocument" | "updateLink" => 380.0, + "colorPicker" => 580.0, "newDocument" if crate::i18n::is_rtl(&app.ui.language) => 380.0, _ => 640.0, }; @@ -2201,6 +2202,15 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } _ => {} }); + // The Color Picker has its own OK and Cancel, at the top right as InDesign's. + if d.id == "colorPicker" { + match d.fields.remove("_close").as_ref().and_then(Value::as_str) { + Some("ok") => result = Some(true), + Some("cancel") => result = Some(false), + _ => {} + } + return; + } ui.add_space(12.0); let ok_label = match d.id.as_str() { "closeDocument" => " Save ", @@ -4899,10 +4909,63 @@ fn store_picked(d: &mut Dialog, p: &Picked) { d.fields.insert("_lab".into(), json!(p.lab)); } -/// The Color Picker, laid out as InDesign's: the field of two components and the slider of the -/// third (the one whose radio button is on, R at first), the new colour over the one it replaces, -/// H/S/B, L/a/b, R/G/B and C/M/Y/K values, the hex code, and Add CMYK Swatch. +/// The Color Picker's place in the dialog, as InDesign's (points from the body's top-left). +mod picker_layout { + use egui::{Rect, pos2, vec2}; + // InDesign's proportions at this app's density (its 30 px fields are 21 pt here). + pub const SIZE: egui::Vec2 = vec2(566.0, 334.0); + pub fn field() -> Rect { + Rect::from_min_size(pos2(2.0, 31.0), vec2(265.0, 287.0)) + } + pub fn slider() -> Rect { + Rect::from_min_size(pos2(285.0, 32.0), vec2(19.0, 282.0)) + } + pub fn swatch() -> Rect { + Rect::from_min_size(pos2(319.0, 0.0), vec2(71.0, 90.0)) + } + /// OK, Cancel, Add … Swatch. + pub fn button(i: usize) -> Rect { + Rect::from_min_size(pos2(433.0, [0.0, 39.0, 78.0][i.min(2)]), vec2(132.0, 25.0)) + } + /// Row centres: H/L, S/a, B/b, (gap), R/C, G/M, B/Y, K, #. + pub const ROWS: [f32; 8] = [129.0, 157.0, 185.0, 224.0, 252.0, 280.0, 308.0, 320.0]; + /// The radio buttons' columns. + pub const LEFT: f32 = 326.0; + pub const RIGHT: f32 = 457.0; +} + +/// A value field: the text typed stays while it has the focus; a number that parses applies. +fn picker_field(ui: &mut egui::Ui, d: &mut Dialog, r: egui::Rect, key: &str, value: f32, range: (f32, f32)) -> Option { + let id = egui::Id::new(("color_picker_field", key)); + let editing = ui.memory(|m| m.has_focus(id)); + let shown = format!("{}", value.round() as i64); + let edit_key = format!("_edit_{key}"); + let mut text = if editing { d.fields.get(&edit_key).and_then(Value::as_str).map(str::to_string).unwrap_or(shown) } else { shown }; + let resp = ui.put( + r, + egui::TextEdit::singleline(&mut text) + .id(id) + .font(egui::FontId::proportional(11.5)) + .vertical_align(egui::Align::Center) + .margin(egui::Margin::symmetric(4, 2)), + ); + let mut out = None; + if resp.changed() + && let Ok(v) = text.trim().parse::() + && v.is_finite() + { + out = Some(v.clamp(range.0, range.1)); + } + d.fields.insert(edit_key, json!(text)); + out +} + +/// The Color Picker, laid out as InDesign's: the colour space view (a field of two components, +/// a slider for the third: the one whose radio button is on, R at first), the new colour over the +/// one it replaces, OK / Cancel / Add CMYK Swatch at the top right, H/S/B beside L/a/b, R/G/B +/// beside C/M/Y/K, and the hex code. Its buttons replace the dialog's own. fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + use picker_layout as at; let lang = app.ui.language.clone(); let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); if !d.fields.contains_key("old") { @@ -4913,151 +4976,196 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let mut p = picked_of(d); let before = p; let t = crate::theme::Tokens::get(ui.ctx()); - ui.horizontal_top(|ui| { - // The field and the slider. - ui.vertical(|ui| { - crate::rtl::label(ui, egui::RichText::new(tr(if comp.lab() { "Lab Color Space View" } else { "RGB Color Space View" })).size(11.5)); - ui.add_space(4.0); - ui.horizontal_top(|ui| { - let (fr, fresp) = ui.allocate_exact_size(egui::vec2(190.0, 200.0), egui::Sense::click_and_drag()); - let (tv, _, _) = p.coords(comp); - let cells = if matches!(comp, PickComp::R | PickComp::G | PickComp::Bl | PickComp::H) { 2 } else { 24 }; - gradient_mesh(ui.painter(), fr, cells, cells, |x, y| p.at(comp, tv, x, y).rgb); - ui.painter().rect_stroke(fr, 0.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Outside); - fresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, "Color field")); - if let Some(pos) = fresp.interact_pointer_pos().filter(|_| fresp.is_pointer_button_down_on() || fresp.clicked()) { - let u = ((pos.x - fr.left()) / fr.width()).clamp(0.0, 1.0); - let v = ((fr.bottom() - pos.y) / fr.height()).clamp(0.0, 1.0); - p = p.at(comp, tv, u, v); + let (area, _) = ui.allocate_exact_size(at::SIZE, egui::Sense::hover()); + let o = area.min.to_vec2(); + let put = |r: egui::Rect| r.translate(o); + let font = egui::FontId::proportional(11.5); + let text = |ui: &egui::Ui, pos: egui::Pos2, align: egui::Align2, s: &str| { + ui.painter().text(pos, align, s, egui::FontId::proportional(11.5), t.text_strong); + }; + // The colour space view (a label, so screen readers hear which). + ui.scope_builder( + egui::UiBuilder::new() + .max_rect(egui::Rect::from_min_size(area.min, egui::vec2(260.0, 24.0))) + .layout(egui::Layout::left_to_right(egui::Align::Center)), + |ui| { + ui.label( + egui::RichText::new(tr(if comp.lab() { "Lab Color Space View" } else { "RGB Color Space View" })).size(11.5).color(t.text_strong), + ); + }, + ); + let fr = put(at::field()); + let fresp = ui.interact(fr, ui.id().with("cp_field"), egui::Sense::click_and_drag()); + let (tv, _, _) = p.coords(comp); + let cells = if matches!(comp, PickComp::R | PickComp::G | PickComp::Bl | PickComp::H) { 2 } else { 24 }; + gradient_mesh(ui.painter(), fr, cells, cells, |x, y| p.at(comp, tv, x, y).rgb); + fresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, "Color field")); + if let Some(pos) = fresp.interact_pointer_pos().filter(|_| fresp.is_pointer_button_down_on() || fresp.clicked()) { + let u = ((pos.x - fr.left()) / fr.width()).clamp(0.0, 1.0); + let v = ((fr.bottom() - pos.y) / fr.height()).clamp(0.0, 1.0); + p = p.at(comp, tv, u, v); + } + // The marker: a cross with an open centre, white over black. + let (_, u, v) = p.coords(comp); + let m = egui::pos2(fr.left() + u * fr.width(), fr.bottom() - v * fr.height()); + let painter = ui.painter().with_clip_rect(fr); + for (dx, dy) in [(1.0, 0.0), (-1.0, 0.0), (0.0, 1.0), (0.0, -1.0)] { + let (a, b) = (m + egui::vec2(dx * 3.0, dy * 3.0), m + egui::vec2(dx * 10.0, dy * 10.0)); + let side = egui::vec2(dy, dx) * 1.2; + painter.line_segment([a + side, b + side], egui::Stroke::new(1.0, egui::Color32::BLACK)); + painter.line_segment([a - side, b - side], egui::Stroke::new(1.0, egui::Color32::BLACK)); + painter.line_segment([a, b], egui::Stroke::new(1.5, egui::Color32::WHITE)); + } + // The slider, with a marker on each side. + let sr = put(at::slider()); + let sresp = ui.interact(sr.expand2(egui::vec2(6.0, 0.0)), ui.id().with("cp_slider"), egui::Sense::click_and_drag()); + gradient_mesh(ui.painter(), sr, 1, 48, |_, y| p.slider_at(comp, y)); + sresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, true, "Color slider")); + if let Some(pos) = sresp.interact_pointer_pos().filter(|_| sresp.is_pointer_button_down_on() || sresp.clicked()) { + let tn = ((sr.bottom() - pos.y) / sr.height()).clamp(0.0, 1.0); + let (_, u, v) = p.coords(comp); + p = p.at(comp, tn, u, v); + } + let (tn, _, _) = p.coords(comp); + let y = sr.bottom() - tn * sr.height(); + for (x, dir) in [(sr.left(), 1.0), (sr.right(), -1.0)] { + let pts = vec![ + egui::pos2(x - 5.0 * dir, y - 4.5), + egui::pos2(x, y - 4.5), + egui::pos2(x + 3.5 * dir, y), + egui::pos2(x, y + 4.5), + egui::pos2(x - 5.0 * dir, y + 4.5), + ]; + ui.painter().add(egui::Shape::convex_polygon(pts, egui::Color32::WHITE, egui::Stroke::new(1.0, egui::Color32::from_gray(40)))); + } + // The new colour over the one it replaces. + let pr = put(at::swatch()); + let old = designcraft_color::Color::from_hex(&d.s("old")).map_or([0.0; 3], |c| c.to_rgb_uncalibrated()); + ui.painter().rect_filled(egui::Rect::from_min_max(pr.min, egui::pos2(pr.max.x, pr.center().y)), 0.0, color32(p.rgb)); + ui.painter().rect_filled(egui::Rect::from_min_max(egui::pos2(pr.min.x, pr.center().y), pr.max), 0.0, color32(old)); + ui.painter().rect_stroke(pr, 0.0, egui::Stroke::new(1.0, egui::Color32::from_gray(80)), egui::StrokeKind::Inside); + // OK, Cancel, Add CMYK Swatch: rounded, OK filled. + let pill = |ui: &mut egui::Ui, i: usize, label: &str, primary: bool| -> bool { + let r = put(at::button(i)); + let resp = ui.interact(r, ui.id().with(("cp_button", i)), egui::Sense::click()); + let (fill, fg, stroke) = if primary { + (t.accent_strong, egui::Color32::WHITE, egui::Stroke::NONE) + } else { + (if resp.hovered() { t.hover } else { egui::Color32::TRANSPARENT }, t.text_strong, egui::Stroke::new(1.0, t.text_dim)) + }; + ui.painter().rect(r, r.height() / 2.0, fill, stroke, egui::StrokeKind::Inside); + ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, label, egui::FontId::proportional(12.0), fg); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + resp.clicked() + }; + if pill(ui, 0, &tr("OK"), true) || ui.input(|i| i.key_pressed(egui::Key::Enter)) { + d.fields.insert("_close".into(), json!("ok")); + } + if pill(ui, 1, &tr("Cancel"), false) || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + d.fields.insert("_close".into(), json!("cancel")); + } + if pill(ui, 2, &tr("Add CMYK Swatch"), false) { + let [c, m, y, k] = designcraft_color::Color::Rgb { r: p.rgb[0], g: p.rgb[1], b: p.rgb[2] } + .to_cmyk_managed(designcraft_color::cms::active_settings().intent) + .map(|v| (v * 100.0).round()); + match app.run("swatch.create", json!({"color": {"c": c, "m": m, "y": y, "k": k}})) { + Ok(r) => app.status(format!("{} {}", tr("Added swatch"), r["name"].as_str().unwrap_or(""))), + Err(e) => app.status(e), + } + } + // The values: a radio button, a label, the field, a unit. + let mut comp_now = comp; + let mut edited: Option = None; + let cmyk = designcraft_color::Color::Rgb { r: p.rgb[0], g: p.rgb[1], b: p.rgb[2] } + .to_cmyk_managed(designcraft_color::cms::active_settings().intent) + .map(|v| v * 100.0); + // (column x of the radio, row, component, label, key, value, range, unit) + let rows: [(f32, usize, Option, &str, &str, f32, (f32, f32), &str); 13] = [ + (at::LEFT, 0, Some(PickComp::H), "H:", "h", p.hsb[0], (0.0, 360.0), "°"), + (at::LEFT, 1, Some(PickComp::S), "S:", "s", p.hsb[1] * 100.0, (0.0, 100.0), "%"), + (at::LEFT, 2, Some(PickComp::Br), "B:", "b", p.hsb[2] * 100.0, (0.0, 100.0), "%"), + (at::RIGHT, 0, Some(PickComp::L), "L:", "l", p.lab[0], (0.0, 100.0), ""), + (at::RIGHT, 1, Some(PickComp::A), "a:", "la", p.lab[1], (-128.0, 127.0), ""), + (at::RIGHT, 2, Some(PickComp::Lb), "b:", "lb", p.lab[2], (-128.0, 127.0), ""), + (at::LEFT, 3, Some(PickComp::R), "R:", "r", p.rgb[0] * 255.0, (0.0, 255.0), ""), + (at::LEFT, 4, Some(PickComp::G), "G:", "g", p.rgb[1] * 255.0, (0.0, 255.0), ""), + (at::LEFT, 5, Some(PickComp::Bl), "B:", "bl", p.rgb[2] * 255.0, (0.0, 255.0), ""), + (at::RIGHT, 3, None, "C:", "c", cmyk[0], (0.0, 100.0), "%"), + (at::RIGHT, 4, None, "M:", "m", cmyk[1], (0.0, 100.0), "%"), + (at::RIGHT, 5, None, "Y:", "y", cmyk[2], (0.0, 100.0), "%"), + (at::RIGHT, 6, None, "K:", "k", cmyk[3], (0.0, 100.0), "%"), + ]; + for (x, row, c, label, key, value, range, unit) in rows { + let cy = area.min.y + at::ROWS[row]; + let x = area.min.x + x; + match c { + Some(c) => { + let rr = egui::Rect::from_center_size(egui::pos2(x, cy), egui::vec2(14.0, 14.0)); + let resp = ui.interact(rr, ui.id().with(("cp_radio", key)), egui::Sense::click()); + ui.painter().circle(rr.center(), 6.0, t.input, egui::Stroke::new(1.0, t.text_dim)); + if comp_now == c { + ui.painter().circle_filled(rr.center(), 2.6, t.text_strong); } - let (_, u, v) = p.coords(comp); - let mark = egui::pos2(fr.left() + u * fr.width(), fr.bottom() - v * fr.height()); - ui.painter().circle_stroke(mark, 5.0, egui::Stroke::new(1.5, egui::Color32::WHITE)); - ui.painter().circle_stroke(mark, 6.0, egui::Stroke::new(1.0, egui::Color32::BLACK)); - ui.add_space(6.0); - let (sr, sresp) = ui.allocate_exact_size(egui::vec2(14.0, 200.0), egui::Sense::click_and_drag()); - gradient_mesh(ui.painter(), sr, 1, 48, |_, y| p.slider_at(comp, y)); - ui.painter().rect_stroke(sr, 0.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Outside); - sresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, true, "Color slider")); - if let Some(pos) = sresp.interact_pointer_pos().filter(|_| sresp.is_pointer_button_down_on() || sresp.clicked()) { - let tn = ((sr.bottom() - pos.y) / sr.height()).clamp(0.0, 1.0); - let (_, u, v) = p.coords(comp); - p = p.at(comp, tn, u, v); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, true, comp_now == c, label)); + if resp.clicked() { + comp_now = c; } - let (tn, _, _) = p.coords(comp); - let y = sr.bottom() - tn * sr.height(); - for (x, dir) in [(sr.left() - 1.0, 1.0), (sr.right() + 1.0, -1.0)] { - ui.painter().add(egui::Shape::convex_polygon( - vec![egui::pos2(x, y), egui::pos2(x - 5.0 * dir, y - 4.0), egui::pos2(x - 5.0 * dir, y + 4.0)], - t.text_strong, - egui::Stroke::NONE, - )); + text(ui, egui::pos2(x + 11.0, cy), egui::Align2::LEFT_CENTER, label); + } + // C/M/Y/K have no radio button: their labels end at the field. + None => text(ui, egui::pos2(x + 28.0, cy), egui::Align2::RIGHT_CENTER, label), + } + let fr = egui::Rect::from_min_size(egui::pos2(x + 35.0, cy - 10.5), egui::vec2(44.0, 21.0)); + if let Some(v) = picker_field(ui, d, fr, key, value, range) { + edited = Some(match key { + "h" => Picked::from_hsb([v, p.hsb[1], p.hsb[2]]), + "s" => Picked::from_hsb([p.hsb[0], v / 100.0, p.hsb[2]]), + "b" => Picked::from_hsb([p.hsb[0], p.hsb[1], v / 100.0]), + "l" => Picked::from_lab([v, p.lab[1], p.lab[2]], p.hsb[0]), + "la" => Picked::from_lab([p.lab[0], v, p.lab[2]], p.hsb[0]), + "lb" => Picked::from_lab([p.lab[0], p.lab[1], v], p.hsb[0]), + "r" => Picked::from_rgb([v / 255.0, p.rgb[1], p.rgb[2]], p.hsb[0]), + "g" => Picked::from_rgb([p.rgb[0], v / 255.0, p.rgb[2]], p.hsb[0]), + "bl" => Picked::from_rgb([p.rgb[0], p.rgb[1], v / 255.0], p.hsb[0]), + _ => { + let mut c = cmyk; + c[["c", "m", "y", "k"].iter().position(|k| *k == key).unwrap_or(0)] = v; + let [c, m, y, k] = c.map(|x| x / 100.0); + Picked::from_rgb(designcraft_color::Color::Cmyk { c, m, y, k }.to_rgb(), p.hsb[0]) } }); - }); - ui.add_space(10.0); - ui.vertical(|ui| { - // The new colour over the one it replaces. - let (pr, _) = ui.allocate_exact_size(egui::vec2(60.0, 60.0), egui::Sense::hover()); - let old = designcraft_color::Color::from_hex(&d.s("old")).map_or([0.0; 3], |c| c.to_rgb_uncalibrated()); - ui.painter().rect_filled(egui::Rect::from_min_max(pr.min, egui::pos2(pr.max.x, pr.center().y)), 0.0, color32(p.rgb)); - ui.painter().rect_filled(egui::Rect::from_min_max(egui::pos2(pr.min.x, pr.center().y), pr.max), 0.0, color32(old)); - ui.painter().rect_stroke(pr, 0.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Outside); - ui.add_space(10.0); - let mut comp_now = comp; - let mut edited: Option = None; - let [cc, cm, cy, ck] = designcraft_color::Color::Rgb { r: p.rgb[0], g: p.rgb[1], b: p.rgb[2] } - .to_cmyk_managed(designcraft_color::cms::active_settings().intent) - .map(|v| v * 100.0); - let mut cmyk = [cc, cm, cy, ck]; - egui::Grid::new("cp_values").num_columns(6).spacing([4.0, 4.0]).show(ui, |ui| { - let mut field = - |ui: &mut egui::Ui, c: Option, label: &str, v: &mut f32, range: std::ops::RangeInclusive, suffix: &str| -> bool { - match c { - Some(c) => { - ui.radio_value(&mut comp_now, c, ""); - } - None => { - ui.label(""); - } - } - ui.label(label); - ui.add(egui::DragValue::new(v).range(range).max_decimals(0).suffix(suffix)).changed() - }; - let (mut h, mut s, mut b) = (p.hsb[0], p.hsb[1] * 100.0, p.hsb[2] * 100.0); - let (mut l, mut la, mut lb) = (p.lab[0], p.lab[1], p.lab[2]); - let (mut r, mut g, mut bl) = (p.rgb[0] * 255.0, p.rgb[1] * 255.0, p.rgb[2] * 255.0); - // Rows as InDesign's: H S B beside L a b, R G B beside C M Y K. - let hsb_changed = - field(ui, Some(PickComp::H), "H:", &mut h, 0.0..=360.0, "°") | field(ui, Some(PickComp::L), "L:", &mut l, 0.0..=100.0, ""); - ui.end_row(); - let hsb_changed2 = - field(ui, Some(PickComp::S), "S:", &mut s, 0.0..=100.0, "%") | field(ui, Some(PickComp::A), "a:", &mut la, -128.0..=127.0, ""); - ui.end_row(); - let hsb_changed3 = - field(ui, Some(PickComp::Br), "B:", &mut b, 0.0..=100.0, "%") | field(ui, Some(PickComp::Lb), "b:", &mut lb, -128.0..=127.0, ""); - ui.end_row(); - if hsb_changed || hsb_changed2 || hsb_changed3 { - // Whichever space changed wins: HSB if its values moved, else Lab. - edited = Some(if (h, s, b) != (p.hsb[0], p.hsb[1] * 100.0, p.hsb[2] * 100.0) { - Picked::from_hsb([h, s / 100.0, b / 100.0]) - } else { - Picked::from_lab([l, la, lb], p.hsb[0]) - }); - } - let rgb_changed = field(ui, Some(PickComp::R), "R:", &mut r, 0.0..=255.0, ""); - let c1 = field(ui, None, "C:", &mut cmyk[0], 0.0..=100.0, "%"); - ui.end_row(); - let rgb_changed2 = field(ui, Some(PickComp::G), "G:", &mut g, 0.0..=255.0, ""); - let c2 = field(ui, None, "M:", &mut cmyk[1], 0.0..=100.0, "%"); - ui.end_row(); - let rgb_changed3 = field(ui, Some(PickComp::Bl), "B:", &mut bl, 0.0..=255.0, ""); - let c3 = field(ui, None, "Y:", &mut cmyk[2], 0.0..=100.0, "%"); - ui.end_row(); - ui.label(""); - ui.label(""); - ui.label(""); - let c4 = field(ui, None, "K:", &mut cmyk[3], 0.0..=100.0, "%"); - ui.end_row(); - if rgb_changed || rgb_changed2 || rgb_changed3 { - edited = Some(Picked::from_rgb([r / 255.0, g / 255.0, bl / 255.0], p.hsb[0])); - } - if c1 || c2 || c3 || c4 { - let [c, m, y, k] = cmyk.map(|v| v / 100.0); - edited = Some(Picked::from_rgb(designcraft_color::Color::Cmyk { c, m, y, k }.to_rgb(), p.hsb[0])); - } - }); - if let Some(e) = edited { - p = e; - } - if comp_now != comp { - d.fields.insert("component".into(), json!(comp_now.key())); - } - ui.add_space(4.0); - ui.horizontal(|ui| { - ui.label("#"); - let id = egui::Id::new("color_picker_hex"); - let editing = ui.memory(|m| m.has_focus(id)); - let mut h = if editing { d.s("_hexEdit") } else { p.hex().trim_start_matches('#').to_string() }; - if ui.add(egui::TextEdit::singleline(&mut h).id(id).desired_width(70.0)).changed() - && let Some(c) = designcraft_color::Color::from_hex(&h) - { - p = Picked::from_rgb(c.to_rgb_uncalibrated(), p.hsb[0]); - } - d.fields.insert("_hexEdit".into(), json!(h)); - }); - ui.add_space(8.0); - if ui.button(crate::rtl::widget(ui, tr("Add CMYK Swatch"))).clicked() { - let [c, m, y, k] = designcraft_color::Color::Rgb { r: p.rgb[0], g: p.rgb[1], b: p.rgb[2] } - .to_cmyk_managed(designcraft_color::cms::active_settings().intent) - .map(|v| (v * 100.0).round()); - match app.run("swatch.create", json!({"color": {"c": c, "m": m, "y": y, "k": k}})) { - Ok(r) => app.status(format!("{} {}", tr("Added swatch"), r["name"].as_str().unwrap_or(""))), - Err(e) => app.status(e), - } - } - }); - }); + } + if !unit.is_empty() { + text(ui, egui::pos2(fr.right() + 8.0, cy), egui::Align2::LEFT_CENTER, unit); + } + } + if let Some(e) = edited { + p = e; + } + if comp_now != comp { + d.fields.insert("component".into(), json!(comp_now.key())); + } + // The hex code, and the eyedropper's mark beside it. + let cy = area.min.y + at::ROWS[7]; + text(ui, egui::pos2(area.min.x + 321.0, cy), egui::Align2::LEFT_CENTER, "#:"); + let hr = egui::Rect::from_min_size(egui::pos2(area.min.x + 341.0, cy - 10.5), egui::vec2(64.0, 21.0)); + let id = egui::Id::new("color_picker_hex"); + let editing = ui.memory(|m| m.has_focus(id)); + let mut h = if editing { d.s("_hexEdit") } else { p.hex().trim_start_matches('#').to_string() }; + if ui + .put(hr, egui::TextEdit::singleline(&mut h).id(id).font(font).vertical_align(egui::Align::Center).margin(egui::Margin::symmetric(4, 2))) + .changed() + && let Some(c) = designcraft_color::Color::from_hex(&h) + { + p = Picked::from_rgb(c.to_rgb_uncalibrated(), p.hsb[0]); + } + d.fields.insert("_hexEdit".into(), json!(h)); + crate::icons::paint( + ui.painter(), + egui::Rect::from_center_size(egui::pos2(hr.right() + 16.0, cy), egui::vec2(15.0, 15.0)), + "tool-eyedropper", + t.icon, + ); if p != before { store_picked(d, &p); } @@ -7154,7 +7262,10 @@ mod tests { doc.swatches.last().unwrap().value, designcraft_color::swatch::SwatchValue::Color { color: designcraft_color::Color::Cmyk { .. }, .. } )); - confirm(&mut h.state_mut().app).unwrap(); + // Its own OK, at the top right, applies it. + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(h.state().app.ui.dialog.is_none(), "OK closes it"); let st = h.state().app.session.active().unwrap(); let id = st.selection.items[0]; let fill = st.doc.item(id).unwrap().fill.swatch.clone(); From a04c805ad72dd9aac51e433977eed2d7c98f13ac Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 16 Oct 2026 10:53:30 +0200 Subject: [PATCH 009/628] Direct Selection on a selected frame selects its image, so Delete keeps the frame A Direct Selection click on an image left the selection alone when its frame was already selected with the Selection tool, so Delete removed the frame and the image together. The click now switches to the image inside, and Delete leaves the empty frame (Fixes #1124). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/tests.rs | 31 +++++++++++++++++++++++++++++++ crates/tools/src/select.rs | 8 +++++++- 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2aacdaf9b..46f5907bf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -347,6 +347,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-16** · A Direct Selection click on an image whose frame is already selected selects the image, so Delete removes only the image and leaves the empty frame, as in InDesign. No readiness percentage change. - **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change. - **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change. - **2026-10-14** · Pathfinder keeps the frontmost object, as InDesign does: a photo placed over shapes and intersected with them shows through the shapes (it was dropped, and the shapes kept their black fill); Add, Exclude Overlap and Minus Back take the front object's fill too, Subtract still keeps the back one. No readiness percentage change. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 616a86398..cdce8b218 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1110,3 +1110,34 @@ fn a_line_fits_when_its_baseline_does() { assert!(!overset(&mut s, 78.5), "the line is set; its descenders hang below the frame"); assert!(overset(&mut s, 50.0), "a baseline below the frame still oversets the line"); } + +#[test] +fn direct_selection_on_a_selected_frame_deletes_only_its_image() { + use designcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; + let dir = std::env::temp_dir().join(format!("dc-direct-delete-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let png = dir.join("photo.png"); + std::fs::write(&png, designcraft_render::Rendered { width: 100, height: 100, pixels: vec![40u8; 100 * 100 * 4] }.to_png()).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let frame = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + s.execute("file.place", &json!({"path": png.to_string_lossy(), "frame": frame})).unwrap(); + let v = ViewInfo::at_zoom(1.0); + let click = |s: &mut Session| { + s.pointer(&PointerEvent::new(PointerKind::Down, 150.0, 150.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 150.0, 150.0), v).unwrap(); + }; + // The frame is selected with the Selection tool, then the image is clicked with Direct Selection. + s.set_tool("selection"); + click(&mut s); + assert!(!s.doc().unwrap().selection.content); + s.set_tool("directSelection"); + click(&mut s); + assert!(s.doc().unwrap().selection.content, "Direct Selection selects the image inside the selected frame"); + assert!(s.tool_key(ToolKey::Delete, Mods::default(), v).unwrap()); + let st = s.doc().unwrap(); + let it = st.doc.item(designcraft_doc::ItemId(frame)).expect("the frame stays"); + assert!(matches!(it.content, designcraft_doc::Content::Unassigned), "{:?}", it.content); + assert_eq!(st.selection.items, vec![designcraft_doc::ItemId(frame)]); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 10da23f85..b66f0a0bd 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -225,6 +225,11 @@ pub fn handles(r: Rect) -> [Point; 8] { ] } +/// The item is a frame holding a placed graphic. +fn has_graphic(cx: &ToolContext, id: ItemId) -> bool { + cx.doc.item(id).is_some_and(|it| matches!(it.content, designcraft_doc::Content::Graphic(_))) +} + fn handle_at(cx: &ToolContext, p: Point) -> Option { let b = cx.selection_bounds()?; let tol = cx.tol(5.0); @@ -538,7 +543,8 @@ impl Tool for SelectionTool { } else { out.push(Action::Exec("selection.toggle".into(), json!({"id": id.0}))); } - } else if !cx.selection.contains(id) { + } else if !cx.selection.contains(id) || (self.direct && !cx.selection.content && has_graphic(cx, id)) { + // Direct Selection on a selected frame selects the graphic inside it. out.push(Action::Exec("selection.set".into(), json!({"ids": [id.0], "content": self.direct}))); } self.drag = Drag::Pending { start: p, hit: true }; From 37e632881a71b474f201adb9c44fd43368b6d1b2 Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:52:01 +0200 Subject: [PATCH 010/628] UI.ColorDefaults: show document defaults in the Color panel When nothing is selected, display the new-object fill and stroke supplied by #1081. Existing selection targets retain their path. Exercise a real spectrum click, existing/new shape colors and undo in the whole-window regression. Co-Authored-By: GPT-6 Astra --- crates/ui-egui/src/panels/swatches.rs | 47 ++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 3e7030e87..9cd71ac82 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -340,7 +340,15 @@ pub enum ColorMode { /// swatch), the spectrum ramp, Add to Swatches. Edits apply unnamed colours (`object.color`). pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let Some((fill, fill_tint, stroke_sw, stroke_tint)) = super::color_target(app) else { + let target = super::color_target(app).or_else(|| { + let state = app.session.active()?; + if !state.selection.items.is_empty() || state.selection.text.is_some() || state.selection.cells.is_some() { + return None; + } + let colors = &state.doc.settings.new_object; + Some((colors.fill.clone(), colors.fill_tint, colors.stroke.clone(), colors.stroke_tint)) + }); + let Some((fill, fill_tint, stroke_sw, stroke_tint)) = target else { ui.label(crate::rtl::widget( ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Select an object or text to color its fill or stroke.")).color(t.text_dim), @@ -546,6 +554,7 @@ fn cmyk_ramp(k: usize, x: f32) -> egui::Color32 { /// The spectrum ramp: hue across, light to dark down; click to apply. fn spectrum(app: &mut DesignApp, ui: &mut egui::Ui, target: &str) { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 36.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr(&app.ui.language, "Color spectrum"))); let at = |u: f32, v: f32| -> [f32; 3] { let c = egui::ecolor::Hsva::new(u, (v * 2.0).min(1.0), (2.0 - v * 2.0).min(1.0), 1.0).to_rgb(); [c[0], c[1], c[2]] @@ -586,6 +595,42 @@ fn color_json(c: &designcraft_color::Color) -> serde_json::Value { } } +#[cfg(test)] +mod color_panel_defaults_tests { + use super::*; + use egui_kittest::kittest::Queryable; + + #[test] + fn color_panel_with_no_selection_edits_future_shapes_without_recoloring_existing_ones() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let old = + designcraft_doc::ItemId(app.run("frame.create", json!({"rect":[72,72,200,200],"content":"unassigned"})).unwrap()["id"].as_u64().unwrap()); + let original = app.session.active().unwrap().doc.item(old).unwrap().stroke.clone(); + app.run("selection.set", json!({"ids":[]})).unwrap(); + app.ui.stroke_front = true; + app.run("window.panel", json!({"panel":"color"})).unwrap(); + let mut h = crate::test_window::open(app, vec2(1440.0, 900.0)); + assert!(h.query_by_label("Select an object or text to color its fill or stroke.").is_none()); + let ramp = h.get_by_label("Color spectrum").rect(); + crate::test_window::click_at(&mut h, egui::pos2(ramp.left() + ramp.width() / 3.0, ramp.center().y)); + let defaults = h.state().app.session.active().unwrap().doc.settings.new_object.clone(); + assert_ne!(defaults.stroke, original.swatch); + assert_eq!(h.state().app.session.active().unwrap().doc.item(old).unwrap().stroke, original); + h.state_mut().app.run("selection.set", json!({"ids":[old.0]})).unwrap(); + assert_eq!(crate::panels::color_target(&mut h.state_mut().app).unwrap().2, original.swatch); + h.state_mut().app.run("selection.set", json!({"ids":[]})).unwrap(); + let new = designcraft_doc::ItemId( + h.state_mut().app.run("frame.create", json!({"rect":[240,72,368,200],"content":"unassigned"})).unwrap()["id"].as_u64().unwrap(), + ); + assert_eq!(h.state().app.session.active().unwrap().doc.item(new).unwrap().stroke.swatch, defaults.stroke); + h.state_mut().app.run("edit.undo", json!({})).unwrap(); + h.state_mut().app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.state().app.session.active().unwrap().doc.settings.new_object.stroke, original.swatch); + assert_eq!(h.state().app.session.active().unwrap().doc.item(old).unwrap().stroke, original); + } +} + /// Stops in `object.gradient` form. fn stops_json(g: &designcraft_color::Gradient) -> Vec { g.stops From a7e1c0e974c226985b61f96f75eb486443cb635c Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:52:53 +0200 Subject: [PATCH 011/628] Format.NullableOverrides: preserve automatic annotation and list-tab values Use the existing explicit-null deserializer for Ruby and Kenten sizes, tints and stroke weights, plus the automatic list tab. Extend native ZIP and legacy JSON inheritance regressions for local and named-style clears, absent overrides and explicit values. Found when a no-edit course-book roundtrip discarded eight CJK defaults. Co-Authored-By: GPT-6 Astra --- crates/doc/src/attrs.rs | 18 +++++++++--------- crates/format/src/lib.rs | 29 ++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index 077637672..d30489c9e 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -1086,7 +1086,7 @@ attr_set! { kenten_font: String = String::new(), kenten_font_style: String = String::new(), /// Mark size in points; `None` is half the text size. - kenten_size: Option = None, + kenten_size: Option = None => "deserialize_explicit_value", /// Horizontal / vertical scale of the mark, 1.0 = 100%. kenten_x_scale: f64 = 1.0, kenten_y_scale: f64 = 1.0, @@ -1099,10 +1099,10 @@ attr_set! { /// Mark fill and stroke swatches (empty = the text's), tints and stroke weight (`None` = /// the text's). kenten_fill: String = String::new(), - kenten_fill_tint: Option = None, + kenten_fill_tint: Option = None => "deserialize_explicit_value", kenten_stroke: String = String::new(), - kenten_stroke_tint: Option = None, - kenten_stroke_weight: Option = None, + kenten_stroke_tint: Option = None => "deserialize_explicit_value", + kenten_stroke_weight: Option = None => "deserialize_explicit_value", kenten_overprint_fill: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto, kenten_overprint_stroke: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto, // Shatai (斜体): the character is compressed across the direction at `shatai_angle`. @@ -1126,7 +1126,7 @@ attr_set! { ruby_font: String = String::new(), ruby_font_style: String = String::new(), /// Points; `None` is half the parent size. - ruby_font_size: Option = None, + ruby_font_size: Option = None => "deserialize_explicit_value", /// 1.0 = 100%. ruby_x_scale: f64 = 1.0, ruby_y_scale: f64 = 1.0, @@ -1147,10 +1147,10 @@ attr_set! { ruby_scaling_min: f64 = 0.66, /// Ruby colour: empty is the text's colour; `None` tint and weight follow the text. ruby_fill: String = String::new(), - ruby_fill_tint: Option = None, + ruby_fill_tint: Option = None => "deserialize_explicit_value", ruby_stroke: String = String::new(), - ruby_stroke_tint: Option = None, - ruby_stroke_weight: Option = None, + ruby_stroke_tint: Option = None => "deserialize_explicit_value", + ruby_stroke_weight: Option = None => "deserialize_explicit_value", ruby_overprint_fill: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto, ruby_overprint_stroke: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto, /// Digits (World-Ready): how 0–9 are drawn. @@ -1351,7 +1351,7 @@ attr_set! { bullet_align: ListAlign = ListAlign::Left, number_align: ListAlign = ListAlign::Left, /// A tab stop for the text after the bullet or number, from the column's left edge. - list_tab: Option = None, + list_tab: Option = None => "deserialize_explicit_value", /// Restart Numbers at This Level After: a paragraph at a higher level (0 = any; otherwise /// that level) starts this level's numbering again. restart_numbers: bool = true, diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index d5653eb9d..c77896b48 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -794,6 +794,14 @@ mod tests { underline_offset: Some(Some(1.0)), strikethrough_weight: Some(Some(2.0)), strikethrough_offset: Some(Some(1.0)), + kenten_size: Some(Some(14.0)), + kenten_fill_tint: Some(Some(0.3)), + kenten_stroke_tint: Some(Some(0.4)), + kenten_stroke_weight: Some(Some(2.0)), + ruby_font_size: Some(Some(14.0)), + ruby_fill_tint: Some(Some(0.3)), + ruby_stroke_tint: Some(Some(0.4)), + ruby_stroke_weight: Some(Some(2.0)), ..Default::default() }; // Legacy JSON loads as format 1, whose upgrade pins Keep Lines Together and Align Left @@ -803,6 +811,7 @@ mod tests { start_at: Some(Some(7)), paragraph_kashida_width: Some(Some(0.5)), kinsoku: Some(Some(Default::default())), + list_tab: Some(Some(36.0)), keep_all_lines: Some(true), drop_cap_align_left: Some(true), ..Default::default() @@ -817,9 +826,18 @@ mod tests { underline_offset: Some(None), strikethrough_weight: Some(None), strikethrough_offset: Some(None), + kenten_size: Some(None), + kenten_fill_tint: Some(None), + kenten_stroke_tint: Some(None), + kenten_stroke_weight: Some(None), + ruby_font_size: Some(None), + ruby_fill_tint: Some(None), + ruby_stroke_tint: Some(None), + ruby_stroke_weight: Some(None), ..Default::default() }; - let clear_para = ParaAttrs { start_at: Some(None), paragraph_kashida_width: Some(None), kinsoku: Some(None), ..Default::default() }; + let clear_para = + ParaAttrs { start_at: Some(None), paragraph_kashida_width: Some(None), kinsoku: Some(None), list_tab: Some(None), ..Default::default() }; let selected = if style_clear { std::sync::Arc::make_mut(&mut d.styles).paragraph.push(designcraft_doc::styles::ParagraphStyle { name: "Defaults".into(), @@ -860,6 +878,15 @@ mod tests { assert_eq!(expected.0.paragraph_kashida_width, None); assert_eq!(expected.0.kinsoku, None); assert_eq!(expected.1.leading_aki, None); + assert_eq!(expected.0.list_tab, None); + assert_eq!(expected.1.kenten_size, None); + assert_eq!(expected.1.kenten_fill_tint, None); + assert_eq!(expected.1.kenten_stroke_tint, None); + assert_eq!(expected.1.kenten_stroke_weight, None); + assert_eq!(expected.1.ruby_font_size, None); + assert_eq!(expected.1.ruby_fill_tint, None); + assert_eq!(expected.1.ruby_stroke_tint, None); + assert_eq!(expected.1.ruby_stroke_weight, None); // Legacy JSON is format 1: its upgrade pins older defaults on the other styles, so only // the native ZIP compares as the whole document. for (bytes, native) in [(save(d).unwrap(), true), (serde_json::to_vec(d).unwrap(), false)] { From b09233ab58e909012a05208d7c817fd15a2cafdf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 23:57:49 +0000 Subject: [PATCH 012/628] macOS: accept .idml on the app icon when another app owns the type When InDesign is installed it exports its own type for .idml, which overrides the type DesignCraft imports, so the IDML entry in CFBundleDocumentTypes no longer matches: Finder refuses the drop on the app's icon and Open With leaves DesignCraft out (Dock drops still work). Add a separate entry by extension, which matches whatever type the file has. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CgGsJkVe5GYG9qK6VFdECa --- packaging/macos/Info.plist.in | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index ba4503678..c68d3fbd8 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -72,6 +72,21 @@ LSItemContentTypes ai.storyteller.designcraft.idml + + + CFBundleTypeName + IDML Document (by extension) + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + CFBundleTypeExtensions + idml + + CFBundleLocalizations + + en + ar + cs + de + fr + it + ja + ka + pl + pt-BR + ru + uk + CFBundleDisplayName DesignCraft CFBundleName From 6f567fdb6d5623176f75c00c25bfa46c4b5f1fb8 Mon Sep 17 00:00:00 2001 From: Hamza Date: Mon, 26 Oct 2026 17:06:38 +0500 Subject: [PATCH 227/628] PDF export: a name escapes every byte a name cannot hold (#1582) A placed media file's type goes into the embedded file's `/Subtype` as a PDF name, and only `/` was escaped. ISO 32000-1 7.3.5 allows only the regular characters in a name, so a type carrying a space, a delimiter, a comment character or `#` ended the name early and left the rest as loose tokens in the file's structure -- in a PDF the user then distributes. `pdf_name` escapes every irregular byte as `#xx`. The test covers the shapes that leaked: a space, `<<`/`>>`, `(`/`)`, `[`/`]`, `%`, `#` itself and a non-ASCII byte, and checks an ordinary type is unchanged. This is the escaping part of DC-01 only. The larger part of that issue -- the post-passes finding the catalog, the page tree and pages by text search over the output and taking the last match -- needs them to stop rediscovering objects at all, through the xref chain or by carrying the offsets each pass writes. That is a rework of `transitions.rs` rather than a patch, and the issue says a reproduction is available to maintainers privately, which I would want to test against before changing how objects are found. --- crates/pdf/src/forms.rs | 42 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/pdf/src/forms.rs b/crates/pdf/src/forms.rs index 07f4cf04c..e8fd57185 100644 --- a/crates/pdf/src/forms.rs +++ b/crates/pdf/src/forms.rs @@ -56,6 +56,25 @@ pub(crate) fn collect_media(doc: &Document, sheets: &[Sheet]) -> Vec String { + let regular = |b: u8| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.' | b'_' | b'~' | b'*' | b'@' | b'$' | b'&' | b'!' | b'^' | b'|'); + let mut o = String::new(); + for b in s.bytes() { + if regular(b) { + o.push(b as char); + } else { + o.push_str(&format!("#{b:02X}")); + } + } + o +} + /// A PDF text string: literal for ASCII, UTF-16BE otherwise. fn pdf_string(s: &str) -> String { if s.is_ascii() { @@ -117,7 +136,7 @@ pub(crate) fn add_fields(pdf: &[u8], fields: &[Placed], media: &[PlacedMedia]) - let Some(&page_ref) = kids.get(*page) else { continue }; let (file, spec, rendition, screen) = (next, next + 1, next + 2, next + 3); next += 4; - let mut body = format!("<>\nstream\n", mime.replace('/', "#2F"), data.len()).into_bytes(); + let mut body = format!("<>\nstream\n", pdf_name(mime), data.len()).into_bytes(); body.extend_from_slice(data); body.extend_from_slice(b"\nendstream"); put(&mut out, file, &body); @@ -255,3 +274,24 @@ pub(crate) fn add_fields(pdf: &[u8], fields: &[Placed], media: &[PlacedMedia]) - out.extend_from_slice(x.as_bytes()); Some(out) } + +#[cfg(test)] +mod tests { + use super::*; + + /// A media type comes from a placed file's own metadata and was written into a PDF name with + /// only `/` escaped, so a type holding a delimiter ended the name early and left the rest as + /// tokens in the file's structure (#1582). + #[test] + fn a_pdf_name_escapes_every_irregular_byte() { + assert_eq!(pdf_name("video/mp4"), "video#2Fmp4"); + assert_eq!(pdf_name("a b"), "a#20b"); + assert_eq!(pdf_name("x<>"), "x#3C#3C#2FType#2FCatalog#3E#3E"); + assert_eq!(pdf_name("a%comment"), "a#25comment"); + assert_eq!(pdf_name("a#1"), "a#231"); + assert_eq!(pdf_name("a(b)"), "a#28b#29"); + assert_eq!(pdf_name("[1 0 R]"), "#5B1#200#20R#5D"); + assert_eq!(pdf_name("application-x_test.v2"), "application-x_test.v2"); + assert_eq!(pdf_name("\u{e9}"), "#C3#A9"); + } +} From 7ad7f22f4df3298ac4f28bad12f2c745fbdffd74 Mon Sep 17 00:00:00 2001 From: Hamza Date: Mon, 26 Oct 2026 17:12:09 +0500 Subject: [PATCH 228/628] Pin the indd dependency by rev, as every other git dependency is (#1599) `indd` was the one git dependency taken from a branch: indd = { git = "https://github.com/storytold/indd-utils", branch = "main" } `Cargo.lock` pinned a commit, but any `cargo update` -- and `.github/dependabot.yml` runs them -- pulls whatever is on that branch into a parser of untrusted INDD files, with no review of what changed. The craft-libs dependencies beside it are all `rev = "b926b5c0..."`. Pinned to `7f3060e5bce78d21ea70e0d5eacb96edce15dfbe`, which is the commit the lock already held: the only change in `Cargo.lock` is the source string, `?branch=main` to `?rev=7f3060e5...`, so no code moves with this. Verified: `cargo check --locked -p designcraft-engine` passes, and `indd_files_go_through_the_converter_and_its_errors_are_open_errors` still passes, so the converter is the same one. Audit DC-18. --- Cargo.lock | 2 +- Cargo.toml | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eabe9e52e..0a0688da5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3120,7 +3120,7 @@ checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" [[package]] name = "indd" version = "0.1.0" -source = "git+https://github.com/storytold/indd-utils?branch=main#7f3060e5bce78d21ea70e0d5eacb96edce15dfbe" +source = "git+https://github.com/storytold/indd-utils?rev=7f3060e5bce78d21ea70e0d5eacb96edce15dfbe#7f3060e5bce78d21ea70e0d5eacb96edce15dfbe" [[package]] name = "indexmap" diff --git a/Cargo.toml b/Cargo.toml index 195f39e67..2924c8275 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,9 +75,12 @@ flo_curves = "0.8" qrcodegen = "1.8" quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } -# INDD/INDT → IDML conversion for File › Open: storytold/indd-utils, tracking its main branch +# INDD/INDT → IDML conversion for File › Open: storytold/indd-utils, pinned by rev as every other +# git dependency here is. It parses untrusted INDD files, and `branch = "main"` meant any +# `cargo update` -- Dependabot runs them -- pulled whatever was on that branch into the parser +# without review (#1599). # (MIT OR Apache-2.0, no dependencies, builds for wasm). -indd = { git = "https://github.com/storytold/indd-utils", branch = "main" } +indd = { git = "https://github.com/storytold/indd-utils", rev = "7f3060e5bce78d21ea70e0d5eacb96edce15dfbe" } # Which paths stored in documents may be followed (craft-libs). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" } From 13013b09d0d73ff34cffdc8aec80437a05fad625 Mon Sep 17 00:00:00 2001 From: PiMinusMax <121135695+PiMinusMax@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:13:10 +0100 Subject: [PATCH 229/628] Effects: inner shadow, glow and bevel follow a transparent image's edge On a frame holding a graphic, Inner Shadow, Inner Glow (from the edges) and Bevel and Emboss are built from what the object paints instead of the frame's path: the colour everywhere but under the painted pixels moved by the offset, blurred, and kept only where the object paints. A transparent part of an image is outside the object, so the effect lines the image's opaque edge; before, it lined the frame and nothing marked the image's own edge. Frames without a graphic draw as before. Satin and a centred Inner Glow still follow the frame. Fixes #1622. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/render/src/fx.rs | 105 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 101 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8c7e3be5a..6b389c152 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -380,6 +380,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-26** · Inner Shadow, Inner Glow (from the edges) and Bevel and Emboss on a frame holding an image follow what the image paints: a transparent part is outside the object, so the effect lines the image's opaque edge, not the frame (#1622). Satin and a centred Inner Glow still follow the frame. No readiness percentage change. - **2026-10-20** · Edit › Keyboard Shortcuts is craft-keymap's shared dialog: commands grouped by menu with Show and Filter, recording that asks before taking a key from another command, undo and redo, named shortcut sets (edits of Default become Custom; an imported set becomes a set of its own), an on-screen US keyboard, InDesign Show Set files reviewed before they apply, JSON export and an HTML summary. ui.json keeps the `shortcuts` map earlier versions read, for the active set. No readiness percentage change. - **2026-10-19** · Import InDesign Set… runs on craft-keymap's importer: a set's Window menu panel items (Layers F7, Pages F12, Swatches F5) and Tools items set the panel and tool shortcuts, all of an item's Default keys apply, and the report adds numeric keypad keys read as main keys and likely commands for unmatched items (`window.importShortcuts`). On InDesign's Windows defaults it matches 366 of 1,629 items (was 233) and changes 23 commands. No readiness percentage change. - **2026-10-19** · Keyboard shortcuts run on the shared craft-keymap crate. Panels take shortcuts, with InDesign's defaults where the key is free (Swatches F5, Color F6, Layers F7, Info F8, Stroke F10, Paragraph Styles F11, Character Styles Shift+F11, Pages F12, Align Shift+F7); tool keys are editable in Edit › Keyboard Shortcuts and `window.setShortcut`, and tools sharing a key take turns (B). Saved shortcuts load unchanged. No readiness percentage change. diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 6147c9cf5..06eb4f4dd 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -211,6 +211,9 @@ impl Renderer { if !it.path.is_closed() { return; } + // An image's edge is where it is opaque, not the frame: inner shadows, glows and bevels + // follow what is painted when the frame holds a graphic. + let painted = matches!(it.content, designcraft_doc::Content::Graphic(_)).then_some((it, page)); // Inner shadow, clipped to the shape. if e.inner_shadow.on && let Some(c) = doc.resolve_color(&e.inner_shadow.color, 1.0) @@ -218,7 +221,7 @@ impl Renderer { let s = &e.inner_shadow; let (sigma, choke) = split(s.size, s.choke); let off = offset(doc.light_angle(s.angle, s.global_light), s.distance); - self.inner_edge(ctx, f, reach, bp, xf, off, sigma, choke, color_of(&c, s.opacity.clamp(0.0, 1.0)), Mix::Normal); + self.inner_edge(ctx, f, reach, bp, xf, off, sigma, choke, color_of(&c, s.opacity.clamp(0.0, 1.0)), Mix::Normal, painted); } // Inner glow: from the edges inwards (or out from the centre), screened over the object. if e.inner_glow.on @@ -248,7 +251,7 @@ impl Renderer { c.pop_layer(); }); } else { - self.inner_edge(ctx, f, reach, bp, xf, Vec2::ZERO, sigma, choke, paint, mix); + self.inner_edge(ctx, f, reach, bp, xf, Vec2::ZERO, sigma, choke, paint, mix, painted); } } // Satin: the shape shifted both ways along the angle, blurred, where exactly one covers. @@ -289,16 +292,18 @@ impl Renderer { let sigma = b.size.max(0.0) / 2.0; if let Some(c) = doc.resolve_color(&b.highlight, 1.0) { // The inverse shape moved away from the light lights the edges facing it. - self.inner_edge(ctx, f, reach, bp, xf, off, sigma, 0.0, color_of(&c, b.highlight_opacity.clamp(0.0, 1.0)), Mix::Screen); + self.inner_edge(ctx, f, reach, bp, xf, off, sigma, 0.0, color_of(&c, b.highlight_opacity.clamp(0.0, 1.0)), Mix::Screen, painted); } if let Some(c) = doc.resolve_color(&b.shadow, 1.0) { - self.inner_edge(ctx, f, reach, bp, xf, -off, sigma, 0.0, color_of(&c, b.shadow_opacity.clamp(0.0, 1.0)), Mix::Multiply); + self.inner_edge(ctx, f, reach, bp, xf, -off, sigma, 0.0, color_of(&c, b.shadow_opacity.clamp(0.0, 1.0)), Mix::Multiply, painted); } } } /// An inner shadow-like edge: everything outside the shape moved by `off`, blurred by - /// `sigma`, clipped to the shape and blended with `mix`. + /// `sigma`, clipped to the shape and blended with `mix`. With `painted` (an object and the + /// page it shows on), the shape is what the object paints, so a transparent part of an image + /// is outside it: the edge runs along the image's opaque pixels, not the frame. #[allow(clippy::too_many_arguments)] fn inner_edge( &mut self, @@ -312,7 +317,33 @@ impl Renderer { choke: f64, paint: peniko::Color, mix: Mix, + painted: Option<(&Item, ShownOn)>, ) { + if let Some((it, page)) = painted { + self.with_blended_filters(ctx, f, reach, sigma, mix, (bp, xf), |me, c, fr| { + let pad = sigma * 4.0 + choke + off.hypot() + 4.0 * fr.px; + let area = reach.inflate(pad, pad); + // The colour everywhere but under the painted pixels moved by `off`, blurred, ... + c.set_transform(fr.view); + c.push_layer(None, None, None, None, None); + c.push_layer(None, None, None, None, Some(blur(sigma))); + c.set_paint(paint); + c.fill_rect(&area); + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestOut)), None, None, None); + let moved = Frame { view: fr.view * Affine::translate(off), ..*fr }; + me.draw_body(c, &moved, it, bp, xf, page); + c.pop_layer(); + c.pop_layer(); + // ... kept only where the object paints. + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None); + me.draw_body(c, fr, it, bp, xf, page); + c.pop_layer(); + c.pop_layer(); + }); + return; + } self.with_blended_filters(ctx, f, reach, sigma, mix, (bp, xf), |_, c, fr| { c.set_transform(fr.view); c.push_layer(None, None, None, None, Some(blur(sigma))); @@ -458,4 +489,68 @@ mod tests { let wide = Offscreen { x0: -14852.0, y0: -14852.0, scale: 15_002.0 / 1024.0, width: 2062, height: 2062 }; assert_eq!(offscreen(object, 15_002.0, 1440, 900), Some(wide)); } + + /// An inner shadow on a frame holding an image whose left half is transparent: the shadow + /// lines the image's opaque part (its edge in the middle of the frame), not only the frame. + #[test] + fn an_inner_shadow_follows_a_transparent_images_edge() { + use designcraft_doc::build::NewDocument; + use designcraft_doc::{Document, SpreadRef}; + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + // 20 × 20: transparent left half, opaque white right half. + let img = image::RgbaImage::from_fn(20, 20, |x, _| if x < 10 { image::Rgba([0, 0, 0, 0]) } else { image::Rgba([255, 255, 255, 255]) }); + let mut png = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let aid = designcraft_doc::AssetId(d.alloc()); + d.assets.insert( + aid, + std::sync::Arc::new(designcraft_doc::Asset { + id: aid, + name: "half.png".into(), + mime: "image/png".into(), + data: std::sync::Arc::new(png), + pixels: Some((20, 20)), + ..Default::default() + }), + ); + let mut it = Item::new( + designcraft_doc::ItemId(d.alloc()), + lid, + designcraft_doc::Shape::Rectangle, + designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 300.0, 300.0)), + ); + it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, + asset: aid, + size: (200.0, 200.0), + xf: Affine::translate((100.0, 100.0)), + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + let s = &mut it.effects.inner_shadow; + s.on = true; + s.color = "[Black]".into(); + s.opacity = 1.0; + s.distance = 0.0; + s.size = 20.0; + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let cache = crate::Cache::new(); + let mut r = Renderer::new(); + r.threads = 0; + let page = r.render_page(&d, &cache, 0, 1.0, false, &crate::RenderOptions::default()).unwrap(); + let lum = |x: u32| { + let p = page.pixel(x, 200); + u32::from(p[0]) + u32::from(p[1]) + u32::from(p[2]) + }; + // The image's opaque part starts at x = 200: just inside it the shadow darkens it; in its + // middle (x = 250, 50 pt from every edge but the frame's right one) it stays light. + assert!(lum(204) + 60 < lum(250), "shadow at the image's own edge: {} vs {}", lum(204), lum(250)); + // The transparent half shows the page: no shadow painted there. + assert!(lum(150) > 700, "nothing over the transparent part: {}", lum(150)); + } } From 5b2252a789369daf65b6f14fd1cf33cfb446f872 Mon Sep 17 00:00:00 2001 From: PiMinusMax <121135695+PiMinusMax@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:11:14 +0200 Subject: [PATCH 230/628] Rulers: a guide being dragged out keeps its resize cursor until dropped The canvas set the tool's cursor every frame the pointer was over it, after the guide drag had set its resize cursor, so the cursor turned back into the tool's as soon as the pointer left the ruler and the drag looked as if it had stopped. A ruler guide drag now keeps the cursor across the guide, as a column guide drag already did. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 7 ++++++ crates/ui-egui/src/guide_selection_tests.rs | 28 +++++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 8c7e3be5a..9e91acdfe 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -408,6 +408,7 @@ Newest first. - **2026-10-24** · Optical Margin Alignment per story with a size (`story.options {opticalMargin, opticalMarginSize}`, off by default): when it is on, the story's paragraphs hang punctuation and letter edges by amounts set for that type size, except paragraphs with Ignore Optical Margin (`ignoreOpticalMargin` in `type.para` and paragraph styles). Table cells and footnotes don't take the story setting yet. When it is off, the per-paragraph switch still hangs at the text's own size. IDML imports and exports both (`StoryPreference/@OpticalMarginAlignment`, `@OpticalMarginSize`, `IgnoreEdgeAlignment`) (#947). No readiness percentage change. - **2026-10-24** · Text frames: a custom baseline grid's Relative To (Text Frame Options › Baseline Options, `object.textFrameOptions {baselineGrid: {relativeTo}}`, IDML `BaselineFrameGridRelativeOption`) measures the grid's start from the top of the page, the top margin, the top of the frame or its top inset. Text Frame Options had no custom baseline grid controls, imports dropped the setting and every frame grid started from the page top, so grid-aligned text in a frame measured from its own top sat on the wrong lines. No readiness percentage change. - **2026-10-24** · Paragraph Style Options › Indents and Spacing has an Align to Grid menu: None, All Lines or First Line Only. It shows the style's current setting. A First Line Only style could be set only in the CJK Grid Settings section, which shows only with CJK features on, or through commands. No readiness percentage change. +- **2026-10-24** · A guide pulled out of a ruler keeps the resize cursor across it until it is dropped; the tool's cursor took over as soon as the pointer left the ruler, so the drag looked as if it had stopped. No readiness percentage change. - **2026-10-24** · Anchored text frames, such as marginal notes, compose where their host story places them: they align to the baseline grid at that position, and page-number markers and spine-relative alignment inside them read the host's page. They composed against a grid measured from their stored position and on no page, so a note set to Only Align First Line to Grid could sit a fraction of a line off the body text's grid line. No readiness percentage change. - **2026-10-24** · Tables: a gradient swatch in a cell fill draws as a gradient on the canvas and in PDF, in one gradient space for the whole table, so it runs across cells without restarting. Cells showed the gradient's first stop as a solid colour. No readiness percentage change. - **2026-10-24** · New documents start with Hyphenate Capitalized Words, Hyphenate Last Word and Hyphenate Across Column off in [Basic Paragraph], as in InDesign. Opened and imported files keep the values they carry. No readiness percentage change. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 64bed51d8..17f649959 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -743,6 +743,13 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { Some(_) => Cursor::ResizeV, None => c, }; + // A ruler guide being dragged keeps the cursor across it until it is dropped, over the + // canvas too (the tool's cursor took over as soon as the pointer left the ruler). + let c = match ui.data(|d| d.get_temp::(egui::Id::new(GUIDE_DRAG))).map(|g| g.orientation) { + Some(designcraft_doc::Orientation::Horizontal) => Cursor::ResizeV, + Some(designcraft_doc::Orientation::Vertical) => Cursor::ResizeH, + None => c, + }; let icon = crate::cursors::show(ui.ctx(), &mut app.cursors, c, p); ui.ctx().set_cursor_icon(icon); draw_cursor_badge(&painter, p, c, &t); diff --git a/crates/ui-egui/src/guide_selection_tests.rs b/crates/ui-egui/src/guide_selection_tests.rs index 626a7b826..8a1cde599 100644 --- a/crates/ui-egui/src/guide_selection_tests.rs +++ b/crates/ui-egui/src/guide_selection_tests.rs @@ -328,3 +328,31 @@ fn a_guide_dragged_out_of_a_ruler_snaps() { drag(&mut h, ruler, dest); assert_eq!(positions(&h), [200.0, 300.0, 400.0, 400.0]); } + +/// A guide pulled out of a ruler keeps the resize cursor across it over the canvas until it is +/// dropped; the tool's arrow came back as soon as the pointer left the ruler. +#[test] +fn a_guide_dragged_out_of_a_ruler_keeps_its_cursor_until_dropped() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let mut h = harness(app); + let rect = h.state().canvas_rect.unwrap(); + for (from, icon) in [ + (pos2(rect.center().x, rect.min.y - crate::canvas::RULER / 2.0), egui::CursorIcon::ResizeVertical), + (pos2(rect.min.x - crate::canvas::RULER / 2.0, rect.center().y), egui::CursorIcon::ResizeHorizontal), + ] { + let to = rect.center(); + h.hover_at(from); + h.step(); + press(&mut h, from, true, Modifiers::NONE); + for k in 1..=4 { + h.hover_at(from + (to - from) * (k as f32 / 4.0)); + h.step(); + assert_eq!(h.output().platform_output.cursor_icon, icon, "step {k} of the drag from {from:?}"); + } + press(&mut h, to, false, Modifiers::NONE); + h.hover_at(to); + h.run_steps(2); + assert_ne!(h.output().platform_output.cursor_icon, icon, "dropped: the tool's cursor again"); + } +} From 30b64114c2ce5f75a4ed18567644f2a3217e3dcc Mon Sep 17 00:00:00 2001 From: PiMinusMax <121135695+PiMinusMax@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:39:07 +0100 Subject: [PATCH 231/628] Adapt to the current egui (Id::unique) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 17f649959..19bcb87e3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -745,7 +745,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { }; // A ruler guide being dragged keeps the cursor across it until it is dropped, over the // canvas too (the tool's cursor took over as soon as the pointer left the ruler). - let c = match ui.data(|d| d.get_temp::(egui::Id::new(GUIDE_DRAG))).map(|g| g.orientation) { + let c = match ui.data(|d| d.get_temp::(egui::Id::unique(GUIDE_DRAG))).map(|g| g.orientation) { Some(designcraft_doc::Orientation::Horizontal) => Cursor::ResizeV, Some(designcraft_doc::Orientation::Vertical) => Cursor::ResizeH, None => c, From 4f03e105943c4e061a1d41830b77d09e5b266713 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:37:07 +0000 Subject: [PATCH 232/628] Reject invalid XML tag names in xml.mapStyle Found while testing crates/engine/src/cmd/xml.rs:~290 (the xml.mapStyle command; the export is in export_xml) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/xml.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/engine/src/cmd/xml.rs b/crates/engine/src/cmd/xml.rs index 8cd50a2cd..8905c2368 100644 --- a/crates/engine/src/cmd/xml.rs +++ b/crates/engine/src/cmd/xml.rs @@ -268,6 +268,11 @@ pub fn specs() -> Vec { |s, p| { let style = str_param(p, "style").unwrap_or("").to_string(); let tag = str_param(p, "tag").map(str::to_string); + if let Some(t) = &tag + && !valid_name(t) + { + return Err(bad("xml.mapStyle", format!("`{t}` isn't a valid XML name"))); + } if s.doc()?.doc.styles.para(&style).is_none() { return Err(bad("xml.mapStyle", format!("no paragraph style `{style}`"))); } @@ -475,6 +480,19 @@ mod tests { assert!(s.execute("xml.validate", &json!({})).is_err()); } + #[test] + fn map_style_rejects_invalid_tag_name() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Head"})).unwrap(); + assert!(s.execute("xml.mapStyle", &json!({"style": "Head", "tag": "a b> Date: Tue, 27 Oct 2026 08:29:34 +0000 Subject: [PATCH 233/628] Fix RTF import swallowing characters after a short \'hh escape Found while testing crates/textimport/src/rtf.rs:~118-131 (the \' branch of import: `i += 4` and `u8::from_str_radix(&hex, 16)`) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/textimport/src/rtf.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/textimport/src/rtf.rs b/crates/textimport/src/rtf.rs index 6254ca668..f7b5a2167 100644 --- a/crates/textimport/src/rtf.rs +++ b/crates/textimport/src/rtf.rs @@ -120,13 +120,15 @@ pub fn import(bytes: &[u8]) -> Result { let next = chars.get(i + 1).copied().unwrap_or(' '); if next == '\'' { // \'hh: a Windows-1252 byte. - let hex: String = chars.iter().skip(i + 2).take(2).collect(); - i += 4; + // Only valid hex digits are consumed; anything else is left for the main loop. + let hex: String = chars.iter().skip(i + 2).take(2).take_while(|c| c.is_ascii_hexdigit()).collect(); + i += 2 + hex.len(); if pending > 0 { pending -= 1; continue; } - if let Ok(b) = u8::from_str_radix(&hex, 16) + if hex.len() == 2 + && let Ok(b) = u8::from_str_radix(&hex, 16) && !stack.last().is_some_and(|s| s.skip) { units.reset(); @@ -302,6 +304,15 @@ mod tests { assert_eq!(import(br"{\rtf1 a\u-4294967231?b}").unwrap().story.text, "ab"); } + /// A short or invalid `\'hh` escape consumes only its valid hex digits. + #[test] + fn short_hex_escape_keeps_closing_brace() { + let i = import(br"{\rtf1 {\b A\'4}B}").unwrap(); + assert_eq!(i.story.text, "AB"); + assert_eq!(i.story.format_after(1).over.font_style, None); + assert_eq!(import(br"{\rtf1 a\'+fb}").unwrap().story.text, "a+fb"); + } + #[test] fn imports_rtf_text_and_formatting() { let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Times;}}{\colortbl;\red0\green0\blue0;}{\*\generator Test;} From 7fcebedf9852d90775240b0e6e7fca7e68137a10 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:07:12 +0000 Subject: [PATCH 234/628] Drop XML-forbidden control characters in file.exportXml Found while testing crates/engine/src/cmd/xml.rs:40 (esc, used by para_xml at :68 and the href at :104) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/xml.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/xml.rs b/crates/engine/src/cmd/xml.rs index 8cd50a2cd..73144d1f6 100644 --- a/crates/engine/src/cmd/xml.rs +++ b/crates/engine/src/cmd/xml.rs @@ -37,8 +37,21 @@ fn tagged(d: &Document) -> Vec { keyed.into_iter().map(|(_, i)| i).collect() } +/// Escapes markup characters and drops characters XML 1.0 cannot hold. fn esc(s: &str) -> String { - s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\t' | '\n' | '\r' => out.push(c), + c if (c as u32) < 0x20 || c == '\u{FFFE}' || c == '\u{FFFF}' => {} + c => out.push(c), + } + } + out } /// Plain text of a story (markers dropped, forced breaks as newlines). @@ -475,6 +488,18 @@ mod tests { assert!(s.execute("xml.validate", &json!({})).is_err()); } + #[test] + fn export_xml_drops_forbidden_control_chars() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s + .execute("frame.create", &json!({"rect": [72, 72, 400, 150], "content": "text", "text": "A\u{1}B\u{8}C\u{FFFE}\u{FFFF}", "caret": false})) + .unwrap(); + s.execute("xml.tag", &json!({"tag": "story", "ids": [a["id"]]})).unwrap(); + let x = s.execute("file.exportXml", &json!({})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(x.contains("ABC"), "{x:?}"); + } + #[test] fn tag_export_and_import_xml() { let mut s = Session::new(); From 93ab34f51531feebc46fbe384735b960f3e65ec0 Mon Sep 17 00:00:00 2001 From: Hamza Date: Tue, 27 Oct 2026 14:16:27 +0500 Subject: [PATCH 235/628] Data merge: the UNC refusal is tested on Windows too (#1601) `image_path_tests` was `#[cfg(all(test, unix))]`, so the assertion that matters most ran only where it matters least: a merge image field naming another computer must resolve to nothing, because on Windows an outbound SMB connection hands over NTLM credentials. That refusal was unverified on Windows. The refusals now run everywhere -- nothing in them is unix-specific, since `image_candidates` delegates to `craft-docpath` -- and the Windows shapes that file refuses are asserted where they have meaning: `\\?\UNC\...`, `\\.\pipe\...`, `\\?\C:\...` and the legacy device names. Resolution beside the data and the document stays unix-only: it asserts what an absolute path means, which cannot be checked on Windows from here. No production code changes. On macOS both cross-platform tests pass; Windows CI is the first real run of the Windows ones, which is the point. Audit DC-20 (its testable part; the privacy note about absolute local paths matches InDesign and is a product decision, not a bug). --- crates/engine/src/cmd/datamerge/fill.rs | 32 +++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/datamerge/fill.rs b/crates/engine/src/cmd/datamerge/fill.rs index 629258d6d..f522065ba 100644 --- a/crates/engine/src/cmd/datamerge/fill.rs +++ b/crates/engine/src/cmd/datamerge/fill.rs @@ -523,20 +523,44 @@ mod link_tests { } } -#[cfg(all(test, unix))] +#[cfg(test)] mod image_path_tests { use std::path::Path; use super::image_candidates; + /// A merge image field names a file, and a field that names **another computer** must resolve + /// to nothing: a UNC path is how a hostile data file turns a merge into an outbound SMB + /// connection, and on Windows that means handing over NTLM credentials. This ran only on + /// unix, so the refusal was unverified on the one platform where UNC means anything (#1601). #[test] - fn image_cells_resolve_beside_the_data_and_never_to_other_computers() { + fn an_image_cell_never_resolves_to_another_computer() { let (data, doc) = (Path::new("/work/data"), Path::new("/work/doc")); for cell in [r"\\192.0.2.1\share\x.png", "file://192.0.2.1/s/x.png", "//192.0.2.1/s/x.png", "https://example.com/x.png"] { assert!(image_candidates(cell, Some(data), Some(doc)).is_empty(), "{cell}"); } - let found = image_candidates("img/x.png", Some(data), Some(doc)); - assert_eq!(found, [data.join("img").join("x.png"), doc.join("img").join("x.png")]); + // A field naming nothing resolves to nothing, whatever the platform. + assert!(image_candidates("", Some(data), Some(doc)).is_empty(), "an empty cell"); assert!(image_candidates("img/x.png", None, None).is_empty(), "not beside the working directory"); } + + /// The Windows shapes `craft-docpath` refuses, asserted where they have meaning: the extended + /// UNC prefix, a device namespace path, and a legacy device name. + #[cfg(windows)] + #[test] + fn windows_device_and_extended_paths_are_refused() { + let (data, doc) = (Path::new(r"C:\work\data"), Path::new(r"C:\work\doc")); + for cell in [r"\\?\UNC\192.0.2.1\share\x.png", r"\\.\pipe\x", r"\\?\C:\secret\x.png", "CON", "NUL.png"] { + assert!(image_candidates(cell, Some(data), Some(doc)).is_empty(), "{cell}"); + } + } + + /// Resolution beside the data and the document, with absolute paths this platform means. + #[cfg(unix)] + #[test] + fn an_image_cell_resolves_beside_the_data_then_the_document() { + let (data, doc) = (Path::new("/work/data"), Path::new("/work/doc")); + let found = image_candidates("img/x.png", Some(data), Some(doc)); + assert_eq!(found, [data.join("img").join("x.png"), doc.join("img").join("x.png")]); + } } From 7c25cd97c4acca6949535e03029e7fa406534071 Mon Sep 17 00:00:00 2001 From: Hamza Date: Tue, 27 Oct 2026 15:25:27 +0500 Subject: [PATCH 236/628] Data merge: an image named by many records is read once (#1595) `place_image` stat'd and read its file for every record, so a sheet naming one image in many rows read it once per row -- up to 256 MiB each time. The audit's case is a 100,000-row CSV naming one large file: about 25 TB of I/O for one merge. A merge now carries an `ImageCache`: the bytes by resolved path, read once and shared. The asset takes the cached `Arc` rather than a copy, so the records share one buffer as well. The cache stops growing past 256 MB of distinct files, so a merge over many large images is slow rather than out of memory. Measured by counting what the engine hands the file system (`io::touched`): without the cache 50 reads for 25 records (a stat and a read each) with it 2 A note on the test, because the first one I wrote was useless: asserting on the bytes the document holds passes either way, since `add_asset` already dedupes identical content. Memory never showed this; the I/O is the bug, so the test counts reads. Audit DC-14. Its second half -- capping the pages a merge produces -- is not here: how many pages a merge may make is a product decision, and the records are the user's own data rather than something arriving from outside. --- crates/engine/src/cmd/datamerge.rs | 8 ++- crates/engine/src/cmd/datamerge/fill.rs | 72 +++++++++++++++++++++--- crates/engine/src/cmd/datamerge/tests.rs | 63 +++++++++++++++++++++ 3 files changed, 131 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/datamerge.rs b/crates/engine/src/cmd/datamerge.rs index e96feb9fb..4d3cd5dd6 100644 --- a/crates/engine/src/cmd/datamerge.rs +++ b/crates/engine/src/cmd/datamerge.rs @@ -18,7 +18,7 @@ use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, id_param, ok, str_param}; use crate::{DocState, Result, Session}; -use fill::{FillReport, Maps, MissingImage, Sources, copy_item, fill_row, identity_map}; +use fill::{FillReport, ImageCache, Maps, MissingImage, Sources, copy_item, fill_row, identity_map}; use parse::{ Table, TileInput, extension, fingerprint_of, relative_between, select_records, status_of, table_from_bytes, table_from_grid, table_from_objects, tile, @@ -210,7 +210,8 @@ fn preview_cmd(s: &mut Session, p: &Value) -> Result { let row = source.rows.get((record as usize).saturating_sub(1)).cloned().unwrap_or_default(); let data_dir = source_file(&source, st.path.as_deref().map(Path::new)).as_deref().and_then(Path::parent).map(Path::to_path_buf); let doc_dir = st.path.as_deref().and_then(parent_dir); - let sources = Sources { options: &options, data_dir: data_dir.as_deref(), doc_dir: doc_dir.as_deref() }; + let images = ImageCache::default(); + let sources = Sources { options: &options, data_dir: data_dir.as_deref(), doc_dir: doc_dir.as_deref(), images: &images }; fill_row(&mut doc, &source.fields, &row, record as u32, &maps, &sources); st.doc = Arc::new(doc); st.preview_record = Some(record as u32); @@ -246,7 +247,8 @@ fn merge_cmd(s: &mut Session, p: &Value) -> Result { warnings.extend(table.warnings.iter().cloned()); let mut merged = src.clone(); let mut missing = Vec::new(); - let sources = Sources { options: &options, data_dir: data_dir.as_deref(), doc_dir: doc_dir.as_deref() }; + let images = ImageCache::default(); + let sources = Sources { options: &options, data_dir: data_dir.as_deref(), doc_dir: doc_dir.as_deref(), images: &images }; let mut sink = Sink { warnings: &mut warnings, missing: &mut missing, parent: false }; if options.per_page == "multiple" { tile_records(&mut merged, &src, &table, &selected, &sources, &mut sink)?; diff --git a/crates/engine/src/cmd/datamerge/fill.rs b/crates/engine/src/cmd/datamerge/fill.rs index 629258d6d..965e87bd7 100644 --- a/crates/engine/src/cmd/datamerge/fill.rs +++ b/crates/engine/src/cmd/datamerge/fill.rs @@ -4,7 +4,7 @@ //! unfilled template. A placeholder whose frame or story is not in the map is left as it is. use std::collections::HashMap; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; use designcraft_doc::{ @@ -134,6 +134,53 @@ pub struct Sources<'a> { pub options: &'a MergeOptions, pub data_dir: Option<&'a Path>, pub doc_dir: Option<&'a Path>, + /// Images this merge has already read, by the path they resolved to. + /// + /// Every record used to stat and read its image again, so a 100,000-row sheet naming the same + /// file read it 100,000 times -- up to 256 MiB each, around 25 TB of I/O for one merge + /// (#1595). Shared by `&`, so the cache is behind a `RefCell`. + pub images: &'a ImageCache, +} + +/// Image files read during one merge: the bytes by path, and the total read so far. +#[derive(Default)] +pub struct ImageCache { + inner: std::cell::RefCell, +} + +#[derive(Default)] +struct ImageCacheInner { + files: std::collections::HashMap>>, + bytes: usize, +} + +/// Bytes of distinct image files one merge keeps. Past this the cache stops growing and files are +/// read as before, so a merge over many large images is slow rather than out of memory. +const MAX_CACHED_IMAGE_BYTES: usize = 256 << 20; + +impl ImageCache { + /// The bytes of `path`, reading it once per merge. `read` is called only on a miss. + fn get_or_read(&self, path: &Path, read: impl FnOnce() -> Option>) -> Option>> { + if let Some(found) = self.inner.borrow().files.get(path) { + return Some(found.clone()); + } + let bytes = std::sync::Arc::new(read()?); + let mut inner = self.inner.borrow_mut(); + if inner.bytes.saturating_add(bytes.len()) <= MAX_CACHED_IMAGE_BYTES { + inner.bytes = inner.bytes.saturating_add(bytes.len()); + inner.files.insert(path.to_path_buf(), bytes.clone()); + } + Some(bytes) + } + + /// How many distinct files this merge has read, for the tests. + pub fn len(&self) -> usize { + self.inner.borrow().files.len() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } } /// Replacements and hyperlinks collected while walking the placeholders of one record. @@ -360,15 +407,20 @@ fn create_link(doc: &mut designcraft_doc::Document, anchor: PlaceholderAnchor, d } fn place_image(doc: &mut designcraft_doc::Document, item: ItemId, field: &str, cell: &str, record: u32, sources: &Sources, report: &mut FillReport) { - let Sources { options, data_dir, doc_dir } = *sources; + let Sources { options, data_dir, doc_dir, images } = *sources; let cands = image_candidates(cell, data_dir, doc_dir); let found = cands.iter().find_map(|p| { - // Refuse huge files before reading them; a too-large image counts as missing. - let len = crate::io::metadata(p).ok()?.len(); - if len == 0 || len > super::MAX_IMAGE_FILE_BYTES { - return None; - } - crate::io::read_file(p).ok().filter(|b| !b.is_empty()).map(|b| (p.clone(), b)) + // Read once per merge: many records naming one file is the normal case, and reading it + // per record is the amplification (#1595). + let bytes = images.get_or_read(p, || { + // Refuse huge files before reading them; a too-large image counts as missing. + let len = crate::io::metadata(p).ok()?.len(); + if len == 0 || len > super::MAX_IMAGE_FILE_BYTES { + return None; + } + crate::io::read_file(p).ok().filter(|b| !b.is_empty()) + })?; + Some((p.clone(), bytes)) }); let Some((path, bytes)) = found else { if let Some(it) = doc.item_mut(item) { @@ -430,7 +482,9 @@ fn place_image(doc: &mut designcraft_doc::Document, item: ItemId, field: &str, c name, mime: designcraft_render::image_mime(&bytes).to_string(), link, - data: Arc::new(bytes), + // The cached buffer is shared with every other record naming this file, rather than + // copied per record (#1595). + data: bytes, pixels: Some((pw, ph)), ..Default::default() }); diff --git a/crates/engine/src/cmd/datamerge/tests.rs b/crates/engine/src/cmd/datamerge/tests.rs index 5cee93730..9e7c284a5 100644 --- a/crates/engine/src/cmd/datamerge/tests.rs +++ b/crates/engine/src/cmd/datamerge/tests.rs @@ -519,6 +519,69 @@ fn typed_fields_keep_style_and_unknown_markers() { assert_eq!(story.format_after(at).over.size, Some(21.0)); } +/// Every record stat'd and read its image again, so a sheet naming one file in many rows read it +/// once per row -- up to 256 MiB each time (#1595). A merge reads a file once and the records +/// share the bytes. +#[test] +fn one_image_named_by_many_records_is_read_once() { + let dir = scratch("image-reads"); + let data = dir.join("data"); + std::fs::create_dir_all(&data).unwrap(); + let shared = dir.join("shared.png"); + write_png(&shared); + // 25 records, all naming the same file. + let rows: String = (0..25).map(|_| format!("{}\n", shared.display())).collect(); + write(&data.join("many.csv"), &format!("@Photo\n{rows}")); + + let mut s = Session::new(); + new_doc(&mut s, 1); + exec(&mut s, "data.source.select", json!({"path": data.join("many.csv")})); + let frame = exec(&mut s, "frame.create", json!({"rect": [36.0, 36.0, 80.0, 80.0], "content": "graphic"})); + exec(&mut s, "data.placeholder.add", json!({"field": "Photo", "item": frame["id"]})); + let r = exec(&mut s, "data.merge", json!({})); + assert_eq!(r["records"], 25); + assert_eq!(r["missingImages"].as_array().map(Vec::len).unwrap_or(0), 0, "every record found the image"); + + // Each record has its graphic, and they share one buffer: the document holds the file's bytes + // once however many records named it. + let doc = &s.documents()[s.active_index().unwrap()].doc; + let graphics: Vec<_> = doc + .spreads + .iter() + .flat_map(|sp| &sp.items) + .filter_map(|it| match &it.content { + Content::Graphic(g) => Some(g.asset), + _ => None, + }) + .collect(); + assert_eq!(graphics.len(), 25, "one graphic per record"); +} + +/// The same merge, counting what the engine actually hands the file system: `io::touched` records +/// every path read, so the reads are counted rather than inferred. The document already shares +/// identical asset bytes, so memory alone does not show this -- the I/O is the bug (#1595). +#[test] +fn a_merge_reads_a_repeated_image_once_not_once_per_record() { + let dir = scratch("image-read-count"); + let data = dir.join("data"); + std::fs::create_dir_all(&data).unwrap(); + let shared = dir.join("shared.png"); + write_png(&shared); + let rows: String = (0..25).map(|_| format!("{}\n", shared.display())).collect(); + write(&data.join("many.csv"), &format!("@Photo\n{rows}")); + + let mut s = Session::new(); + new_doc(&mut s, 1); + exec(&mut s, "data.source.select", json!({"path": data.join("many.csv")})); + let frame = exec(&mut s, "frame.create", json!({"rect": [36.0, 36.0, 80.0, 80.0], "content": "graphic"})); + exec(&mut s, "data.placeholder.add", json!({"field": "Photo", "item": frame["id"]})); + + let (r, seen) = crate::io::touched(|| exec(&mut s, "data.merge", json!({}))); + assert_eq!(r["records"], 25); + let reads = seen.iter().filter(|p| p.ends_with("shared.png")).count(); + assert!(reads <= 2, "the image is read once for the merge, not once per record: {reads} reads for 25 records"); +} + #[test] fn images_resolve_in_order_and_roles_win() { let dir = scratch("images"); From e6a14a516a2f847922e6a2e25a68c43646256bc0 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:02:53 +0000 Subject: [PATCH 237/628] Keep underline, strikethrough, caps and tracking in EPUB/HTML character styles and overrides Found while testing crates/epub/src/css.rs:~128-153 (the character-style loop in stylesheet_with) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/epub/src/css.rs | 31 +++++++++++++++++++++++++------ crates/epub/src/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/crates/epub/src/css.rs b/crates/epub/src/css.rs index 526b10a40..3a8639cec 100644 --- a/crates/epub/src/css.rs +++ b/crates/epub/src/css.rs @@ -150,6 +150,7 @@ pub(crate) fn stylesheet_with(doc: &Document, generics: &BTreeMap out.push_str("text-transform: uppercase; "), + Some(designcraft_doc::Capitalization::SmallCaps | designcraft_doc::Capitalization::OpenTypeAllSmallCaps) => { + out.push_str("font-variant: small-caps; ") + } + _ => {} + } + // One declaration: a later `text-decoration` would replace the earlier one. + match (c.underline == Some(true), c.strikethrough == Some(true)) { + (true, true) => out.push_str("text-decoration: underline line-through; "), + (true, false) => out.push_str("text-decoration: underline; "), + (false, true) => out.push_str("text-decoration: line-through; "), + (false, false) => {} + } +} + pub(crate) fn override_css(doc: &Document, o: &CharAttrs) -> String { let mut s = String::new(); if let Some(f) = &o.font_style { @@ -174,12 +198,7 @@ pub(crate) fn override_css(doc: &Document, o: &CharAttrs) -> String { { let _ = write!(s, "color: {h}; "); } - if o.underline == Some(true) { - s.push_str("text-decoration: underline; "); - } - if o.capitalization == Some(designcraft_doc::Capitalization::AllCaps) { - s.push_str("text-transform: uppercase; "); - } + attrs_decor_css(o, &mut s); s.trim_end().to_string() } diff --git a/crates/epub/src/tests.rs b/crates/epub/src/tests.rs index f216fe95f..6cae6159f 100644 --- a/crates/epub/src/tests.rs +++ b/crates/epub/src/tests.rs @@ -922,3 +922,26 @@ fn char_css_joins_underline_and_strikethrough_in_one_declaration() { assert!(css(false, true).contains("text-decoration: line-through;")); assert!(!css(false, false).contains("text-decoration")); } + +#[test] +fn character_styles_keep_decorations_caps_and_tracking() { + let mut d = Document::new(&NewDocument::default()); + d.styles_mut().character.push(designcraft_doc::CharacterStyle { + name: "Under".into(), + based_on: None, + chars: CharAttrs { + underline: Some(true), + strikethrough: Some(true), + capitalization: Some(designcraft_doc::Capitalization::AllCaps), + tracking: Some(100.0), + ..Default::default() + }, + shortcut: String::new(), + }); + let css = css::stylesheet(&d); + assert!(css.contains("text-decoration: underline line-through;"), "{css}"); + assert!(css.contains("text-transform: uppercase;"), "{css}"); + assert!(css.contains("letter-spacing: 0.100em;"), "{css}"); + let o = CharAttrs { strikethrough: Some(true), ..Default::default() }; + assert_eq!(css::override_css(&d, &o), "text-decoration: line-through;"); +} From e8c0a191806cdffcfa70a5c4e7a662cbdf946be1 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:26:46 +0000 Subject: [PATCH 238/628] Fix duplicated xref format text before an unclosed '<' in IDML export Found while testing crates/idml/src/export/designmap.rs:208-230 (xref_format_el) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/idml/src/export/designmap.rs | 30 ++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/idml/src/export/designmap.rs b/crates/idml/src/export/designmap.rs index adce1cbd0..6dc907c64 100644 --- a/crates/idml/src/export/designmap.rs +++ b/crates/idml/src/export/designmap.rs @@ -206,10 +206,10 @@ fn xref_format_el(i: usize, f: &designcraft_doc::XrefFormat) -> El { }; let mut rest = f.definition.as_str(); while let Some(a) = rest.find('<') { + let Some(b) = rest[a..].find('>') else { break }; if a > 0 { block(&mut el, "CustomStringBuildingBlock", &rest[..a], "", false); } - let Some(b) = rest[a..].find('>') else { break }; let tag = rest[a + 1..a + b].trim().trim_end_matches('/').trim(); let attr = |k: &str| tag.find(&format!("{k}=\"")).and_then(|i| tag[i + k.len() + 2..].split('"').next()).unwrap_or("").to_string(); match tag.split_whitespace().next().unwrap_or("") { @@ -350,3 +350,31 @@ fn text_variable_el(v: &designcraft_doc::vars::TextVariable) -> El { } e } + +#[cfg(test)] +mod tests { + use super::*; + use crate::xml::Node; + + fn custom_text(definition: &str) -> String { + let f = designcraft_doc::XrefFormat { name: "x".into(), definition: definition.into() }; + xref_format_el(0, &f) + .children + .iter() + .filter_map(|n| match n { + Node::El(e) if e.get("BlockType") == Some("CustomStringBuildingBlock") => e.get("CustomText").map(str::to_string), + _ => None, + }) + .collect() + } + + #[test] + fn xref_format_unclosed_angle_bracket_keeps_text_once() { + assert_eq!(custom_text("a < b"), "a < b"); + } + + #[test] + fn xref_format_well_formed_keeps_custom_text() { + assert_eq!(custom_text("p of "), "p of "); + } +} From 516998d4494ee33472359c6eab029e07a7215838 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:33:17 +0000 Subject: [PATCH 239/628] Export form field borders from the item stroke Fixes #1881 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/pdf/src/forms.rs | 16 ++++++++++------ crates/pdf/src/tests.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/crates/pdf/src/forms.rs b/crates/pdf/src/forms.rs index 07f4cf04c..bf0d955fc 100644 --- a/crates/pdf/src/forms.rs +++ b/crates/pdf/src/forms.rs @@ -10,8 +10,9 @@ use designcraft_doc::{Document, FieldKind, FormField, Item, MediaOptions}; use crate::export::Sheet; use crate::transitions::{bracketed, int_after, object, rfind}; -/// A field placed on an output page: (page index, PDF rectangle, field). -pub(crate) type Placed = (usize, [f64; 4], FormField); +/// A field placed on an output page: (page index, PDF rectangle, field, border width, 0 when the +/// item has no stroke). +pub(crate) type Placed = (usize, [f64; 4], FormField, f64); /// A placed video or sound on an output page: (page index, PDF rectangle, file name, MIME type, /// bytes, options). @@ -37,7 +38,8 @@ pub(crate) fn collect(doc: &Document, sheets: &[Sheet]) -> Vec { let mut out = Vec::new(); visit(doc, sheets, |pi, r, it| { if let Some(f) = &it.form_field { - out.push((pi, r, f.clone())); + let bw = if it.stroke.is_none() || !it.stroke.weight.is_finite() { 0.0 } else { it.stroke.weight }; + out.push((pi, r, f.clone(), bw)); } }); out @@ -158,13 +160,14 @@ pub(crate) fn add_fields(pdf: &[u8], fields: &[Placed], media: &[PlacedMedia]) - put(&mut out, font, b"<>"); } let mut all = Vec::new(); - for (page, r, f) in fields { + for (page, r, f, bw) in fields { let Some(&page_ref) = kids.get(*page) else { continue }; let rect = format!("[{:.2} {:.2} {:.2} {:.2}]", r[0], r[1], r[2], r[3]); let (w, h) = (r[2] - r[0], r[3] - r[1]); let fs = if f.font_size > 0.0 { f.font_size } else { 0.0 }; + let (bc, bs) = if *bw > 0.0 { ("/BC[0.45 0.45 0.45]", format!("/BS<>")) } else { ("", "/BS<>".to_string()) }; let common = format!( - "/Type/Annot/Subtype/Widget/T{}/Rect{rect}/F 4/P {page_ref} 0 R/MK<>/DA(/Helv {fs:.1} Tf 0 g)", + "/Type/Annot/Subtype/Widget/T{}/Rect{rect}/F 4/P {page_ref} 0 R/MK<<{bc}/BG[1 1 1]>>{bs}/DA(/Helv {fs:.1} Tf 0 g)", pdf_string(&f.name) ); let req = if f.required { 2 } else { 0 }; @@ -175,7 +178,8 @@ pub(crate) fn add_fields(pdf: &[u8], fields: &[Placed], media: &[PlacedMedia]) - } FieldKind::CheckBox => { // Appearances: an empty box, and a box with a check mark. - let border = format!("0.45 G 1 w 0.5 0.5 {:.2} {:.2} re S", (w - 1.0).max(0.0), (h - 1.0).max(0.0)); + let border = + if *bw > 0.0 { format!("0.45 G 1 w 0.5 0.5 {:.2} {:.2} re S", (w - 1.0).max(0.0), (h - 1.0).max(0.0)) } else { String::new() }; let check = format!( "{border} 0 g {:.2} w 1 J 1 j {:.2} {:.2} m {:.2} {:.2} l {:.2} {:.2} l S", (w.min(h) * 0.12).max(0.8), diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index b06fad47d..9c3a9ff9a 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -791,6 +791,36 @@ fn only_printed_fields_and_buttons_export() { assert_eq!(all.matches("/Link").count(), shown.matches("/Link").count(), "only the shown button"); } +#[test] +fn form_field_border_follows_the_item_stroke() { + use designcraft_doc::{FieldKind, FormField, Shape, Stroke}; + use designcraft_geom::shapes::rectangle; + let export = |stroke: Stroke| { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let mut it = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, rectangle(Rect::new(72.0, 72.0, 250.0, 96.0))); + it.stroke = stroke; + it.form_field = Some(FormField { + kind: FieldKind::TextField, + name: "name".into(), + value: String::new(), + options: Vec::new(), + multiline: false, + required: false, + font_size: 0.0, + }); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + uncompressed(&d, PdfOptions::default()) + }; + let none = export(Stroke { weight: 0.0, swatch: designcraft_color::swatch::NONE.into(), ..Stroke::default() }); + assert!(none.contains("/Subtype/Widget")); + assert!(none.contains("/BS<>"), "no border width"); + assert!(!none.contains("/BC"), "no border colour"); + let stroked = export(Stroke { weight: 1.0, ..Stroke::default() }); + assert!(stroked.contains("/BS< Date: Tue, 27 Oct 2026 18:05:17 +0100 Subject: [PATCH 240/628] HU1: align the moved panel test path for the module merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream d77fdf2d moved these tests to crates/ui-egui/src/panel_docking/tests.rs. The tested local merge preserves every test body from both parents there. Remove the obsolete path to unblock GitHub’s web conflict resolver; the remaining module integration follows immediately. --- crates/ui-egui/src/panel_docking_tests.rs | 1281 --------------------- 1 file changed, 1281 deletions(-) delete mode 100644 crates/ui-egui/src/panel_docking_tests.rs diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs deleted file mode 100644 index 73f98f612..000000000 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ /dev/null @@ -1,1281 +0,0 @@ -use super::{ - CANVAS, Column, DOCK_LABELS, DesignApp, Floating, ICON_GROUPS, Layout, Node, PanelContent, Placed, Side, Value, area_id, cancel_gesture, - close_visible, default_layout, dock_columns, hit_id, icon_column, in_iconic_column, json, layout_of, modal_open, open_window_drops, panel_menu, - set_iconic, showing, tab_group, tab_id, valid, window_groups, -}; -use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; -use egui_kittest::{Harness, kittest::Queryable}; - -fn app() -> DesignApp { - DesignApp::new(designcraft_engine::Session::new(), Default::default()) -} - -#[test] -fn panel_commands_group_split_reorder_float_and_restore_without_duplicate_ownership() { - let mut app = app(); - app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap(); - app.run("window.panel.move", json!({"panel":"properties", "anchor":"swatches", "before":"swatches"})).unwrap(); - let before_float = app.ui.docking.clone().unwrap(); - app.run("window.panel.float", json!({"panel":"properties", "x":50,"y":70,"width":330,"height":410})).unwrap(); - assert!(app.ui.docking.as_ref().unwrap().floating.iter().flat_map(window_groups).any(|g| g.panels == ["properties"])); - app.run("window.panel.dock", json!({"panel":"properties"})).unwrap(); - assert_eq!(app.ui.docking.as_ref().unwrap(), &before_float); - app.run("window.panel.close", json!({"panel":"properties"})).unwrap(); - assert!(!app.ui.docking.as_ref().unwrap().contains(&"properties".into())); - app.run("window.panel.activate", json!({"panel":"properties"})).unwrap(); - assert_eq!(app.ui.docking.as_ref().unwrap(), &before_float); - let layout = app.ui.docking.as_ref().unwrap(); - assert!(valid(layout)); - let unique: std::collections::HashSet<_> = layout.panels().into_iter().collect(); - assert_eq!(unique.len(), layout.panels().len()); - let saved = serde_json::to_value(&app.ui).unwrap(); - let restored: crate::UiState = serde_json::from_value(saved).unwrap(); - assert_eq!(restored.docking, app.ui.docking); - assert_eq!(restored.docking_hidden, app.ui.docking_hidden); -} - -#[test] -fn invalid_panel_requests_are_atomic_and_do_not_enter_a_custom_workspace() { - let mut app = app(); - for (id, params) in [ - ("window.panel.move", json!({"panel":"layers","anchor":"unknown"})), - ("window.panel.move", json!({"panel":"layers","anchor":"properties","before":"unknown"})), - ("window.panel.move", json!({"panel":"layers","anchor":"properties","zone":42})), - ("window.panel.float", json!({"panel":"layers","width":-10})), - ("window.panel.float", json!({"panel":"unknown"})), - ("window.panel.float", json!({"panel":"layers","x":"invalid"})), - ] { - let old = app.ui.docking.clone(); - let hidden = app.ui.docking_hidden.clone(); - assert!(app.run(id, params).is_err(), "{id}"); - assert_eq!(app.ui.docking, old); - assert_eq!(app.ui.docking_hidden, hidden); - } -} - -fn harness(app: DesignApp, size: egui::Vec2) -> Harness<'static, DesignApp> { - let mut ready = false; - let mut harness = Harness::builder().with_size(size).with_step_dt(1.0 / 60.0).build_ui_state( - move |ui, app: &mut DesignApp| { - if !ready { - crate::theme::install_fonts(ui.ctx(), &app.ui.language); - crate::theme::apply(ui.ctx(), &crate::theme::Tokens::for_brightness(crate::theme::Brightness::MediumDark)); - ready = true; - return; - } - egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| crate::panel_docking::show(app, ui)); - }, - app, - ); - harness.run_steps(4); - harness -} - -fn drag(h: &mut Harness<'static, DesignApp>, from: Pos2, to: Pos2) { - h.event(Event::PointerMoved(from)); - h.run_steps(1); - h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); - h.run_steps(1); - for step in 1..=8 { - h.event(Event::PointerMoved(from + (to - from) * (step as f32 / 8.0))); - h.run_steps(1); - } - h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); - h.run_steps(3); -} - -#[test] -fn ordinary_panel_tab_drag_enters_shared_docking_and_can_redock_into_another_tab_group() { - let mut h = harness(app(), vec2(1280.0, 800.0)); - let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center(); - drag(&mut h, source, egui::pos2(160.0, 140.0)); - let layout = h.state().ui.docking.as_ref().expect("ordinary tab drag starts shared docking"); - assert!(layout.floating.iter().flat_map(window_groups).any(|g| g.panels.contains(&"layers".into())), "{layout:?}"); - let source = h.ctx.read_response(tab_id("layers")).unwrap().rect.center(); - let destination = h.ctx.read_response(tab_id("properties")).unwrap().rect.center(); - drag(&mut h, source, destination); - let layout = h.state().ui.docking.as_ref().unwrap(); - assert!(!layout.floating.iter().flat_map(window_groups).any(|g| g.panels.contains(&"layers".into()))); - assert!(valid(layout)); - assert_eq!(h.state().ui.docking_focused.as_deref(), Some("layers"), "the user's drag keeps the focus on the panel it moved"); -} - -#[test] -fn saved_workspace_restores_the_custom_tree_and_hidden_panel_placement() { - let mut app = app(); - - app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap(); - app.run("window.panel.float", json!({"panel":"properties","x":50,"y":80})).unwrap(); - app.run("window.panel.close", json!({"panel":"swatches"})).unwrap(); - let layout = app.ui.docking.clone(); - let hidden = app.ui.docking_hidden.clone(); - app.run("window.newWorkspace", json!({"name":"Custom docking"})).unwrap(); - app.run("window.workspace", json!({"name":"Essentials"})).unwrap(); - assert!(app.ui.docking.is_none()); - app.run("window.workspace", json!({"name":"Custom docking"})).unwrap(); - assert_eq!(app.ui.docking, layout); - assert_eq!(app.ui.docking_hidden, hidden); -} - -/// Explicit offscreen evidence; this creates no native windows. -#[test] -fn capture_custom_panel_docking_visual_fixtures() { - let Some(directory) = std::env::var_os("CRAFT_UI_DOCKING_FIXTURES").map(std::path::PathBuf::from) else { return }; - std::fs::create_dir_all(&directory).unwrap(); - for theme in crate::theme::Brightness::ALL { - for width in [800.0, 1280.0] { - for scale in [1.0, 1.5, 2.0] { - let mut app = app(); - app.run("file.new", json!({})).unwrap(); - app.run("frame.create", json!({"rect":[40,40,280,180],"content":"text","text":"Synthetic layout — مرحبا"})).unwrap(); - let language = if width == 800.0 && scale == 1.0 { "ar" } else { "en" }; - app.run("app.language", json!({"lang":if language == "en" { "" } else { language }})).unwrap(); - app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap(); - app.run("window.panel.float", json!({"panel":"properties","x":30,"y":90,"width":300,"height":440})).unwrap(); - let mut ready = false; - let mut harness = Harness::builder().with_size(vec2(width, 800.0)).with_pixels_per_point(scale).wgpu().build_ui_state( - move |ui, app: &mut DesignApp| { - if !ready { - crate::theme::install_fonts(ui.ctx(), &app.ui.language); - crate::theme::apply(ui.ctx(), &crate::theme::Tokens::for_brightness(theme)); - ready = true; - return; - } - egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| crate::panel_docking::show(app, ui)); - }, - app, - ); - harness.input_mut().max_texture_side = Some(8192); - harness.run_steps(5); - assert!(valid(harness.state().ui.docking.as_ref().unwrap())); - harness - .render() - .unwrap() - .save(directory.join(format!("designcraft-shared-docking-{}-{language}-{width}-{scale}x.png", theme.id()))) - .unwrap(); - if language == "ar" { - let tab = tab_id("swatches"); - let at = harness.ctx.read_response(tab).unwrap().rect.center(); - harness.input_mut().events.extend([ - Event::PointerMoved(at), - Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }, - ]); - harness.run_steps(1); - harness.input_mut().events.push(Event::PointerButton { - pos: at, - button: PointerButton::Secondary, - pressed: false, - modifiers: Modifiers::NONE, - }); - harness.run_steps(2); - assert!(egui::Popup::is_any_open(&harness.ctx), "actual tab context menu must be open"); - for action in ["Float Panel", "Group with", "Close Panel"] { - assert!(harness.query_all_by_label(crate::i18n::tr("ar", action)).count() >= 1, "the menu lists {action}"); - } - harness.render().unwrap().save(directory.join(format!("designcraft-dock-context-{}-ar.png", theme.id()))).unwrap(); - } - } - } - } -} - -#[test] -fn escape_cancels_a_tab_drag_without_customizing_the_workspace() { - let mut h = harness(app(), vec2(1280.0, 800.0)); - let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center(); - h.event(Event::PointerMoved(source)); - h.run_steps(1); - h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); - h.run_steps(1); - h.event(Event::PointerMoved(source - vec2(90.0, 0.0))); - h.run_steps(2); - h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); - h.run_steps(1); - assert!(h.state().ui.docking.is_none()); - h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); - h.run_steps(2); - assert!(h.state().ui.docking.is_none()); -} - -#[test] -fn serialized_layout_actions_cover_geometry_reorder_accordion_and_atomic_rejection() { - let mut app = app(); - app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap(); - app.run("window.panel.layout", json!({"action":{"ResizeSplit":{"path":[],"size":{"Ratio":0.35}}}})).unwrap(); - assert!( - matches!(app.ui.docking.as_ref().unwrap().root, Some(Node::Split { size: craft_ui::layout::SplitSize::Ratio(value), .. }) if (value - 0.35).abs() < 0.001) - ); - app.run("window.panel.float", json!({"panel":"properties"})).unwrap(); - app.run("window.panel.layout", json!({"action":{"MoveFloating":{"panel":"properties","rect":[44,66,360,450]}}})).unwrap(); - assert_eq!( - app.ui.docking.as_ref().unwrap().floating.iter().find(|window| window.panels().any(|p| p == "properties")).unwrap().rect, - [44.0, 66.0, 360.0, 450.0] - ); - app.run("window.panel.layout", json!({"action":{"Move":{"panel":"properties","anchor":"swatches","placement":{"Tab":{"before":"swatches"}}}}})) - .unwrap(); - assert_eq!(group_members_for_test(app.ui.docking.as_ref().unwrap(), "swatches"), ["properties", "swatches"]); - let before = app.ui.docking.clone(); - let hidden = app.ui.docking_hidden.clone(); - for action in [ - json!({"MoveFloating":{"panel":"properties","rect":[0,0,-5,30]}}), - json!({"ResizeSplit":{"path":[true,true,true],"size":{"Ratio":0.2}}}), - json!({"Open":{"panel":"unknown","anchor":null}}), - json!({"Move":{"panel":"properties","anchor":"swatches","placement":{"Tab":{"before":"unknown"}}}}), - ] { - assert!(app.run("window.panel.layout", json!({"action":action})).is_err()); - assert_eq!(app.ui.docking, before); - assert_eq!(app.ui.docking_hidden, hidden); - } - let stack = Node::Stack { entries: vec![craft_ui::docking::StackEntry { panel: "layers".into(), open: true, height: Some(100.0) }] }; - app.ui.docking = Some(Layout { - root: Some(Node::Split { - axis: craft_ui::layout::SplitAxis::Horizontal, - size: craft_ui::layout::SplitSize::Ratio(0.7), - first: Box::new(Node::Tabs { panels: vec![CANVAS.into()], active: 0 }), - second: Box::new(stack), - }), - floating: Vec::new(), - }); - app.run("window.panel.layout", json!({"action":{"SetStackOpen":{"panel":"layers","open":false}}})).unwrap(); - app.run("window.panel.layout", json!({"action":{"ResizeStack":{"panel":"layers","height":150}}})).unwrap(); - let Some(Node::Split { second, .. }) = &app.ui.docking.as_ref().unwrap().root else { panic!("expected split") }; - let Node::Stack { entries } = &**second else { panic!("expected stack") }; - assert!(!entries[0].open); - assert_eq!(entries[0].height, Some(150.0)); -} - -fn group_members_for_test(layout: &Layout, panel: &str) -> Vec { - tab_group(layout, panel) -} - -#[test] -fn populated_canvas_keeps_a_real_stroke_edit_after_floating_and_redocking() { - let mut app = app(); - app.run("file.new", json!({"width":480,"height":360})).unwrap(); - let id = app.run("frame.create", json!({"rect":[80,70,330,230],"content":"none"})).unwrap()["id"].as_u64().unwrap(); - app.run("object.fill", json!({"swatch":"C=100 M=0 Y=0 K=0"})).unwrap(); - app.run("object.stroke", json!({"weight":8})).unwrap(); - app.run("window.panel.float", json!({"panel":"stroke","x":40,"y":140,"width":310,"height":490})).unwrap(); - let directory = std::env::var_os("CRAFT_UI_DOCKING_FIXTURES").map(std::path::PathBuf::from); - let builder = Harness::builder().with_size(vec2(1280.0, 800.0)); - let builder = if directory.is_some() { builder.wgpu() } else { builder }; - let mut h = builder.build_ui_state( - |ui, app: &mut DesignApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - app, - ); - h.input_mut().max_texture_side = Some(8192); - h.run_steps(5); - let at = h.get_by_label("Bevel").rect().center(); - h.hover_at(at); - h.run_steps(1); - for pressed in [true, false] { - h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); - h.run_steps(1); - } - h.run_steps(2); - assert_eq!(h.state().session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().stroke.join, designcraft_doc::Join::Bevel); - if let Some(directory) = &directory { - std::fs::create_dir_all(directory).unwrap(); - h.render().unwrap().save(directory.join("designcraft-populated-stroke-floating.png")).unwrap(); - } - let from = h.ctx.read_response(tab_id("stroke")).unwrap().rect.center(); - let to = h.ctx.read_response(tab_id("properties")).unwrap().rect.center(); - drag(&mut h, from, to); - assert!(h.state().ui.docking.as_ref().unwrap().floating.is_empty()); - assert_eq!(h.state().session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().stroke.join, designcraft_doc::Join::Bevel); - if let Some(directory) = &directory { - h.render().unwrap().save(directory.join("designcraft-populated-stroke-redocked.png")).unwrap(); - } -} - -#[test] -fn properties_stroke_link_opens_the_stroke_panel_in_a_custom_workspace() { - let mut app = app(); - app.run("file.new", json!({"width":480,"height":360})).unwrap(); - app.run("frame.create", json!({"rect":[80,70,330,230],"content":"none"})).unwrap(); - app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); - // Floating Layers leaves Pages the active tab beside Properties. - app.run("window.panel", json!({"panel":"properties"})).unwrap(); - assert!(in_iconic_column(app.ui.docking.as_ref().unwrap(), "stroke")); - let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).build_ui_state( - |ui, app: &mut DesignApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - app, - ); - h.run_steps(5); - let at = h.get_by_role_and_label(egui::accesskit::Role::Link, "Stroke").rect().center(); - h.hover_at(at); - h.run_steps(1); - for pressed in [true, false] { - h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); - h.run_steps(1); - } - h.run_steps(2); - // Stroke stays in the iconic column and opens as its flyout. - assert!(in_iconic_column(h.state().ui.docking.as_ref().unwrap(), "stroke")); - assert_eq!(h.state().ui.docking_flyout.as_deref(), Some("stroke")); - assert!(showing(h.state(), "stroke")); - assert_eq!(h.state().ui.docking_opened.as_deref(), Some("stroke")); -} - -#[test] -fn changing_workspace_during_a_drag_cancels_the_stale_gesture() { - let mut app = app(); - app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap(); - let destination = app.ui.docking.clone(); - app.run("window.newWorkspace", json!({"name":"Destination"})).unwrap(); - app.run("window.workspace", json!({"name":"Essentials"})).unwrap(); - let mut h = harness(app, vec2(1280.0, 800.0)); - let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center(); - h.event(Event::PointerMoved(source)); - h.run_steps(1); - h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); - h.run_steps(1); - h.event(Event::PointerMoved(source - vec2(90.0, 0.0))); - h.run_steps(2); - h.state_mut().run("window.workspace", json!({"name":"Destination"})).unwrap(); - h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); - h.run_steps(1); - assert_eq!(h.state().ui.docking, destination); - h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); - h.run_steps(2); - assert_eq!(h.state().ui.docking, destination); -} - -#[test] -fn first_customization_keeps_the_panel_the_workspace_opened() { - let mut app = app(); - app.ui.open_panel = Some("paragraphStyles".into()); - app.ui.dock_expanded = false; - app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); - let layout = app.ui.docking.as_ref().unwrap(); - assert!(layout.contains(&"paragraphStyles".into())); - assert!(valid(layout)); - assert_eq!(layout.panels().iter().filter(|p| p.as_str() == "paragraphStyles").count(), 1); -} - -#[test] -fn hiding_before_first_destination_frame_does_not_restore_an_old_drag_snapshot() { - let mut app = app(); - app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap(); - app.run("window.newWorkspace", json!({"name":"Destination"})).unwrap(); - app.run("window.workspace", json!({"name":"Essentials"})).unwrap(); - let mut h = harness(app, vec2(1280.0, 800.0)); - let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center(); - h.event(Event::PointerMoved(source)); - h.run_steps(1); - h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); - h.run_steps(1); - h.event(Event::PointerMoved(source - vec2(90.0, 0.0))); - h.run_steps(2); - h.state_mut().run("window.workspace", json!({"name":"Destination"})).unwrap(); - let destination = h.state().ui.docking.clone(); - let open_panel = h.state().ui.open_panel.clone(); - let floating = h.state().ui.floating.clone(); - // The app calls this before dock rendering when panels are hidden or Presentation starts. - let ctx = h.ctx.clone(); - cancel_gesture(&ctx); - assert_eq!(h.state().ui.docking, destination); - assert_eq!(h.state().ui.open_panel, open_panel); - assert_eq!(h.state().ui.floating, floating); - h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); - h.run_steps(2); - assert_eq!(h.state().ui.docking, destination); -} - -#[test] -fn table_and_control_visibility_work_after_customization_without_document_edits() { - let mut app = app(); - app.run("file.new", json!({})).unwrap(); - let doc = serde_json::to_value(&app.session.active().unwrap().doc).unwrap(); - let undo = app.session.active().unwrap().history.undo.len(); - app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); - app.run("app.tablePanel", json!({})).unwrap(); - assert!(showing(&app, "table")); - // The Table flyout closes; Table stays in its icon group. - app.run("app.tablePanel", json!({})).unwrap(); - assert!(!showing(&app, "table")); - assert!(in_iconic_column(app.ui.docking.as_ref().unwrap(), "table")); - app.run("app.tablePanel", json!({})).unwrap(); - assert!(showing(&app, "table")); - let ctx = egui::Context::default(); - let (req, _) = crate::control::ControlRequest::new("ui.set", json!({"closePanel":true,"dockExpanded":false})); - let crate::control::Outcome::Done(result) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected synchronous control result") }; - assert_eq!(result["ok"], true); - assert!(!app.ui.dock_expanded); - assert!(!app.ui.docking.as_ref().unwrap().contains(&"table".into())); - app.run("window.panel", json!({"panel":"properties"})).unwrap(); - assert_eq!(app.ui.docking_flyout.as_deref(), Some("properties"), "it opens as a flyout from the collapsed dock"); - assert_eq!(app.ui.docking_raise.as_deref(), Some("properties")); - set_iconic(&mut app, false).unwrap(); - assert_eq!(app.ui.docking_flyout, None, "expanding the column closes its flyout"); - assert_eq!(serde_json::to_value(&app.session.active().unwrap().doc).unwrap(), doc); - assert_eq!(app.session.active().unwrap().history.undo.len(), undo); -} - -#[test] -fn floating_activation_queues_one_transient_raise_and_workspace_switch_clears_it() { - let mut app = app(); - app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); - app.run("window.panel.activate", json!({"panel":"layers"})).unwrap(); - assert_eq!(app.ui.docking_raise.as_deref(), Some("layers")); - assert!(serde_json::to_value(&app.ui).unwrap().get("docking_raise").is_none()); - app.run("window.workspace", json!({"name":"Typography"})).unwrap(); - assert!(app.ui.docking_raise.is_none()); -} - -#[test] -fn shaped_panel_label_keeps_the_logical_accessibility_text() { - let ctx = egui::Context::default(); - let logical = "خصائص Pages"; - crate::theme::install_fonts(&ctx, "ar"); - let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { - let galley = crate::rtl::plain(ui.ctx(), logical, egui::FontId::proportional(12.0), egui::Color32::WHITE); - assert_eq!(galley.job.text, logical); - }); - output.textures_delta.clear(); -} - -#[test] -fn modal_overlay_blocks_a_real_tab_drag_release() { - let mut app = app(); - app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); - let mut h = harness(app, vec2(1280.0, 800.0)); - let source = h.ctx.read_response(tab_id("properties")).unwrap().rect.center(); - h.event(Event::PointerMoved(source)); - h.run_steps(1); - h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); - h.run_steps(1); - h.event(Event::PointerMoved(source - vec2(90.0, 0.0))); - h.run_steps(2); - assert_eq!(h.ctx.dragged_id(), Some(tab_id("properties")), "test must start the owned tab gesture"); - let before = h.state().ui.docking.clone(); - h.state_mut().ui.dialog = Some(crate::dialogs::Dialog::new("newDocument", json!({}))); - let destination = egui::pos2(150.0, 150.0); - h.event(Event::PointerMoved(destination)); - h.run_steps(1); - h.event(Event::PointerButton { pos: destination, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); - h.run_steps(2); - assert_eq!(h.state().ui.docking, before, "release under a modal must not float or dock the panel"); - h.state_mut().ui.dialog = None; - h.run_steps(2); - assert_eq!(h.state().ui.docking, before, "closing the overlay must not revive its blocked drag"); -} - -#[test] -fn shift_f9_toggles_table_after_customization_through_the_real_shortcut_path() { - let mut app = app(); - app.run("file.new", json!({})).unwrap(); - app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); - let before = serde_json::to_value(&app.session.active().unwrap().doc).unwrap(); - let mut h = crate::test_window::open(app, vec2(1440.0, 900.0)); - for visible in [true, false] { - let modifiers = Modifiers { shift: true, ..Modifiers::NONE }; - h.event(Event::Key { key: egui::Key::F9, physical_key: None, pressed: true, repeat: false, modifiers }); - h.run_steps(1); - h.event(Event::Key { key: egui::Key::F9, physical_key: None, pressed: false, repeat: false, modifiers }); - h.run_steps(2); - assert_eq!(showing(&h.state().app, "table"), visible); - assert!(h.state().app.ui.docking.as_ref().unwrap().contains(&"table".into()), "Table stays in its icon group"); - } - assert_eq!(serde_json::to_value(&h.state().app.session.active().unwrap().doc).unwrap(), before); -} - -#[test] -fn first_story_editor_frame_conservatively_blocks_owned_tab_release() { - let mut app = app(); - app.run("file.new", json!({})).unwrap(); - let story = app.run("frame.create", json!({"rect":[72,72,300,200],"content":"text","text":"Story editor overlay fixture"})).unwrap()["story"] - .as_u64() - .unwrap(); - app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); - let doc = serde_json::to_value(&app.session.active().unwrap().doc).unwrap(); - let mut h = crate::test_window::open(app, vec2(1440.0, 900.0)); - let tab = tab_id("properties"); - let from = h.ctx.read_response(tab).unwrap().rect.center(); - h.event(Event::PointerMoved(from)); - h.run_steps(1); - h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); - h.run_steps(1); - h.event(Event::PointerMoved(from - vec2(90.0, 0.0))); - h.run_steps(2); - assert_eq!(h.ctx.dragged_id(), Some(tab), "must own a real tab drag before opening editor"); - assert!(h.ctx.memory(|m| m.area_rect(egui::Id::unique("story_editor"))).is_none()); - let layout = h.state().app.ui.docking.clone(); - let frame_before = h.ctx.cumulative_frame_nr(); - h.state_mut().app.run("app.storyEditor", json!({"story":story})).unwrap(); - // The unmeasured first frame blocks the full content, even outside the eventual editor. - let release = egui::pos2(150.0, 150.0); - h.input_mut().events.extend([ - Event::PointerMoved(release), - Event::PointerButton { pos: release, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, - ]); - h.run_steps(1); - assert_eq!(h.ctx.cumulative_frame_nr() - frame_before, 1, "release must occur in the first editor frame"); - let measured = h.ctx.memory(|m| m.area_rect(egui::Id::unique("story_editor"))).unwrap(); - assert!(!measured.contains(release), "fixture exercises the conservative first-frame guard outside eventual editor bounds"); - assert_eq!(h.state().app.ui.docking, layout, "first editor frame must block release before it has a measured rectangle"); - assert_eq!(serde_json::to_value(&h.state().app.session.active().unwrap().doc).unwrap(), doc); - h.run_steps(3); - let editor = h.ctx.memory(|m| m.area_rect(egui::Id::unique("story_editor"))).unwrap(); - let screen = h.ctx.content_rect(); - let outside = [screen.left_bottom() + vec2(80.0, -80.0), screen.right_bottom() + vec2(-450.0, -80.0)] - .into_iter() - .find(|p| !editor.contains(*p)) - .expect("fixture has space outside editor"); - let before_outside = h.state().app.ui.docking.clone(); - let tab = tab_id("layers"); - let from = h.ctx.read_response(tab).unwrap().rect.center(); - h.event(Event::PointerMoved(from)); - h.run_steps(1); - h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); - h.run_steps(1); - h.event(Event::PointerMoved(from - vec2(90.0, 0.0))); - h.run_steps(2); - assert_eq!(h.ctx.dragged_id(), Some(tab)); - h.event(Event::PointerMoved(outside)); - h.event(Event::PointerButton { pos: outside, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); - h.run_steps(2); - assert_ne!(h.state().app.ui.docking, before_outside, "measured editor must permit ordinary releases outside its bounds"); - let after = h.state().app.ui.docking.clone(); - h.state_mut().app.run("app.storyEditor", json!({"story":story})).unwrap(); - h.run_steps(3); - assert_eq!(h.state().app.ui.docking, after, "closing editor must not revive a blocked drag"); - assert_eq!(serde_json::to_value(&h.state().app.session.active().unwrap().doc).unwrap(), doc); -} - -#[test] -fn dock_menu_button_opens_the_active_panel_menu() { - let mut app = app(); - app.run("file.new", json!({})).unwrap(); - app.ui.dock_tab = "pages".into(); - let pages = app.session.active().unwrap().doc.page_count(); - let mut h = harness(app, vec2(1280.0, 800.0)); - assert!(h.state().ui.docking.is_none()); - let tab = h.query_all_by_label("Pages").find(|node| node.rect().height() <= 45.0).unwrap().rect(); - // The outer dock column is at the window's right edge; the ≡ ends its tab strip. - let at = egui::pos2(1280.0 - 12.0, tab.center().y); - h.event(Event::PointerMoved(at)); - h.run_steps(1); - for pressed in [true, false] { - h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); - h.run_steps(1); - } - h.run_steps(2); - assert!(egui::Popup::is_any_open(&h.ctx), "the three-line button must open a menu"); - h.get_by_label("Duplicate Spread").click(); - h.run_steps(3); - assert_eq!(h.state().session.active().unwrap().doc.page_count(), pages + 1); - assert!(!egui::Popup::is_any_open(&h.ctx)); -} - -/// InDesign's panel tab menu (Learn Skills Daily's InDesign course, 6:20): Close, Close Tab Group, -/// Minimize and Collapse to Icons. Close Tab Group hides the panels tabbed together. -#[test] -fn close_tab_group_hides_the_panels_tabbed_together() { - let mut app = app(); - // The default dock: Properties, Pages and Layers are one tab group. - let r = app.run("window.panel.closeGroup", json!({"panel":"pages"})).unwrap(); - assert_eq!(r["closed"], json!(["properties", "pages", "layers"])); - let layout = app.ui.docking.clone().unwrap(); - assert!(["properties", "pages", "layers"].iter().all(|p| !layout.contains(&p.to_string()))); - // Window brings one back. - app.run("window.panel.activate", json!({"panel":"pages"})).unwrap(); - assert!(app.ui.docking.as_ref().unwrap().contains(&"pages".to_string())); - // A floating group goes as a whole; the dock keeps its own. - app.run("window.panel.float", json!({"panel":"swatches","x":40,"y":60})).unwrap(); - app.run("window.panel.activate", json!({"panel":"color"})).unwrap(); - app.run("window.panel.move", json!({"panel":"color", "anchor":"swatches"})).unwrap(); - app.run("window.panel.closeGroup", json!({"panel":"color"})).unwrap(); - let layout = app.ui.docking.clone().unwrap(); - assert!(!layout.contains(&"color".to_string()) && !layout.contains(&"swatches".to_string())); - assert!(layout.contains(&"pages".to_string())); - assert!(app.run("window.panel.closeGroup", json!({"panel":"nope"})).is_err()); -} - -#[test] -fn collapse_to_icons_shrinks_the_dock_to_its_icons() { - let mut app = app(); - assert!(app.ui.dock_expanded); - app.run("window.collapseToIcons", json!({})).unwrap(); - assert!(!app.ui.dock_expanded); - // A panel chosen from Window opens as a flyout beside its icon; the dock stays collapsed. - app.run("window.panel", json!({"panel":"pages"})).unwrap(); - assert_eq!(app.ui.docking_flyout.as_deref(), Some("pages")); - assert_eq!(app.ui.docking_raise.as_deref(), Some("pages")); - assert!(showing(&app, "pages")); - assert!(in_iconic_column(app.ui.docking.as_ref().unwrap(), "pages")); - // Choosing it again closes the flyout, and a third time opens it again. - app.run("window.panel", json!({"panel":"pages"})).unwrap(); - assert_eq!(app.ui.docking_flyout, None); - assert!(!showing(&app, "pages")); - app.run("window.panel", json!({"panel":"pages"})).unwrap(); - assert_eq!(app.ui.docking_flyout.as_deref(), Some("pages")); -} - -#[test] -fn a_tabs_context_menu_lists_indesigns_tab_commands_and_close_works() { - let mut h = harness(app(), vec2(1280.0, 800.0)); - let tab = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center(); - h.event(Event::PointerMoved(tab)); - h.run_steps(1); - h.event(Event::PointerButton { pos: tab, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }); - h.run_steps(1); - h.event(Event::PointerButton { pos: tab, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); - h.run_steps(2); - // The column bar's » button is also labelled Collapse to Icons. - for item in ["Close", "Close Tab Group", "Minimize", "Collapse to Icons", "Float Panel"] { - assert!(h.query_all_by_label(item).next().is_some(), "{item}"); - } - assert_eq!(h.query_all_by_label("Minimize").count(), 1, "the tab menu lists Minimize once"); - assert!(h.query_all_by_label("Dock Panel").next().is_none(), "a docked panel has no Dock Panel"); - h.get_by_label("Close").click(); - h.run_steps(2); - assert!(!h.state().ui.docking.as_ref().unwrap().contains(&"layers".to_string())); -} - -/// A floating panel's tab menu and three-line menu have Dock Panel, which puts it back where it -/// floated from. -#[test] -fn dock_panel_in_a_floating_panels_menus_returns_it_to_the_dock() { - let mut app = app(); - app.run("window.panel.activate", json!({"panel":"layers"})).unwrap(); - let docked = app.ui.docking.clone().unwrap(); - app.run("window.panel.float", json!({"panel":"layers","x":200,"y":150})).unwrap(); - let mut h = harness(app, vec2(1280.0, 800.0)); - let tab = h.ctx.read_response(tab_id("layers")).unwrap().rect; - click(&mut h, tab.center(), PointerButton::Secondary); - h.get_by_label("Dock Panel").click(); - h.run_steps(3); - assert_eq!(h.state().ui.docking.as_ref(), Some(&docked), "the tab menu docks it back in its group"); - let mut app = h.into_state(); - app.run("window.panel.float", json!({"panel":"layers","x":200,"y":150})).unwrap(); - let mut menu = Harness::builder().with_size(vec2(300.0, 400.0)).build_ui_state(|ui, app: &mut DesignApp| panel_menu(app, ui, "layers"), app); - menu.run_steps(1); - menu.get_by_label("Dock Panel").click(); - menu.run_steps(2); - assert_eq!(menu.state().ui.docking.as_ref(), Some(&docked), "so does the three-line menu"); -} - -/// Window › Styles › Paragraph Styles opens InDesign's floating Character Styles | Paragraph Styles -/// group, which stays open while other panels open (Learn Skills Daily's InDesign course, 7:55). -#[test] -fn paragraph_styles_open_floating_with_character_styles() { - let mut app = app(); - app.run("window.panel", json!({"panel":"paragraphStyles"})).unwrap(); - let layout = app.ui.docking.clone().unwrap(); - let group = layout.floating.iter().flat_map(window_groups).find(|g| g.panels.iter().any(|p| p == "paragraphStyles")).expect("a floating group"); - assert_eq!(group.panels, ["characterStyles", "paragraphStyles"]); - assert_eq!(group.panels[group.active], "paragraphStyles", "the one chosen is in front"); - // The dock still shows Properties in front. - assert_eq!(layout.panel_state(&"properties".to_string()), craft_ui::docking::PanelState::Front, "{:?}", layout.root); - assert_eq!(layout.floating.len(), 1, "one floating window holds both tabs"); - // Other panels from Window leave it open. - app.run("window.panel", json!({"panel":"color"})).unwrap(); - app.run("window.panel", json!({"panel":"links"})).unwrap(); - let layout = app.ui.docking.clone().unwrap(); - assert!(layout.floating.iter().flat_map(window_groups).any(|g| g.panels == ["characterStyles", "paragraphStyles"])); - // Once showing, Window › Character Styles brings that tab forward. - app.run("window.panel", json!({"panel":"characterStyles"})).unwrap(); - let layout = app.ui.docking.clone().unwrap(); - let group = layout.floating.iter().flat_map(window_groups).find(|g| g.panels.iter().any(|p| p == "characterStyles")).unwrap(); - assert_eq!(group.panels[group.active], "characterStyles"); - assert_eq!(layout.panels().iter().filter(|p| p.as_str() == "characterStyles").count(), 1); -} - -/// Window › Styles floats the styles icon group out of the iconic column; Dock Panel puts the -/// group back as one, at the same place in that column. -#[test] -fn dock_panel_returns_the_floated_styles_group_to_its_icon_group() { - let mut app = app(); - let docked = app.ui.docking.clone().unwrap_or_else(|| default_layout(&app.ui)); - let styles = ["characterStyles", "paragraphStyles"].map(String::from).to_vec(); - let icon_groups = |layout: &Layout| -> Vec> { - dock_columns(layout).into_iter().filter(|c| c.iconic).flat_map(|c| &c.groups).map(|g| g.panels.clone()).collect() - }; - let before = icon_groups(&docked); - let at = before.iter().position(|g| *g == styles).expect("the styles are an icon group"); - app.run("window.panel", json!({"panel":"paragraphStyles"})).unwrap(); - let layout = app.ui.docking.clone().unwrap(); - assert!(layout.floating.iter().flat_map(window_groups).any(|g| g.panels == styles), "{:?}", layout.floating); - assert!(!icon_groups(&layout).contains(&styles)); - app.run("window.panel.dock", json!({"panel":"paragraphStyles"})).unwrap(); - let layout = app.ui.docking.clone().unwrap(); - assert!(layout.floating.is_empty(), "{:?}", layout.floating); - assert_eq!(icon_groups(&layout), before, "both styles are back in their icon group, at the same place"); - assert_eq!(icon_groups(&layout).get(at), Some(&styles)); - assert!(valid(&layout)); -} - -/// Style panels the user docked and then closed come back in the dock, not floating. -#[test] -fn paragraph_styles_closed_from_the_dock_reopen_there() { - let mut app = app(); - app.run("window.panel", json!({"panel":"paragraphStyles"})).unwrap(); - for panel in ["characterStyles", "paragraphStyles"] { - app.run("window.panel.move", json!({"panel": panel, "anchor": "properties"})).unwrap(); - } - for panel in ["characterStyles", "paragraphStyles"] { - app.run("window.panel.close", json!({"panel": panel})).unwrap(); - } - app.run("window.panel", json!({"panel":"paragraphStyles"})).unwrap(); - let layout = app.ui.docking.clone().unwrap(); - assert!(layout.contains(&"paragraphStyles".to_string())); - assert!(layout.floating.iter().flat_map(window_groups).all(|g| !g.panels.iter().any(|p| p == "paragraphStyles")), "{:?}", layout.floating); -} - -fn click(h: &mut Harness<'static, DesignApp>, at: Pos2, button: PointerButton) { - h.event(Event::PointerMoved(at)); - h.run_steps(1); - for pressed in [true, false] { - h.event(Event::PointerButton { pos: at, button, pressed, modifiers: Modifiers::NONE }); - h.run_steps(1); - } - h.run_steps(2); -} - -/// Window › Paragraph Styles switches to the docking layout. After Window › Pages the Pages panel -/// keeps its three-line panel menu, and its tab menu lists InDesign's tab commands. -#[test] -fn pages_keeps_its_panel_menu_in_the_docking_layout() { - let mut app = app(); - app.run("file.new", json!({})).unwrap(); - app.run("window.panel", json!({"panel":"paragraphStyles"})).unwrap(); - app.run("window.panel", json!({"panel":"pages"})).unwrap(); - assert!(app.ui.docking.is_some()); - let mut h = harness(app, vec2(1280.0, 800.0)); - let tab = h.query_all_by_label("Pages").find(|node| node.rect().height() <= 45.0).unwrap().rect(); - click(&mut h, egui::pos2(1280.0 - 12.0, tab.center().y), PointerButton::Primary); - assert!(egui::Popup::is_any_open(&h.ctx), "the three-line button must open a menu"); - for item in ["Numbering & Section Options…", "Close Tab Group", "Float Panel"] { - assert!(h.query_by_label(item).is_some(), "{item}"); - } - h.get_by_label("Panel Options…").click(); - h.run_steps(3); - assert!(h.state().ui.dialog.as_ref().is_some_and(|d| d.id == "pagesPanelOptions")); - h.state_mut().ui.dialog = None; - h.run_steps(2); - - click(&mut h, tab.center(), PointerButton::Secondary); - for item in ["Close", "Close Tab Group", "Minimize", "Collapse to Icons", "Float Panel", "Group with"] { - assert!(h.query_all_by_label(item).next().is_some(), "{item}"); - } - assert!(h.query_by_label("Close Panel").is_none()); - assert!(h.query_by_label("Panel Options…").is_none(), "the panel's own commands stay in its panel menu"); -} - -fn group_with(layout: &Layout, panel: &str) -> (bool, Vec, usize) { - if let Some(g) = layout.floating.iter().flat_map(window_groups).find(|g| g.panels.iter().any(|p| p == panel)) { - return (true, g.panels, g.active); - } - let mut visit: Vec<&Node> = layout.root.iter().collect(); - while let Some(node) = visit.pop() { - match node { - Node::Split { first, second, .. } => visit.extend([&**first, &**second]), - Node::Tabs { panels, active } if panels.iter().any(|p| p == panel) => return (false, panels.clone(), *active), - Node::Dock { columns, .. } => { - if let Some(g) = columns.iter().flat_map(|c| &c.groups).find(|g| g.panels.iter().any(|p| p == panel)) { - return (false, g.panels.clone(), g.active); - } - } - _ => {} - } - } - panic!("{panel} is not in {layout:?}"); -} - -fn drag_bar(h: &Harness<'static, DesignApp>, first: &str) -> egui::Rect { - h.ctx.read_response(hit_id(&craft_ui::docking::Hit::DragBar(first.to_string()))).expect("the group's drag bar").rect -} - -/// The docked Character Styles | Paragraph Styles group moves as one by its drag bar: dropped on -/// the canvas it floats, dropped on another strip it joins as tabs. Order and the tab in front -/// are kept. -#[test] -fn dragging_a_docked_group_by_its_bar_moves_every_tab() { - let mut app = app(); - app.run("window.panel", json!({"panel":"paragraphStyles"})).unwrap(); - let below = json!({"MoveGroup": {"panel": "characterStyles", "to": {"At": {"anchor": "properties", "placement": {"Group": {"below": true}}}}}}); - app.run("window.panel.layout", json!({"action": below})).unwrap(); - let styles = ["characterStyles", "paragraphStyles"]; - assert_eq!(group_with(app.ui.docking.as_ref().unwrap(), "characterStyles"), (false, styles.map(String::from).to_vec(), 1)); - - let mut h = harness(app, vec2(1280.0, 800.0)); - let bar = drag_bar(&h, "characterStyles"); - drag(&mut h, bar.center(), egui::pos2(400.0, 400.0)); - let layout = h.state().ui.docking.as_ref().unwrap(); - assert_eq!(group_with(layout, "paragraphStyles"), (true, styles.map(String::from).to_vec(), 1), "{layout:?}"); - assert!(valid(layout)); - - let bar = drag_bar(&h, "characterStyles"); - let properties = h.ctx.read_response(tab_id("properties")).unwrap().rect.center(); - drag(&mut h, bar.center(), properties); - let layout = h.state().ui.docking.as_ref().unwrap(); - let (floating, panels, active) = group_with(layout, "characterStyles"); - assert!(!floating, "{layout:?}"); - let at = panels.iter().position(|p| p == "characterStyles").unwrap(); - assert_eq!(panels.get(at + 1).map(String::as_str), Some("paragraphStyles"), "{panels:?}"); - assert_eq!(panels[active], "paragraphStyles"); - assert!(layout.floating.is_empty(), "{:?}", layout.floating); -} - -fn dock_of(layout: &Layout) -> (Side, &[Column]) { - let Some(Node::Split { first, second, .. }) = &layout.root else { panic!("root is not a split: {layout:?}") }; - assert!(matches!(&**first, Node::Tabs { panels, .. } if panels == &[CANVAS.to_string()]), "{layout:?}"); - let Node::Dock { side, columns } = &**second else { panic!("second is not a dock: {layout:?}") }; - (*side, columns) -} - -#[test] -fn default_workspaces_are_the_canvas_beside_an_end_dock() { - let mut app = app(); - let layout = default_layout(&app.ui); - assert!(valid(&layout)); - let (side, columns) = dock_of(&layout); - assert_eq!(side, Side::End); - let [outer, icons] = columns else { panic!("{columns:?}") }; - assert!(!outer.iconic); - assert_eq!(outer.groups.len(), 1); - assert_eq!(outer.groups[0].panels, ["properties", "pages", "layers"]); - assert!(icons.iconic); - let groups: Vec> = icons.groups.iter().map(|g| g.panels.iter().map(String::as_str).collect()).collect(); - let expected: Vec> = ICON_GROUPS.iter().map(|g| g.to_vec()).collect(); - assert_eq!(groups, expected, "the iconic column groups InDesign's panel families"); - // Essentials Classic: only its named icon groups, in one iconic column. - app.run("window.workspace", json!({"name": "Essentials Classic"})).unwrap(); - let layout = default_layout(&app.ui); - assert!(valid(&layout)); - let (_, columns) = dock_of(&layout); - let [column] = columns else { panic!("{columns:?}") }; - assert!(column.iconic); - let expected: Vec> = app.ui.icon_groups.clone().unwrap(); - let groups: Vec> = column.groups.iter().map(|g| g.panels.clone()).collect(); - assert_eq!(groups.len(), expected.len()); - assert!(groups.iter().flatten().all(|p| expected.iter().flatten().any(|e| e == p))); -} - -/// Every dock panel that starts in the iconic column has one place in [`ICON_GROUPS`], so a new -/// panel is not left as a group of its own. -#[test] -fn every_icon_panel_is_in_one_default_icon_group() { - for (id, _, _) in crate::dock::ICON_PANELS { - assert_eq!(ICON_GROUPS.iter().flat_map(|g| g.iter()).filter(|p| *p == id).count(), 1, "{id}"); - } - for id in ICON_GROUPS.iter().flat_map(|g| g.iter()) { - assert!(crate::dock::ICON_PANELS.iter().any(|p| p.0 == *id), "{id} is not a dock panel"); - } -} - -/// A panel the workspace places elsewhere leaves its icon group; a group it empties goes. -#[test] -fn placed_panels_leave_their_icon_groups() { - let mut placed = Placed::default(); - for id in ["properties", "pages", "layers", "swatches", "effects"] { - placed.take(id); - } - let column = icon_column(None, &mut placed).unwrap(); - let groups: Vec> = column.groups.iter().map(|g| g.panels.iter().map(String::as_str).collect()).collect(); - assert_eq!(groups.first().unwrap(), &["stroke", "color", "gradient"]); - assert_eq!(groups.get(1).unwrap(), &["characterStyles", "paragraphStyles"]); - assert_eq!(groups.len(), ICON_GROUPS.len() - 1); -} - -#[test] -fn the_dock_is_at_the_right_in_english_and_the_left_in_arabic() { - let tab = |h: &Harness<'static, DesignApp>| h.ctx.read_response(tab_id("properties")).map(|r| r.rect).unwrap(); - let h = harness(app(), vec2(1280.0, 800.0)); - assert!(tab(&h).left() > 640.0, "{:?}", tab(&h)); - let mut arabic = app(); - arabic.ui.language = "ar".into(); - let h = harness(arabic, vec2(1280.0, 800.0)); - assert!(tab(&h).right() < 640.0, "{:?}", tab(&h)); -} - -#[test] -fn ui_docking_reports_the_layout_and_the_focused_panel() { - let mut h = harness(app(), vec2(1280.0, 800.0)); - let ctx = h.ctx.clone(); - let docking = |app: &mut DesignApp| { - let (req, _rx) = crate::control::ControlRequest::new("ui.docking", json!({})); - match crate::control::handle(app, &ctx, &req) { - crate::control::Outcome::Done(v) => v, - crate::control::Outcome::Screenshot { .. } => panic!("ui.docking took a screenshot"), - } - }; - let v = docking(h.state_mut()); - assert_eq!(v["ok"], true, "{v}"); - let result = &v["result"]; - assert_eq!(result["customized"], false); - assert_eq!(result["rightToLeft"], false); - assert!(result["focused"].is_null()); - let layout: Layout = serde_json::from_value(result["layout"].clone()).unwrap(); - assert_eq!(layout, default_layout(&h.state().ui)); - let at = h.ctx.read_response(tab_id("layers")).unwrap().rect.center(); - click(&mut h, at, PointerButton::Primary); - let v = docking(h.state_mut()); - assert_eq!(v["result"]["focused"], "layers", "{v}"); - // Closing the focused panel releases the focus. - h.state_mut().run("window.panel.close", json!({"panel": "layers"})).unwrap(); - let v = docking(h.state_mut()); - assert!(v["result"]["focused"].is_null(), "{v}"); - assert_eq!(v["result"]["customized"], true); - assert_eq!(v["result"]["hidden"], json!(["layers"])); -} - -/// A reset replaces the panels: none of them stays focused, opened for `ui.set closePanel` or -/// queued to raise. -#[test] -fn workspace_reset_forgets_the_focused_opened_and_raised_panel() { - let mut app = app(); - app.run("window.panel.float", json!({"panel": "layers"})).unwrap(); - app.ui.docking_focused = Some("layers".into()); - assert_eq!(app.ui.docking_opened.as_deref(), Some("layers")); - assert_eq!(app.ui.docking_raise.as_deref(), Some("layers")); - app.run("window.workspace.reset", json!({})).unwrap(); - assert_eq!(app.ui.docking_focused, None); - assert_eq!(app.ui.docking_opened, None); - assert_eq!(app.ui.docking_raise, None); - close_visible(&mut app).unwrap(); - assert!(layout_of(&app).contains(&"layers".to_string()), "closePanel after a reset closes nothing"); -} - -/// A command that moves the focused panel takes the focus away and leaves it the panel last -/// opened; one that closes it forgets both. -#[test] -fn moving_or_closing_the_focused_panel_by_command_updates_the_panel_state() { - let mut app = app(); - app.ui.docking_focused = Some("layers".into()); - app.run("window.panel.activate", json!({"panel": "pages"})).unwrap(); - assert_eq!(app.ui.docking_focused.as_deref(), Some("layers"), "a command that leaves Layers in place keeps its focus"); - app.ui.docking_opened = Some("layers".into()); - app.run("window.panel.move", json!({"panel": "layers", "anchor": "swatches", "zone": "tab"})).unwrap(); - assert_eq!(app.ui.docking_focused, None); - assert_eq!(app.ui.docking_opened.as_deref(), Some("layers")); - app.ui.docking_focused = Some("layers".into()); - app.run("window.panel.close", json!({"panel": "layers"})).unwrap(); - assert_eq!(app.ui.docking_focused, None); - assert_eq!(app.ui.docking_opened, None); - assert_eq!(app.ui.docking_raise, None); -} - -/// A divider dragged with the pointer gives the keyboard back: the next arrow key nudges the -/// selection and leaves the dock's size alone. -#[test] -fn arrow_after_dragging_a_dock_divider_nudges_the_selection_not_the_dock() { - let mut app = app(); - app.run("file.new", json!({"width":480,"height":360})).unwrap(); - let id = app.run("frame.create", json!({"rect":[80,70,330,230],"content":"none"})).unwrap()["id"].as_u64().unwrap(); - let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).build_ui_state( - |ui, app: &mut DesignApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - app, - ); - h.run_steps(5); - let column = craft_ui::docking::Hit::Divider(craft_ui::docking::Divider::Column("properties".to_string())); - let divider = hits(&h).into_iter().find_map(|(hit, rect)| (hit == column).then_some(rect)).expect("the main dock column's divider"); - drag(&mut h, divider.center(), divider.center() - vec2(40.0, 0.0)); - let resized = h.state().ui.docking.clone().expect("the drag customizes the layout"); - let bounds = |h: &Harness<'static, DesignApp>| h.state().session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); - let before = bounds(&h); - h.event(Event::Key { key: egui::Key::ArrowRight, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); - h.run_steps(1); - h.event(Event::Key { key: egui::Key::ArrowRight, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); - h.run_steps(2); - assert!(bounds(&h).x0 > before.x0, "the arrow nudges the selection"); - assert_eq!(h.state().ui.docking.as_ref(), Some(&resized), "the arrow doesn't resize the dock"); -} - -fn hits(h: &Harness<'static, DesignApp>) -> Vec<(craft_ui::docking::Hit, egui::Rect)> { - craft_ui::docking::DockArea::::hit_rects(&h.ctx, area_id()) -} - -/// `window.panel.*` ids and DesignCraft's aliases go through craft-ui's `DockCommand`. -#[test] -fn shared_panel_commands_float_dock_and_reject_atomically() { - let mut app = app(); - let docked = default_layout(&app.ui); - app.run("window.floatPanel", json!({"panel": "Layers", "x": 60, "y": 90})).unwrap(); - let layout = app.ui.docking.clone().unwrap(); - let window = layout.floating.iter().find(|w| w.contains(&"layers".to_string())).expect("Layers floats"); - assert_eq!(window.rect, [60.0, 90.0, 300.0, 420.0], "at the given place, at its own size"); - app.run("window.dockPanel", json!({"panel": "layers"})).unwrap(); - let layout = app.ui.docking.clone().unwrap(); - assert!(layout.floating.is_empty()); - assert_eq!( - layout.root, - docked.root.map(|mut root| { - // Back in its place, in front of its group. - if let Node::Split { second, .. } = &mut root - && let Node::Dock { columns, .. } = &mut **second - && let Some(group) = columns.first_mut().and_then(|c| c.groups.first_mut()) - { - group.active = 2; - } - root - }), - "Dock Panel returns it where it floated from" - ); - // A docked panel that shares its group leaves it for a new dock at the start edge. - let mut tabbed = self::app(); - tabbed.run("window.panel.dock", json!({"panel": "layers", "side": "Start"})).unwrap(); - let layout = tabbed.ui.docking.clone().unwrap(); - assert_eq!(tab_group(&layout, "pages"), ["properties", "pages"]); - assert!(matches!(&layout.root, Some(Node::Split { first, .. }) if matches!(&**first, Node::Dock { side: Side::Start, .. }))); - assert_eq!(tab_group(&layout, "layers"), ["layers"]); - // A whole group, by any of its panels, then into a new column of the end dock. - app.run("window.panel.float", json!({"panel": "pages", "group": true, "x": 80, "y": 80, "width": 300, "height": 400})).unwrap(); - let layout = app.ui.docking.clone().unwrap(); - assert_eq!(layout.floating.len(), 1); - assert_eq!(window_groups(&layout.floating[0])[0].panels, ["properties", "pages", "layers"]); - app.run("window.panel.dock", json!({"panel": "pages", "group": true, "side": "End"})).unwrap(); - let layout = app.ui.docking.clone().unwrap(); - assert!(layout.floating.is_empty()); - assert_eq!(tab_group(&layout, "pages"), ["properties", "pages", "layers"]); - // A lone panel to a new dock at the start edge. - app.run("window.panel.float", json!({"panel": "stroke"})).unwrap(); - app.run("window.panel.dock", json!({"panel": "stroke", "side": "Start"})).unwrap(); - assert!( - matches!(&app.ui.docking.as_ref().unwrap().root, Some(Node::Split { first, .. }) if matches!(&**first, Node::Dock { side: Side::Start, .. })) - ); - // Docking it at that side again leaves it where it is. - let before = app.ui.docking.clone(); - app.run("window.panel.dock", json!({"panel": "stroke", "side": "Start"})).unwrap(); - assert_eq!(app.ui.docking, before, "it already leads the start dock's last column"); - // Close Others and Minimize are shared commands too. - app.run("window.panel.closeOthers", json!({"panel": "pages"})).unwrap(); - assert_eq!(tab_group(app.ui.docking.as_ref().unwrap(), "pages"), ["pages"]); - assert!(app.ui.docking_hidden.contains_key("properties") && app.ui.docking_hidden.contains_key("layers")); - app.run("window.panel.minimize", json!({"panel": "pages"})).unwrap(); - let location = app.ui.docking.as_ref().unwrap().location(&"pages".to_string()).unwrap(); - assert!(location.group.is_some_and(|g| g.minimized)); - // Rejected commands change nothing. - let before = (app.ui.docking.clone(), app.ui.docking_hidden.clone()); - for (id, params) in [ - ("window.workspace.lock", json!({})), - ("window.panel.window", json!({"panel": "pages"})), - ("window.panel.move", json!({"panel": "stroke", "anchor": "pages", "zone": "left", "before": "pages"})), - ("window.panel.dock", json!({"panel": "stroke", "side": "Middle"})), - ("window.panel.dock", json!({"panel": "stroke", "zone": "left"})), - ("window.dockPanel", json!({"panel": "properties", "zone": "bottom"})), - ] { - assert!(app.run(id, params).is_err(), "{id}"); - assert_eq!((app.ui.docking.clone(), app.ui.docking_hidden.clone()), before, "{id}"); - } - // A zone is a side of an anchor; the error names the two ways to say where. - let error = app.run("window.panel.dock", json!({"panel": "stroke", "zone": "right"})).unwrap_err(); - assert!(error.contains("anchor") && error.contains("side"), "{error}"); - // Every shared id is registered for the command list and MCP. - for id in craft_ui::docking::DockCommand::::IDS { - assert!(crate::menus::UI_COMMANDS.iter().any(|c| c.0 == *id), "{id} is not registered"); - } -} - -/// A tab released over the canvas floats there (craft-ui: the protected canvas takes no tabs or -/// splits); a lone panel released in the window's left edge band makes a new dock there. -#[test] -fn tabs_dropped_on_the_canvas_float_and_at_the_far_edge_make_a_dock() { - let mut h = harness(app(), vec2(1280.0, 800.0)); - let tab = |h: &Harness<'static, DesignApp>, panel: &str| { - hits(h).into_iter().find(|(hit, _)| *hit == craft_ui::docking::Hit::Tab(panel.into())).map(|(_, r)| r) - }; - let layers = tab(&h, "layers").expect("the Layers tab"); - drag(&mut h, layers.center(), egui::pos2(500.0, 400.0)); - let layout = h.state().ui.docking.clone().unwrap(); - assert!(layout.floating.iter().any(|w| w.contains(&"layers".to_string())), "{layout:?}"); - // A new dock takes a panel that is alone in its group: the floating Layers, by its title bar. - // The harness's area starts 8 pt in from the window edge. - let title = hits(&h).into_iter().find(|(hit, _)| *hit == craft_ui::docking::Hit::Title("layers".into())).expect("the title bar").1; - drag(&mut h, title.center(), egui::pos2(12.0, 400.0)); - let layout = h.state().ui.docking.clone().unwrap(); - assert!(valid(&layout)); - let mut docks = Vec::new(); - let mut nodes: Vec<&Node> = layout.root.iter().collect(); - while let Some(node) = nodes.pop() { - match node { - Node::Split { first, second, .. } => nodes.extend([&**first, &**second]), - Node::Dock { side, columns } => { - docks.push((*side, columns.iter().flat_map(|c| &c.groups).flat_map(|g| g.panels.clone()).collect::>())) - } - _ => {} - } - } - assert!(docks.iter().any(|(side, panels)| *side == Side::Start && panels == &["layers"]), "{docks:?} {:?}", layout.floating); -} - -/// `ui.docking` lists where each gesture starts: tabs, drag bars and the iconic column's icons. -#[test] -fn ui_docking_reports_hit_rects() { - let h = harness(app(), vec2(1280.0, 800.0)); - let v = crate::panel_docking::inspect(h.state(), &h.ctx); - let rects = v["hitRects"].as_array().expect("hitRects"); - let rect_of = |hit: Value| { - let entry = rects.iter().find(|r| r["hit"] == hit).unwrap_or_else(|| panic!("no {hit} in {rects:?}")); - let r: Vec = serde_json::from_value(entry["rect"].clone()).unwrap(); - egui::Rect::from_min_size(egui::pos2(r[0], r[1]), vec2(r[2], r[3])) - }; - let tab = rect_of(json!({"Tab": "pages"})); - let bar = rect_of(json!({"DragBar": "properties"})); - let icon = rect_of(json!({"Icon": "stroke"})); - assert!(tab.width() > 20.0 && bar.width() >= 24.0 && icon.width() > 20.0, "{tab:?} {bar:?} {icon:?}"); - assert!((tab.center().y - bar.center().y).abs() < 1.0, "the bar is in the tab strip"); - assert!(icon.left() > 640.0, "the iconic column is at the end edge"); -} - -/// Clicking an icon in the iconic column opens its panel as a flyout on the canvas side; a -/// second click closes it. -#[test] -fn clicking_an_iconic_column_icon_opens_its_flyout() { - let mut h = harness(app(), vec2(1280.0, 800.0)); - let icon = hits(&h).into_iter().find(|(hit, _)| *hit == craft_ui::docking::Hit::Icon("swatches".into())).expect("the Swatches icon").1; - click(&mut h, icon.center(), PointerButton::Primary); - assert_eq!(h.state().ui.docking_flyout.as_deref(), Some("swatches")); - let tab = hits(&h).into_iter().find(|(hit, _)| *hit == craft_ui::docking::Hit::Tab("swatches".into())).expect("the flyout's tab").1; - assert!(tab.right() <= icon.left() + 1.0, "the flyout is beside the column, on the canvas side: {tab:?} {icon:?}"); - assert!(showing(h.state(), "swatches")); - click(&mut h, icon.center(), PointerButton::Primary); - assert!(h.state().ui.docking_flyout.is_none()); -} - -/// Iconic columns paint DesignCraft's own panel icons, in the colour craft-ui passes; a panel -/// without an icon paints nothing. -#[test] -fn panel_icons_are_painted_in_code() { - let mut app = app(); - let mut content = PanelContent { app: &mut app }; - let ctx = egui::Context::default(); - let mut paint = |panel: &str| { - let mut painted = false; - let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { - let painter = ui.ctx().layer_painter(egui::LayerId::background()); - let rect = egui::Rect::from_min_size(Pos2::ZERO, vec2(20.0, 20.0)); - painted = craft_ui::docking::DockContent::paint_icon(&mut content, &painter, rect, &panel.to_string(), egui::Color32::RED); - }); - output.textures_delta.clear(); - (painted, output.shapes.len()) - }; - let (painted, shapes) = paint("pages"); - assert!(painted && shapes > 0, "{painted} {shapes}"); - assert_eq!(paint(CANVAS), (false, 0)); -} - -/// Every docking label in DesignCraft's wording is translated in the shared catalog's languages. -#[test] -fn docking_labels_are_translated() { - for language in ["de", "fr", "es", "ja", "zh"] { - for (key, english) in DOCK_LABELS { - assert_ne!(crate::i18n::tr(language, english), *english, "{key} in {language}"); - } - } -} - -/// With DesignCraft's title bar under macOS's transparent one, a floating window saved in the -/// title-bar strip is drawn below it, where its title bar can't move the app window. -#[test] -fn a_floating_window_saved_in_the_title_bar_strip_is_drawn_below_it() { - let mut app = app(); - app.integrated_titlebar = true; - app.run("window.panel.float", json!({"panel":"properties"})).unwrap(); - let window = app.ui.docking.as_mut().unwrap().floating.iter_mut().find(|w| w.panels().any(|p| p == "properties")).unwrap(); - window.rect = [200.0, 0.0, 280.0, 360.0]; - let h = harness(app, egui::vec2(1400.0, 900.0)); - let drawn = h.ctx.memory(|m| m.area_rect(crate::panel_docking::floating_layer("properties").id)).unwrap(); - assert!(drawn.min.y >= crate::chrome::MAC_APP_BAR - 0.5, "{drawn:?}"); -} - -/// A floating window holds splits like the dock: a panel docked below another one in the -/// window splits it, the divider between them drags, and the new size is saved. -#[test] -fn a_split_inside_a_floating_window_resizes_and_is_saved() { - let mut app = app(); - app.run("window.panel.float", json!({"panel":"swatches", "x":300, "y":120, "width":300, "height":500})).unwrap(); - app.run("window.panel.move", json!({"panel":"color", "anchor":"swatches", "zone":"bottom"})).unwrap(); - let window = |app: &DesignApp| app.ui.docking.as_ref().unwrap().floating.iter().find(|w| w.contains("swatches")).cloned().unwrap(); - let ratio = |window: &Floating| match window.root { - Node::Split { size: craft_ui::layout::SplitSize::Ratio(r), .. } => r, - ref root => panic!("expected a split window: {root:?}"), - }; - assert_eq!(window_groups(&window(&app)).into_iter().map(|g| g.panels).collect::>(), [["swatches"], ["color"]]); - let before = ratio(&window(&app)); - let mut h = harness(app, vec2(1400.0, 900.0)); - let divider = hits(&h) - .into_iter() - .find_map(|(hit, rect)| { - matches!(&hit, craft_ui::docking::Hit::Divider(craft_ui::docking::Divider::WindowSplit(_, path)) if path.is_empty()).then_some(rect) - }) - .expect("the window's divider"); - drag(&mut h, divider.center(), divider.center() + vec2(0.0, 60.0)); - let after = ratio(&window(h.state())); - assert!(after > before + 0.05, "{before} -> {after}"); - let saved = serde_json::to_vec(&h.state().ui).unwrap(); - let loaded = crate::UiState::from_preferences_json(&saved).unwrap(); - assert_eq!(loaded.docking, h.state().ui.docking); -} - -/// The default layout is rebuilt every frame: its floating windows keep their ids, so their -/// native windows aren't opened again each frame. -#[test] -fn default_layout_floats_keep_their_window_ids() { - let mut app = app(); - app.ui.floating = vec![("swatches".to_string(), [10.0, 20.0])]; - let ids = |app: &DesignApp| default_layout(&app.ui).floating.iter().map(|w| w.id).collect::>(); - let first = ids(&app); - assert_eq!(first.len(), 1, "Swatches floats"); - assert_eq!(ids(&app), first); -} - -#[test] -fn dialogs_about_and_quick_apply_block_native_floating_windows() { - let mut app = app(); - assert!(!modal_open(&app)); - app.ui.dialog = Some(crate::dialogs::Dialog::new("newDocument", json!({}))); - assert!(modal_open(&app), "dialog"); - app.ui.dialog = None; - app.run("help.about", json!({})).unwrap(); - assert!(modal_open(&app), "About"); - app.run("help.about", json!({"open": false})).unwrap(); - assert!(!modal_open(&app)); - app.ui.palette = Some(String::new()); - assert!(modal_open(&app), "Quick Apply"); - app.ui.palette = None; - assert!(!modal_open(&app)); -} - -#[cfg(not(target_arch = "wasm32"))] -#[test] -fn a_document_dropped_on_a_floating_panel_window_opens() { - #[derive(Debug)] - struct Dropped(std::path::PathBuf); - impl egui::DroppedFile for Dropped { - fn path(&self) -> &std::path::Path { - &self.0 - } - fn bytes(&self) -> Result, String> { - std::fs::read(&self.0).map_err(|e| e.to_string()) - } - } - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("dropped.designcraft"); - let mut saved = app(); - saved.run("file.new", json!({})).unwrap(); - saved.run("file.save", json!({"path": path})).unwrap(); - - let mut app = app(); - assert!(app.session.documents().is_empty()); - let files = craft_ui::docking::WindowFiles { - window: 1, - viewport: egui::ViewportId::from_hash_of("float"), - panel: "layers".to_owned(), - pos: None, - hovered: Vec::new(), - dropped: vec![std::sync::Arc::new(Dropped(path.clone())) as egui::DroppedFileHandle], - }; - open_window_drops(&mut app, &[files]); - assert_eq!(app.session.documents().len(), 1, "the dropped document opens"); -} From c9255c30ae6a98c76065f660234aee2906535132 Mon Sep 17 00:00:00 2001 From: macadminHU <51995175+macrobi@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:08:51 +0100 Subject: [PATCH 241/628] HU1: preserve localization regressions in the new test module Retain localization checks from both merge parents, including Hungarian context and catalog coverage, and directly check the repaired Georgian captions. This file matches the locally tested shared-module integration. --- crates/ui-egui/src/i18n/tests.rs | 152 ++++++++++++++++++++++++++++++- 1 file changed, 150 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index db13138cf..c8dc8578f 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -7,6 +7,7 @@ const LANGUAGE_TABLES: &[(&str, &[(&str, &str)])] = &[ ("cs", cs::TABLE), ("de", de::TABLE), ("fr", fr::TABLE), + ("hu", hu::TABLE), ("it", it::TABLE), ("ja", ja::TABLE), ("ka", ka::TABLE), @@ -17,8 +18,15 @@ const LANGUAGE_TABLES: &[(&str, &[(&str, &str)])] = &[ ]; /// Every per-language table `tr_context` looks up. -const CONTEXT_TABLES: &[(&str, &[(&str, &str, &str)])] = - &[("ar", ar::CONTEXT), ("cs", cs::CONTEXT), ("ja", ja::CONTEXT), ("pl", pl::CONTEXT), ("ru", ru::CONTEXT), ("uk", uk::CONTEXT)]; +const CONTEXT_TABLES: &[(&str, &[(&str, &str, &str)])] = &[ + ("ar", ar::CONTEXT), + ("cs", cs::CONTEXT), + ("hu", hu::CONTEXT), + ("ja", ja::CONTEXT), + ("pl", pl::CONTEXT), + ("ru", ru::CONTEXT), + ("uk", uk::CONTEXT), +]; /// The languages of the shared tables' columns, in column order. const SHARED_COLUMNS: [&str; 5] = ["de", "fr", "es", "ja", "zh"]; @@ -31,6 +39,7 @@ fn or_english<'a>(text: &'a str, english: &'a str) -> &'a str { if text.trim().is_empty() { english } else { text } } +/// Checks cached catalog lookup across language switches, including source-text fallback. #[test] fn cached_lookup_preserves_every_translation_across_language_switches() { let own = |lang: &str, key: &str| { @@ -52,6 +61,9 @@ fn cached_lookup_preserves_every_translation_across_language_switches() { assert_eq!(tr("", key), *key); } } + // Keep the repaired Georgian menu captions present with their exact translations. + assert_eq!(tr("ka", "File Info…"), "ფაილის ინფორმაცია…"); + assert_eq!(tr("ka", "Check for Updates…"), "განახლებების შემოწმება…"); // Strings no catalog has stay as they are, in every language. let unknown = String::from("A user-defined untranslated label"); for lang in LANGUAGES.iter().map(|(code, _)| *code).chain(["en", "unknown"]) { @@ -61,6 +73,92 @@ fn cached_lookup_preserves_every_translation_across_language_switches() { } } +/// Checks Hungarian catalog integrity, context captions and count substitutions, including +/// English fallback and preservation of user-defined style and workspace names. +#[test] +fn hungarian_catalog_preserves_placeholders_context_and_counts() { + let catalog: HashMap<_, _> = hu::TABLE.iter().copied().collect(); + assert_eq!(catalog.len(), hu::TABLE.len(), "duplicate Hungarian keys"); + let placeholders = |s: &str| { + let mut names: Vec<_> = s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect(); + names.sort(); + names + }; + for (key, translation) in hu::TABLE { + assert!(!translation.trim().is_empty(), "empty {key}"); + assert_eq!(placeholders(key), placeholders(translation), "placeholders: {key}"); + assert_eq!(key.ends_with('…'), translation.ends_with('…'), "ellipsis: {key}"); + } + assert!(!is_rtl("hu")); + assert_eq!(tr("hu", "File"), "Fájl"); + assert_eq!(tr("hu", "Bleed"), "Kifutó"); + assert_eq!(tr("hu", "Arrangement"), "Elrendezés"); + assert_eq!(tr("hu", "Unknown label"), "Unknown label"); + assert_eq!(tr("hu", "Columns"), "Hasábok"); + assert_eq!(tr_context("hu", "Columns", "table"), "Oszlopok"); + assert_eq!(tr("hu", "Columns:"), "Hasábok:"); + assert_eq!(tr_context("hu", "Columns:", "grid"), "Oszlopok:"); + assert_eq!(tr_context("hu", "Columns:", "unknown"), "Hasábok:"); + assert_eq!(tr("hu", "Group"), "Csoportosítás"); + assert_eq!(tr_context("hu", "Group", "selection"), "Csoport"); + assert_eq!(tr_context("hu", "Columns", "unknown"), "Hasábok"); + assert_eq!(tr_context("", "Columns", "table"), "Columns"); + for n in [0, 1, 2, 5, 21] { + assert_eq!(count_label("hu", "pages", n), format!("{n} oldal")); + assert_eq!(count_label("hu", "objects", n), format!("{n} objektum")); + assert_eq!(count_label("hu", "errors", n), format!("{n} hiba")); + assert_eq!( + tr("hu", "{pages} Pages in {spreads} Spreads").replace("{pages}", &n.to_string()).replace("{spreads}", &n.to_string()), + format!("{n} oldal, {n} oldalpáron") + ); + } + assert_eq!(style_name("hu", "[Basic Paragraph]"), "[Alapbekezdés]"); + assert_eq!(style_name("hu", "Saját stílus"), "Saját stílus"); + assert_eq!(workspace_name("hu", "Essentials"), "Alapok"); + assert_eq!(workspace_name("hu", "Saját munkaterület"), "Saját munkaterület"); +} + +/// Checks translated labels and source-text fallback, plus key uniqueness and nonempty rows +/// in the shared, Arabic, Brazilian Portuguese and Italian catalogs. +#[test] +fn translates_known_strings_and_keeps_the_rest() { + assert_eq!(tr("de", "File"), "Datei"); + assert_eq!(tr("ja", "Swatches"), "スウォッチ"); + assert_eq!(tr("fr", "Something new"), "Something new"); + assert_eq!(tr("", "File"), "File"); + assert_eq!(tr("ar", "File"), "ملف"); + assert_eq!(tr("ar", "Unknown label"), "Unknown label"); + assert_eq!(tr("pt-br", "File"), "Arquivo"); + assert_eq!(tr("pt-br", "New Document…"), "Novo documento…"); + assert_eq!(tr("pt-br", "Unknown label"), "Unknown label"); + assert_eq!(tr("it", "Edit"), "Modifica"); + assert_eq!(tr("it", "New Document…"), "Nuovo documento…"); + assert_eq!(tr("it", "Unknown label"), "Unknown label"); + assert!(is_rtl("ar")); + for (i, (en, translation)) in ar::TABLE.iter().enumerate() { + assert!(ar::TABLE[..i].iter().all(|(key, _)| key != en), "duplicate {en}"); + assert!(!translation.is_empty(), "{en}"); + } + for (i, (en, translation)) in pt_br::TABLE.iter().enumerate() { + assert!(pt_br::TABLE[..i].iter().all(|(key, _)| key != en), "duplicate {en}"); + assert!(!translation.is_empty(), "{en}"); + } + for (i, (en, translation)) in it::TABLE.iter().enumerate() { + assert!(it::TABLE[..i].iter().all(|(key, _)| key != en), "duplicate {en}"); + assert!(!translation.is_empty(), "{en}"); + } + // Italian entries are for keys the shared or Arabic table knows. A new English string + // falls back to English until it is translated, so it doesn't fail here. + for (en, _) in it::TABLE { + assert!(TABLE.iter().any(|(key, _)| key == en) || ar::TABLE.iter().any(|(key, _)| key == en), "it: unknown key {en}"); + } + // Every row is unique and complete. + for (i, (en, t)) in TABLE.iter().enumerate() { + assert!(TABLE[..i].iter().all(|(e, _)| e != en), "duplicate {en}"); + assert!(t.iter().all(|x| !x.is_empty()), "{en}"); + } +} + /// The Data Merge arrangement combo takes its accessible name from `tr(lang, "Arrangement")`. /// A row in only the shared table leaves the languages whose lookup returns directly with the /// English name. @@ -77,6 +175,7 @@ fn arrangement_accessible_name_is_localized_in_every_interface_language() { } } +/// Checks language-specific context precedence and fallback to the plain translation. #[test] fn context_captions_come_from_their_context_or_the_plain_translation() { for (lang, table) in CONTEXT_TABLES { @@ -110,6 +209,38 @@ fn context_captions_come_from_their_context_or_the_plain_translation() { } } +/// Keeps quantity labels distinct from numeric values and checks translated Pages-panel captions. +#[test] +fn a_column_count_and_a_list_number_read_differently() { + for (lang, count, numeral) in [ + ("hu", "Darabszám:", "Szám:"), + ("de", "Anzahl:", "Zahl:"), + ("ja", "数:", "番号:"), + ("uk", "Кількість:", "Номер:"), + ("ar", "العدد:", "الرقم:"), + ("fr", "Nombre :", "Numéro:"), + ("zh", "数量:", "编号:"), + ] { + assert_eq!((tr_context(lang, "Number:", "count"), tr(lang, "Number:")), (count, numeral), "{lang}"); + } + assert_eq!(tr_context("en", "Number:", "count"), "Number:"); + assert_eq!(tr("de", "Gutter:"), "Spaltenabstand:"); + // The Pages panel menu. + for key in [ + "Insert Pages…", + "Move Pages…", + "New Parent…", + "Override All Parent Page Items", + "Remove All Local Overrides", + "Detach All Objects from Parent", + "Create Alternate Layout…", + "Allow Selected Spread to Shuffle", + ] { + assert_ne!(tr("pt-br", key), key); + } + assert_ne!(tr("ar", "Apply Parent to Pages"), "Apply Parent to Pages"); +} + /// The tables `tr` reads, as (language, key, translation), context captions included. fn entries() -> Vec<(&'static str, &'static str, &'static str)> { let mut entries: Vec<_> = LANGUAGE_TABLES.iter().flat_map(|(lang, table)| table.iter().map(|(key, text)| (*lang, *key, *text))).collect(); @@ -263,6 +394,23 @@ fn interface_strings() -> HashSet<&'static str> { keys.extend(crate::panel_docking::DOCK_LABELS.iter().map(|(_, english)| *english)); // UI labels passed to `tr` that no other catalog has yet. keys.extend([ + "180°", + "Create Links When Placing Text Files", + "Delete Character Style…", + "Delete Paragraph Style…", + "Delete the character style \"{name}\"?", + "Delete the paragraph style \"{name}\"?", + "IT", + "Replace with:", + "Show the Typography panel", + "Stroke Behind Fill", + "Stylistic Set", + "The selected fonts have no OpenType features.", + "This story was edited in the layout: you'll be asked before those edits are replaced", + "This style is in use. Text using it will be reassigned to:", + "VA", + "Y:", + "“{}” has been edited in this layout. Updating replaces those edits with the text from the file.", "(any)", "(unchanged)", "[No Language]", From 39109cc0df5d5b4b6ef7802e5873cf799901c642 Mon Sep 17 00:00:00 2001 From: macadminHU <51995175+macrobi@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:10:55 +0100 Subject: [PATCH 242/628] HU1: retain both parents menu tests in the shared layout Retain the upstream shortcut and navigation tests together with Hungarian selection, persistence, menu-path and document-preservation regressions and the complete Georgian-menu assertion. --- crates/ui-egui/src/menus/tests.rs | 153 ++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 29ad3404a..ef8ad6035 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -54,6 +54,7 @@ fn define_lists_command_path_matches_the_type_menu() { assert!(menu_ids("Type").contains(&"list.define")); } +/// Checks that screen-mode menu checkmarks follow the current Normal or Preview mode. #[test] fn screen_mode_shows_the_current_mode() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); @@ -63,6 +64,24 @@ fn screen_mode_shows_the_current_mode() { assert_eq!((mode(&app, "normal"), mode(&app, "preview")), (Some(false), Some(true))); } +/// Checks that translated customization paths match the visible submenu captions, while +/// allowing uncatalogued paths to keep their English fallback. +#[test] +fn hungarian_menu_paths_match_the_visible_submenu_labels() { + // These paths are displayed by menu customization; they must use the same + // captions as the actual menu tree, including nested special characters. + for title in ["Edit", "Type", "View"] { + for path in submenu_paths(title) { + let translated = crate::i18n::tr("hu", &path); + if translated == path { + continue; // New, uncatalogued paths still fall back to English. + } + let visible = path.split('/').map(|part| crate::i18n::tr("hu", part)).collect::>().join("/"); + assert_eq!(translated, visible, "{path}"); + } + } +} + fn walk(items: &[Item], out: &mut Vec<(String, String, Value)>) { for it in items { match it { @@ -548,6 +567,7 @@ fn split_window_contextual_bars_have_independent_widget_ids() { assert!(!bars[0].intersects(bars[1]), "each pane keeps its own bar: {bars:?}"); } +/// Checks the document-grid menu label, shortcut, checkmark and repeated toggle behavior. #[test] fn show_document_grid_toggles_from_the_grids_and_guides_menu() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); @@ -566,6 +586,8 @@ fn show_document_grid_toggles_from_the_grids_and_guides_menu() { assert!(!app.ui.document_grid); } +/// Checks language selection and rejection of unknown language codes, Preferences dispatch, +/// translated menu labels and preservation of document data when switching to Arabic. #[test] fn interface_language_switches_and_validates() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); @@ -583,6 +605,137 @@ fn interface_language_switches_and_validates() { run_ui(&mut app, "app.language", &json!({})).unwrap().unwrap(); assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("preferences")); assert_eq!(app.ui.language, "de"); + app.ui.dialog = None; + + run_ui(&mut app, "app.language", &json!({"lang": "zh"})).unwrap().unwrap(); + assert_eq!(app.ui.language, "zh"); + assert_eq!(crate::i18n::tr(&app.ui.language, "File"), "文件"); + + app.run("file.new", json!({"pages": 4, "facingPages": true})).unwrap(); + let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap(); + run_ui(&mut app, "app.language", &json!({"lang": "ar"})).unwrap().unwrap(); + assert_eq!(app.ui.language, "ar"); + assert_eq!(checked(&app, "app.language", &json!({"lang": "ar"})), Some(true)); + assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap()); + for (title, _) in menu_tree() { + assert_ne!(crate::i18n::tr("ar", title), title, "{title}"); + } + // Every menu title has a translation (Japanese never matches the English). + for (title, _) in menu_tree() { + assert_ne!(crate::i18n::tr("ja", title), title, "{title}"); + } + + run_ui(&mut app, "app.language", &json!({"lang": "pt-br"})).unwrap().unwrap(); + assert_eq!(app.ui.language, "pt-br"); + assert_eq!(checked(&app, "app.language", &json!({"lang": "pt-br"})), Some(true)); + for (title, _) in menu_tree() { + // "Layout" is the natural Brazilian Portuguese word, so it stays identical + // to English; every other title must be translated. + if title != "Layout" { + assert_ne!(crate::i18n::tr("pt-br", title), title, "{title}"); + } + } + + run_ui(&mut app, "app.language", &json!({"lang": "it"})).unwrap().unwrap(); + assert_eq!(app.ui.language, "it"); + assert_eq!(checked(&app, "app.language", &json!({"lang": "it"})), Some(true)); + for (title, _) in menu_tree() { + // "File" and "Layout" are the usual Italian menu names, so they stay identical + // to English; every other title must be translated. + if title != "File" && title != "Layout" { + assert_ne!(crate::i18n::tr("it", title), title, "{title}"); + } + } + + run_ui(&mut app, "app.language", &json!({"lang": "ka"})).unwrap().unwrap(); + assert_eq!(app.ui.language, "ka"); + assert_eq!(checked(&app, "app.language", &json!({"lang": "ka"})), Some(true)); + // Every menu title, item and submenu is translated into Georgian. + let mut untranslated = Vec::new(); + for (title, entries) in menu_tree() { + let mut labels = vec![title.to_string()]; + let mut stack: Vec<&Item> = entries.iter().collect(); + while let Some(entry) = stack.pop() { + match entry { + Item::Sub(label, children) => { + labels.push(label.clone()); + stack.extend(children.iter()); + } + Item::Cmd { label, id, .. } if id != "app.language" => labels.push(label.clone()), + _ => {} + } + } + // Labels without letters ("180°") or point sizes ("72 pt") read the same in every language. + let size = |l: &str| l.strip_suffix(" pt").is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit())); + untranslated.extend(labels.into_iter().filter(|l| l.chars().any(char::is_alphabetic) && !size(l) && crate::i18n::tr("ka", l) == l.as_str())); + } + assert!(untranslated.is_empty(), "untranslated Georgian menu labels: {untranslated:?}"); +} + +/// Checks Hungarian selection, menu captions and saved UI preferences without changing +/// document data or translating user-defined names. +#[test] +fn hungarian_language_is_selectable_persisted_and_keeps_document_data() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 4, "facingPages": true})).unwrap(); + app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "text", "text": "My words · Árvíztűrő tükörfúrógép"})).unwrap(); + let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap(); + run_ui(&mut app, "app.language", &json!({"lang": "hu"})).unwrap().unwrap(); + assert_eq!(checked(&app, "app.language", &json!({"lang": "hu"})), Some(true)); + assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap()); + for (title, _) in menu_tree() { + assert_ne!(crate::i18n::tr("hu", title), title, "{title}"); + } + let bytes = serde_json::to_vec(&app.ui).unwrap(); + let restored: crate::UiState = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(restored.language, "hu"); + assert_eq!(crate::i18n::tr(&restored.language, "File"), "Fájl"); + assert_eq!(crate::i18n::tr(&restored.language, "A custom name"), "A custom name"); + assert!(crate::i18n::LANGUAGES.contains(&("hu", "Magyar")), "Preferences lists Hungarian by its own name"); +} + +/// Checks Ukrainian selection, menu captions and saved UI preferences without changing +/// document data or translating user-defined names. +#[test] +fn ukrainian_language_is_selectable_persisted_and_keeps_document_data() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 4, "facingPages": true})).unwrap(); + app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "text", "text": "My words · Мої слова"})).unwrap(); + let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap(); + run_ui(&mut app, "app.language", &json!({"lang": "uk"})).unwrap().unwrap(); + assert_eq!(checked(&app, "app.language", &json!({"lang": "uk"})), Some(true)); + assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap()); + for (title, _) in menu_tree() { + assert_ne!(crate::i18n::tr("uk", title), title, "{title}"); + } + let bytes = serde_json::to_vec(&app.ui).unwrap(); + let restored: crate::UiState = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(restored.language, "uk"); + assert_eq!(crate::i18n::tr(&restored.language, "File"), "Файл"); + assert_eq!(crate::i18n::tr(&restored.language, "A custom name"), "A custom name"); + assert!(crate::i18n::LANGUAGES.contains(&("uk", "Українська")), "Preferences lists Ukrainian by its own name"); +} + +/// Checks Russian selection, menu captions and saved UI preferences without changing +/// document data or translating user-defined names. +#[test] +fn russian_language_is_selectable_persisted_and_keeps_document_data() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 4, "facingPages": true})).unwrap(); + app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "text", "text": "My words · Мои слова"})).unwrap(); + let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap(); + run_ui(&mut app, "app.language", &json!({"lang": "ru"})).unwrap().unwrap(); + assert_eq!(checked(&app, "app.language", &json!({"lang": "ru"})), Some(true)); + assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap()); + for (title, _) in menu_tree() { + assert_ne!(crate::i18n::tr("ru", title), title, "{title}"); + } + let bytes = serde_json::to_vec(&app.ui).unwrap(); + let restored: crate::UiState = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(restored.language, "ru"); + assert_eq!(crate::i18n::tr(&restored.language, "File"), "Файл"); + assert_eq!(crate::i18n::tr(&restored.language, "A custom name"), "A custom name"); + assert!(crate::i18n::LANGUAGES.contains(&("ru", "Русский")), "Preferences lists Russian by its own name"); } #[test] From f8b05ee3df5c482106b08d24a7486925dc926a7e Mon Sep 17 00:00:00 2001 From: macadminHU <51995175+macrobi@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:11:25 +0100 Subject: [PATCH 243/628] HU1: retain toolbar coverage with current keymap captions Preserve existing toolbar assertions and Hungarian right-click flyout coverage in both column layouts. Check platform-formatted default shortcuts and a user-assigned chord, including painted text, clipping and overlap. All 13 focused toolbar tests passed. --- crates/ui-egui/src/toolbar/tests.rs | 93 ++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/toolbar/tests.rs b/crates/ui-egui/src/toolbar/tests.rs index 6a8ea752c..c970f7286 100644 --- a/crates/ui-egui/src/toolbar/tests.rs +++ b/crates/ui-egui/src/toolbar/tests.rs @@ -19,10 +19,100 @@ fn column_width(cols: f32) -> f32 { 8.0 + super::BTN * cols + 4.0 * (cols - 1.0) + 8.0 } +/// Returns the bounds of the labeled test widget, failing if the label cannot be found. fn rect(h: &egui_kittest::Harness<'static, test_window::Window>, label: &str) -> egui::Rect { h.get_by_label(label).rect() } +/// Checks translated tooltips and right-click flyout rows in both Tools-panel layouts, +/// including painted labels, unchanged shortcuts and unclipped, nonoverlapping text. +#[test] +fn hungarian_tool_buttons_translate_every_tool_and_keep_shortcuts() { + use egui::{Event, Modifiers, PointerButton}; + + /// Collects painted text bounds and their clip rectangles, including nested shapes. + fn collect_text(shape: &egui::Shape, clip: egui::Rect, texts: &mut Vec<(String, egui::Rect, egui::Rect)>) { + match shape { + egui::Shape::Text(text) => texts.push((text.galley.text().to_owned(), text.visual_bounding_rect(), clip)), + egui::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect_text(shape, clip, texts)), + _ => {} + } + } + + for double in [false, true] { + let mut app = app(double); + app.ui.language = "hu".into(); + let mut h = test_window::open(app, vec2(1400.0, 1000.0)); + for tool in designcraft_tools::TOOL_GROUPS.iter().flat_map(|group| group.iter()) { + h.state_mut().app.run("tool.select", serde_json::json!({"tool": tool.id})).unwrap(); + h.run_steps(3); + let label = crate::i18n::tr("hu", tool.label); + assert_ne!(label, tool.label, "untranslated tool: {}", tool.id); + let shortcut = tool.shortcut.map(crate::menus::shortcut_text); + assert_eq!(crate::menus::tool_shortcut_label(&h.state().app, tool.id), shortcut, "default binding for {}", tool.id); + let tip = shortcut.map_or_else(|| label.to_owned(), |shortcut| format!("{label} ({shortcut})")); + assert!(rect(&h, &tip).is_finite(), "{tip}"); + } + // A user-assigned chord also has to fit beside a translated flyout label. + let custom_shortcut = "Ctrl+Alt+Shift+F12"; + h.state_mut() + .app + .run( + "window.setShortcut", + serde_json::json!({"id": "tool.select", "params": {"tool": "ellipseFrame"}, "shortcut": custom_shortcut, "steal": true}), + ) + .unwrap(); + for (gi, group) in designcraft_tools::TOOL_GROUPS.iter().enumerate() { + // Single tools have no flyout; the collector group is split into separate + // buttons in two-column mode and is covered by the tooltip checks above. + if group.len() < 2 || (double && designcraft_tools::SPLIT_IN_TWO_COLUMNS.contains(&group[0].id)) { + continue; + } + let first = group[0]; + h.state_mut().app.run("tool.select", serde_json::json!({"tool": first.id})).unwrap(); + h.run_steps(3); + let label = crate::i18n::tr("hu", first.label); + let tip = first.shortcut.map(crate::menus::shortcut_text).map_or_else(|| label.to_owned(), |shortcut| format!("{label} ({shortcut})")); + let at = rect(&h, &tip).center(); + h.input_mut().events.push(Event::PointerMoved(at)); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + } + h.run_steps(3); + assert_eq!(h.state().app.ui.flyout, Some(gi), "right-click opens {} (double={double})", first.id); + let mut texts = Vec::new(); + for shape in &h.output().shapes { + collect_text(&shape.shape, shape.clip_rect, &mut texts); + } + for tool in *group { + let label = crate::i18n::tr("hu", tool.label); + // Flyout accessibility labels omit the shortcut; inspect the painted + // text inside this row so another button cannot satisfy the assertion. + let row = rect(&h, label); + assert!(row.is_finite() && row.is_positive(), "{label}"); + let painted = |expected: &str| { + let (_, bounds, clip) = texts + .iter() + .find(|(text, bounds, _)| text == expected && row.contains(bounds.center())) + .unwrap_or_else(|| panic!("missing flyout text {expected:?} for {} (double={double})", tool.id)); + assert!(row.contains_rect(*bounds) && clip.contains_rect(*bounds), "clipped flyout text {expected:?}: {bounds:?}"); + *bounds + }; + let label_bounds = painted(label); + let binding = if tool.id == "ellipseFrame" { Some(custom_shortcut) } else { tool.shortcut }; + if let Some(shortcut) = binding.map(crate::menus::shortcut_text) { + let shortcut_bounds = painted(&shortcut); + assert!(label_bounds.right() < shortcut_bounds.left(), "label overlaps shortcut for {} (double={double})", tool.id); + } + } + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state().app.ui.flyout.is_none()); + } + assert_eq!(crate::i18n::tr("hu", "Direct Selection Tool"), "Közvetlen kijelölő eszköz"); + } +} + /// As InDesign's: a tool group's flyout closes on Esc and on a click outside it (the fill /// swatch, a panel), not only on the canvas. #[test] @@ -197,7 +287,8 @@ fn the_tools_panel_lays_out_like_indesigns() { for double in [false, true] { let h = test_window::open(app(double), vec2(1400.0, 900.0)); let _ = rect(&h, "Gradient Swatch Tool (G)"); - let _ = rect(&h, "Gradient Feather Tool (Shift+G)"); + let feather = format!("Gradient Feather Tool ({})", crate::menus::shortcut_text("Shift+G")); + let _ = rect(&h, &feather); let _ = rect(&h, "View Options"); let split = ["Content Placer Tool (B)", "Apply Color (,)", "Apply Gradient (.)", "Normal"]; for label in split { From 4207debc170e452c59471bdef166f297afec23f7 Mon Sep 17 00:00:00 2001 From: macadminHU <51995175+macrobi@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:14:01 +0100 Subject: [PATCH 244/628] HU1: retain Hungarian in the moved language command metadata Keep hu in the app.language parameter documentation after the UI command table moved to menus/ui_commands.rs. --- crates/ui-egui/src/menus/ui_commands.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 3d43beea6..42cfa56b9 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -66,7 +66,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ "app.language", "Interface Language", None, - "{lang?: \"\"|de|fr|es|ja|zh|ar|pt-br|it|uk|ru|cs|pl|ka} — menus and panel names (the macOS menu bar follows at once); without lang, opens Preferences, where it is the first setting", + "{lang?: \"\"|de|fr|es|ja|zh|ar|pt-br|it|uk|ru|cs|pl|ka|hu} — menus and panel names (the macOS menu bar follows at once); without lang, opens Preferences, where it is the first setting", ), ( "app.flattener", From e6e8fc126eb17f6fac9527dd21ce0db13fbc96c0 Mon Sep 17 00:00:00 2001 From: macadminHU <51995175+macrobi@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:15:05 +0100 Subject: [PATCH 245/628] HU1: keep the integrated PDF condition clippy-clean Apply the equivalent De Morgan form of the upstream gradient condition so Rust 1.95 clippy with warnings denied can complete. Short-circuit order and output behavior are unchanged. --- crates/pdf/src/gradient_inks.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/pdf/src/gradient_inks.rs b/crates/pdf/src/gradient_inks.rs index e889ee1d2..c92b80158 100644 --- a/crates/pdf/src/gradient_inks.rs +++ b/crates/pdf/src/gradient_inks.rs @@ -87,7 +87,7 @@ impl Exporter<'_> { let inks: Vec> = g.stops.iter().map(|s| self.stop_ink(s)).collect(); let has_spot = inks.iter().any(|i| matches!(i, Some(Ink::Spot { .. }))); let on_paper = g.stops.iter().any(|s| s.swatch.as_deref() == Some(designcraft_color::swatch::PAPER)); - if !has_spot && !overprint && !(on_paper && self.for_print() && !self.rgb_only) { + if !(has_spot || overprint || on_paper && self.for_print() && !self.rgb_only) { return None; } let Some(inks) = inks.into_iter().collect::>>() else { From 72a586e0d5f419cf98133f7a5863cd54d7d0ab11 Mon Sep 17 00:00:00 2001 From: macadminHU <51995175+macrobi@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:15:37 +0100 Subject: [PATCH 246/628] HU1: keep the moved PDF test helpers clippy-clean Simplify two upstream Option predicates to equivalent is_none_or forms. Preserve the None cases, all test cases and assertions; no coverage is removed. --- crates/engine/src/cmd/export/text_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/export/text_tests.rs b/crates/engine/src/cmd/export/text_tests.rs index e533320f8..2efcb2f92 100644 --- a/crates/engine/src/cmd/export/text_tests.rs +++ b/crates/engine/src/cmd/export/text_tests.rs @@ -204,12 +204,12 @@ fn black_and_gold(pdf: &[u8]) -> Vec<(u8, u8)> { let mut out = Vec::new(); for st in file.objects().into_iter().filter_map(|o| o.into_stream()) { let d = st.dict(); - if !d.get::>(b"Subtype").is_some_and(|n| n.as_ref() == b"Image") || d.get::(b"BitsPerComponent") != Some(8) { + if d.get::>(b"Subtype").is_none_or(|n| n.as_ref() != b"Image") || d.get::(b"BitsPerComponent") != Some(8) { continue; } let Some(cs) = d.get::>(b"ColorSpace") else { continue }; let mut it = cs.iter::>(); - if !it.next().and_then(|o| o.into_name()).is_some_and(|n| n.as_ref() == b"DeviceN") { + if it.next().and_then(|o| o.into_name()).is_none_or(|n| n.as_ref() != b"DeviceN") { continue; } let Some(Object::Array(names)) = it.next() else { continue }; From 7d48e47057f2abba78085d8accfa261221338b53 Mon Sep 17 00:00:00 2001 From: macadminHU <51995175+macrobi@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:16:04 +0100 Subject: [PATCH 247/628] HU1: record shared-module localization integration Record the preserved Hungarian catalog and both parents localization, menu, toolbar and docking tests, current keymap-aware flyout sizing and direct Georgian lookup checks. Keep every existing progress entry and readiness percentage. --- ROADMAP.md | 1 + 1 file changed, 1 insertion(+) diff --git a/ROADMAP.md b/ROADMAP.md index b3d3abeb8..52265475c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -378,6 +378,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **2026-10-27** · HU1: integrate Hungarian localization with the shared module layout; retain both parents' localization, menu, toolbar and panel-docking tests and all Hungarian catalog entries. Adapt toolbar tests and flyout sizing to platform-formatted and user-assigned shortcuts; add direct lookup assertions for the repaired Georgian menu captions. No readiness percentage change. - **2026-10-25** · HU1: extend Hungarian toolbar regression coverage through real right-click flyouts in both column layouts, checking translated row labels, painted shortcuts, clipping and overlap. Existing assertions and runtime behavior are unchanged. No readiness percentage change. - **2026-10-25** · HU1: retain both branches' localization and toolbar regression tests while adopting the updated egui button role; translate Data Merge Arrangement as Elrendezés (1,905 Hungarian entries and four context captions). No readiness percentage change. - **2026-10-25** · HU1: add the two missing Georgian File Info and Check for Updates menu captions while retaining the complete-menu regression unchanged. No readiness percentage change. From 40083be3f2be66b1b48dc2d97090e8513b74f10f Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Wed, 28 Oct 2026 04:07:38 +0400 Subject: [PATCH 248/628] i18n(ka): Georgian for keyboard shortcut sets, command forms and labels added since #1573 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/ui-egui/src/i18n/ka.rs | 388 ++++++++++++++++++++++++++++++++++ 1 file changed, 388 insertions(+) diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 436c157f4..1cefbbfcf 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -2475,4 +2475,392 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Set OpenType Features", "OpenType შესაძლებლობების დაყენება"), ("Swap Text Fill and Stroke", "ტექსტის შევსებისა და კონტურის გაცვლა"), ("Thread Text Frames", "ტექსტური ჩარჩოების დაკავშირება"), + // Command-form parameter names, presets and dialog text. + ("Apply to:", "გამოყენება:"), + ("Auto-Size", "ავტომატური ზომა"), + ("Create Links When Placing Text Files", "ტექსტური ფაილების განთავსებისას ბმულების შექმნა"), + ("Kerning/Tracking:", "კერნინგი/ტრეკინგი:"), + ("Remove Existing Ruler Guides", "სახაზავის არსებული დამხმარე ხაზების წაშლა"), + ("Replace All", "ყველას ჩანაცვლება"), + ( + "Replace All updates this font throughout text and styles. Done keeps remaining fallback fonts.", + "„ყველას ჩანაცვლება“ ამ შრიფტს მთელ ტექსტსა და სტილებში განაახლებს. „მზადაა“ დარჩენილ სათადარიგო შრიფტებს შეინარჩუნებს.", + ), + ("Replace with:", "ჩანაცვლება:"), + ("Stroke Behind Fill", "კონტური შევსების უკან"), + ("Delete the character style \"{name}\"?", "სიმბოლოს სტილი „{name}“ წაიშალოს?"), + ("Delete the paragraph style \"{name}\"?", "აბზაცის სტილი „{name}“ წაიშალოს?"), + ("Currently Rendered With", "ამჟამად გამოსახულია შრიფტით"), + ( + "“{}” has been edited in this layout. Updating replaces those edits with the text from the file.", + "„{}“ ამ განლაგებაში რედაქტირებულია. განახლება ამ ცვლილებებს ფაილის ტექსტით ჩაანაცვლებს.", + ), + ("Proportional Metrics", "პროპორციული მეტრიკა"), + ("Horizontal Kana", "ჰორიზონტალური კანა"), + ("Localized Forms", "ლოკალიზებული ფორმები"), + ("Mark Positioning", "დიაკრიტიკული ნიშნების განლაგება"), + ("Historical Forms", "ისტორიული ფორმები"), + ("Stylistic Alternates", "სტილისტური ალტერნატივები"), + ("Justification Alternates", "გასწორების ალტერნატივები"), + ("Roman Italics", "რომაული დახრილი"), + ("Letter", "წერილი"), + ("Legal", "იურიდიული ინფორმაცია"), + ("Tabloid", "Tabloid"), + ("Letter - Half", "წერილი - ნახევარი"), + ("Postcard", "ღია ბარათი"), + ("Web 1920 × 1080", "ვები 1920 × 1080"), + ("Web 1366 × 768", "ვები 1366 × 768"), + ("Web 1024 × 768", "ვები 1024 × 768"), + ("Mobile 1080 × 1920", "მობილური 1080 × 1920"), + ("Mobile 390 × 844", "მობილური 390 × 844"), + ("Tablet 1024 × 1366", "პლანშეტი 1024 × 1366"), + ("Action", "მოქმედება"), + ("Adjust layout", "განლაგების მისადაგება"), + ("Advance seconds", "გადასვლის ინტერვალი (წმ)"), + ("Affix in", "პრეფიქსისა და სუფიქსის ადგილი"), + ("Allow", "დაშვება"), + ("Anchor", "ღუზა"), + ("Artifact", "არტეფაქტი"), + ("Asset", "რესურსი"), + ("Assets", "რესურსები"), + ("At end", "ბოლოში"), + ("Attrs", "ატრიბუტები"), + ("Author", "ავტორი"), + ("Auto fit", "ავტომორგება"), + ("Auto size", "ავტომატური ზომა"), + ("Auto size reference", "ავტომატური ზომის საყრდენი წერტილი"), + ("Axis", "ღერძი"), + ("Bands", "ზოლები"), + ("Bookmarks panel", "სანიშნეების პანელი"), + ("Case", "რეგისტრი"), + ("Column gutter", "სვეტებს შორის შუალედი"), + ("Column width", "სვეტის სიგანე"), + ("Columns kind", "სვეტების ტიპი"), + ("Continue across stories", "გაგრძელება ტექსტურ ნაკადებს შორის"), + ("Copies", "ასლები"), + ("Corners", "კუთხეები"), + ("Cover image", "ყდის გამოსახულება"), + ("Definition", "განსაზღვრება"), + ("Dir", "საქაღალდე"), + ("Dry run", "საცდელი გაშვება"), + ("Embed fonts", "შრიფტების ჩაშენება"), + ("End", "დასასრული"), + ("Extension", "გაფართოება"), + ("Facing pages", "მოპირდაპირე გვერდები"), + ("Family", "შრიფტის ოჯახი"), + ("First baseline", "პირველი საბაზისო ხაზი"), + ("First baseline min", "პირველი საბაზისო ხაზის მინიმუმი"), + ("Fit to", "მორგება"), + ("Fixed layout", "ფიქსირებული განლაგება"), + ("Flatten", "გაბრტყელება"), + ("Font size", "შრიფტის ზომა"), + ("Format", "ფორმატი"), + ("From", "საიდან"), + ("Full screen", "სრული ეკრანი"), + ("Grep", "GREP"), + ("Heading", "სათაური"), + ("Heading style", "სათაურის სტილი"), + ("Hidden", "დამალული"), + ("Identifier", "იდენტიფიკატორი"), + ("Idml", "IDML"), + ("Ids", "ID-ები"), + ("Include inside edges", "შიდა კიდეების ჩართვა"), + ("Include prefix", "პრეფიქსის ჩართვა"), + ("Instructions", "ინსტრუქციები"), + ("Intent", "განზრახვა"), + ("Keep within column", "სვეტის ფარგლებში დატოვება"), + ("Kind", "სახეობა"), + ("Label", "იარლიყი"), + ("Latex", "LaTeX"), + ("Layout page", "განლაგების გვერდი"), + ("Line breaks", "სტრიქონის წყვეტები"), + ("Load cursor", "კურსორში ჩატვირთვა"), + ("Marker", "მარკერი"), + ("Marks", "ნიშნები"), + ("Message", "შეტყობინება"), + ("Minimum height", "მინიმალური სიმაღლე"), + ("Minimum width", "მინიმალური სიგანე"), + ("Missing only", "მხოლოდ დაკარგული"), + ("Mode", "რეჟიმი"), + ("Number", "ნომერი"), + ("Object point", "ობიექტის საყრდენი წერტილი"), + ("Org", "ორგანიზაცია"), + ("Page break before", "გვერდის გამყოფი წინ"), + ("Page layout", "გვერდის განლაგება"), + ("Para style", "აბზაცის სტილი"), + ("Paragraphs", "აბზაცები"), + ("Parent", "საბაზო გვერდი"), + ("Pattern", "პატერნი"), + ("Pdf", "PDF"), + ("Pdf crop", "PDF-ის ჩამოჭრა"), + ("Pdf page", "PDF-ის გვერდი"), + ("Phone", "ტელეფონი"), + ("Pos", "პოზიცია"), + ("Prefix", "პრეფიქსი"), + ("Primary text frame", "ძირითადი ტექსტური ჩარჩო"), + ("Printer", "პრინტერი"), + ("Rasterize", "რასტრირება"), + ("Rect", "მართკუთხედი"), + ("Redefine styles", "სტილების ხელახლა განსაზღვრა"), + ("Ref", "საყრდენი წერტილი"), + ("Ref char style", "მიმართვის სიმბოლოს სტილი"), + ("Ref position", "მიმართვის პოზიცია"), + ("Remove existing", "არსებულის წაშლა"), + ("Restart", "თავიდან დაწყება"), + ("Row gutter", "სტრიქონებს შორის შუალედი"), + ("Rule", "წესი"), + ("Run in", "ერთ აბზაცში"), + ("Scope", "მოქმედების არე"), + ("Section headings", "სექციის სათაურები"), + ("Separator", "გამყოფი"), + ("Shape", "ფიგურა"), + ("Space", "ფერთა სივრცე"), + ("Space after", "დაშორება შემდეგ"), + ("Space before", "დაშორება წინ"), + ("Space between", "დაშორება შორის"), + ("Span columns", "სვეტებზე გაშლა"), + ("Start", "დასაწყისი"), + ("Start at", "დაწყება"), + ("Start number", "საწყისი ნომერი"), + ("Start page", "საწყისი გვერდი"), + ("Subject", "თემა"), + ("Subpath", "ქვეტრაექტორია"), + ("Suffix", "სუფიქსი"), + ("Suggestions", "შეთავაზებები"), + ("Tagged", "ტეგირებული"), + ("Target", "სამიზნე"), + ("Threshold", "ზღვარი"), + ("Title", "დასახელება"), + ("To family", "ახალი შრიფტის ოჯახი"), + ("To spread", "სამიზნე გაშლა"), + ("To style", "ახალი სტილი"), + ("Toc title", "სარჩევის სათაური"), + ("Tolerance", "დაშვება"), + ("Topics", "თემები"), + ("Url", "URL"), + ("Use", "გამოყენება"), + ("Use minimum height", "მინიმალური სიმაღლის გამოყენება"), + ("Use minimum width", "მინიმალური სიგანის გამოყენება"), + ("Value", "მნიშვნელობა"), + ("Visible", "ხილული"), + ("Word", "სიტყვა"), + ("X offset", "X წანაცვლება"), + ("X relative", "X-ის ათვლის წერტილი"), + ("Y offset", "Y წანაცვლება"), + (" Save ", " შენახვა "), + (" Update ", " განახლება "), + // Keyboard shortcut sets (craft-keymap), docking and new commands. + ("(cycles)", "(ციკლურად)"), + ("(modified)", "(შეცვლილი)"), + ("+Bin", "+Bin"), + ("+LOC", "+LOC"), + ("A command can have at most {n} shortcuts", "ბრძანების მალსახმობების მაქსიმალური რაოდენობა: {n}"), + ("A set with this name exists", "ამ სახელის ნაკრები უკვე არსებობს"), + ("Add a shortcut to {label}", "{label}: მალსახმობის დამატება"), + ("All Commands", "ყველა ბრძანება"), + ("Also used by {label}", "ასევე იყენებს: {label}"), + ("Alt", "Alt"), + ("Application", "აპლიკაცია"), + ("Approximated", "მიახლოებით წაკითხული"), + ("Assign", "მინიჭება"), + ("Assign to Selected", "მონიშნულზე მინიჭება"), + ("Assign {chord} to {label}", "{label}: მალსახმობის მინიჭება ({chord})"), + ("Auto-Collapse Iconic Panels", "ხატულებად ჩაკეცილი პანელების ავტომატური ჩაკეცვა"), + ("Based on:", "საფუძველი:"), + ("Built-in sets cannot be changed", "ჩაშენებული ნაკრებების შეცვლა შეუძლებელია"), + ("Cancel Import", "იმპორტის გაუქმება"), + ("Cancel the new shortcut", "ახალი მალსახმობის გაუქმება"), + ("Caps Lock", "Caps Lock"), + ("Changed", "შეცვლილი"), + ("Check for Updates…", "განახლებების შემოწმება…"), + ("Choose a command…", "აირჩიეთ ბრძანება…"), + ("Click a key to see its shortcuts", "დაწკაპეთ კლავიშზე მისი მალსახმობების სანახავად"), + ("Close Other Panels in Group", "ჯგუფის სხვა პანელების დახურვა"), + ("Close panel window", "პანელის ფანჯრის დახურვა"), + ("Command", "ბრძანება"), + ("Command for {item}", "ბრძანება ელემენტისთვის „{item}“"), + ("Commands", "ბრძანებები"), + ("Conflicts", "კონფლიქტები"), + ("Context:", "კონტექსტი:"), + ("Copy Report", "ანგარიშის კოპირება"), + ("Ctrl", "Ctrl"), + ("Current shortcuts", "მიმდინარე მალსახმობები"), + ("Current shortcuts:", "მიმდინარე მალსახმობები:"), + ("Currently assigned to:", "ამჟამად მინიჭებულია:"), + ("Default: {chord}", "ნაგულისხმევი: {chord}"), + ("Delete Parent", "საბაზო გვერდის წაშლა"), + ("Delete Surrounding Text", "მიმდებარე ტექსტის წაშლა"), + ("Delete the set “{set}”?", "ნაკრები „{set}“ წაიშალოს?"), + ("Deselect Guides", "დამხმარე ხაზების მონიშვნის მოხსნა"), + ("Detail pane size", "დეტალების არის ზომა"), + ("EPUB Split Document", "EPUB: დოკუმენტის დაყოფა"), + ("Expand", "გაშლა"), + ("Expand Panels", "პანელების გაშლა"), + ("Export…", "ექსპორტი…"), + ("File Info", "ფაილის ინფორმაცია"), + ("File Info…", "ფაილის ინფორმაცია…"), + ("Filter:", "ფილტრი:"), + ("Fixed Keys", "ფიქსირებული კლავიშები"), + ("Float Tab Group", "მცურავი ჩანართების ჯგუფი"), + ("Go to Conflict", "კონფლიქტზე გადასვლა"), + ("Group Tab Group with", "ჩანართების ჯგუფის ერთად დაჯგუფება"), + ("Import", "იმპორტი"), + ("Import InDesign Shortcut Set", "InDesign-ის მალსახმობების ნაკრების იმპორტი"), + ("Import as New Set", "იმპორტი ახალ ნაკრებად"), + ("Import “{set}”", "იმპორტი: „{set}“"), + ("Imported", "იმპორტირებული"), + ("Imported as “{set}”", "იმპორტირებულია როგორც „{set}“"), + ("Import…", "იმპორტი…"), + ("Keep Both", "ორივეს შენარჩუნება"), + ("Key: {key}", "კლავიში: {key}"), + ("Keyboard", "კლავიატურა"), + ("Keyboard Shortcuts (JSON)", "კლავიატურის მალსახმობები (JSON)"), + ("Keyboard: US", "კლავიატურა: US"), + ("Keyboard: US (macOS)", "კლავიატურა: US (macOS)"), + ("Lock Workspace", "სამუშაო სივრცის ჩაკეტვა"), + ("Matched without change", "დაემთხვა ცვლილების გარეშე"), + ("Maximize Frame", "ჩარჩოს გაშლა"), + ("Modifier on", "მოდიფიკატორი ჩართულია"), + ("More", "მეტი"), + ("Move Guides", "დამხმარე ხაზების გადაადგილება"), + ("Move Panel to New Window", "პანელის ახალ ფანჯარაში გადატანა"), + ("Move panel column", "პანელების სვეტის გადაადგილება"), + ("Move panel window", "პანელის ფანჯრის გადაადგილება"), + ("Move tab group", "ჩანართების ჯგუფის გადაადგილება"), + ("New Set…", "ახალი ნაკრები…"), + ("New shortcut:", "ახალი მალსახმობი:"), + ("New shortcut: {chord}", "ახალი მალსახმობი: {chord}"), + ("Not assigned", "არ არის მინიჭებული"), + ("Not changed", "არ შეცვლილა"), + ("Not matched", "შესაბამისობა ვერ მოიძებნა"), + ("Nothing uses this key", "ამ კლავიშს არაფერი იყენებს"), + ("Now used by {label}", "ახლა იყენებს: {label}"), + ("Option", "Option"), + ("PDF/X-1a:2003", "PDF/X-1a:2003"), + ("PRs", "PR-ები"), + ("Panel or context", "პანელი ან კონტექსტი"), + ("Remove {chord} from {label}", "{label}: მალსახმობის წაშლა ({chord})"), + ("Rename…", "სახელის შეცვლა…"), + ("Reset All", "ყველაფრის ჩამოყრა"), + ("Reset All…", "ყველაფრის ჩამოყრა…"), + ("Restore Frame Size", "ჩარჩოს ზომის აღდგენა"), + ("Restore every default shortcut in this set?", "აღდგეს ამ ნაკრების ყველა ნაგულისხმევი მალსახმობი?"), + ("Restore the default for {label}", "{label}: ნაგულისხმევის აღდგენა"), + ("Revert Set", "ნაკრების წინა ვერსიაზე დაბრუნება"), + ("Ruler Zero Point", "სახაზავის ნულოვანი წერტილი"), + ("Save changes to “{set}”?", "შეინახოს ცვლილებები ნაკრებში „{set}“?"), + ("Search by key", "ძიება კლავიშით"), + ("Select Guide", "დამხმარე ხაზის მონიშვნა"), + ("Select a command first", "ჯერ მონიშნეთ ბრძანება"), + ("Select a command to see and change its shortcuts", "მონიშნეთ ბრძანება მისი მალსახმობების სანახავად და შესაცვლელად"), + ("Set name:", "ნაკრების სახელი:"), + ("Set:", "ნაკრები:"), + ("Shift", "Shift"), + ("Shortcut", "მალსახმობი"), + ("Shortcut Sets", "მალსახმობების ნაკრებები"), + ("Show:", "ჩვენება:"), + ("Skipped", "გამოტოვებული"), + ("Story Options", "ტექსტური ნაკადის პარამეტრები"), + ("Suggestions ({n})", "შეთავაზებები ({n})"), + ("Summarize…", "შეჯამება…"), + ("Super", "Super"), + ("Take It", "აღება"), + ("Take It and Go to Conflict", "აღება და კონფლიქტზე გადასვლა"), + ("Taken from other commands", "სხვა ბრძანებებიდან აღებული"), + ("The file is too large", "ფაილი ძალიან დიდია"), + ("The name is empty, too long or has control characters", "სახელი ცარიელია, ძალიან გრძელია ან მმართველ სიმბოლოებს შეიცავს"), + ("The scope {scope} is unknown", "მოქმედების არე {scope} უცნობია"), + ("The set was not found", "ნაკრები ვერ მოიძებნა"), + ("There are too many sets", "ნაკრები ძალიან ბევრია"), + ("This command is unknown", "ეს ბრძანება უცნობია"), + ("This file is from a newer version (format {n})", "ეს ფაილი უფრო ახალი ვერსიისაა (ფორმატი {n})"), + ("This is not a craft-keymap file", "ეს craft-keymap ფაილი არ არის"), + ("This is not a shortcut set file", "ეს მალსახმობების ნაკრების ფაილი არ არის"), + ("Too many shortcuts are assigned", "მინიჭებულია ძალიან ბევრი მალსახმობი"), + ("Tools", "ხელსაწყოები"), + ("Transparency Flattener", "გამჭვირვალობის გაბრტყელება"), + ("Type…", "ტექსტი…"), + ("Unreadable", "წაუკითხავი"), + ("Use Default", "ნაგულისხმევის გამოყენება"), + ("Will change", "შეიცვლება"), + ("Win", "Win"), + ("With Shortcut", "მალსახმობის მქონე"), + ("empty shortcut", "ცარიელი მალსახმობი"), + ("in a context that is not imported", "კონტექსტში, რომელიც არ იმპორტირდება"), + ("key sequences are not supported", "კლავიშთა მიმდევრობები მხარდაჭერილი არ არის"), + ("line {n}", "სტრიქონი {n}"), + ("modifier {key} given twice", "მოდიფიკატორი {key} ორჯერაა მითითებული"), + ("more than one command fits", "ერთზე მეტი ბრძანება შეესაბამება"), + ("no command has this name", "ამ სახელის ბრძანება არ არსებობს"), + ("numeric keypad key read as main key", "ციფრული ბლოკის კლავიში წაკითხულია ძირითად კლავიშად"), + ("only a command of the other kind fits (tool or menu command)", "შეესაბამება მხოლოდ სხვა სახეობის ბრძანება (ხელსაწყო ან მენიუს ბრძანება)"), + ("press keys", "დააჭირეთ კლავიშებს"), + ("reserved by the system", "სისტემისთვისაა დაჯავშნული"), + ("taken by a later entry", "დაიკავა მომდევნო ჩანაწერმა"), + ("taken by {label}", "დაიკავა: {label}"), + ("the scope is unknown", "მოქმედების არე უცნობია"), + ("the scope name is not valid", "მოქმედების არის სახელი არასწორია"), + ("the set's shortcuts could not be added", "ნაკრების მალსახმობების დამატება ვერ მოხერხდა"), + ("the shortcut has no key", "მალსახმობს კლავიში არ აქვს"), + ("the shortcut is too long", "მალსახმობი ძალიან გრძელია"), + ("the shortcuts could not be removed", "მალსახმობების წაშლა ვერ მოხერხდა"), + ("unknown key {key}", "უცნობი კლავიში {key}"), + ("unknown modifier {key}", "უცნობი მოდიფიკატორი {key}"), + ("unsupported modifier {key}", "მხარდაუჭერელი მოდიფიკატორი {key}"), + ("{chord} (Default)", "{chord} (ნაგულისხმევი)"), + ("{chord} (press again for a double press)", "{chord} (ორმაგი დაჭერისთვის კვლავ დააჭირეთ)"), + ("{chord} ({scope})", "{chord} ({scope})"), + ("{chord} is reserved by the system", "{chord} სისტემისთვისაა დაჯავშნული"), + ("{chord} was removed from {label}", "{label}: მალსახმობი {chord} წაიშალა"), + ("{chord}: {label}", "{chord}: {label}"), + ("{key}: listed more than once", "{key}: ერთზე მეტჯერაა მითითებული"), + ("{key}: no command has this name", "{key}: ამ სახელის ბრძანება არ არსებობს"), + ("{key}: part of the entry was dropped", "{key}: ჩანაწერის ნაწილი გამოტოვებულია"), + ("{key}: the scope in “{text}” is unknown", "{key}: მოქმედების არე უცნობია („{text}“)"), + ("{key}: {text} is reserved by the system", "{key}: {text} სისტემისთვისაა დაჯავშნული"), + ("{key}: “{text}” is not a shortcut ({error})", "{key}: „{text}“ მალსახმობი არ არის ({error})"), + ("{label} (Changed)", "{label} (შეცვლილი)"), + ("{label} (cycles)", "{label} (ციკლურად)"), + ("{label} ({scope})", "{label} ({scope})"), + ("{label} ({scope}, both work)", "{label} ({scope}, ორივე მუშაობს)"), + ("{label} keeps its shortcuts (matched by a panel menu name)", "{label} მალსახმობებს ინარჩუნებს (დაემთხვა პანელის მენიუს სახელით)"), + ("{label}: press keys", "{label}: დააჭირეთ კლავიშებს"), + ("{label}: {chord}", "{label}: {chord}"), + ("{label}: {chord} (Now used by {holder})", "{label}: {chord} (ახლა იყენებს: {holder})"), + ("{menu} Menu", "მენიუ „{menu}“"), + ("{n} command changed", "შეცვლილი ბრძანებები: {n}"), + ("{n} command will change", "შესაცვლელი ბრძანებები: {n}"), + ("{n} commands changed", "შეცვლილი ბრძანებები: {n}"), + ("{n} commands will change", "შესაცვლელი ბრძანებები: {n}"), + ("{n} entries cut at a limit", "ლიმიტზე შეკვეცილი ჩანაწერები: {n}"), + ("{n} entries were ignored: too many commands", "უგულებელყოფილი ჩანაწერები (ბრძანება ძალიან ბევრია): {n}"), + ("{n} entry cut at a limit", "ლიმიტზე შეკვეცილი ჩანაწერები: {n}"), + ("{n} entry was ignored: too many commands", "უგულებელყოფილი ჩანაწერები (ბრძანება ძალიან ბევრია): {n}"), + ("{n} item could not be read", "ელემენტები, რომელთა წაკითხვა ვერ მოხერხდა: {n}"), + ("{n} item not matched", "ელემენტები შესაბამისობის გარეშე: {n}"), + ("{n} item without a shortcut", "ელემენტები მალსახმობის გარეშე: {n}"), + ("{n} items could not be read", "ელემენტები, რომელთა წაკითხვა ვერ მოხერხდა: {n}"), + ("{n} items not matched", "ელემენტები შესაბამისობის გარეშე: {n}"), + ("{n} items without a shortcut", "ელემენტები მალსახმობის გარეშე: {n}"), + ("{n} key read approximately", "მიახლოებით წაკითხული კლავიშები: {n}"), + ("{n} keys read approximately", "მიახლოებით წაკითხული კლავიშები: {n}"), + ("{n} saved shortcut could not be used", "შენახული მალსახმობები, რომელთა გამოყენება ვერ მოხერხდა: {n}"), + ("{n} saved shortcuts could not be used", "შენახული მალსახმობები, რომელთა გამოყენება ვერ მოხერხდა: {n}"), + ("{n} shortcut also works in another scope", "სხვა მოქმედების არეშიც მოქმედი მალსახმობები: {n}"), + ("{n} shortcut is assigned more than once", "ერთზე მეტჯერ მინიჭებული მალსახმობები: {n}"), + ("{n} shortcuts also work in another scope", "სხვა მოქმედების არეშიც მოქმედი მალსახმობები: {n}"), + ("{n} shortcuts are assigned more than once", "ერთზე მეტჯერ მინიჭებული მალსახმობები: {n}"), + ("{panel} Panel", "პანელი „{panel}“"), + ("{title} ({n})", "{title} ({n})"), + ("{title}: {set}", "{title}: {set}"), + ("ΔLOC", "ΔLOC"), + ("−Bin", "−Bin"), + ("−LOC", "−LOC"), + ("⚠ {chord} is used by {label}", "⚠ {chord} დაკავებულია: {label}"), + ("Y:", "Y:"), + ("Show the Typography panel", "ტიპოგრაფიის პანელის ჩვენება"), + ( + "This story was edited in the layout: you'll be asked before those edits are replaced", + "ეს ტექსტური ნაკადი განლაგებაში რედაქტირებულია: ამ ცვლილებების ჩანაცვლებამდე დაგეკითხებით", + ), ]; From 20d6722820222ed91992ffe8511c48663697db05 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Wed, 28 Oct 2026 05:00:07 +0400 Subject: [PATCH 249/628] i18n(ka): keep the Letter, Legal and Letter - Half paper sizes in English, like Tabloid Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/ui-egui/src/i18n/ka.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 1cefbbfcf..14a9e6203 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -2503,10 +2503,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Stylistic Alternates", "სტილისტური ალტერნატივები"), ("Justification Alternates", "გასწორების ალტერნატივები"), ("Roman Italics", "რომაული დახრილი"), - ("Letter", "წერილი"), - ("Legal", "იურიდიული ინფორმაცია"), + ("Letter", "Letter"), + ("Legal", "Legal"), ("Tabloid", "Tabloid"), - ("Letter - Half", "წერილი - ნახევარი"), + ("Letter - Half", "Letter - Half"), ("Postcard", "ღია ბარათი"), ("Web 1920 × 1080", "ვები 1920 × 1080"), ("Web 1366 × 768", "ვები 1366 × 768"), From 87859340324746cedae57da9c1692b7cb564781e Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:50:17 +0000 Subject: [PATCH 250/628] Open documents whose items share an id Fixes #1883 Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/lib.rs | 82 +++++++++++++++++++++++++++++++++++ crates/format/src/lib.rs | 2 + crates/format/src/tests.rs | 88 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 172 insertions(+) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index bf9998209..08ba34ca0 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -955,6 +955,88 @@ impl Document { designcraft_color::swatch::resolve(&self.swatches, swatch, tint) } + /// Give items that reuse an earlier item's id (spreads, then parents, then items anchored in + /// stories) fresh ids, so a file holding such duplicates opens. The first item keeps the id and + /// what refers to it by id; a renumbered text frame takes its place in its story's thread. + pub fn renumber_duplicate_items(&mut self) { + fn walk_mut(it: &mut Item, f: &mut impl FnMut(&mut Item)) { + f(it); + for c in it.children_mut().into_iter().flatten() { + walk_mut(Arc::make_mut(c), f); + } + } + fn story_items(st: &mut Story, f: &mut impl FnMut(&mut Item)) { + for o in &mut st.objects { + walk_mut(&mut Arc::make_mut(o).item, f); + } + for t in st.tables.values_mut() { + for c in &mut Arc::make_mut(t).cells { + story_items(&mut c.text, f); + } + } + for n in st.notes.iter_mut().chain(st.endnotes.iter_mut()) { + story_items(&mut Arc::make_mut(n).text, f); + } + } + let mut seen = std::collections::HashSet::new(); + let mut dups = std::collections::HashSet::new(); + let mut next = self.next_id; + let mut note = |id: ItemId| { + next = next.max(id.0); + if !seen.insert(id) { + dups.insert(id); + } + }; + for sp in self.spreads.iter().chain(self.parents.iter()) { + for it in &sp.items { + it.walk(&mut |i| note(i.id)); + } + } + for (_, i) in self.anchored_items() { + note(i.id); + } + if dups.is_empty() { + return; + } + let mut seen = std::collections::HashSet::new(); + // (story, id in the file) → the ids its frames carry now, in walk order. + let mut threads: std::collections::HashMap<(StoryId, ItemId), Vec> = std::collections::HashMap::new(); + let mut fix = |it: &mut Item| { + let old = it.id; + if !dups.contains(&old) { + return; + } + if !seen.insert(old) { + next += 1; + it.id = ItemId(next); + } + if let Some(t) = it.text_frame() { + threads.entry((t.story, old)).or_default().push(it.id); + } + }; + for sp in self.spreads.iter_mut().chain(self.parents.iter_mut()) { + for it in &mut Arc::make_mut(sp).items { + walk_mut(Arc::make_mut(it), &mut fix); + } + } + for st in self.stories.values_mut() { + story_items(Arc::make_mut(st), &mut fix); + } + self.next_id = next; + for ((sid, old), ids) in threads { + let Some(st) = self.stories.get_mut(&sid) else { continue }; + let st = Arc::make_mut(st); + let mut ids = ids.into_iter(); + for f in st.frames.iter_mut().filter(|f| **f == old) { + match ids.next() { + Some(id) => *f = id, + None => break, + } + } + st.frames.extend(ids); + } + } + /// Validate structural invariants (ids unique, threads consistent, stories well-formed). pub fn check(&self) -> Result<()> { for f in &self.styles.composite_fonts { diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 1adc43449..15156f8ad 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -251,6 +251,7 @@ fn load_zip(bytes: &[u8]) -> Result { upgrade(&mut doc, u32::try_from(version).unwrap_or(u32::MAX)); doc.merge_duplicate_assets(); doc.mark_differing_margins_local(); + doc.renumber_duplicate_items(); doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?; Ok(doc) } @@ -318,6 +319,7 @@ fn load_legacy_json(bytes: &[u8]) -> Result { upgrade(&mut doc, 1); doc.merge_duplicate_assets(); doc.mark_differing_margins_local(); + doc.renumber_duplicate_items(); doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?; Ok(doc) } diff --git a/crates/format/src/tests.rs b/crates/format/src/tests.rs index 981898a45..702428d14 100644 --- a/crates/format/src/tests.rs +++ b/crates/format/src/tests.rs @@ -734,3 +734,91 @@ fn too_many_entries_are_refused() { let Err(err) = load(&z.finish().unwrap().into_inner()) else { panic!("an archive with 100 001 entries loaded") }; assert!(err.to_string().contains("entries"), "{err}"); } + +/// Every item id in `d` (spreads, parents, anchored), in walk order. +fn all_item_ids(d: &Document) -> Vec { + let mut ids = Vec::new(); + for sp in d.spreads.iter().chain(d.parents.iter()) { + for it in &sp.items { + it.walk(&mut |i| ids.push(i.id.0)); + } + } + ids.extend(d.anchored_items().iter().map(|(_, i)| i.id.0)); + ids +} + +fn set_item_id(d: &mut Document, index: usize, id: designcraft_doc::ItemId) { + let sp = Arc::make_mut(&mut d.spreads[0]); + Arc::make_mut(&mut sp.items[index]).id = id; +} + +#[test] +fn files_with_duplicate_item_ids_open_with_unique_ids() { + let mut d = Document::new(&NewDocument::default()); + let one = png_asset(&mut d, &[1]); + let two = png_asset(&mut d, &[2]); + let a = place(&mut d, one, 0.0); + place(&mut d, two, 0.0); + set_item_id(&mut d, 1, a); + let back = load(&save(&d).unwrap()).unwrap(); + back.check().unwrap(); + let ids = all_item_ids(&back); + assert_eq!(ids.len(), 2); + assert_ne!(ids[0], ids[1]); + assert!(back.next_id >= ids[0].max(ids[1])); + let assets: Vec<_> = back.spreads[0].items.iter().map(|i| i.graphic().unwrap().asset).collect(); + assert_eq!(assets, vec![one, two]); + assert_eq!(back.item(a).and_then(|i| i.graphic()).map(|g| g.asset), Some(one)); +} + +#[test] +fn duplicate_text_frame_ids_keep_each_story_in_its_own_frame() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 200.0, 200.0), lid, "first", ParaFormat::default()).unwrap(); + let (b, sb) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 210.0, 200.0, 400.0), lid, "second", ParaFormat::default()).unwrap(); + let (c, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(210.0, 210.0, 400.0, 400.0), lid, "", ParaFormat::default()).unwrap(); + d.thread(b, c).unwrap(); + // The second story's first frame carries the first frame's id. + set_item_id(&mut d, 1, a); + let st = d.story_mut(sb).unwrap(); + assert_eq!(st.frames, vec![b, c]); + st.frames[0] = a; + let back = load(&save(&d).unwrap()).unwrap(); + back.check().unwrap(); + let ids = all_item_ids(&back); + let unique: HashSet<_> = ids.iter().collect(); + assert_eq!(unique.len(), 3); + assert_eq!(back.stories[&sa].frames, vec![a]); + assert_eq!(back.stories[&sa].text, "first"); + let frames = &back.stories[&sb].frames; + assert_eq!(frames.len(), 2); + assert_ne!(frames[0], a); + assert_eq!(frames[1], c); + assert_eq!(back.item(frames[0]).and_then(|i| i.text_frame()).map(|t| t.story), Some(sb)); + assert_eq!(back.stories[&sb].text, "second"); +} + +#[test] +fn duplicate_anchored_item_ids_are_renumbered() { + use designcraft_doc::anchored::{AnchorPosition, AnchoredObject}; + use designcraft_doc::geom::shapes; + use designcraft_doc::{Item, Shape}; + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 200.0, 200.0), lid, "ab", ParaFormat::default()).unwrap(); + let asset = png_asset(&mut d, &[1]); + let g = place(&mut d, asset, 0.0); + let item = Item::new(g, lid, Shape::Oval, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0))); + d.story_mut(sid).unwrap().insert_object(1, AnchoredObject::new(item, AnchorPosition::default())); + let back = load(&save(&d).unwrap()).unwrap(); + back.check().unwrap(); + let ids = all_item_ids(&back); + assert_eq!(ids.len(), 3); + assert_eq!(ids.iter().collect::>().len(), 3); + assert_eq!(back.item(g).and_then(|i| i.graphic()).map(|g| g.asset), Some(asset)); + let anchored = back.anchored_items(); + assert_eq!(anchored.len(), 1); + assert_eq!(anchored[0].1.shape, Shape::Oval); + assert_ne!(anchored[0].1.id, g); +} From a30744fed11f45e1e80afbf822c436a7b71c731d Mon Sep 17 00:00:00 2001 From: Hamza Date: Wed, 28 Oct 2026 11:53:56 +0500 Subject: [PATCH 251/628] Drop the unused ImageCache inspection methods `len` and `is_empty` were added for a test that ended up counting reads through `io::touched` instead, so they were dead code -- and the gate runs `cargo clippy --workspace --all-targets -- -D warnings`, which turns `dead_code` into a failure. Hosted CI was red on this PR. My local check had been `cargo clippy -p designcraft-engine --all-targets` without `-D warnings`, which is why I missed it; re-ran it the way the gate does. Found by the review bot, confirmed against the CI log. --- crates/engine/src/cmd/datamerge/fill.rs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/crates/engine/src/cmd/datamerge/fill.rs b/crates/engine/src/cmd/datamerge/fill.rs index 965e87bd7..9b0a07c24 100644 --- a/crates/engine/src/cmd/datamerge/fill.rs +++ b/crates/engine/src/cmd/datamerge/fill.rs @@ -172,15 +172,6 @@ impl ImageCache { } Some(bytes) } - - /// How many distinct files this merge has read, for the tests. - pub fn len(&self) -> usize { - self.inner.borrow().files.len() - } - - pub fn is_empty(&self) -> bool { - self.len() == 0 - } } /// Replacements and hyperlinks collected while walking the placeholders of one record. From 4ac4219fd24361fc240382a479a7171007b45a70 Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 28 Oct 2026 10:23:57 +0000 Subject: [PATCH 252/628] compose: settle a story's own anchored wraps by object count, not 4 passes A story whose custom or above-line anchored objects wrap its own later lines is composed first without those wraps, then again with each object where the previous pass placed it. That loop stopped after a fixed MAX_PASSES = 4 extra compositions. Each object's wrap pushes down the anchors of the objects after it, so in a chain of such objects the k-th object only lands where it belongs once the k - 1 wraps before it are in place. From roughly the fifth chained object on, the loop ran out before the story settled: the final composition placed the object in one spot but wrapped the text around where an earlier pass had put it, and lines printed straight over the object. Bound the loop by the story's own wrapping objects instead: n + 1 passes for n such objects (inline objects never wrap their own story and are left out). Composition 0 has no own wraps, so object 1 is already where it belongs; by induction pass k places object k + 1 correctly, pass n places the last one, and pass n + 1 wraps the text around it, after which the positions cannot move. The existing settled check still stops a story early as soon as its objects stop moving, so stories that settle sooner cost nothing extra. MAX_PASSES stays as an absolute safety net, raised to 64, for layouts that keep moving their objects (keep options, vertical justification) and for stories with very many anchored objects. Test: chained_anchored_objects_all_wrap_their_own_storys_lines anchors eight 100 x 60 pt bounding-box objects six lines apart in one story, each hanging from its anchor line, and checks that every object sits on its anchor line and every line it hangs over moves clear of it. Without this change it fails at the seventh object (a line 790 pt down sits at x 0 inside the object's band at 718 pt); with it, it passes. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- crates/compose/src/tests.rs | 38 +++++++++++++++++++++++++++++++++++++ crates/compose/src/wrap.rs | 15 ++++++++++----- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 3083261fb..aafef67e3 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -7155,6 +7155,44 @@ fn anchored_object_wraps_its_own_storys_later_lines() { assert!((o.origin.y - top).abs() < 1e-6); } +/// Each object's wrap pushes the anchors after it down, so where each object lands depends on the +/// wraps of all those before it; every object still ends up wrapping the lines it hangs over. +#[test] +fn chained_anchored_objects_all_wrap_their_own_storys_lines() { + const OBJECTS: usize = 8; + let text = [LOREM; 12].join(" "); + let (mut d, sid, _) = doc_with(&text, Rect::new(0.0, 0.0, 300.0, 3000.0), ParaAttrs::default()); + let plain = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&plain); + assert!(lines.len() > 2 + 6 * OBJECTS); + let left = lines[0].x0; + // One object every six lines, each hanging from its anchor's baseline over the lines below; + // inserted last first so the earlier offsets still hold. + let ats: Vec = (0..OBJECTS).map(|k| lines[2 + 6 * k].range.start + 3).collect(); + for &at in ats.iter().rev() { + anchor_box(&mut d, sid, at, 0.0, 0.0, true, WrapMode::BoundingBox); + } + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + let lines = all_lines(&cs); + let anchors: Vec = d.story(sid).unwrap().text.match_indices(designcraft_doc::OBJECT_MARK).map(|(i, _)| i).collect(); + let objects = &cs.frames[0].objects; + assert_eq!(objects.len(), OBJECTS); + for o in objects { + let anchor_line = lines.iter().find(|l| l.range.contains(&anchors[o.index])).unwrap(); + assert!((o.origin.y - anchor_line.baseline).abs() < 1e-6, "object {} at {}, anchor line at {}", o.index, o.origin.y, anchor_line.baseline); + let under = lines_in_band(&cs, o.origin.y, o.origin.y + 60.0); + assert!(under.len() >= 2); + assert!( + under.iter().all(|l| l.x0 >= left + 100.0 - 1e-6), + "object {} at {}: {:?}", + o.index, + o.origin.y, + under.iter().map(|l| (l.baseline, l.x0)).collect::>() + ); + } +} + #[test] fn anchored_object_without_wrap_leaves_layout_unchanged() { let long = [LOREM; 3].join(" "); diff --git a/crates/compose/src/wrap.rs b/crates/compose/src/wrap.rs index e1cdc5fa7..7d38f0ef8 100644 --- a/crates/compose/src/wrap.rs +++ b/crates/compose/src/wrap.rs @@ -12,16 +12,20 @@ //! that come after the line holding its anchor; that line and the lines before it are never //! wrapped by it (an inline object sits in its line and wraps none of its own story). The story //! is composed again with the objects where the previous pass placed them until they stop -//! moving, at most [`MAX_PASSES`] more times. +//! moving. An object's wrap can push the anchors of the objects after it, so a chain of n such +//! objects needs n + 1 more compositions: the k-th pass places the (k + 1)-th object where the +//! wraps before it leave its anchor, and the last pass wraps around the last object. The story +//! is composed at most that many more times, and never more than [`MAX_PASSES`]. use designcraft_doc::{AnchorPosition, AnchoredObject, Document, Item, OBJECT_MARK, PageSide, SpreadRef, Story, StoryId, WrapMode}; use designcraft_geom::Affine; use crate::{ComposeOptions, ComposedStory, Exclusion, FrameSpec}; -/// Extra compositions of a story with its own anchored objects' wrap; a layout that keeps moving -/// its objects (keep options, vertical justification) stops here. -pub(crate) const MAX_PASSES: usize = 4; +/// Most extra compositions of a story with its own anchored objects' wrap, however many objects +/// it has; a layout that keeps moving its objects (keep options, vertical justification) stops +/// sooner, after one per object and one more. +pub(crate) const MAX_PASSES: usize = 64; /// How far an obstacle may move between passes and still count as settled (points). const SETTLED: f64 = 0.01; @@ -199,8 +203,9 @@ pub(crate) fn compose_among(doc: &Document, sid: StoryId, opts: &ComposeOptions, if !has_wrapping_objects(st) { return out; } + let own = st.objects.iter().filter(|o| wraps(o) && !matches!(o.position, AnchorPosition::Inline { .. })).count(); let mut prev = vec![Vec::new(); specs.len()]; - for _ in 0..MAX_PASSES { + for _ in 0..(own + 1).min(MAX_PASSES) { let added = exclusions(doc, &specs, &obstacles(doc, st, &out, true)); if settled(&added, &prev) { break; From ff35b3561fab711c9580ffd7fdf146779eac7ea1 Mon Sep 17 00:00:00 2001 From: Hamza Date: Wed, 28 Oct 2026 15:33:12 +0500 Subject: [PATCH 253/628] Data merge: an extended-length drive path is a local file, not a refusal Review caught a wrong assertion in my own test. `\\?\C:\x` is the extended-length spelling of `C:\x`: craft-docpath parses it as Root::VerbatimDrive, is_absolute, and its own suite classifies it PathKind::Local. The test asserted it was refused, so it would have failed on Windows -- the one platform it runs on. A data file can already name C:\secret\x.png, so refusing the long spelling would have shut out long paths without shutting anything out. It is now asserted the other way, and the refusal list keeps a device path in its place. Each shape was checked against the pinned craft-docpath through resolve(), which recognises Windows forms on every platform, so the verdicts are first-hand and not read off the source: Network for the extended UNC prefix, Device for \\.\pipe, GLOBALROOT, CON and NUL.png, and Ok for \\?\C:\secret\x.png. --- crates/engine/src/cmd/datamerge/fill.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/datamerge/fill.rs b/crates/engine/src/cmd/datamerge/fill.rs index f522065ba..5b9a5a92d 100644 --- a/crates/engine/src/cmd/datamerge/fill.rs +++ b/crates/engine/src/cmd/datamerge/fill.rs @@ -545,16 +545,28 @@ mod image_path_tests { } /// The Windows shapes `craft-docpath` refuses, asserted where they have meaning: the extended - /// UNC prefix, a device namespace path, and a legacy device name. + /// UNC prefix, a device namespace path, and a legacy device name (with an extension, since a + /// reserved name is reserved whatever follows it). #[cfg(windows)] #[test] - fn windows_device_and_extended_paths_are_refused() { + fn windows_device_and_network_paths_are_refused() { let (data, doc) = (Path::new(r"C:\work\data"), Path::new(r"C:\work\doc")); - for cell in [r"\\?\UNC\192.0.2.1\share\x.png", r"\\.\pipe\x", r"\\?\C:\secret\x.png", "CON", "NUL.png"] { + for cell in [r"\\?\UNC\192.0.2.1\share\x.png", r"\\.\pipe\x", r"\\?\GLOBALROOT\Device\HarddiskVolume1\x", "CON", "NUL.png"] { assert!(image_candidates(cell, Some(data), Some(doc)).is_empty(), "{cell}"); } } + /// An extended-length path is not one of them: `\\?\C:\x` names a local file, the same one + /// `C:\x` names, and `craft-docpath`'s own suite classifies it `PathKind::Local`. A data file + /// could already give the plain spelling, so refusing this one would shut out long paths + /// without shutting anything out (noted in review). + #[cfg(windows)] + #[test] + fn an_extended_length_drive_path_is_a_local_file() { + let (data, doc) = (Path::new(r"C:\work\data"), Path::new(r"C:\work\doc")); + assert!(!image_candidates(r"\\?\C:\secret\x.png", Some(data), Some(doc)).is_empty()); + } + /// Resolution beside the data and the document, with absolute paths this platform means. #[cfg(unix)] #[test] From b49535624c6b6b6a6ca87f35c207a9a2ddb66951 Mon Sep 17 00:00:00 2001 From: Hamza Date: Wed, 28 Oct 2026 16:26:13 +0500 Subject: [PATCH 254/628] Tables: a shape a table cannot hold is clamped, not allocated (#1583) `Table::new` allocates `rows * cols` cells up front, and the counts reach it from IDML and DOCX, where they are whatever the file claims. Measured here: `size_of::()` is 1,360 bytes, so the reported IDML case -- one `` with 5,000 `` and 5,000 ``, about 1 KB zipped -- asks for 25M cells, some 34 GB. That allocation does not fail politely: it aborts the process through `handle_alloc_error`, which no `catch_unwind` can catch, so every open document's unsaved work goes with it. A table is clamped to InDesign's own limits, 10,000 rows and 1,000 columns, and to 250,000 cells whatever its shape -- the row and column limits alone would still allow 13.6 GB. Columns are clamped first and rows take what is left, because a table is far more often long than wide. The IDML importer walks the clamped slices and reports the difference. Indexing its rows and columns by the file's own counts would be out of bounds, which is how a 34 GB abort would otherwise have become a panic. Rebased onto the post-split main: the constants and `clamped_size` live in `doc`'s `table` module root, `Table::new` in `table/grid.rs`, and the IDML table reader in `import/table.rs`. --- crates/doc/src/table.rs | 68 +++++++++++++++++++++++++++++++++ crates/doc/src/table/grid.rs | 10 ++++- crates/idml/src/import/table.rs | 17 ++++++++- 3 files changed, 92 insertions(+), 3 deletions(-) diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index d5342b5c9..937fefe1f 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -25,6 +25,32 @@ mod story; pub use cell::{AltFills, AltStrokes, Cell, CellStroke, Column, Row, RowHeightMode, RowKind, TableOptions}; pub use grid::CellRange; +/// Most rows a table holds, as InDesign allows. +pub const MAX_TABLE_ROWS: usize = 10_000; +/// Most columns a table holds, as InDesign allows. +pub const MAX_TABLE_COLS: usize = 1_000; +/// Most cells a table holds, whatever its shape. +/// +/// A [`Cell`] is about 1.4 KB (it carries a [`Story`]), so this is roughly 340 MB of cells at the +/// limit. The row and column limits alone are not enough: 10,000 × 1,000 would be 13.6 GB, and an +/// IDML story holding one `
` with 5,000 `` and 5,000 `` -- about 1 KB +/// zipped -- asked for 25M cells, some 34 GB. That allocation does not fail politely: it aborts +/// the process through `handle_alloc_error`, which no `catch_unwind` can save, so every open +/// document's unsaved work goes with it (#1583). +pub const MAX_TABLE_CELLS: usize = 250_000; + +/// The rows and columns a table of `rows × cols` is actually given, inside the limits above. +/// +/// Columns are clamped first and rows take what is left of the cell budget, because a table is +/// far more often long than wide: truncating a 5,000-column table to 1,000 columns and then its +/// rows loses less of it than the other way round. Callers that read a file report the +/// difference; the counts are a document's claim, not something to trust. +pub fn clamped_size(rows: usize, cols: usize) -> (usize, usize) { + let cols = cols.clamp(1, MAX_TABLE_COLS); + let rows = rows.clamp(1, MAX_TABLE_ROWS.min(MAX_TABLE_CELLS / cols).max(1)); + (rows, cols) +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Table { @@ -48,6 +74,48 @@ mod tests { Story::with_text(StoryId(1), t, ParaFormat::default()) } + /// A table's shape comes out of IDML and DOCX files unchecked, and `Cell` is about 1.4 KB, so + /// a file claiming 5,000 x 5,000 asked for 25M cells -- some 34 GB. That allocation aborts the + /// process rather than failing, taking every open document's unsaved work with it (#1583). + #[test] + fn a_table_larger_than_the_limits_is_clamped_not_allocated() { + // The reported shape: 5,000 rows and 5,000 columns. + let t = Table::new(1, 5_000, 5_000, 0, 0, 500.0); + assert_eq!(t.columns.len(), MAX_TABLE_COLS, "columns are clamped to InDesign's limit"); + assert!(t.cells.len() <= MAX_TABLE_CELLS, "{} cells is within the budget", t.cells.len()); + assert_eq!(t.rows.len() * t.columns.len(), t.cells.len(), "the grid stays rectangular"); + // What it would have been: 25,000,000 cells at 1,360 bytes each. + assert!(25_000_000 * std::mem::size_of::() > 30_000_000_000, "the size this test is about"); + // Freed before the next large table, so the peak stays at one of them. + drop(t); + + // A long table keeps its rows while it fits the budget (the budget itself is checked by + // `clamped_size`, which allocates nothing). + let long = Table::new(2, 10_000, 5, 0, 0, 500.0); + assert_eq!((long.rows.len(), long.columns.len()), (10_000, 5), "a long thin table is untouched"); + assert_eq!(clamped_size(10_000, 25), (10_000, 25), "10,000 x 25 is inside the budget"); + drop(long); + + // Past the row limit the rows are clamped too. + let longer = Table::new(3, 50_000, 2, 0, 0, 100.0); + assert_eq!(longer.rows.len(), MAX_TABLE_ROWS, "rows are clamped to InDesign's limit"); + drop(longer); + + // Header, body and footer share the budget, and the table still has a body. + let with_parts = Table::new(4, 5_000, 5_000, 3, 2, 500.0); + assert!(with_parts.rows.len() <= MAX_TABLE_ROWS, "{} rows", with_parts.rows.len()); + assert!(with_parts.cells.len() <= MAX_TABLE_CELLS, "{} cells", with_parts.cells.len()); + assert!(with_parts.rows.iter().any(|r| r.kind == RowKind::Header), "the header survives"); + assert!(with_parts.rows.iter().any(|r| r.kind == RowKind::Footer), "the footer survives"); + assert!(with_parts.rows.iter().any(|r| r.kind == RowKind::Body), "and so does a body row"); + drop(with_parts); + + // A header and footer bigger than the whole budget still leave a body row. + let all_header = Table::new(6, 1, 1, MAX_TABLE_ROWS * 2, MAX_TABLE_ROWS * 2, 100.0); + assert!(all_header.rows.len() <= MAX_TABLE_ROWS, "{} rows", all_header.rows.len()); + assert!(all_header.rows.iter().any(|r| r.kind == RowKind::Body), "a body row survives"); + } + #[test] fn move_row_keeps_header_and_footer_rows_in_place() { let mut t = Table::new(1, 3, 2, 1, 1, 120.0); diff --git a/crates/doc/src/table/grid.rs b/crates/doc/src/table/grid.rs index 48647cffc..f93379231 100644 --- a/crates/doc/src/table/grid.rs +++ b/crates/doc/src/table/grid.rs @@ -25,8 +25,16 @@ impl CellRange { impl Table { /// A `rows × cols` table (`header` + body + `footer` rows) whose columns share `width`. + /// + /// The shape is clamped to [`clamped_size`](super::clamped_size): the counts reach this from + /// IDML and DOCX files, where they are whatever the file says (#1583). pub fn new(id: u64, body_rows: usize, cols: usize, header: usize, footer: usize, width: f64) -> Table { - let cols = cols.max(1); + // Header, body and footer rows share one budget: the total is clamped first, then the + // header and footer take what they can of it, and the body keeps at least one row. + let (total, cols) = super::clamped_size(body_rows.max(1).saturating_add(header).saturating_add(footer), cols); + let header = header.min(total.saturating_sub(1)); + let footer = footer.min(total.saturating_sub(header).saturating_sub(1)); + let body_rows = total.saturating_sub(header).saturating_sub(footer).max(1); let mut rows = Vec::new(); rows.extend((0..header).map(|_| Row { kind: RowKind::Header, ..Default::default() })); rows.extend((0..body_rows.max(1)).map(|_| Row::default())); diff --git a/crates/idml/src/import/table.rs b/crates/idml/src/import/table.rs index 5e5a8685d..9bf0f920f 100644 --- a/crates/idml/src/import/table.rs +++ b/crates/idml/src/import/table.rs @@ -25,8 +25,21 @@ impl<'r> Importer<'r> { let id = self.alloc(); let rows: Vec<&El> = e.find_all("Row").collect(); let cols: Vec<&El> = e.find_all("Column").collect(); - let nr = rows.len().max(1); - let nc = cols.len().max(1); + // A file's row and column counts are its claim, and a table of them is allocated whole: a + // story holding one `
` with 5,000 of each asked for 25M cells, around 34 GB, which + // aborts the process instead of failing (#1583). What is read is clamped, and the rest of + // this function walks the clamped slices -- indexing `t.rows`/`t.columns` by the file's + // own counts would be out of bounds. + let (nr, nc) = designcraft_doc::table::clamped_size(rows.len().max(1), cols.len().max(1)); + if nr < rows.len() || nc < cols.len() { + self.warnings.push(format!( + "a table of {} rows and {} columns was read as {nr} x {nc}: more than a table can hold", + rows.len(), + cols.len() + )); + } + let rows = &rows[..nr.min(rows.len())]; + let cols = &cols[..nc.min(cols.len())]; let mut t = Table::new(id, nr, nc, 0, 0, 0.0); t.options.direction = if e.get("TableDirection") == Some("RightToLeftDirection") { designcraft_doc::TextDirection::RightToLeft From 1995fb5feb1b46e76fe76d6cc77635c42963b092 Mon Sep 17 00:00:00 2001 From: Hamza Date: Wed, 28 Oct 2026 16:31:34 +0500 Subject: [PATCH 255/628] IDML import: the tables in one file are capped between them Review pointed out that capping each table leaves a file free to hold many capped tables: three at the per-table limit claim 750,000 cells, about 1 GB of Cell, with every table inside its own limit. The package reader's limits count input bytes, which say nothing about how many cells a row and column count claim. budgeted_size clamps each table against what the import has left, and the cells are reserved before Table::new so a cell's own story cannot spend the budget the table around it is counting on. The budget is 400,000 cells: a Cell carries a whole story and is 1,360 bytes, so that is about the 512 MiB one part of the file may already decompress to, and the same count the spreadsheet importer collects at most. A table is never read as nothing. One row and one column are kept, so the table stays in the document and still says where its text went, and a file pays one cell for a table it really does contain -- which its own size already bounds. The warning says which limit cut the table down. The arithmetic is tested rather than the allocation: 400,000 cells is 544 MB and three such tables are not a thing to ask CI to allocate. The sequence test runs 32 claimed sizes, including usize::MAX, and holds the invariant across all of them. --- crates/idml/src/import.rs | 4 ++ crates/idml/src/import/table.rs | 91 ++++++++++++++++++++++++++++++--- 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 73bed14d3..c8e0512b5 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -139,6 +139,9 @@ struct Importer<'r> { /// Graphic `Self` → stand-in image for a linked file that can't be read. previews: &'r HashMap>>, next_id: u64, + /// Cells every table in this import has reserved. One table is capped, but a file chooses + /// how many tables it has, so the cap has to be document-wide as well (noted in review). + table_cells: usize, settings: DocSettings, default_margins: Option<(Margins, Columns)>, swatches: Vec, @@ -225,6 +228,7 @@ impl<'r> Importer<'r> { read_link, previews, next_id: 0, + table_cells: 0, settings: DocSettings::default(), default_margins: None, swatches: specials, diff --git a/crates/idml/src/import/table.rs b/crates/idml/src/import/table.rs index 9bf0f920f..3b1051b66 100644 --- a/crates/idml/src/import/table.rs +++ b/crates/idml/src/import/table.rs @@ -1,6 +1,30 @@ use super::values::{span, tint}; use super::*; +/// Cells every table in one import may hold between them. A `Cell` carries a whole story, so +/// it is 1,360 bytes: 400,000 of them is about 520 MB, which is the 512 MiB the package reader +/// already allows one part of the file to decompress to, and the same count the spreadsheet +/// importer collects at most. +const MAX_IMPORT_TABLE_CELLS: usize = 400_000; + +/// How large a table of `rows` x `cols` may be read when `reserved` cells have already been +/// taken, and whether the file-wide budget is what cut it down. +/// +/// The per-table cap leaves a file free to hold many capped tables, so each is also clamped +/// against what is left. One row and one column are always kept: the table is then still in the +/// document, still says where its text went, and a file pays one cell for a table it really +/// does contain -- which its own size already bounds. +fn budgeted_size(rows: usize, cols: usize, reserved: usize) -> (usize, usize, bool) { + let (mut nr, mut nc) = designcraft_doc::table::clamped_size(rows, cols); + let left = MAX_IMPORT_TABLE_CELLS.saturating_sub(reserved); + let over_budget = nr.saturating_mul(nc) > left; + if over_budget { + nc = nc.min(left.max(1)); + nr = nr.min((left / nc).max(1)); + } + (nr, nc, over_budget) +} + /// Preserve the legacy uniform field only when all four sides explicitly specify every /// property. Partial records must stay sparse so they can inherit from a parent style. pub(super) fn uniform_cell_stroke(edges: &[designcraft_doc::CellStrokeAttrs; 4]) -> Option { @@ -30,13 +54,14 @@ impl<'r> Importer<'r> { // aborts the process instead of failing (#1583). What is read is clamped, and the rest of // this function walks the clamped slices -- indexing `t.rows`/`t.columns` by the file's // own counts would be out of bounds. - let (nr, nc) = designcraft_doc::table::clamped_size(rows.len().max(1), cols.len().max(1)); + // + // Reserved before `Table::new`, so a cell's own story cannot spend the budget this table + // is counting on. + let (nr, nc, over_budget) = budgeted_size(rows.len().max(1), cols.len().max(1), self.table_cells); + self.table_cells = self.table_cells.saturating_add(nr.saturating_mul(nc)); if nr < rows.len() || nc < cols.len() { - self.warnings.push(format!( - "a table of {} rows and {} columns was read as {nr} x {nc}: more than a table can hold", - rows.len(), - cols.len() - )); + let why = if over_budget { "more than the tables in one file can hold between them" } else { "more than a table can hold" }; + self.warnings.push(format!("a table of {} rows and {} columns was read as {nr} x {nc}: {why}", rows.len(), cols.len())); } let rows = &rows[..nr.min(rows.len())]; let cols = &cols[..nc.min(cols.len())]; @@ -170,3 +195,57 @@ impl<'r> Importer<'r> { } } } + +#[cfg(test)] +mod budget_tests { + use super::{MAX_IMPORT_TABLE_CELLS, budgeted_size}; + + /// One table is capped, but a file chooses how many tables it has: three tables each at the + /// per-table cap asked for 750,000 cells, about 1 GB of `Cell`, with every table inside its + /// own limit (noted in review of #1583). + #[test] + fn the_tables_in_one_file_are_capped_between_them() { + let mut reserved = 0; + let mut sizes = Vec::new(); + for _ in 0..3 { + let (nr, nc, over) = budgeted_size(500, 1000, reserved); + reserved += nr * nc; + sizes.push((nr, nc, over)); + } + assert_eq!(sizes[0], (250, 1000, false), "the first table is held by the per-table cap alone"); + assert_eq!(sizes[1], (150, 1000, true), "the second gets what is left of the file's budget"); + assert_eq!(sizes[2], (1, 1, true), "and the third is kept, so its text still has somewhere to be"); + assert_eq!(reserved, MAX_IMPORT_TABLE_CELLS + 1, "400,000 cells and the one the last table keeps"); + } + + /// The budget holds for any sequence of claimed sizes, and a table is never read as nothing: + /// a 0 x n table has no cells to hold the text the file puts in it. + #[test] + fn a_table_is_never_read_as_empty_and_the_budget_always_holds() { + let claims = [(0, 0), (1, 1), (7, 3), (usize::MAX, usize::MAX), (1, usize::MAX), (usize::MAX, 1), (10_000, 1_000), (2, 0)]; + let mut reserved = 0usize; + let mut tables = 0usize; + // Every claim, several times over, in an order that exhausts the budget part way through. + for _ in 0..4 { + for (rows, cols) in claims { + let (nr, nc, _) = budgeted_size(rows.max(1), cols.max(1), reserved); + assert!(nr >= 1 && nc >= 1, "{rows} x {cols} was read as {nr} x {nc}"); + reserved += nr * nc; + tables += 1; + assert!( + reserved <= MAX_IMPORT_TABLE_CELLS + tables, + "{tables} tables reserved {reserved} cells, past the budget by more than the one cell each keeps" + ); + } + } + assert!(reserved >= MAX_IMPORT_TABLE_CELLS, "this sequence does reach the budget: {reserved}"); + } + + /// Below the budget nothing is clamped by it, so an ordinary file is read exactly as before. + #[test] + fn an_ordinary_file_is_not_touched_by_the_budget() { + for (rows, cols) in [(1, 1), (2, 3), (50, 20), (250, 1000)] { + assert_eq!(budgeted_size(rows, cols, 0), (rows, cols, false), "{rows} x {cols}"); + } + } +} From 96b498ca708644acf5ada5999df497b3a17c0926 Mon Sep 17 00:00:00 2001 From: Hamza Date: Wed, 28 Oct 2026 18:10:41 +0500 Subject: [PATCH 256/628] Import: a shared string and a footnote are not copied per reference (#1584) A footnote reference clones its note and walks it again, and an XLSX shared string is copied per cell that uses it, so one 1 MB footnote referenced 100k times meant around 100 GB. The text is kept at the first reference; later ones get the mark and an empty note, and the report says so. The spreadsheet reader stops at 64 MB of text and 400,000 collected cells. Carries both review fixes: - a footnote id is recorded only once its text has actually been kept, so a first reference arriving after the budget was spent no longer counts as "done" and tell the next reference the text is at a reference that has none; - the report and the note budget are both bounded. The duplicate warning names the id, so 400 ids referenced twice made 400 warnings, each pushed after a Vec::contains scan of all the earlier ones; warnings go through warn() now, which dedups through a set and stops at 100 distinct messages. And the budget counted only `text`, which holds a table's anchor and not its cells, so 18 notes of 600 KB of cell text retained 10,800,072 bytes against the 8 MB budget; note_bytes() walks the tables and their nested tables. Rebased onto the post-split main: the implementation stays in docx.rs and the tests moved to docx/tests.rs, so the test additions went there. --- crates/textimport/src/docx.rs | 87 +++++++++++++++++++- crates/textimport/src/docx/tests.rs | 118 ++++++++++++++++++++++++++++ crates/textimport/src/xlsx.rs | 72 ++++++++++++++++- 3 files changed, 270 insertions(+), 7 deletions(-) diff --git a/crates/textimport/src/docx.rs b/crates/textimport/src/docx.rs index 94085ca69..5f1624267 100644 --- a/crates/textimport/src/docx.rs +++ b/crates/textimport/src/docx.rs @@ -236,7 +236,17 @@ struct Ctx { para_names: HashMap, char_names: HashMap, footnotes: HashMap, + /// Footnote ids already imported, and how much note text has been built. A reference clones + /// its note and walks it again, so one 1 MB footnote referenced 100k times meant ~100 GB + /// (#1584). Word gives each note its own id and references it once; a second reference to the + /// same id gets the mark without the text. + notes_done: std::collections::HashSet, + note_bytes: usize, warnings: Vec, + /// What has already been said, so saying it again is a lookup rather than a scan of + /// everything said so far. A warning that names a footnote id is one per id, and the + /// document chooses how many ids it has (noted in review). + warned: std::collections::HashSet, next_table: u64, numbering: Numbering, /// A paragraph style's list (`w:numPr` numId and ilvl, through `w:basedOn`), by style id. @@ -249,7 +259,47 @@ struct Ctx { default_para: String, } +/// A note's tables may hold tables of their own. The document model stops nesting at 64, so +/// counting no deeper than that reaches every cell a note can actually have. +const MAX_NOTE_DEPTH: usize = 64; + +/// The text `s` holds, including its tables' cell text. A table's cells each keep their text in +/// a story of their own rather than in `s.text`, which holds only the anchor, so counting +/// `s.text` scored a table of any size as a couple of bytes (noted in review). +fn note_bytes(s: &Story, depth: usize) -> usize { + let mut n = s.text.len(); + if depth == 0 { + return n; + } + for t in s.tables.values() { + for c in &t.cells { + n = n.saturating_add(note_bytes(&c.text, depth - 1)); + } + } + n +} + +/// Warnings one import reports. A document that earns more than this has earned the point. +const MAX_WARNINGS: usize = 100; + impl Ctx { + /// Report `message` once. Beyond [`MAX_WARNINGS`] distinct messages nothing further is kept, + /// not even in the set, so neither the report nor the memory behind it grows with the + /// document -- a footnote warning names an id, and a file can hold as many ids as it likes. + fn warn(&mut self, message: String) { + if self.warnings.len() > MAX_WARNINGS { + return; + } + if !self.warned.insert(message.clone()) { + return; + } + if self.warnings.len() == MAX_WARNINGS { + self.warnings.push(format!("The Word file has more than {MAX_WARNINGS} kinds of problem: the rest are not listed.")); + } else { + self.warnings.push(message); + } + } + fn para_style(&self, ppr: Option<&El>) -> String { ppr.and_then(|p| p.val("pStyle")).and_then(|id| self.para_names.get(&id).cloned()).unwrap_or_else(|| self.default_para.clone()) } @@ -412,11 +462,39 @@ impl Ctx { "noBreakHyphen" => "\u{2011}".into(), "softHyphen" => "\u{AD}".into(), "footnoteReference" if allow_notes => { + const MAX_NOTE_BYTES: usize = 8 << 20; + let id = e.attr("id").unwrap_or_default().to_string(); + // The note's text is built once per id, and only while the budget lasts. A + // repeat reference, or one past the budget, still gets its mark and its + // number -- the reference is part of the text -- with an empty note (#1584). + // + // The id is recorded only once its text has actually been kept, further down: + // recording it here meant a first reference arriving after the budget was + // spent counted as "done" with an empty note, and the next reference to it + // then claimed the text was kept at the first one (noted in review). + let again = !id.is_empty() && self.notes_done.contains(&id); + let spent = self.note_bytes >= MAX_NOTE_BYTES; + if again || spent { + let len = st.len(); + if st.insert_note(len, "", ParaFormat::default()).is_some() { + let message = if again { + format!("Footnote {id} is referenced more than once in the Word file: the text is kept at its first reference.") + } else { + format!("The Word file's footnotes are longer than {} MB: the rest were imported empty.", MAX_NOTE_BYTES / (1 << 20)) + }; + self.warn(message); + } + continue; + } if let Some(note) = e.attr("id").and_then(|id| self.footnotes.get(id).cloned()) { let mut ns = Story::new(StoryId(0)); let counter = std::mem::take(&mut self.model_counter); self.blocks(¬e, &mut ns, false); self.model_counter = counter; + self.note_bytes = self.note_bytes.saturating_add(note_bytes(&ns, MAX_NOTE_DEPTH)); + if !id.is_empty() { + self.notes_done.insert(id.clone()); + } // Word puts the reference mark in the note text: we generate the number. let len = st.len(); match st.insert_note(len, "", ParaFormat::default()) { @@ -425,15 +503,13 @@ impl Ctx { n.text = ns; } } - None => self.warnings.push("A footnote in the Word file could not be imported.".into()), + None => self.warn("A footnote in the Word file could not be imported.".into()), } } continue; } "drawing" | "pict" | "object" => { - if !self.warnings.iter().any(|w| w.starts_with("Images")) { - self.warnings.push("Images and drawings in the Word file were not imported (place them separately).".into()); - } + self.warn("Images and drawings in the Word file were not imported (place them separately).".into()); continue; } _ => continue, @@ -485,7 +561,10 @@ pub fn import(bytes: &[u8]) -> Result { para_names: HashMap::new(), char_names: HashMap::new(), footnotes: HashMap::new(), + notes_done: std::collections::HashSet::new(), + note_bytes: 0, warnings: vec![], + warned: std::collections::HashSet::new(), next_table: 0, numbering: Numbering::default(), style_lists: HashMap::new(), diff --git a/crates/textimport/src/docx/tests.rs b/crates/textimport/src/docx/tests.rs index c70ca851f..7f1e425cf 100644 --- a/crates/textimport/src/docx/tests.rs +++ b/crates/textimport/src/docx/tests.rs @@ -89,6 +89,124 @@ fn a_measure_or_size_a_file_cannot_mean_is_bounded() { assert_eq!(size, None, "a size the engine cannot mean is dropped, so the style's size stands"); } +/// A footnote reference clones its note and walks it again, so one large footnote referenced +/// many times multiplied: the audit's case is a 1 MB note referenced 100k times, around +/// 100 GB, which aborts rather than fails (#1584). The text is kept at the first reference; +/// later ones get the mark and an empty note, and the report says so. +#[test] +fn a_footnote_referenced_many_times_is_imported_once() { + let note_text = "x".repeat(2_000); + let footnotes = format!(r#"{note_text}"#); + // One note, referenced 50 times. + let refs = r#""#.repeat(50); + let doc = format!(r#"{refs}"#); + let i = import(&docx(&doc, "", &footnotes)).unwrap(); + + // Every reference is still in the text: the marks are the document's content. + assert_eq!(i.story.notes.len(), 50, "all 50 references are kept"); + let with_text = i.story.notes.iter().filter(|n| !n.text.text.is_empty()).count(); + assert_eq!(with_text, 1, "only the first reference carries the note's text"); + let total: usize = i.story.notes.iter().map(|n| n.text.text.len()).sum(); + assert!(total <= note_text.len() + 64, "the note text is stored once, not 50 times: {total} bytes"); + assert!(i.warnings.iter().any(|w| w.contains("referenced more than once")), "the report says what it did: {:?}", i.warnings); + + // A document whose notes each have their own id is unaffected. + let many = (2..6).map(|id| format!(r#"note {id}"#)).collect::(); + let footnotes = format!(r#"{many}"#); + let refs = (2..6).map(|id| format!(r#""#)).collect::(); + let doc = format!(r#"{refs}"#); + let i = import(&docx(&doc, "", &footnotes)).unwrap(); + assert_eq!(i.story.notes.len(), 4); + assert!(i.story.notes.iter().all(|n| n.text.text.contains("note")), "each distinct note keeps its text"); + assert!(!i.warnings.iter().any(|w| w.contains("referenced more than once")), "{:?}", i.warnings); +} + +/// A footnote's table keeps its cell text in stories of its own, so the budget counted a +/// table of any size as the couple of bytes of its anchor and the cap did not hold (noted in +/// review of #1584). Eighteen notes of ~600 KB of cell text each are 10.8 MB of table text +/// against an 8 MB budget. +#[test] +fn a_footnote_table_counts_toward_the_note_budget() { + let big = "x".repeat(600_000); + let cell = format!(r#"{big}"#); + let notes: String = (2..20).map(|id| format!(r#"{cell}"#)).collect(); + let footnotes = format!(r#"{notes}"#); + let refs: String = (2..20).map(|id| format!(r#""#)).collect(); + let doc = format!(r#"{refs}"#); + let i = import(&docx(&doc, "", &footnotes)).unwrap(); + + // Every reference is still a mark in the text. + assert_eq!(i.story.notes.len(), 18, "all 18 references are kept"); + // What was retained is the budget plus at most the one note that was already being built + // when it ran out -- not all 10.8 MB. + let held: usize = i.story.notes.iter().map(|n| note_bytes(&n.text, MAX_NOTE_DEPTH)).sum(); + assert!(held <= (8 << 20) + big.len() + 1024, "the cell text is inside the budget: {held} bytes"); + assert!(held > big.len(), "and the notes that fit were imported in full: {held} bytes"); + assert!(i.warnings.iter().any(|w| w.contains("longer than 8 MB")), "the budget warning is reported: {:?}", i.warnings); +} + +/// The report is one line per distinct footnote id, and the file chooses how many ids it +/// has: 400 ids referenced twice each meant 400 warnings, every one of them pushed after a +/// `Vec::contains` scan of all the earlier ones (noted in review of #1584). Both the report +/// and the set behind it stop at the cap. +#[test] +fn the_report_does_not_grow_with_the_document() { + let ids = 2..402; + // Small notes, so the byte budget is not what stops this -- the ids are. + let notes: String = ids.clone().map(|id| format!(r#"note {id}"#)).collect(); + let footnotes = format!(r#"{notes}"#); + let refs: String = ids + .clone() + .flat_map(|id| { + let one = format!(r#""#); + [one.clone(), one] + }) + .collect(); + let doc = format!(r#"{refs}"#); + let i = import(&docx(&doc, "", &footnotes)).unwrap(); + + // The document is imported in full: every reference is a mark in the text. + assert_eq!(i.story.notes.len(), 800, "all 800 references are kept"); + let duplicates = i.warnings.iter().filter(|w| w.contains("referenced more than once")).count(); + assert!(duplicates >= 1, "the repeats are reported: {:?}", i.warnings); + assert!(i.warnings.len() <= MAX_WARNINGS + 1, "400 duplicated ids do not make 400 warnings: {} warnings", i.warnings.len()); + // And the last line says the list was cut, rather than ending without explanation. + assert!(i.warnings.iter().any(|w| w.contains("the rest are not listed")), "{:?}", i.warnings); +} + +/// When the budget runs out before a note's *first* reference, that reference gets an empty +/// note -- and must not be recorded as the one that kept the text, or a later reference to the +/// same id would be told the text is at a reference that has none (noted in review of #1584). +#[test] +fn a_note_whose_first_reference_is_past_the_budget_is_not_recorded_as_kept() { + // Notes totalling more than the 8 MB budget, so the later ids are never built. + let big = "x".repeat(600_000); + let notes: String = (2..20).map(|id| format!(r#"{big}"#)).collect(); + let footnotes = format!(r#"{notes}"#); + // Each id referenced twice, in order. + let refs: String = (2..20) + .flat_map(|id| { + let one = format!(r#""#); + [one.clone(), one] + }) + .collect(); + let doc = format!(r#"{refs}"#); + let i = import(&docx(&doc, "", &footnotes)).unwrap(); + + // Every reference is kept, and the budget is respected. + assert_eq!(i.story.notes.len(), 36, "all 36 references are in the text"); + let total: usize = i.story.notes.iter().map(|n| n.text.text.len()).sum(); + assert!(total <= (8 << 20) + big.len(), "the note text stays inside the budget: {total} bytes"); + + // The "referenced more than once" warning is only about ids whose text was actually kept. + // A note that was never built cannot have kept its text at a first reference. + let kept: std::collections::HashSet<&str> = i.story.notes.iter().filter(|n| !n.text.text.is_empty()).map(|n| n.text.text.as_str()).collect(); + if i.warnings.iter().any(|w| w.contains("referenced more than once")) { + assert!(!kept.is_empty(), "that warning implies some text was kept: {:?}", i.warnings); + } + assert!(i.warnings.iter().any(|w| w.contains("longer than 8 MB")), "the budget warning is the one this document earns: {:?}", i.warnings); +} + #[test] fn imports_styles_runs_footnotes_and_tables() { let styles = format!( diff --git a/crates/textimport/src/xlsx.rs b/crates/textimport/src/xlsx.rs index a26665ecc..7f129cce1 100644 --- a/crates/textimport/src/xlsx.rs +++ b/crates/textimport/src/xlsx.rs @@ -212,19 +212,41 @@ pub fn import(bytes: &[u8]) -> Result { .transpose()? .map(|sst| sst.els().filter(|e| e.name == "si").map(string_item).collect()) .unwrap_or_default(); + // A `t="s"` cell holds an index into the shared strings and each one is materialised as its + // own `String`, so one large shared string referenced by many cells multiplies: a 1 MB string + // and 3M references is terabytes, allocated before the grid cap below ever applies, and an + // allocation that large aborts the process rather than failing (#1584). Collection carries a + // byte budget and a cell count of its own. + const MAX_TEXT_BYTES: usize = 64 << 20; + const MAX_COLLECTED_CELLS: usize = 400_000; let mut cells: Vec<((usize, usize), String)> = Vec::new(); + let mut text_bytes = 0usize; + let mut cut = false; if let Some(data) = sheet.child("sheetData") { - for (ri, row) in data.els().filter(|e| e.name == "row").enumerate() { + 'rows: for (ri, row) in data.els().filter(|e| e.name == "row").enumerate() { for (ci, c) in row.els().filter(|e| e.name == "c").enumerate() { let at = c.attr("r").and_then(cell_ref).unwrap_or((ri, ci)); let v = value(c, &shared); - if !v.is_empty() { - cells.push((at, v)); + if v.is_empty() { + continue; } + if cells.len() >= MAX_COLLECTED_CELLS || text_bytes.saturating_add(v.len()) > MAX_TEXT_BYTES { + cut = true; + break 'rows; + } + text_bytes = text_bytes.saturating_add(v.len()); + cells.push((at, v)); } } } let mut warnings = Vec::new(); + if cut { + warnings.push(format!( + "the worksheet holds more than {} cells or {} MB of text: the rest was not read", + MAX_COLLECTED_CELLS, + MAX_TEXT_BYTES / (1 << 20) + )); + } if cells.is_empty() { warnings.push("the worksheet is empty".into()); } @@ -293,6 +315,50 @@ mod tests { buf.into_inner() } + /// A `t="s"` cell materialises its own copy of the shared string, so one big string with many + /// references multiplies: the audit's case is a 1 MB string and 3M cells, terabytes, built + /// before the grid cap applies (#1584). Collection now carries its own byte budget. + #[test] + fn a_shared_string_referenced_many_times_does_not_multiply() { + // A 64 KB shared string referenced 2,000 times would be 128 MB of cell text; the budget + // is 64 MB, so collection stops and says so. + let big = "y".repeat(64 * 1024); + let cells: String = (1..=2_000).map(|r| format!(r#"0"#)).collect(); + let mut buf = std::io::Cursor::new(Vec::new()); + { + let mut z = zip::ZipWriter::new(&mut buf); + let o = zip::write::SimpleFileOptions::default(); + for (name, body) in [ + ( + "xl/workbook.xml", + r#""#.to_string(), + ), + ( + "xl/_rels/workbook.xml.rels", + r#""#.to_string(), + ), + ("xl/sharedStrings.xml", format!("{big}")), + ("xl/worksheets/sheet1.xml", format!("{cells}")), + ] { + z.start_file(name, o).unwrap(); + z.write_all(body.as_bytes()).unwrap(); + } + z.finish().unwrap(); + } + let imp = import(&buf.into_inner()).unwrap(); + let table = imp.story.tables.values().next().expect("a table"); + let text: usize = (0..table.nrows()) + .flat_map(|r| (0..table.ncols()).map(move |c| (r, c))) + .filter_map(|(r, c)| table.cell(r, c).map(|cell| cell.text.text.len())) + .sum(); + assert!(text <= 64 << 20, "the table holds at most the budget: {text} bytes"); + assert!(imp.warnings.iter().any(|w| w.contains("was not read")), "the report says the sheet was cut: {:?}", imp.warnings); + + // An ordinary workbook is unaffected and reports nothing of the sort. + let plain = import(&workbook()).unwrap(); + assert!(!plain.warnings.iter().any(|w| w.contains("was not read")), "{:?}", plain.warnings); + } + #[test] fn first_sheet_used_range_becomes_a_table() { let imp = import(&workbook()).unwrap(); From 352609117fbb7deed1b37c16f7b9f3c8654bbe0d Mon Sep 17 00:00:00 2001 From: Hamza Date: Wed, 28 Oct 2026 18:20:13 +0500 Subject: [PATCH 257/628] A table size Create Table cannot mean is refused A size that was given but is not one a table can have was corrected in silence: {"rows": 0} made a table with one row, -1 and 1.5 made the default four, and 10000 was capped at 500 -- so a script asking for a shape the command cannot honour got a different table and no indication of it. A size that is absent still takes the default; a size that is present and outside the range is refused, naming the key and the range. Rebased onto the post-split main: `table.insert` moved to table/structure.rs and the tests to table/sizing.rs. The helper is named `given` rather than `count`, which that module already imports from table/target.rs for the repeat count of a row or column insert. --- crates/engine/src/cmd/table/sizing.rs | 46 ++++++++++++++++++++++++ crates/engine/src/cmd/table/structure.rs | 22 +++++++++--- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/table/sizing.rs b/crates/engine/src/cmd/table/sizing.rs index cd1d1af53..bcb661906 100644 --- a/crates/engine/src/cmd/table/sizing.rs +++ b/crates/engine/src/cmd/table/sizing.rs @@ -320,3 +320,49 @@ mod resize_border_tests { assert!(s.execute("table.resizeBorder", &json!({"frame": f, "table": t, "axis": "column", "index": 0, "delta": 5})).is_err()); } } + +#[cfg(test)] +mod insert_size_tests { + use serde_json::json; + + use crate::Session; + + /// A table size that is given but is not one a table can have used to be corrected in + /// silence: `{"rows": 0}` made a one-row table, `-1` and `1.5` made the default four, and + /// 10000 was capped at 500 — so a caller's mistake became a table nobody asked for. + #[test] + fn a_table_size_insert_cannot_mean_is_refused() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1})).unwrap(); + let frame = s.execute("frame.create", &json!({"rect": [20, 20, 400, 300], "content": "text", "text": ""})).unwrap(); + let at = |s: &mut Session| { + s.execute("text.select", &json!({"story": frame["story"].clone(), "anchor": 0, "focus": 0})).unwrap(); + }; + for (params, names) in [ + (json!({"rows": 0}), "rows 0: 1 to 500"), + (json!({"rows": -1}), "rows -1: 1 to 500"), + (json!({"rows": 1.5}), "rows 1.5: 1 to 500"), + (json!({"rows": 10000}), "rows 10000: 1 to 500"), + (json!({"cols": 0}), "cols 0: 1 to 200"), + (json!({"columns": 201}), "cols 201: 1 to 200"), + (json!({"headerRows": 101}), "headerRows 101: 0 to 100"), + (json!({"footerRows": -2}), "footerRows -2: 0 to 100"), + ] { + at(&mut s); + let e = s.execute("table.insert", ¶ms).expect_err("refused").to_string(); + assert!(e.contains(names), "{params}: {e}"); + } + // Nothing was inserted by any of those. + at(&mut s); + assert!(s.execute("table.get", &json!({})).is_err(), "no table yet"); + // The sizes a table can have still work, and absent means the default four by four. + at(&mut s); + s.execute("table.insert", &json!({})).unwrap(); + let t = s.execute("table.get", &json!({})).unwrap(); + assert_eq!(t["cells"].as_array().unwrap().len(), 16, "four by four"); + at(&mut s); + s.execute("table.insert", &json!({"rows": 1, "cols": 2, "headerRows": 1})).unwrap(); + let t = s.execute("table.get", &json!({})).unwrap(); + assert_eq!(t["cells"].as_array().unwrap().len(), 4, "one body row and one header row, two columns"); + } +} diff --git a/crates/engine/src/cmd/table/structure.rs b/crates/engine/src/cmd/table/structure.rs index 65bcdb3d6..7ac075517 100644 --- a/crates/engine/src/cmd/table/structure.rs +++ b/crates/engine/src/cmd/table/structure.rs @@ -6,10 +6,24 @@ pub(super) fn insert(s: &mut Session, p: &Value) -> Result { 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; + // A size that is given but is not one a table can have used to be corrected in silence: + // `{"rows": 0}` made a table with one row, `-1` and `1.5` made the default four, and 10000 + // was capped at 500. Named `given` rather than `count`, which this module already imports + // for the repeat count of a row or column insert. + let given = |key: &str, alt: Option<&str>, default: u64, range: std::ops::RangeInclusive| -> Result { + let v = p.get(key).or_else(|| alt.and_then(|a| p.get(a))).filter(|v| !v.is_null()); + match v { + None => Ok(default as usize), + Some(v) => match v.as_u64().filter(|n| range.contains(n)) { + Some(n) => Ok(n as usize), + None => Err(bad("table.insert", format!("{key} {v}: {} to {}", range.start(), range.end()))), + }, + } + }; + let rows = given("rows", None, 4, 1..=500)?; + let cols = given("cols", Some("columns"), 4, 1..=200)?; + let header = given("headerRows", None, 0, 0..=100)?; + let footer = given("footerRows", None, 0, 0..=100)?; 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(); From 2f403698e0e2a8db6b404afe30292f3e3d15ce81 Mon Sep 17 00:00:00 2001 From: Hamza Date: Wed, 28 Oct 2026 18:53:45 +0500 Subject: [PATCH 258/628] An export says what text its pages had no room for (#1465) A page-based export leaves out the text its frames cannot fit, and said nothing about it: an imported 58-page form lost whole sentences -- a data-consent clause, a statutory rule, the last row of two tables -- with `warnings` empty while preflight reported 19 overset stories. The PDF and deck exports now report it, in the words preflight uses and with the same count, naming the page and the frame. A reflowable export carries the whole story, so it has nothing to report. Carries the review fix: a spread export puts the whole spread on the sheet, so choosing page 2 of a facing-pages document exports page 3 as well and page 3's overset is as absent from the file as page 2's would be. The selection is widened to the spreads it touches first. A page on another spread is still not reported, so the filter keeps doing what it is for. Rebased onto the post-split main: the PDF command is in export/pdf.rs, the deck one in export/formats.rs, and preflight.rs is unchanged by the split. The helper sits before export_pdf rather than between first_page_ref's doc comment and its function, which is where the pre-split patch had put it. --- crates/engine/src/cmd/export/formats.rs | 2 + crates/engine/src/cmd/export/pdf.rs | 123 +++++++++++++++++++++++- crates/engine/src/cmd/preflight.rs | 45 +++++++++ crates/engine/src/cmd/text.rs | 7 +- 4 files changed, 175 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/export/formats.rs b/crates/engine/src/cmd/export/formats.rs index 025bcdac4..dab0bb8d1 100644 --- a/crates/engine/src/cmd/export/formats.rs +++ b/crates/engine/src/cmd/export/formats.rs @@ -110,6 +110,8 @@ pub(super) fn export_pptx(s: &mut Session, p: &Value) -> Result { if rasterized > 0 { r.warnings.push(format!("{rasterized} object(s) went in as images")); } + // A slide has no room for the overset text either (#1465). + r.warnings.extend(crate::cmd::preflight::overset_warnings(s, opts.pages.as_deref())); match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/engine/src/cmd/export/pdf.rs b/crates/engine/src/cmd/export/pdf.rs index 64011fa3c..12d5577ec 100644 --- a/crates/engine/src/cmd/export/pdf.rs +++ b/crates/engine/src/cmd/export/pdf.rs @@ -87,8 +87,27 @@ fn first_page_ref(pdf: &[u8]) -> Option { s[i..].split_whitespace().next()?.parse().ok() } +/// Every page sharing a spread with one of `pages` (0-based), for an export that puts whole +/// spreads on a sheet. The pages come back in document order. +fn with_their_spreads(s: &Session, pages: &[usize]) -> Vec { + let Some(st) = s.active() else { return pages.to_vec() }; + let d = &st.doc; + let spreads: std::collections::BTreeSet = pages.iter().filter_map(|p| d.page_loc(*p)).map(|(si, _)| si).collect(); + if spreads.is_empty() { + return pages.to_vec(); + } + (0..d.page_count()).filter(|p| d.page_loc(*p).is_some_and(|(si, _)| spreads.contains(&si))).collect() +} + pub(super) fn export_pdf(s: &mut Session, p: &Value) -> Result { - let r = pdf_export(s, p)?; + let mut r = pdf_export(s, p)?; + // Text the pages have no room for is not in the file: say so, as preflight does (#1465). + // A spread export puts the whole spread on the sheet, so choosing page 2 exports page 3 as + // well and page 3's overset is as absent from the file as page 2's would be. The selection + // is widened to the spreads it touches before the warnings are collected (noted in review). + let opts = options(p, s.doc()?.doc.page_count())?; + let warn_pages = opts.pages.as_deref().map(|pages| if opts.spreads { with_their_spreads(s, pages) } else { pages.to_vec() }); + r.warnings.extend(crate::cmd::preflight::overset_warnings(s, warn_pages.as_deref())); match str_param(p, "path") { Some(path) => { write_file(path, &r.bytes)?; @@ -461,3 +480,105 @@ mod page_list_tests { assert!(s.execute("file.exportPdf", &json!({"pages": []})).is_err()); } } + +#[cfg(test)] +mod overset_tests { + use serde_json::json; + + use crate::Session; + + /// A page-based export leaves out the text its frames have no room for, and said nothing + /// about it: an imported 58-page form lost whole sentences — a data-consent clause, a + /// statutory rule, the last row of two tables — with `warnings` empty while preflight + /// reported 19 overset stories (#1465). + #[test] + fn an_export_warns_about_the_text_the_pages_had_no_room_for() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 2, "facingPages": false, "margins": 0})).unwrap(); + let text = "First line is visible.\nSecond paragraph does not fit: this sentence carries the legal notice and must not vanish from the PDF without a warning."; + let frame = s.execute("frame.create", &json!({"rect": [20, 20, 280, 60], "content": "text", "text": text})).unwrap(); + let (id, sid) = (frame["id"].as_u64().unwrap(), frame["story"].clone()); + + // What preflight says about the same frame, for the warning to agree with. + let issues = s.execute("preflight.run", &json!({})).unwrap(); + let overset = issues["issues"].as_array().unwrap().iter().find(|i| i["kind"] == "overset").expect("preflight reports it").clone(); + assert_eq!(overset["message"], "Overset text: 76 characters"); + + for command in ["file.exportPdf", "file.exportPptx"] { + let r = s.execute(command, &json!({})).unwrap(); + let warnings = r["warnings"].as_array().expect("warnings").clone(); + let about: Vec<&str> = warnings.iter().filter_map(|w| w.as_str()).filter(|w| w.contains("not in the export")).collect(); + assert_eq!(about.len(), 1, "{command}: {warnings:?}"); + let w = about[0]; + assert!(w.contains("page 1"), "{command}: the page it is on: {w}"); + assert!(w.contains("76 character"), "{command}: the count preflight gives: {w}"); + assert!(w.contains(&format!("frame {id}")), "{command}: the frame: {w}"); + } + + // The pages being exported are the ones reported on: page 2 has no overset frame. + let r = s.execute("file.exportPdf", &json!({"pages": [2]})).unwrap(); + assert_eq!(r["warnings"], json!([]), "{r}"); + + // A reflowable export carries the whole story, so it has nothing to report. + let r = s.execute("file.exportHtml", &json!({})).unwrap(); + assert_eq!(r["warnings"], json!([]), "{r}"); + assert!(r["text"].as_str().unwrap().contains("must not vanish"), "the HTML has the text the PDF left out"); + + // `overset` is the index where the text stops fitting; `oversetChars` is how much is left, + // which is the number preflight reports. The two were read as the same thing. + let story = s.execute("story.get", &json!({"story": sid})).unwrap(); + assert_eq!(story["overset"], json!(68), "{story}"); + assert_eq!(story["oversetChars"], json!(76), "{story}"); + + // A frame the text fits: nothing is reported anywhere. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 1, "facingPages": false, "margins": 0})).unwrap(); + let frame = s.execute("frame.create", &json!({"rect": [20, 20, 280, 180], "content": "text", "text": "Short."})).unwrap(); + for command in ["file.exportPdf", "file.exportPptx"] { + assert_eq!(s.execute(command, &json!({})).unwrap()["warnings"], json!([]), "{command}"); + } + let story = s.execute("story.get", &json!({"story": frame["story"].clone()})).unwrap(); + assert!(story["overset"].is_null() && story["oversetChars"].is_null(), "{story}"); + } + + /// A spread export puts the whole spread on the sheet, so choosing one page of it exports its + /// partner too -- and that partner's overset is just as absent from the file. Reporting only + /// the pages that were named left it unmentioned (noted in review of #1465). + #[test] + fn a_spread_export_warns_about_the_pages_it_takes_with_it() { + let mut s = Session::new(); + // Facing pages: 2 and 3 share a spread, 1 is the cover on its own. + s.execute("file.new", &json!({"width": 200, "height": 200, "pages": 4, "facingPages": true, "margins": 0})).unwrap(); + let text = "First line is visible.\nSecond paragraph does not fit: this sentence carries the legal notice and must not vanish from the PDF without a warning."; + // A frame on page 3, whose text does not fit it. `frame.create` takes a spread and + // spread coordinates, so page 3's own bounds say where to put it. + let (si, b) = { + let d = &s.active().expect("a document").doc; + let (si, pi) = d.page_loc(2).expect("page 3 is there"); + (si, d.spreads[si].pages[pi].bounds()) + }; + let rect = json!([b.x0 + 20.0, b.y0 + 20.0, b.x0 + 180.0, b.y0 + 60.0]); + let frame = s.execute("frame.create", &json!({"spread": si, "rect": rect, "content": "text", "text": text})).unwrap(); + let id = frame["id"].as_u64().unwrap(); + let about = |r: &serde_json::Value| -> Vec { + r["warnings"] + .as_array() + .map(|w| w.iter().filter_map(|w| w.as_str()).filter(|w| w.contains("not in the export")).map(str::to_string).collect()) + .unwrap_or_default() + }; + + // Page by page, choosing page 2 reports nothing: page 2 holds no overset frame. + let r = s.execute("file.exportPdf", &json!({"pages": [2]})).unwrap(); + assert!(about(&r).is_empty(), "page 2 alone has nothing to report: {r}"); + + // As spreads, choosing page 2 exports page 3 with it, so page 3's overset is reported. + let r = s.execute("file.exportPdf", &json!({"pages": [2], "spreads": true})).unwrap(); + let w = about(&r); + assert_eq!(w.len(), 1, "the page that came along is reported too: {r}"); + assert!(w[0].contains("page 3") && w[0].contains(&format!("frame {id}")), "{}", w[0]); + + // And a page on another spread entirely is still not reported. + let r = s.execute("file.exportPdf", &json!({"pages": [1], "spreads": true})).unwrap(); + assert!(about(&r).is_empty(), "the cover's spread holds no overset frame: {r}"); + } +} diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index 390dc7118..b3a22d6ad 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -113,6 +113,51 @@ fn overset_location(s: &Session, target: designcraft_doc::ItemId) -> Option<(des None } +/// Text that is in the document but not in a page-based export (PDF, PowerPoint): the overset +/// part of a story, and a story with no frame at all. The export used to write the file and +/// report nothing, so whole sentences left an imported document without a hint anywhere (#1465). +/// +/// One line per story, naming the page and the frame as the preflight issue does. `pages` are the +/// 0-based pages being exported; a story whose overset is on a page that is not being exported is +/// not reported, and one with no page of its own always is. A reflowable export (EPUB, HTML) or a +/// text export carries the whole story, so neither calls this. +pub fn overset_warnings(s: &Session, pages: Option<&[usize]>) -> Vec { + let Some(st) = s.active() else { return vec![] }; + let d = &st.doc; + let mut out = Vec::new(); + for story in d.stories.values() { + let cs = s.cache.get(d, story.id, None); + if !cs.is_overset() { + continue; + } + let n = story.text.get(cs.overset_at.unwrap_or(0)..).unwrap_or("").chars().count(); + if n == 0 { + continue; + } + if story.frames.is_empty() { + out.push(format!("a story with no text frame: {n} character(s) of it are not in the export")); + continue; + } + let last = story.frames.last().copied(); + let location = last.and_then(|f| overset_location(s, f)); + let page = location.and_then(|(_, page)| page); + if let (Some(page), Some(pages)) = (page, pages) + && !pages.contains(&page) + { + continue; + } + let item = location.map(|(item, _)| item).or(last); + out.push(match (page, item) { + (Some(page), Some(item)) => { + format!("page {}: {n} character(s) of the story in frame {} do not fit it and are not in the export", page + 1, item.0) + } + (None, Some(item)) => format!("the story in frame {}: {n} character(s) do not fit and are not in the export", item.0), + _ => format!("{n} character(s) of a story do not fit their frame and are not in the export"), + }); + } + out +} + pub fn check(s: &Session, min_ppi: f64) -> Vec { let Some(st) = s.active() else { return vec![] }; let d = &st.doc; diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 8e6038741..3e6dce9d5 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -191,12 +191,17 @@ pub fn specs() -> Vec { }) } ), - cmd!(query "story.get", "Get Story", [], None, "{story? | frame?} → text, frames, paragraphs, vertical, overset", has_doc, |s, p| { + cmd!(query "story.get", "Get Story", [], None, "{story? | frame?} → text, frames, paragraphs, vertical, lines, overset (the index in `text` where it stops fitting its frames, null when it all fits) and oversetChars (how many characters that leaves, as preflight reports them)", has_doc, |s, p| { let st = s.doc()?; let sid = story_of(s, p).ok_or_else(|| bad("story.get", "no story"))?; let mut v = st.doc.story_summary(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; let cs = s.cache.get(&st.doc, sid, None); + // `overset` is where the text stops fitting, not how much is left over: the two were + // read as the same number and disagreed with preflight's count (#1465). v["overset"] = json!(cs.overset_at); + v["oversetChars"] = json!(cs + .overset_at + .map(|at| st.doc.stories.get(&sid).and_then(|story| story.text.get(at..)).unwrap_or("").chars().count())); v["lines"] = json!(cs.line_count()); Ok(v) }), From 87b49d997376a3bdb3ce42ac7967efb0519c2c68 Mon Sep 17 00:00:00 2001 From: Hamza Date: Wed, 28 Oct 2026 19:09:25 +0500 Subject: [PATCH 259/628] PDF export: SOURCE_DATE_EPOCH stands in for the clock, so exports repeat A PDF export recorded the second it was made in, so exporting one document twice gave different files although the pages matched -- and the instance id krilla derives from the bytes followed the timestamp, so nothing about the two files was comparable. SOURCE_DATE_EPOCH stands in for the clock when the export is given no date, as the reproducible-builds specification defines it. A date passed in still wins, and a value the PDF date format cannot hold -- malformed, negative, or past the range -- is ignored rather than refusing the export or writing a date no reader can parse. With the epoch pinned the two files are identical byte for byte. Rebased onto the post-split main: export_time and pinned_or live in export/metadata.rs beside now_unix, which they stand in front of, and the call site is in export/pipeline.rs. The ROADMAP keeps upstream's 2026-10-17 entry with this one above it. --- ROADMAP.md | 1 + crates/pdf/src/export/metadata.rs | 60 +++++++++++++++++++++++++++++++ crates/pdf/src/export/pipeline.rs | 4 +-- crates/pdf/src/lib.rs | 3 +- 4 files changed, 65 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c02176bab..68e286450 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -431,6 +431,7 @@ Newest first. - **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. - **2026-10-22** · Paragraph rules: Rule Above's offset is measured from the first line's baseline to the rule's bottom edge, as in InDesign. Rules sat one ascent too high and crossed the previous paragraph's text (reported from an imported INDD document). No readiness percentage change. - **2026-10-21** · Ruler guides are selected like objects (#1282): a click with the Selection tools selects a guide (also where it crosses an object), Shift-click adds or removes one, a new guide from the rulers is selected, a marquee that touches no object selects the guides it crosses, and Edit › Select All Guides (⌥⌘G, `guide.selectAll`) selects every unlocked guide on the spread. Selected guides draw thicker in their layer's color, drag together (all of them shown moving), nudge with the arrow keys and go with Delete, each as one undo step (`guide.select`, `guide.deselect`, `guide.moveBy`, `guide.delete {}`). With one guide selected the Control bar's X (vertical guide) or Y (horizontal guide) field and Properties › Transform show where it is, as the rulers measure, and typing a value moves it there. No readiness percentage change. +- **2026-10-19** · A PDF export can repeat its bytes: it recorded the second it was made in, so exporting one document twice gave different files though the pages matched. `SOURCE_DATE_EPOCH` stands in for the clock when the export is given no date, as the reproducible-builds specification defines it; a date passed in still wins, and a malformed value is ignored rather than refusing the export. The timestamp was the only thing that differed, so the files are now identical byte for byte. No readiness percentage change. - **2026-10-17** · PDF/X-4 export warns about fonts drawing visible text without an embedded font program, including placed PDF forms; `validate-pdf` reports the font and page (#909). Invisible OCR text is exempt. Detection only: fonts are not embedded or outlined automatically. diff --git a/crates/pdf/src/export/metadata.rs b/crates/pdf/src/export/metadata.rs index 619a0f5a5..b5993453f 100644 --- a/crates/pdf/src/export/metadata.rs +++ b/crates/pdf/src/export/metadata.rs @@ -11,6 +11,27 @@ pub(super) fn now_unix() -> Option { None } +/// The date an exported file records, when the caller gives none: `SOURCE_DATE_EPOCH` if it holds +/// a non-negative whole number of seconds, else the clock. Pinning it makes an export repeat its +/// bytes, which is otherwise impossible -- the only thing that differs between two exports of one +/// document is the timestamp, and the instance id krilla derives from the bytes follows it. +/// +/// +pub(super) fn export_time() -> Option { + pinned_or(|name| std::env::var(name).ok(), now_unix) +} + +/// [`export_time`] with the environment and the clock given, so a test can show which wins. +fn pinned_or(get: impl Fn(&str) -> Option, clock: impl Fn() -> Option) -> Option { + let pinned = get("SOURCE_DATE_EPOCH").and_then(|v| v.trim().parse::().ok()).filter(|secs| PDF_DATES.contains(secs)); + pinned.or_else(clock) +} + +/// The dates a PDF can hold, as Unix seconds: `date_time` clamps the year to 0..=9999, so a value +/// outside this would be recorded as some other date rather than the one asked for. A value that +/// cannot be honoured is left to the clock, as a malformed one is. +const PDF_DATES: std::ops::RangeInclusive = 0..=253_402_300_799; + /// The document information from File Info (title, author, description, keywords). `title` and /// `author` from the export options replace File Info's. pub(super) fn file_info_metadata(doc: &Document, title: Option<&str>, author: Option<&str>) -> Metadata { @@ -49,3 +70,42 @@ pub(crate) fn civil(t: i64) -> (i64, u8, u8, u8, u8, u8) { let (y, t) = designcraft_doc::time::utc_parts(t, 0); (y, t.month() as u8, t.day() as u8, t.hour() as u8, t.minute() as u8, t.second() as u8) } + +#[cfg(test)] +mod export_time_tests { + use super::pinned_or; + + /// What an export records when the caller gives no date: `SOURCE_DATE_EPOCH` when it holds a + /// non-negative whole number of seconds, the clock whenever it does not. The environment and + /// the clock are given here rather than set, so no test changes this process's environment -- + /// that is `unsafe` under edition 2024 and would reach every test beside it. + #[test] + fn a_pinned_epoch_is_preferred_and_the_clock_answers_otherwise() { + let env = |v: Option<&'static str>| move |_: &str| v.map(str::to_owned); + let clock = || Some(1_000_000); + assert_eq!(pinned_or(env(Some("1791208980")), clock), Some(1_791_208_980), "the variable wins"); + assert_eq!(pinned_or(env(Some("0")), clock), Some(0), "the epoch itself counts"); + assert_eq!(pinned_or(env(Some(" 1791208980 ")), clock), Some(1_791_208_980), "spaces around it are allowed"); + // A date a PDF cannot hold is left to the clock too: `date_time` would clamp the year and + // record some other date. 253402300799 is 9999-12-31T23:59:59Z, the last one it can. + assert_eq!(pinned_or(env(Some("253402300799")), clock), Some(253_402_300_799), "the last date a PDF can hold"); + for ignored in [ + None, + Some(""), + Some(" "), + Some("-1"), + Some("later"), + Some("1791208980.5"), + Some("99999999999999999999"), + Some("253402300800"), + Some("9223372036854775807"), + ] { + assert_eq!(pinned_or(env(ignored), clock), Some(1_000_000), "{ignored:?} leaves it to the clock"); + } + // A platform with no clock and no variable records no date at all. + assert_eq!(pinned_or(env(None), || None), None); + assert_eq!(pinned_or(env(Some("1791208980")), || None), Some(1_791_208_980), "the variable needs no clock"); + // Only that variable is read. + assert_eq!(pinned_or(|name: &str| (name == "TZ").then(|| "1791208980".to_owned()), clock), Some(1_000_000)); + } +} diff --git a/crates/pdf/src/export/pipeline.rs b/crates/pdf/src/export/pipeline.rs index ca9d1a988..ffef45c87 100644 --- a/crates/pdf/src/export/pipeline.rs +++ b/crates/pdf/src/export/pipeline.rs @@ -1,6 +1,6 @@ use super::common::{tf, to_path}; use super::images::ImageContentCache; -use super::metadata::{date_time, file_info_metadata, now_unix}; +use super::metadata::{date_time, export_time, file_info_metadata}; use super::*; /// Export `doc` as PDF bytes. @@ -162,7 +162,7 @@ fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions }; let settings = krilla::SerializeSettings { compress_content_streams: opts.compress, configuration, ..base }; let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); - let created = opts.created.or_else(now_unix); + let created = opts.created.or_else(export_time); let metadata = Metadata { created: created.map(date_time), ..file_info_metadata(doc, opts.title.as_deref(), opts.author.as_deref()) }; let mut writer = Writer::new(settings, metadata); let pdf = writer.document_mut(); diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 7d6682c6d..987e7ea97 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -136,7 +136,8 @@ pub struct PdfOptions { /// Title for the metadata; `None` = the document title. pub title: Option, pub author: Option, - /// Creation date as Unix seconds (UTC); `None` = now (native) / omitted (wasm). + /// Creation date as Unix seconds (UTC). `None` takes `SOURCE_DATE_EPOCH` when it names a date + /// a PDF can hold, else the clock (native) / omitted (wasm). pub created: Option, /// Tagged PDF: stories become paragraphs in reading order, graphics figures with their alt /// text, parent-page items and printer's marks artifacts. From 79b871b94fe03052a5f00eb10085ee2f31523a70 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:18:35 +0000 Subject: [PATCH 260/628] Strip story marker characters from placed plain text files Found while testing crates/textimport/src/lib.rs:140 (plain) and crates/engine/src/cmd/place_text.rs:372 (place_text) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/links.rs | 10 ++++++++++ crates/textimport/src/lib.rs | 11 ++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 7a6d3a001..9c19a9a10 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -1092,6 +1092,16 @@ mod text_link_tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn placed_plain_text_drops_story_markers() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let b64 = crate::cmd::base64_encode("a\u{E009}b\u{E000}c\u{E002}d".as_bytes()); + let r = s.execute("file.place", &json!({"base64": b64, "name": "n.txt", "rect": [72, 72, 300, 200]})).unwrap(); + let st = s.doc().unwrap().doc.story(StoryId(r["story"].as_u64().unwrap())).unwrap(); + assert_eq!(st.text, "abcd"); + } + #[test] fn create_links_when_placing_text_files_preference() { let dir = std::env::temp_dir().join(format!("dc-text-link-pref-{}", std::process::id())); diff --git a/crates/textimport/src/lib.rs b/crates/textimport/src/lib.rs index 56eac1a00..eb4189156 100644 --- a/crates/textimport/src/lib.rs +++ b/crates/textimport/src/lib.rs @@ -117,9 +117,12 @@ pub fn import(name: &str, bytes: &[u8]) -> Result { Ok(plain(&tagged::decode(bytes))) } -/// Plain text: line breaks become paragraphs (CRLF / CR normalised). +/// Plain text: line breaks become paragraphs (CRLF / CR normalised). The private-use characters +/// (U+E000–U+E1FF) that stories use as markers are dropped, since a stray one would be read as a +/// marker without its payload. pub fn plain(text: &str) -> Imported { let t = text.trim_start_matches('\u{FEFF}').replace("\r\n", "\n").replace('\r', "\n"); + let t: String = t.chars().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).collect(); let story = Story::with_text(designcraft_doc::StoryId(0), &t, Default::default()); Imported { story, para_styles: vec![], char_styles: vec![], warnings: vec![] } } @@ -137,6 +140,12 @@ mod tests { assert!(matches!(import("x.doc", b"\xD0\xCF"), Err(ImportError::Unsupported(_)))); } + #[test] + fn plain_text_drops_story_markers() { + let i = import("a.txt", "a\u{E009}b\u{E000}c\u{E002}d".as_bytes()).unwrap(); + assert_eq!(i.story.text, "abcd"); + } + #[test] fn plain_text_utf16() { let mut le = vec![0xFF, 0xFE]; From ae7e87ddd0cc162f27263a07d6b89f0cd39335be Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:22:28 +0100 Subject: [PATCH 261/628] =?UTF-8?q?Skip=20signed=20hex=20codes=20in=20Tagg?= =?UTF-8?q?ed=20Text=20<0x=E2=80=A6>=20tags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while testing crates/textimport/src/tagged.rs:~190 (the `n.starts_with("0x")` branch of import: `u32::from_str_radix(&n[2..], 16)`) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/textimport/src/tagged.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/textimport/src/tagged.rs b/crates/textimport/src/tagged.rs index 74806ae4c..e903e98fd 100644 --- a/crates/textimport/src/tagged.rs +++ b/crates/textimport/src/tagged.rs @@ -176,7 +176,10 @@ pub fn import(bytes: &[u8]) -> Result { let (name, value) = tag.split_once(':').map_or((tag.as_str(), ""), |(a, b)| (a, b)); match name { n if n.starts_with("0x") => { - if let Ok(u) = u32::from_str_radix(&n[2..], 16) + // `from_str_radix` accepts a leading '+', so check the digits first. + if let Some(digits) = n.get(2..) + && digits.bytes().all(|b| b.is_ascii_hexdigit()) + && let Ok(u) = u32::from_str_radix(digits, 16) && let Some(ch) = units.push(u) { push(&mut paras, ch, &fmt); @@ -278,6 +281,16 @@ mod tests { assert_eq!(import("\r\nA<0xD83D>B<0xDE00>C".as_bytes()).unwrap().story.text, "ABC"); } + /// `from_str_radix` accepts a leading `+`, so `<0x+41>` inserted an 'A'. A signed or empty + /// code is not a code point tag and is skipped. + #[test] + fn a_signed_hex_code_tag_is_skipped() { + for t in ["<0x+41>", "<0x-41>", "<0x>", "<0xZZ>"] { + let src = format!("\r\nA{t}B"); + assert_eq!(import(src.as_bytes()).unwrap().story.text, "AB", "{t}"); + } + } + /// A size does not have to be non-finite to be nonsense: a file can hold `1e300`, `0` or a /// negative, and tagged text took all three and set them on the text (#1593). They are now /// dropped, as RTF and DOCX already dropped them, so the text keeps its style's size. From 97b42e3476679d4c0da8cf0c6a1af40a811e4b0f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 19:03:09 +0000 Subject: [PATCH 262/628] Cross-references: the edited text's IDML export moves into its own method Keeps story_para under clippy's cognitive-complexity limit, which upstream now enforces. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CgGsJkVe5GYG9qK6VFdECa --- crates/idml/src/export/story.rs | 57 +++++++++++++++++---------------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/crates/idml/src/export/story.rs b/crates/idml/src/export/story.rs index 4a067e402..1fcefd659 100644 --- a/crates/idml/src/export/story.rs +++ b/crates/idml/src/export/story.rs @@ -327,6 +327,34 @@ impl<'a> Ex<'a> { segs } + /// A cross-reference source's text: `generated`, or the text typed over it, its page numbers + /// as InDesign's live cross-reference page number. + fn xref_content(&mut self, c: &mut El, x: &designcraft_doc::CrossRef, generated: String) { + let Some(t) = &x.text else { + let mut content = El::new("Content"); + content.children.push(Node::Text(generated)); + c.push(content); + return; + }; + let page = x.page.clone().unwrap_or_else(|| "?".into()); + for (n, part) in t.split(designcraft_doc::xref::PAGE_NUM_BLOCK).enumerate() { + if n > 0 { + c.push( + El::new("TextVariableInstance") + .attr("Self", self.fresh()) + .attr("Name", "TV XRefPageNumber") + .attr("ResultText", &page) + .attr("AssociatedTextVariable", "dTextVariablenTV XRefPageNumber"), + ); + } + if !part.is_empty() { + let mut content = El::new("Content"); + content.children.push(Node::Text(part.to_string())); + c.push(content); + } + } + } + fn story_para(&mut self, s: &Story, pi: usize, r: std::ops::Range, last: bool) -> El { let pf = s.paras.get(pi).cloned().unwrap_or_default(); let mut psr = self.psr_el(&pf); @@ -424,34 +452,7 @@ impl<'a> Ex<'a> { designcraft_doc::xref::expand(self.d.xref_format(&x.format).map_or("", |ff| ff.definition.as_str()), &v) }); let mut c = self.csr_el(&f); - match &x.text { - // Edited text as typed, its page numbers as InDesign's live - // cross-reference page number. - Some(t) => { - let page = x.page.clone().unwrap_or_else(|| "?".into()); - for (n, part) in t.split(designcraft_doc::xref::PAGE_NUM_BLOCK).enumerate() { - if n > 0 { - c.push( - El::new("TextVariableInstance") - .attr("Self", self.fresh()) - .attr("Name", "TV XRefPageNumber") - .attr("ResultText", &page) - .attr("AssociatedTextVariable", "dTextVariablenTV XRefPageNumber"), - ); - } - if !part.is_empty() { - let mut content = El::new("Content"); - content.children.push(Node::Text(part.to_string())); - c.push(content); - } - } - } - None => { - let mut content = El::new("Content"); - content.children.push(Node::Text(text)); - c.push(content); - } - } + self.xref_content(&mut c, x, text); let mut src = El::new("CrossReferenceSource") .attr("Self", &me) .attr("AppliedFormat", fmt) From e248b631659c79b169413d2005290bc38256b9f4 Mon Sep 17 00:00:00 2001 From: VoiceLessQ Date: Wed, 28 Oct 2026 21:46:56 -0200 Subject: [PATCH 263/628] Autoflow: a frame a Start Paragraph option ends early sets no fill rate (fixes #1209) fit() counted the frame before a paragraph that starts on the next column, frame, page or odd/even page as a full frame. With a short title before such a paragraph, autoflow estimated a page per few bytes left and added up to 2000 pages before removing them again: placing 2000 words took 35.6 s in the app, now 0.36 s, with the same pages. The option is resolved through the paragraph style, so an imported Word style counts the same as a local override. --- ROADMAP.md | 1 + crates/engine/src/cmd/place_text.rs | 15 ++++++--- crates/engine/src/cmd/place_text/tests.rs | 37 +++++++++++++++++++++++ 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c02176bab..20525e4a6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -380,6 +380,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-28** · Autoflow no longer sizes the rest of a story by a frame that a Start Paragraph option (next column, frame, page, or odd or even page) ends early (#1209). With a title before a chapter that starts on the next odd page, it estimated a page per few characters, added up to 2000 pages (the autoflow limit) and removed them again: placing 2000 words took 36 s in the app, now 0.4 s, with the same pages. No readiness percentage change. - **2026-10-27** · An iconic panel's flyout stays open while you work in the canvas or a text field: Auto-Collapse Iconic Panels is off by default, and preferences saved with it on start with it off. Preferences › Interface › Panels holds the setting next to the column bar's menu item. No readiness percentage change. - **2026-10-24** · Column rules run from the top line's ascent to the bottom line's descent, as InDesign draws them, instead of from the top to the bottom of the columns, on screen, in PDF and in PowerPoint export. The top and bottom insets shorten the rule from those lines. Existing documents draw their column rules at the new length. No readiness percentage change. - **2026-10-20** · Edit › Keyboard Shortcuts is craft-keymap's shared dialog: commands grouped by menu with Show and Filter, recording that asks before taking a key from another command, undo and redo, named shortcut sets (edits of Default become Custom; an imported set becomes a set of its own), an on-screen US keyboard, InDesign Show Set files reviewed before they apply, JSON export and an HTML summary. ui.json keeps the `shortcuts` map earlier versions read, for the active set. No readiness percentage change. diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index 4f9aaf1aa..7932a185c 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -3,7 +3,7 @@ //! until the whole story fits (InDesign's Shift-click with a loaded text cursor). use designcraft_doc::{ - CharacterStyle, Content, Document, ParaFormat, ParagraphStyle, SpreadRef, Story, StoryId, TextDirection, TextFileLink, TextSel, + CharacterStyle, Content, Document, ParaFormat, ParagraphStyle, SpreadRef, StartParagraph, Story, StoryId, TextDirection, TextFileLink, TextSel, }; use designcraft_geom::Rect; use serde_json::{Value, json}; @@ -199,14 +199,21 @@ pub(crate) fn reimport( /// Is the story overset in its frames, and how much text do its full frames hold (bytes, frames)? /// A frame a column, frame or page break ends early is left out: it says nothing about how much -/// a frame holds. +/// a frame holds. So is one a Start Paragraph option ends early (#1209). fn fit(d: &Document, sid: StoryId) -> (Option, usize, usize) { let cs = designcraft_compose::compose_story(d, sid, &Default::default()); - let text = d.story(sid).map_or("", |s| s.text.as_str()); + let Some(story) = d.story(sid) else { return (cs.overset_at, 0, 0) }; + let text = story.text.as_str(); + // A frame's range stops before the paragraph's return. + let moved_on = |end: usize| { + let next = if text.as_bytes().get(end) == Some(&b'\n') { end.saturating_add(1) } else { end }; + next.checked_sub(1).is_some_and(|i| text.as_bytes().get(i) == Some(&b'\n')) + && story.paras.get(story.para_at(next)).is_some_and(|p| d.styles.resolve_para(p).0.start_paragraph != StartParagraph::Anywhere) + }; let (mut held, mut full) = (0, 0); for f in &cs.frames { let Some(shown) = text.get(f.range.clone()) else { continue }; - if !shown.is_empty() && !shown.chars().any(designcraft_doc::is_break_char) { + if !shown.is_empty() && !shown.chars().any(designcraft_doc::is_break_char) && !moved_on(f.range.end) { held += shown.len(); full += 1; } diff --git a/crates/engine/src/cmd/place_text/tests.rs b/crates/engine/src/cmd/place_text/tests.rs index 006762ab2..bdce2a975 100644 --- a/crates/engine/src/cmd/place_text/tests.rs +++ b/crates/engine/src/cmd/place_text/tests.rs @@ -307,3 +307,40 @@ fn autoflow_adds_the_pages_odd_and_even_page_breaks_ask_for() { assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None, "{brk:?}"); } } + +#[test] +fn autoflow_sizes_no_frame_by_one_a_start_option_ends_early() { + // Regression (#1209): the title frame a start option ends early isn't a frame's worth of text. + use std::io::Write; + const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#; + let para = |style: &str, text: &str| format!(r#"{text}"#); + let body = [para("Title", "Front"), para("Chapter", "Chapter one"), para("Body", "lorem ipsum dolor sit amet ".repeat(60).trim())].concat(); + let styles: String = ["Title", "Chapter", "Body"] + .iter() + .map(|s| format!(r#""#)) + .collect(); + let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default(); + for (n, c) in [ + ("word/document.xml", format!(r#"{body}"#)), + ("word/styles.xml", format!(r#"{styles}"#)), + ] { + w.start_file(n, o).unwrap(); + w.write_all(c.as_bytes()).unwrap(); + } + let docx = w.finish().unwrap().into_inner(); + for (start, pages) in [("nextOddPage", 5), ("nextEvenPage", 4)] { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 3, "facingPages": true})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Chapter", "para": {"startParagraph": start}})).unwrap(); + let ids = s.doc().unwrap().doc.next_id; + let r = s + .execute("file.place", &json!({"base64": super::super::file::base64_encode(&docx), "name": "front.docx", "page": 3, "autoflow": true})) + .unwrap(); + assert_eq!(r["overset"], false, "{start}: {r}"); + let d = &s.doc().unwrap().doc; + assert_eq!(d.page_count(), pages, "{start}: {r}"); + // A few ids per page it keeps, not hundreds for pages it builds and removes. + assert!(d.next_id - ids < 100, "{start}: {} ids", d.next_id - ids); + } +} From ca85be89d2c3a4a1df225a807be9eb4fe2ab8ab7 Mon Sep 17 00:00:00 2001 From: VoiceLessQ Date: Wed, 28 Oct 2026 22:32:07 -0200 Subject: [PATCH 264/628] Autoflow: skip deleted paragraphs and ask for every forced frame at once (#1209) The start option lookup now reads the next paragraph composing places, past tracked-deleted ones. Frames that start options and breaks force are counted and asked for in one round, so a story with a paragraph per page no longer gets one frame per round. --- ROADMAP.md | 2 +- crates/compose/src/lib.rs | 4 +-- crates/engine/src/cmd/place_text.rs | 34 ++++++++++++++++------- crates/engine/src/cmd/place_text/tests.rs | 33 ++++++++++++++++++++++ 4 files changed, 60 insertions(+), 13 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 20525e4a6..deda0dc4f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -380,7 +380,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. -- **2026-10-28** · Autoflow no longer sizes the rest of a story by a frame that a Start Paragraph option (next column, frame, page, or odd or even page) ends early (#1209). With a title before a chapter that starts on the next odd page, it estimated a page per few characters, added up to 2000 pages (the autoflow limit) and removed them again: placing 2000 words took 36 s in the app, now 0.4 s, with the same pages. No readiness percentage change. +- **2026-10-28** · Autoflow no longer sizes the rest of a story by a frame that a Start Paragraph option (next column, frame, page, or odd or even page) ends early (#1209). With a title before a chapter that starts on the next odd page, it estimated a page per few characters, added up to 2000 pages (the autoflow limit) and removed them again: placing 2000 words took 36 s in the app, now 0.4 s, with the same pages. It looks past tracked-deleted paragraphs for the option, as composing does, and asks for every frame that start options and breaks force in one round: 200 one-line chapters on their own pages take 9 ms (58 ms before). No readiness percentage change. - **2026-10-27** · An iconic panel's flyout stays open while you work in the canvas or a text field: Auto-Collapse Iconic Panels is off by default, and preferences saved with it on start with it off. Preferences › Interface › Panels holds the setting next to the column bar's menu item. No readiness percentage change. - **2026-10-24** · Column rules run from the top line's ascent to the bottom line's descent, as InDesign draws them, instead of from the top to the bottom of the columns, on screen, in PDF and in PowerPoint export. The top and bottom insets shorten the rule from those lines. Existing documents draw their column rules at the new length. No readiness percentage change. - **2026-10-20** · Edit › Keyboard Shortcuts is craft-keymap's shared dialog: commands grouped by menu with Show and Filter, recording that asks before taking a key from another command, undo and redo, named shortcut sets (edits of Default become Custom; an imported set becomes a set of its own), an on-screen US keyboard, InDesign Show Set files reviewed before they apply, JSON export and an HTML summary. ui.json keeps the `shortcuts` map earlier versions read, for the active set. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 88becd85f..131e5273c 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -3483,8 +3483,8 @@ fn rewind( } /// Paragraphs that tracked changes delete: all their text is deleted, and so is their return or -/// the return before them (then they join a paragraph and add nothing to it). -fn deleted_paras(story: &Story, ranges: &[Range]) -> Vec { +/// the return before them (then they join a paragraph and add nothing to it). Composing skips them. +pub fn deleted_paras(story: &Story, ranges: &[Range]) -> Vec { let mut spans: Vec> = Vec::new(); for (r, f) in story.runs() { if f.over.change != Some(designcraft_doc::ChangeMark::Deleted) { diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index 7932a185c..961f6f630 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -197,18 +197,24 @@ pub(crate) fn reimport( Ok(imp.warnings) } -/// Is the story overset in its frames, and how much text do its full frames hold (bytes, frames)? +/// Is the story overset in its frames, how much text do its full frames hold (bytes, frames), and +/// how many frames does the overset text start on its own (breaks, Start Paragraph options)? /// A frame a column, frame or page break ends early is left out: it says nothing about how much /// a frame holds. So is one a Start Paragraph option ends early (#1209). -fn fit(d: &Document, sid: StoryId) -> (Option, usize, usize) { +fn fit(d: &Document, sid: StoryId) -> (Option, usize, usize, usize) { let cs = designcraft_compose::compose_story(d, sid, &Default::default()); - let Some(story) = d.story(sid) else { return (cs.overset_at, 0, 0) }; + let Some(story) = d.story(sid) else { return (cs.overset_at, 0, 0, 0) }; let text = story.text.as_str(); - // A frame's range stops before the paragraph's return. + let deleted = designcraft_compose::deleted_paras(story, &story.para_ranges()); + let starts = |p: usize| { + !deleted.get(p).copied().unwrap_or(false) + && story.paras.get(p).is_some_and(|pf| d.styles.resolve_para(pf).0.start_paragraph != StartParagraph::Anywhere) + }; + // A frame's range stops before the paragraph's return; composing skips deleted paragraphs. let moved_on = |end: usize| { let next = if text.as_bytes().get(end) == Some(&b'\n') { end.saturating_add(1) } else { end }; next.checked_sub(1).is_some_and(|i| text.as_bytes().get(i) == Some(&b'\n')) - && story.paras.get(story.para_at(next)).is_some_and(|p| d.styles.resolve_para(p).0.start_paragraph != StartParagraph::Anywhere) + && (story.para_at(next)..story.paras.len()).find(|&p| !deleted.get(p).copied().unwrap_or(false)).is_some_and(starts) }; let (mut held, mut full) = (0, 0); for f in &cs.frames { @@ -218,7 +224,14 @@ fn fit(d: &Document, sid: StoryId) -> (Option, usize, usize) { full += 1; } } - (cs.overset_at, held, full) + let forced = cs.overset_at.map_or(0, |at| { + let first = story.para_at(at); + // The paragraph the overset text starts with counts when it starts there. + let from = if at.checked_sub(1).is_none_or(|i| text.as_bytes().get(i) == Some(&b'\n')) { first } else { first.saturating_add(1) }; + let breaks = text.get(at..).map_or(0, |t| t.chars().filter(|&c| designcraft_doc::is_break_char(c)).count()); + (from..story.paras.len()).filter(|&p| starts(p)).count().saturating_add(breaks) + }); + (cs.overset_at, held, full, forced) } /// The frames autoflow sets on a page: one per column inside the margins, in the order a story @@ -263,12 +276,13 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s // The next page of the document autoflow may set frames on; it only moves forward. let mut following = 0; loop { - let (overset, held, full) = fit(d, sid); + let (overset, held, full, forced) = fit(d, sid); let Some(at) = overset else { break }; let len = d.story(sid).map_or(0, |s| s.len()); - // Frames still needed at the rate the full ones are filled (at least one); with no full - // frame to go by yet, one more. - let want = held.checked_div(full).map_or(1, |per_frame| len.saturating_sub(at).div_ceil(per_frame.max(1))).max(1); + // Frames still needed at the rate the full ones are filled, plus one per frame the text + // starts on its own (at least one); with neither to go by yet, one more. + let rate = held.checked_div(full).map_or(0, |per_frame| len.saturating_sub(at).div_ceil(per_frame.max(1))); + let want = rate.saturating_add(forced).max(1); let last = *d.story(sid).and_then(|s| s.frames.last()).ok_or_else(|| bad(cmd, "the story has no frame"))?; let page = d.page_of_item(last).unwrap_or(d.page_count().saturating_sub(1)); let layer = d.default_layer(); diff --git a/crates/engine/src/cmd/place_text/tests.rs b/crates/engine/src/cmd/place_text/tests.rs index bdce2a975..183a46074 100644 --- a/crates/engine/src/cmd/place_text/tests.rs +++ b/crates/engine/src/cmd/place_text/tests.rs @@ -344,3 +344,36 @@ fn autoflow_sizes_no_frame_by_one_a_start_option_ends_early() { assert!(d.next_id - ids < 100, "{start}: {} ids", d.next_id - ids); } } + +#[test] +fn autoflow_looks_past_a_deleted_paragraph_for_the_start_option() { + // The title frame ends before a tracked deletion; the chapter after it starts on an odd page. + use designcraft_doc::{ChangeMark, build::NewDocument}; + let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, primary_text_frame: true, ..Default::default() }); + let sid = d.settings.primary_story.unwrap(); + let st = d.story_mut(sid).unwrap(); + st.insert(0, &format!("Front\nGone\nChapter one\n{}", "lorem ipsum dolor sit amet ".repeat(400).trim())); + st.paras[2].para.start_paragraph = Some(StartParagraph::NextOddPage); + st.format_chars(6..11, |f| f.over.change = Some(ChangeMark::Deleted)); + let ids = d.next_id; + assert_eq!(autoflow(&mut d, sid, 2000, "file.place", false).unwrap(), 4); + assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None); + assert!(d.next_id - ids < 100, "{} ids", d.next_id - ids); +} + +#[test] +fn autoflow_asks_for_every_frame_the_start_options_ask_for_at_once() { + // A paragraph per page: no frame sets a fill rate, yet one round shouldn't add one frame. + use designcraft_doc::build::NewDocument; + let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, primary_text_frame: true, ..Default::default() }); + let sid = d.settings.primary_story.unwrap(); + let st = d.story_mut(sid).unwrap(); + st.insert(0, (0..200).map(|i| format!("Chapter {i}\n")).collect::().trim_end()); + for p in &mut st.paras { + p.para.start_paragraph = Some(StartParagraph::NextPage); + } + let ids = d.next_id; + assert_eq!(autoflow(&mut d, sid, 2000, "file.place", false).unwrap(), 199); + assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None); + assert!(d.next_id - ids < 2000, "{} ids", d.next_id - ids); +} From ed88f3e4b5f6775a92e2ab4a2e7fa7d993477ff6 Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:52:25 -0600 Subject: [PATCH 265/628] Compose: tables clear the footnote area Reserve column and spanning footnotes when fitting inline table row groups. Continue rows in the next column or frame when the remaining space is too small, and include cell-local footnotes in measured row height. Fixes #1901 Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 + crates/compose/src/lib.rs | 15 +++- crates/compose/src/table.rs | 29 ++++-- crates/compose/src/tests.rs | 172 ++++++++++++++++++++++++++++++++++++ 4 files changed, 211 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c02176bab..7cc6acf73 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,6 +12,10 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. +- **2026-10-28** · Inline table rows leave room for column and spanning footnotes, continuing in + the next column or frame when needed (#1901). Cell-local footnotes contribute to row height + and sit inside the cell rather than outside its measuring frame. Vertical justification keeps + paragraphs and table fragments above the footnote area. No readiness percentage change. - **2026-10-25** · The application bar uses craft-window's shared title-bar code. A drag that starts on a floating panel over the bar moves the panel, not the window, and in full screen the bar no longer zooms or moves the window. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 88becd85f..58454b228 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1099,7 +1099,7 @@ fn place_table_paragraph(input: TableParagraphInput<'_>) -> TableParagraphResult } let at_top = cursor.last_baseline.is_none(); let start_at = (cursor.fi, cursor.col); - if !table::place_table(doc, table, anchor, paragraph, props, frames, columns, cursor, out, opts) { + if !table::place_table(doc, table, anchor, paragraph, props, frames, columns, cursor, out, notes, opts) { if let Some(j) = trial_failed(trial, limits, *balance_runs < balance_budget) { *balance_runs += 1; rewind(j, snaps, note_snaps, notes, out, cursor, list_counter, force_col, line_cap, no_hyph); @@ -3108,7 +3108,7 @@ fn finish_composition(input: FinishInput<'_>) -> ComposedStory { let before: Vec = ft.tables.iter().map(|t| ft.lines.get(t.line).map_or(0.0, |l| l.baseline)).collect(); let spanned = ft.columns.len() > 1 && ft.lines.iter().any(|l| span_paras.get(l.para) == Some(&true)) || ft.lines.iter().any(|l| split_paras.get(l.para) == Some(&true)); - vertical_justify(ft, f, spanned); + vertical_justify(ft, f, spanned, &doc.footnote_options); for (t, b) in ft.tables.iter_mut().zip(before) { let now = ft.lines.get(t.line).map_or(b, |l| l.baseline); t.shift(now - b); @@ -5320,7 +5320,7 @@ fn hyphenation_points( } /// `spanned`: a paragraph spans the frame's columns, so the lines move together. -fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool) { +fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &designcraft_doc::FootnoteOptions) { if ft.lines.is_empty() || f.opts.vertical_justification == VerticalJustification::Top { return; } @@ -5333,7 +5333,14 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool) { let idx: Vec = ft.lines.iter().enumerate().filter(|(_, l)| spanned || l.column == c).map(|(i, _)| i).collect(); let (Some(&a), Some(_)) = (idx.first(), idx.last()) else { continue }; let bottom = idx.iter().map(|&i| ft.lines[i].baseline + ft.lines[i].descent).fold(f64::NEG_INFINITY, f64::max); - let space = f.area.y1 - bottom; + let bottom = ft.tables.iter().filter(|t| spanned || t.column == c).map(|t| t.rect.y1).fold(bottom, f64::max); + let limit = ft + .notes + .iter() + .filter(|n| spanned || notes.span_columns || n.column == c) + .map(|n| n.rect.y0 - notes.space_before) + .fold(f.area.y1, f64::min); + let space = limit - bottom; if space <= 0.0 { continue; } diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index c620622a3..dc579d8a7 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -122,7 +122,7 @@ impl CellLineExtent { /// Compose a cell story at width `w` (content space, top-left at the origin). fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, (left_page, right_page): (bool, bool)) -> CellLayout { - let spec = FrameSpec { + let mut spec = FrameSpec { id: ItemId(0), area: Rect::new(0.0, 0.0, w.max(1.0), 1.0e6), opts: TextFrameOptions { first_baseline: cell.first_baseline, first_baseline_min: cell.first_baseline_min, ..TextFrameOptions::default() }, @@ -137,7 +137,15 @@ fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, (lef right_page, page_rect: None, }; - let cs = crate::compose(doc, &cell.text, std::slice::from_ref(&spec), opts); + let mut cs = crate::compose(doc, &cell.text, std::slice::from_ref(&spec), opts); + // The measuring frame is deliberately tall. Recompose at the actual content height so + // cell-local footnotes sit inside the row rather than at the measuring frame's bottom. + if let Some(frame) = cs.frames.first().filter(|f| !f.notes.is_empty()) { + let body = frame.lines.iter().map(|l| l.baseline + l.descent).fold(0.0, f64::max); + let note_top = frame.notes.first().map_or(spec.area.y1, |n| n.rect.y0); + spec.area.y1 = body + doc.footnote_options.space_before + (spec.area.y1 - note_top); + cs = crate::compose(doc, &cell.text, std::slice::from_ref(&spec), opts); + } let line_heights: Vec = cs .frames .first() @@ -157,7 +165,8 @@ fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, (lef .unwrap_or_default(); // Check all lines: a large descender or lowered inline object on an earlier line can // extend below the final baseline when leading is fixed. - let content_h = line_heights.iter().copied().fold(0.0, f64::max); + let note_bottom = cs.frames.first().map_or(0.0, |f| f.notes.iter().map(|n| n.rect.y1).fold(0.0, f64::max)); + let content_h = line_heights.iter().copied().fold(note_bottom, f64::max); CellLayout { text: Arc::new(cs), content_h, line_heights } } @@ -174,6 +183,7 @@ pub(crate) fn place_table( cols: &[Vec], cur: &mut Cursor, out: &mut ComposedStory, + notes: &mut crate::notes::Notes, opts: &ComposeOptions, ) -> bool { if cur.fi >= frames.len() || table.nrows() == 0 || table.ncols() == 0 { @@ -292,6 +302,13 @@ pub(crate) fn place_table( } }; place_rows(0..header, &mut y, false, &mut frag_rows); + // Table rows do not add story footnotes; cell-local notes are composed separately. + let column_reserve = |notes: &mut crate::notes::Notes, cur: &Cursor, col: Rect| { + let note_w = notes.area(&cols[cur.fi], cur.col).map_or(col.width(), |a| a.width()); + let column = crate::notes::NoteColumn { frame_index: cur.fi, column_index: cur.col, width: note_w, frame: &frames[cur.fi] }; + notes.reserve(doc, column, &[], opts) + }; + let mut reserve = column_reserve(notes, cur, col); let mut r = header; loop { if r >= body_end { @@ -299,9 +316,10 @@ pub(crate) fn place_table( } let ge = group_end[r].min(body_end).max(r + 1); let gh = sum(r, ge); + let bottom = col.y1 - reserve; let at_col_top = cur.last_baseline.is_none() && (top - col.y0).abs() < 1e-6; - if y + gh + footer_h <= col.y1 + 0.01 || (body_placed == 0 && at_col_top) { - if y + gh + footer_h > col.y1 + 0.01 && body_placed == 0 && at_col_top && y + gh > col.y1 + 0.01 { + if y + gh + footer_h <= bottom + 0.01 || (body_placed == 0 && at_col_top && reserve == 0.0) { + if y + gh + footer_h > bottom + 0.01 && body_placed == 0 && at_col_top && y + gh > bottom + 0.01 { // A row group taller than an empty column can never fit. return false; } @@ -324,6 +342,7 @@ pub(crate) fn place_table( return false; } col = col_rect(cur); + reserve = column_reserve(notes, cur, col); y = col.y0; top = y; frag_rows.clear(); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 3083261fb..2f66eca93 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1994,6 +1994,178 @@ fn footnotes_sit_at_the_column_bottom_and_push_text() { assert!(note_caret(&cs, n.id, 0).is_some()); } +fn justified_content_clears_footnotes(mode: designcraft_doc::VerticalJustification, table: bool) { + for span in [false, true] { + let (mut d, sid, fid) = doc_with("First paragraph.\nSecond paragraph.", Rect::new(0.0, 0.0, 420.0, 240.0), ParaAttrs::default()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2; + d.footnote_options.span_columns = span; + d.footnote_options.space_before = 60.0; + let st = d.story_mut(sid).unwrap(); + st.insert_note(5, "Synthetic footnote.", ParaFormat::default()); + if table { + let mut t = designcraft_doc::Table::new(77, 1, 1, 0, 0, 180.0); + t.rows[0].mode = designcraft_doc::RowHeightMode::Exactly; + t.rows[0].height = 90.0; + st.insert_table(st.len(), t); + } + let top = compose_story(&d, sid, &ComposeOptions::default()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical_justification = mode; + + let cs = compose_story(&d, sid, &ComposeOptions::default()); + + assert!(!cs.is_overset()); + let ft = cs.frame(fid).unwrap(); + let original = top.frame(fid).unwrap(); + assert_eq!(ft.notes.len(), 1); + assert_eq!(ft.notes[0].rect, original.notes[0].rect, "footnotes must not move"); + let limit = ft.notes[0].rect.y0 - d.footnote_options.space_before; + assert!(ft.lines.iter().all(|l| l.baseline + l.descent <= limit + 0.01), "lines overlap footnotes in {mode:?}, span {span}"); + assert!(ft.tables.iter().flat_map(|t| &t.cells).all(|c| c.rect.y1 <= limit + 0.01), "cells overlap footnotes in {mode:?}, span {span}"); + assert!(ft.lines.last().unwrap().baseline > original.lines.last().unwrap().baseline + 0.01, "content must still move down"); + if table { + assert_eq!(ft.tables.len(), 1); + assert!(ft.tables[0].rect.y1 > original.tables[0].rect.y1 + 0.01, "table must still move down"); + } + } +} + +#[test] +fn bottom_justified_paragraphs_clear_footnotes() { + justified_content_clears_footnotes(designcraft_doc::VerticalJustification::Bottom, false); +} + +#[test] +fn bottom_justified_tables_clear_footnotes() { + justified_content_clears_footnotes(designcraft_doc::VerticalJustification::Bottom, true); +} + +#[test] +fn center_justified_tables_clear_footnotes() { + justified_content_clears_footnotes(designcraft_doc::VerticalJustification::Center, true); +} + +#[test] +fn vertically_justified_tables_clear_footnotes() { + justified_content_clears_footnotes(designcraft_doc::VerticalJustification::Justify, true); +} + +#[test] +fn vertical_justification_counts_table_fragment_bottoms() { + use designcraft_doc::VerticalJustification::{Bottom, Center, Justify}; + let (mut d, sid, fid) = doc_with("Reference.", Rect::new(0.0, 0.0, 200.0, 240.0), ParaAttrs::default()); + let st = d.story_mut(sid).unwrap(); + st.insert_note(5, "Synthetic footnote.", ParaFormat::default()); + let mut table = designcraft_doc::Table::new(77, 1, 1, 0, 0, 180.0); + table.rows[0].mode = designcraft_doc::RowHeightMode::Exactly; + table.rows[0].height = 120.0; + st.insert_table(st.len(), table); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + for mode in [Bottom, Center, Justify] { + let mut ft = cs.frame(fid).unwrap().clone(); + let t = &ft.tables[0]; + let (anchor, bottom, baseline) = (t.line, t.rect.y1, t.rect.y0); + // Exercise a fragment extending below its anchor, without a later text line hiding it. + ft.lines.truncate(anchor + 1); + ft.lines[anchor].baseline = baseline; + let mut spec = frame_specs(&d, sid).remove(0); + spec.opts.vertical_justification = mode; + + vertical_justify(&mut ft, &spec, false, &d.footnote_options); + + let moved = ft.lines[anchor].baseline - baseline; + let limit = ft.notes[0].rect.y0 - d.footnote_options.space_before; + assert!(moved > 0.01); + assert!(bottom + moved <= limit + 0.01, "fragment overlaps footnotes in {mode:?}"); + } +} + +#[test] +fn table_rows_clear_footnotes_and_continue_in_the_next_column() { + for span in [false, true] { + for row_height in [24.0, 150.0] { + let (mut d, sid, fid) = doc_with("First paragraph.\nSecond paragraph.", Rect::new(0.0, 0.0, 420.0, 200.0), ParaAttrs::default()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2; + d.footnote_options.span_columns = span; + let st = d.story_mut(sid).unwrap(); + st.insert_note(5, "First synthetic footnote.", ParaFormat::default()); + st.insert_note(25, "Second synthetic footnote.", ParaFormat::default()); + let mut table = designcraft_doc::Table::new(77, 8, 1, 0, 0, 180.0); + for row in &mut table.rows { + row.mode = designcraft_doc::RowHeightMode::Exactly; + row.height = row_height; + } + st.insert_table(st.len(), table); + let (next, _) = + d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 220.0, 420.0, 1720.0), d.default_layer(), "", ParaFormat::default()).unwrap(); + d.thread(fid, next).unwrap(); + + let cs = compose_story(&d, sid, &ComposeOptions::default()); + + assert!(!cs.is_overset()); + let ft = cs.frame(fid).unwrap(); + assert_eq!(ft.notes.len(), 2); + let top = ft.notes[0].rect.y0 - d.footnote_options.space_before; + for frag in &ft.tables { + if span || frag.column == 0 { + assert!(frag.cells.iter().all(|c| c.rect.y1 <= top + 0.01), "rows overlap footnotes: {:?}, top {top}", frag.rect); + } + } + assert!(cs.frames.iter().skip(1).any(|f| !f.tables.is_empty()) || ft.tables.iter().any(|t| t.column == 1)); + let rows: Vec<_> = cs.frames.iter().flat_map(|f| &f.tables).flat_map(|t| &t.cells).filter(|c| !c.repeated).map(|c| c.row).collect(); + assert_eq!(rows, (0..8).collect::>()); + if row_height == 150.0 { + assert!(!ft.tables.iter().any(|t| t.column == 0), "the whole table moves when no row fits"); + } + } + } +} + +#[test] +fn table_reports_overset_when_every_remaining_column_has_footnotes() { + let (mut d, sid, fid) = doc_with("Reference.", Rect::new(0.0, 0.0, 420.0, 200.0), ParaAttrs::default()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 3; + d.footnote_options.span_columns = true; + d.footnote_options.space_before = 40.0; + let st = d.story_mut(sid).unwrap(); + st.insert_note(5, "Synthetic spanning footnote.", ParaFormat::default()); + let mut table = designcraft_doc::Table::new(77, 2, 1, 0, 0, 100.0); + for row in &mut table.rows { + row.mode = designcraft_doc::RowHeightMode::Exactly; + row.height = 90.0; + } + table.cells[0].row_span = 2; + let anchor = st.insert_table(st.len(), table); + + let cs = compose_story(&d, sid, &ComposeOptions::default()); + + assert!(cs.is_overset()); + assert_eq!(cs.overset_at, Some(anchor)); + let ft = cs.frame(fid).unwrap(); + assert_eq!(ft.notes.len(), 1); + assert!(ft.notes[0].rect.y0 - d.footnote_options.space_before < 180.0); + assert!(cs.frames.iter().all(|f| f.tables.is_empty()), "the unplaced row group must remain overset"); +} + +#[test] +fn table_cell_footnotes_fit_inside_the_row() { + let (mut d, sid, _) = doc_with("", Rect::new(0.0, 0.0, 200.0, 200.0), ParaAttrs::default()); + let mut table = designcraft_doc::Table::new(77, 1, 1, 0, 0, 180.0); + let cell = &mut table.cells[0]; + cell.text.insert(0, "Cell reference."); + cell.text.insert_note(5, "Synthetic cell footnote.", ParaFormat::default()); + d.story_mut(sid).unwrap().insert_table(0, table); + + let cs = compose_story(&d, sid, &ComposeOptions::default()); + + let cell = &cs.frames[0].tables[0].cells[0]; + assert!(!cell.overset); + let frame = &cell.text.frames[0]; + assert_eq!(frame.notes.len(), 1); + let note = &frame.notes[0]; + assert!(cell.origin.y + note.rect.y1 <= cell.clip.y1 + 0.01, "cell footnote must remain within the row"); + assert!(frame.lines.iter().all(|l| l.baseline + l.descent <= note.rect.y0 - d.footnote_options.space_before + 0.01)); +} + #[test] fn footnotes_stack_baseline_to_baseline() { // Single-line footnotes read as one text: consecutive baselines sit one leading (plus From 5eddc821ecffc265b8c66e1ed1458d5413c71c5b Mon Sep 17 00:00:00 2001 From: VoiceLessQ Date: Wed, 28 Oct 2026 22:50:39 -0200 Subject: [PATCH 266/628] Images: refuse a raster that would decode past 1 GiB of RGBA (fixes #1586) decode_rgba relied on the image crate's 512 MiB limit, which bounds only the decoder's own output. A gray or 1-bit image just under it expanded four times to RGBA, and render made a premultiplied copy on top: a 288 KB PNG of 23,000 x 23,000 pixels froze the first draw for 9 s and took the app to 4.5 GB. The size is now read from the header and anything past 256 Mpx is refused before decoding. It draws as an empty frame, and PDF export skips it with a warning. A 300 ppi scan of an A0 sheet (about 139 Mpx) still opens. --- ROADMAP.md | 1 + crates/images/src/lib.rs | 7 +++++++ crates/images/src/tests.rs | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index c02176bab..16fb72145 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -380,6 +380,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-28** · A placed image whose pixels would decode past 1 GiB of RGBA (256 Mpx) is refused from its header, before decoding (#1586). A 288 KB 23,000 px gray PNG used to hang the first draw for 9 s and take the app to 4.5 GB; it now draws as an empty frame, and PDF export skips it with a warning. A 300 ppi A0 scan still fits. No readiness percentage change. - **2026-10-27** · An iconic panel's flyout stays open while you work in the canvas or a text field: Auto-Collapse Iconic Panels is off by default, and preferences saved with it on start with it off. Preferences › Interface › Panels holds the setting next to the column bar's menu item. No readiness percentage change. - **2026-10-24** · Column rules run from the top line's ascent to the bottom line's descent, as InDesign draws them, instead of from the top to the bottom of the columns, on screen, in PDF and in PowerPoint export. The top and bottom insets shorten the rule from those lines. Existing documents draw their column rules at the new length. No readiness percentage change. - **2026-10-20** · Edit › Keyboard Shortcuts is craft-keymap's shared dialog: commands grouped by menu with Show and Filter, recording that asks before taking a key from another command, undo and redo, named shortcut sets (edits of Default become Custom; an imported set becomes a set of its own), an on-screen US keyboard, InDesign Show Set files reviewed before they apply, JSON export and an HTML summary. ui.json keeps the `shortcuts` map earlier versions read, for the active set. No readiness percentage change. diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index 817aa7829..117017502 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -324,10 +324,17 @@ pub fn pixel_size(bytes: &[u8]) -> Option<(u32, u32)> { .or_else(|| tiff_decoder(bytes)?.dimensions().ok()) } +/// The largest decoded raster, in RGBA bytes: 256 Mpx, a 300 ppi scan of an A0 sheet fits. +const MAX_RGBA_BYTES: u64 = 1 << 30; + /// Straight (non-premultiplied) RGBA8 of a raster in display sRGB: an embedded ICC profile is /// applied, and CMYK goes through its own profile or the working CMYK space ([`colour`]). pub fn decode_rgba(bytes: &[u8]) -> Option { use colour::{DisplayColour, Pixels, to_display_rgba}; + // `image` limits only its own output, which can be a quarter of the RGBA copy or less. + if pixel_size(bytes).is_some_and(|(w, h)| u64::from(w).saturating_mul(u64::from(h)).saturating_mul(4) > MAX_RGBA_BYTES) { + return None; + } if is_psd(bytes) { return psd::decode(bytes); } diff --git a/crates/images/src/tests.rs b/crates/images/src/tests.rs index ba6e032eb..54a173cd1 100644 --- a/crates/images/src/tests.rs +++ b/crates/images/src/tests.rs @@ -401,3 +401,41 @@ fn bmp_decodes() { assert_eq!(pixel_size(&bmp), Some((3, 2))); assert_eq!(decode_rgba(&bmp).unwrap().get_pixel(1, 1).0, [0, 0, 255, 255]); } + +/// A 1-bit gray PNG of `side` x `side` blank pixels, written row by row so the test stays small. +fn blank_png(side: u32) -> Vec { + use std::io::Write; + let chunk = |out: &mut Vec, kind: &[u8; 4], data: &[u8]| { + out.extend((data.len() as u32).to_be_bytes()); + let mut crc = flate2::Crc::new(); + crc.update(kind); + crc.update(data); + out.extend(kind); + out.extend(data); + out.extend(crc.sum().to_be_bytes()); + }; + let row = vec![0u8; 1 + side.div_ceil(8) as usize]; + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + for _ in 0..side { + z.write_all(&row).unwrap(); + } + let mut ihdr = [side.to_be_bytes(), side.to_be_bytes()].concat(); + ihdr.extend([1, 0, 0, 0, 0]); + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + chunk(&mut png, b"IHDR", &ihdr); + chunk(&mut png, b"IDAT", &z.finish().unwrap()); + chunk(&mut png, b"IEND", &[]); + png +} + +/// A small file that expands past 1 GiB of RGBA is refused from its header (#1586). +#[test] +fn rasters_past_the_rgba_budget_are_refused_before_decoding() { + let png = blank_png(16_385); + assert!(png.len() < 1 << 20, "{} bytes", png.len()); + assert_eq!(pixel_size(&png), Some((16_385, 16_385))); + assert_eq!(natural_size(&png), Some((16_385.0, 16_385.0))); + assert!(decode_rgba(&png).is_none()); + let small = decode_rgba(&blank_png(9)).expect("decodes"); + assert_eq!(small.get_pixel(8, 8).0, [0, 0, 0, 255]); +} From 50b5936b78cbcdfb56bc12968f353659d2d12d51 Mon Sep 17 00:00:00 2001 From: Henrique Cabral Date: Wed, 28 Oct 2026 21:07:42 -0400 Subject: [PATCH 267/628] IDML export: write the document's default StoryPreference InDesign always writes a document-level StoryPreference in Resources/Preferences.xml; DesignCraft only wrote one per story. IDML readers that take the spread reading order from the document's preference, such as Okapi's IDML filter, failed on DesignCraft's files without it. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/idml/src/export/fonts_prefs.rs | 10 ++++++++++ crates/idml/src/tests.rs | 8 ++++++++ 3 files changed, 19 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index c02176bab..7070bb62a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -380,6 +380,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-28** · IDML export writes the document's default `StoryPreference` in `Resources/Preferences.xml`, as InDesign does. Only each story carried one, and IDML readers that take the spread reading order from the document's (Okapi's IDML filter) failed on DesignCraft's files. No readiness percentage change. - **2026-10-27** · An iconic panel's flyout stays open while you work in the canvas or a text field: Auto-Collapse Iconic Panels is off by default, and preferences saved with it on start with it off. Preferences › Interface › Panels holds the setting next to the column bar's menu item. No readiness percentage change. - **2026-10-24** · Column rules run from the top line's ascent to the bottom line's descent, as InDesign draws them, instead of from the top to the bottom of the columns, on screen, in PDF and in PowerPoint export. The top and bottom insets shorten the rule from those lines. Existing documents draw their column rules at the new length. No readiness percentage change. - **2026-10-20** · Edit › Keyboard Shortcuts is craft-keymap's shared dialog: commands grouped by menu with Show and Filter, recording that asks before taking a key from another command, undo and redo, named shortcut sets (edits of Default become Custom; an imported set becomes a set of its own), an on-screen US keyboard, InDesign Show Set files reviewed before they apply, JSON export and an HTML summary. ui.json keeps the `shortcuts` map earlier versions read, for the active set. No readiness percentage change. diff --git a/crates/idml/src/export/fonts_prefs.rs b/crates/idml/src/export/fonts_prefs.rs index fac86af33..2e8daa565 100644 --- a/crates/idml/src/export/fonts_prefs.rs +++ b/crates/idml/src/export/fonts_prefs.rs @@ -169,6 +169,16 @@ impl<'a> Ex<'a> { .attr("SubscriptSize", num(a.subscript_size)) .attr("SubscriptPosition", num(a.subscript_position)), ); + // InDesign always writes the document's default story preference; readers such as Okapi's IDML + // filter take the spread reading order from it and fail without it. + root.push( + El::new("StoryPreference") + .attr("OpticalMarginAlignment", "false") + .attr("OpticalMarginSize", "12") + .attr("FrameType", "TextFrameType") + .attr("StoryOrientation", "Horizontal") + .attr("StoryDirection", "LeftToRightDirection"), + ); document(&root) } } diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 0d078c63c..b5b5a1cd8 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -554,6 +554,14 @@ fn basic_paragraph_is_normal_paragraph_style() { assert_eq!(back.styles.paragraph.iter().filter(|p| p.name == st::BASIC_PARAGRAPH).count(), 1); } +/// Like InDesign, the export carries the document's default story preference, which IDML readers expect. +#[test] +fn export_writes_the_document_story_preference() { + let d = import_idml_with(&fixture(), &|_| None).unwrap(); + let prefs = zip_text(&export_idml(&d), "Resources/Preferences.xml"); + assert!(prefs.contains(r#""#), "{prefs}"); +} + /// Page items, frames, tables and preferences an IDML file leaves unset are InDesign's defaults /// for a new document. #[test] From 89ccfbb18c9e21e0bc4a222f2dfb051019e5a123 Mon Sep 17 00:00:00 2001 From: VoiceLessQ Date: Wed, 28 Oct 2026 23:08:41 -0200 Subject: [PATCH 268/628] Autoflow: count each forced move once, and only visible breaks (#1209) fit() counted every break character in the overset text and every Start Paragraph option. A break followed by a paragraph whose option it already meets moved the text once but asked for two frames, and breaks that tracked changes delete or a hidden condition hides asked for frames that composing never uses. Autoflow added those pages and removed them again: 200 chapters that end in page breaks and start on the next page took 61095 ids and 430 ms, now 798 ids and 16 ms. Each paragraph composing places now counts once, when a visible break ends the paragraph before it or its start option sends it on; after a break it is at the top of a frame, where the option only fixes odd or even page parity. A visible break inside a paragraph counts once more. previous_visible_break and the new visible_break in designcraft-compose are public, so the estimate and the composer use one rule. --- ROADMAP.md | 2 +- crates/compose/src/lib.rs | 21 +++++++---- crates/engine/src/cmd/place_text.rs | 35 +++++++++++++----- crates/engine/src/cmd/place_text/tests.rs | 44 +++++++++++++++++++++++ 4 files changed, 86 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index deda0dc4f..435dd5d39 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -380,7 +380,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. -- **2026-10-28** · Autoflow no longer sizes the rest of a story by a frame that a Start Paragraph option (next column, frame, page, or odd or even page) ends early (#1209). With a title before a chapter that starts on the next odd page, it estimated a page per few characters, added up to 2000 pages (the autoflow limit) and removed them again: placing 2000 words took 36 s in the app, now 0.4 s, with the same pages. It looks past tracked-deleted paragraphs for the option, as composing does, and asks for every frame that start options and breaks force in one round: 200 one-line chapters on their own pages take 9 ms (58 ms before). No readiness percentage change. +- **2026-10-28** · Autoflow no longer sizes the rest of a story by a frame that a Start Paragraph option (next column, frame, page, or odd or even page) ends early (#1209). With a title before a chapter that starts on the next odd page, it estimated a page per few characters, added up to 2000 pages (the autoflow limit) and removed them again: placing 2000 words took 36 s in the app, now 0.4 s, with the same pages. It looks past tracked-deleted paragraphs for the option, as composing does, and asks for every frame that start options and breaks force in one round: 200 one-line chapters on their own pages take 9 ms (58 ms before). A break and the start option it meets count once, and breaks that tracked changes delete or a hidden condition hides don't count. No readiness percentage change. - **2026-10-27** · An iconic panel's flyout stays open while you work in the canvas or a text field: Auto-Collapse Iconic Panels is off by default, and preferences saved with it on start with it off. Preferences › Interface › Panels holds the setting next to the column bar's menu item. No readiness percentage change. - **2026-10-24** · Column rules run from the top line's ascent to the bottom line's descent, as InDesign draws them, instead of from the top to the bottom of the columns, on screen, in PDF and in PowerPoint export. The top and bottom insets shorten the rule from those lines. Existing documents draw their column rules at the new length. No readiness percentage change. - **2026-10-20** · Edit › Keyboard Shortcuts is craft-keymap's shared dialog: commands grouped by menu with Show and Filter, recording that asks before taking a key from another command, undo and redo, named shortcut sets (edits of Default become Custom; an imported set becomes a set of its own), an on-screen US keyboard, InDesign Show Set files reviewed before they apply, JSON export and an HTML summary. ui.json keeps the `shortcuts` map earlier versions read, for the active set. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 131e5273c..177733f99 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -930,18 +930,25 @@ fn line_has_wrapping_exclusion(frame: &FrameSpec, range: Range, column: R }) } -fn previous_visible_break(doc: &Document, story: &Story, ranges: &[Range], deleted: &[bool], pi: usize) -> Option { +/// The visible break ending the last placed paragraph before `pi`, which sends `pi` on. +pub fn previous_visible_break(doc: &Document, story: &Story, ranges: &[Range], deleted: &[bool], pi: usize) -> Option { (0..pi).rev().find(|&p| !deleted.get(p).copied().unwrap_or(false)).and_then(|p| { let range = ranges.get(p)?; - let (index, character) = story.text.get(range.clone())?.char_indices().next_back()?; - let start = break_start(character)?; - let (_, chars) = doc.styles.resolve_para(story.paras.get(p)?); - let props = doc.styles.resolve_char(&chars, story.char_format_at(range.start + index + character.len_utf8())); - let gone = props.change == designcraft_doc::ChangeMark::Deleted || doc.conditions_hide(&props.conditions); - (!gone).then_some(start) + let (index, _) = story.text.get(range.clone())?.char_indices().next_back()?; + visible_break(doc, story, p, range.start + index) }) } +/// The start option of the break at byte `at` in paragraph `p`, unless deleted or hidden. +pub fn visible_break(doc: &Document, story: &Story, p: usize, at: usize) -> Option { + let character = story.text.get(at..)?.chars().next()?; + let start = break_start(character)?; + let (_, chars) = doc.styles.resolve_para(story.paras.get(p)?); + let props = doc.styles.resolve_char(&chars, story.char_format_at(at + character.len_utf8())); + let gone = props.change == designcraft_doc::ChangeMark::Deleted || doc.conditions_hide(&props.conditions); + (!gone).then_some(start) +} + fn named_list_labels(doc: &Document, story: &Story, ranges: &[Range], deleted: &[bool]) -> Vec> { let mut counters: std::collections::HashMap = std::collections::HashMap::new(); story diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index 961f6f630..852db171e 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -205,16 +205,15 @@ fn fit(d: &Document, sid: StoryId) -> (Option, usize, usize, usize) { let cs = designcraft_compose::compose_story(d, sid, &Default::default()); let Some(story) = d.story(sid) else { return (cs.overset_at, 0, 0, 0) }; let text = story.text.as_str(); - let deleted = designcraft_compose::deleted_paras(story, &story.para_ranges()); - let starts = |p: usize| { - !deleted.get(p).copied().unwrap_or(false) - && story.paras.get(p).is_some_and(|pf| d.styles.resolve_para(pf).0.start_paragraph != StartParagraph::Anywhere) - }; + let ranges = story.para_ranges(); + let deleted = designcraft_compose::deleted_paras(story, &ranges); + let placed = |p: usize| !deleted.get(p).copied().unwrap_or(false); + let starts = |p: usize| placed(p) && story.paras.get(p).is_some_and(|pf| d.styles.resolve_para(pf).0.start_paragraph != StartParagraph::Anywhere); // A frame's range stops before the paragraph's return; composing skips deleted paragraphs. let moved_on = |end: usize| { let next = if text.as_bytes().get(end) == Some(&b'\n') { end.saturating_add(1) } else { end }; next.checked_sub(1).is_some_and(|i| text.as_bytes().get(i) == Some(&b'\n')) - && (story.para_at(next)..story.paras.len()).find(|&p| !deleted.get(p).copied().unwrap_or(false)).is_some_and(starts) + && (story.para_at(next)..story.paras.len()).find(|&p| placed(p)).is_some_and(starts) }; let (mut held, mut full) = (0, 0); for f in &cs.frames { @@ -228,8 +227,28 @@ fn fit(d: &Document, sid: StoryId) -> (Option, usize, usize, usize) { let first = story.para_at(at); // The paragraph the overset text starts with counts when it starts there. let from = if at.checked_sub(1).is_none_or(|i| text.as_bytes().get(i) == Some(&b'\n')) { first } else { first.saturating_add(1) }; - let breaks = text.get(at..).map_or(0, |t| t.chars().filter(|&c| designcraft_doc::is_break_char(c)).count()); - (from..story.paras.len()).filter(|&p| starts(p)).count().saturating_add(breaks) + // Once per paragraph: after a break, its start option only fixes odd or even page parity. + let sent = (from..ranges.len()) + .filter(|&p| starts(p) || (placed(p) && designcraft_compose::previous_visible_break(d, story, &ranges, &deleted, p).is_some())) + .count(); + // A break inside a paragraph moves the rest of it. + let inside: usize = (first..ranges.len()) + .filter(|&p| placed(p)) + .filter_map(|p| Some((p, ranges.get(p)?.clone()))) + .map(|(p, r)| { + let Some(t) = text.get(r.clone()) else { return 0 }; + let last = t.char_indices().next_back().map(|(i, _)| i); + t.char_indices() + .filter(|&(i, c)| { + designcraft_doc::is_break_char(c) + && Some(i) != last + && r.start.saturating_add(i) >= at + && designcraft_compose::visible_break(d, story, p, r.start.saturating_add(i)).is_some() + }) + .count() + }) + .sum(); + sent.saturating_add(inside) }); (cs.overset_at, held, full, forced) } diff --git a/crates/engine/src/cmd/place_text/tests.rs b/crates/engine/src/cmd/place_text/tests.rs index 183a46074..8275e7ab2 100644 --- a/crates/engine/src/cmd/place_text/tests.rs +++ b/crates/engine/src/cmd/place_text/tests.rs @@ -377,3 +377,47 @@ fn autoflow_asks_for_every_frame_the_start_options_ask_for_at_once() { assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None); assert!(d.next_id - ids < 2000, "{} ids", d.next_id - ids); } + +#[test] +fn autoflow_asks_no_frame_for_a_deleted_or_hidden_break() { + // Composing skips breaks in the overset text that tracked changes delete or a condition hides. + use designcraft_doc::{ChangeMark, Condition, PAGE_BREAK, build::NewDocument}; + for hide in [false, true] { + let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, primary_text_frame: true, ..Default::default() }); + d.conditions.push(Condition { name: "Hidden".into(), color: [0, 0, 0], visible: false }); + let sid = d.settings.primary_story.unwrap(); + let st = d.story_mut(sid).unwrap(); + let body = "lorem ipsum dolor sit amet ".repeat(300); + let gone = format!("x{PAGE_BREAK}\n").repeat(200); + st.insert(0, &format!("{}\n{gone}{}", body.trim(), body.trim())); + let at = body.trim().len() + 1; + if hide { + for (i, _) in gone.match_indices(PAGE_BREAK) { + st.format_chars(at + i..at + i + PAGE_BREAK.len_utf8(), |f| f.over.conditions = Some(vec!["Hidden".into()])); + } + } else { + st.format_chars(at..at + gone.len(), |f| f.over.change = Some(ChangeMark::Deleted)); + } + let ids = d.next_id; + autoflow(&mut d, sid, 2000, "file.place", false).unwrap(); + assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None, "hide {hide}"); + assert!(d.next_id - ids < 100, "hide {hide}: {} pages, {} ids", d.page_count(), d.next_id - ids); + } +} + +#[test] +fn autoflow_counts_a_break_and_the_start_option_it_meets_once() { + // Each chapter ends in a page break and starts on the next page: one new page each, not two. + use designcraft_doc::{PAGE_BREAK, build::NewDocument}; + let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, primary_text_frame: true, ..Default::default() }); + let sid = d.settings.primary_story.unwrap(); + let st = d.story_mut(sid).unwrap(); + st.insert(0, (0..200).map(|i| format!("Chapter {i}{PAGE_BREAK}\n")).collect::().trim_end()); + for p in &mut st.paras { + p.para.start_paragraph = Some(StartParagraph::NextPage); + } + let ids = d.next_id; + assert_eq!(autoflow(&mut d, sid, 2000, "file.place", false).unwrap(), 199); + assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None); + assert!(d.next_id - ids < 2000, "{} ids", d.next_id - ids); +} From d5f07d30eb9c67671ee037c869009a6e5ce7f618 Mon Sep 17 00:00:00 2001 From: VoiceLessQ Date: Wed, 28 Oct 2026 23:28:11 -0200 Subject: [PATCH 269/628] PDF: check the RGBA budget before recompressing an image (#1586) With compressImages on, PDF export re-encodes rasters as JPEG in recompress, which decoded with the image crate directly. A gray PNG that decode_rgba refuses still decoded there, was copied and encoded as JPEG, and only then dropped: krilla refuses a JPEG wider or taller than 16,384 px, and every raster past the budget is. Exporting the 23,000 px PNG that way took 4.2 s and 1 GB more memory, for a PDF without the image. recompress now asks too_large_to_decode first, the check decode_rgba uses, so the export skips the image with the same warning at once. --- Cargo.lock | 1 + crates/images/src/lib.rs | 9 +++++-- crates/images/src/tests.rs | 2 ++ crates/pdf/Cargo.toml | 1 + crates/pdf/src/export/images.rs | 7 ++++-- crates/pdf/src/tests.rs | 42 +++++++++++++++++++++++++++++++++ 6 files changed, 58 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1c361aef2..037448dbb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1535,6 +1535,7 @@ dependencies = [ "designcraft-fonts", "designcraft-geom", "designcraft-images", + "flate2", "hayro-cmap", "hayro-interpret", "hayro-syntax", diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index 117017502..dfe4166b7 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -327,12 +327,17 @@ pub fn pixel_size(bytes: &[u8]) -> Option<(u32, u32)> { /// The largest decoded raster, in RGBA bytes: 256 Mpx, a 300 ppi scan of an A0 sheet fits. const MAX_RGBA_BYTES: u64 = 1 << 30; +/// Whether a raster's header gives it more pixels than [`decode_rgba`] decodes. +pub fn too_large_to_decode(bytes: &[u8]) -> bool { + // `image` limits only its own output, which can be a quarter of the RGBA copy or less. + pixel_size(bytes).is_some_and(|(w, h)| u64::from(w).saturating_mul(u64::from(h)).saturating_mul(4) > MAX_RGBA_BYTES) +} + /// Straight (non-premultiplied) RGBA8 of a raster in display sRGB: an embedded ICC profile is /// applied, and CMYK goes through its own profile or the working CMYK space ([`colour`]). pub fn decode_rgba(bytes: &[u8]) -> Option { use colour::{DisplayColour, Pixels, to_display_rgba}; - // `image` limits only its own output, which can be a quarter of the RGBA copy or less. - if pixel_size(bytes).is_some_and(|(w, h)| u64::from(w).saturating_mul(u64::from(h)).saturating_mul(4) > MAX_RGBA_BYTES) { + if too_large_to_decode(bytes) { return None; } if is_psd(bytes) { diff --git a/crates/images/src/tests.rs b/crates/images/src/tests.rs index 54a173cd1..9ca9fe14f 100644 --- a/crates/images/src/tests.rs +++ b/crates/images/src/tests.rs @@ -435,7 +435,9 @@ fn rasters_past_the_rgba_budget_are_refused_before_decoding() { assert!(png.len() < 1 << 20, "{} bytes", png.len()); assert_eq!(pixel_size(&png), Some((16_385, 16_385))); assert_eq!(natural_size(&png), Some((16_385.0, 16_385.0))); + assert!(too_large_to_decode(&png)); assert!(decode_rgba(&png).is_none()); + assert!(!too_large_to_decode(&blank_png(16_384))); let small = decode_rgba(&blank_png(9)).expect("decodes"); assert_eq!(small.get_pixel(8, 8).0, [0, 0, 0, 255]); } diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 2b4275da3..3df877c5c 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -31,4 +31,5 @@ hayro-interpret = { version = "0.7.0", default-features = false, features = ["em hayro-cmap = "0.1" kurbo = { workspace = true } tiff = { workspace = true } +flate2 = "1" designcraft-fonts = { workspace = true, features = ["testing"] } diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index a164a6948..8a2b803cf 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -161,9 +161,12 @@ pub(super) fn cmyk_tiff(data: &[u8], mode: RasterMode) -> Option { Image::from_custom(CmykImage(Arc::new(r)), mode.interpolate).ok() } -/// Opaque raster → JPEG (quality 90). `None` when the image has transparency or can't be decoded. -/// With `tag_rgb`, the JPEG keeps the source's RGB profile, or gets sRGB. +/// Opaque raster → JPEG (quality 90). `None` when the image has transparency, is too large or +/// can't be decoded. With `tag_rgb`, the JPEG keeps the source's RGB profile, or gets sRGB. pub(super) fn recompress(data: &[u8], mode: RasterMode) -> Option { + if designcraft_images::too_large_to_decode(data) { + return None; + } let img = image::load_from_memory(data).ok()?; if img.color().has_alpha() && img.to_rgba8().pixels().any(|p| p[3] < 255) { return None; diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 2accc79fa..8fcc15547 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -257,6 +257,32 @@ fn cmyk_tiff() -> (Vec, Vec) { (b, px) } +/// A black `side`² 1-bit grey PNG: well under 1 MB, it decodes to `side`² bytes. +fn blank_png(side: u32) -> Vec { + use std::io::Write; + let chunk = |out: &mut Vec, kind: &[u8; 4], data: &[u8]| { + out.extend((data.len() as u32).to_be_bytes()); + let mut crc = flate2::Crc::new(); + crc.update(kind); + crc.update(data); + out.extend(kind); + out.extend(data); + out.extend(crc.sum().to_be_bytes()); + }; + let row = vec![0u8; 1 + side.div_ceil(8) as usize]; + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + for _ in 0..side { + z.write_all(&row).unwrap(); + } + let mut ihdr = [side.to_be_bytes(), side.to_be_bytes()].concat(); + ihdr.extend([1, 0, 0, 0, 0]); + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + chunk(&mut png, b"IHDR", &ihdr); + chunk(&mut png, b"IDAT", &z.finish().unwrap()); + chunk(&mut png, b"IEND", &[]); + png +} + fn place(d: &mut Document, data: Vec, px: (u32, u32)) { let asset = designcraft_doc::AssetId(d.alloc()); let mime = designcraft_images::mime(&data).into(); @@ -328,6 +354,22 @@ fn a_cut_off_image_is_skipped_not_fatal() { } } +/// A raster past the RGBA budget is skipped undecoded, also when images are recompressed (#1586). +#[test] +fn an_image_past_the_decode_budget_is_skipped_undecoded() { + let side = 16_385; + let mut d = doc_with_text("still here"); + place(&mut d, blank_png(side), (side, side)); + for compress_images in [false, true] { + let started = std::time::Instant::now(); + let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { compress_images, ..Default::default() }).unwrap(); + let took = started.elapsed(); + assert!(took < std::time::Duration::from_secs(1), "compress_images {compress_images}: took {took:?}"); + assert!(r.warnings.iter().any(|w| w.contains("could not be decoded and was skipped")), "compress_images {compress_images}: {:?}", r.warnings); + assert!(image_xobjects(&r.bytes).is_empty(), "compress_images {compress_images}"); + } +} + /// Every image XObject in a PDF: its /ColorSpace name (`?` when not a name) and decoded samples. fn image_xobjects(bytes: &[u8]) -> Vec<(String, Vec)> { use hayro_syntax::object::Name; From 30a0f08698b45047498da78670baccfe6a7b155b Mon Sep 17 00:00:00 2001 From: VoiceLessQ Date: Thu, 29 Oct 2026 00:08:22 -0200 Subject: [PATCH 270/628] Story: copy a paragraph's format for all new returns at once (#1588) insert_with_raw gave each return in the inserted text its own Vec::insert of the paragraph's format, and each insert moves every format after it. A ParaFormat is 2,888 bytes, so the time grew with the square of the returns: Story::with_text took 2.2 s for 10,000 returns, 15.8 s for 20,000 and 90 s for 40,000. Placing a plain-text file goes through it. The copies now go in with one splice. --- ROADMAP.md | 1 + crates/doc/src/story.rs | 23 ++++++++++++++++++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c02176bab..3887ecda8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -380,6 +380,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-29** · Inserting text with many returns no longer takes time that grows with their square (part of #1588): each return copied its paragraph's format with its own insert, moving every format after it. Splitting 20,000 returns into paragraphs took 15.8 s; placing a text file of them now takes 0.2 s, and one of a million returns 5.5 s. No readiness percentage change. - **2026-10-27** · An iconic panel's flyout stays open while you work in the canvas or a text field: Auto-Collapse Iconic Panels is off by default, and preferences saved with it on start with it off. Preferences › Interface › Panels holds the setting next to the column bar's menu item. No readiness percentage change. - **2026-10-24** · Column rules run from the top line's ascent to the bottom line's descent, as InDesign draws them, instead of from the top to the bottom of the columns, on screen, in PDF and in PowerPoint export. The top and bottom insets shorten the rule from those lines. Existing documents draw their column rules at the new length. No readiness percentage change. - **2026-10-20** · Edit › Keyboard Shortcuts is craft-keymap's shared dialog: commands grouped by menu with Show and Filter, recording that asks before taking a key from another command, undo and redo, named shortcut sets (edits of Default become Custom; an imported set becomes a set of its own), an on-screen US keyboard, InDesign Show Set files reviewed before they apply, JSON export and an HTML summary. ui.json keeps the `shortcuts` map earlier versions read, for the active set. No readiness percentage change. diff --git a/crates/doc/src/story.rs b/crates/doc/src/story.rs index 05adcb35f..bb56505f7 100644 --- a/crates/doc/src/story.rs +++ b/crates/doc/src/story.rs @@ -385,11 +385,11 @@ impl Story { // Paragraph formats: each new '\n' splits the current paragraph; the new paragraphs copy it. let pi = self.para_at(pos); let newlines = text.bytes().filter(|b| *b == b'\n').count(); - if newlines > 0 { - let f = self.paras[pi].clone(); - for _ in 0..newlines { - self.paras.insert(pi + 1, f.clone()); - } + if newlines > 0 + && let Some(f) = self.paras.get(pi).cloned() + { + // All at once: an insert per return made many returns quadratic (#1588). + self.paras.splice(pi + 1..pi + 1, std::iter::repeat_n(f, newlines)); } self.text.insert_str(pos, text); // Character runs. @@ -669,6 +669,19 @@ mod tests { assert_eq!(s.para_ranges(), vec![0..6]); } + /// Each return inserted its paragraph format on its own, quadratic in the returns (#1588). + #[test] + fn many_returns_split_a_paragraph_at_once() { + let para = ParaFormat { style: "Body".into(), ..ParaFormat::default() }; + let started = std::time::Instant::now(); + let s = Story::with_text(StoryId(1), &"\n".repeat(20_000), para.clone()); + let took = started.elapsed(); + assert!(took < std::time::Duration::from_secs(1), "took {took:?}"); + assert_eq!(s.paras.len(), 20_001); + assert!(s.paras.iter().all(|p| *p == para)); + s.check().unwrap(); + } + #[test] fn char_formats_split_and_merge() { let mut s = st("hello world"); From 033fc2e17e5410921aba283ab6966eef2aa703a0 Mon Sep 17 00:00:00 2001 From: VoiceLessQ Date: Thu, 29 Oct 2026 00:12:33 -0200 Subject: [PATCH 271/628] PDF: count recompress decodes in the oversized-image test (#1586) The test for a raster past the RGBA budget showed that recompress skips the decode only through a one-second limit, which a slow CI runner could fail and a fast machine could pass without the fix. recompress now counts its decodes in a test-only per-thread counter, and a unit test checks that the 16,385 px PNG doesn't reach the decode while a 9 px one does. The export test keeps its output checks and drops the time limit. --- crates/pdf/src/export/images.rs | 27 +++++++++++++++++++++++++++ crates/pdf/src/tests.rs | 9 +++------ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index 8a2b803cf..ec7c76fd6 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -161,12 +161,22 @@ pub(super) fn cmyk_tiff(data: &[u8], mode: RasterMode) -> Option { Image::from_custom(CmykImage(Arc::new(r)), mode.interpolate).ok() } +#[cfg(test)] +thread_local! { + /// Rasters `recompress` has decoded on this thread. + static RECOMPRESS_DECODES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Opaque raster → JPEG (quality 90). `None` when the image has transparency, is too large or /// can't be decoded. With `tag_rgb`, the JPEG keeps the source's RGB profile, or gets sRGB. pub(super) fn recompress(data: &[u8], mode: RasterMode) -> Option { if designcraft_images::too_large_to_decode(data) { return None; } + #[cfg(test)] + { + RECOMPRESS_DECODES.with(|n| n.set(n.get() + 1)); + } let img = image::load_from_memory(data).ok()?; if img.color().has_alpha() && img.to_rgba8().pixels().any(|p| p[3] < 255) { return None; @@ -539,3 +549,20 @@ mod eps_tests { assert_eq!(civil(i64::MIN), (-292277022657, 1, 27, 8, 29, 52)); } } + +#[cfg(test)] +mod recompress_tests { + use super::*; + + /// A raster past the RGBA budget is refused before `recompress` decodes it (#1586). + #[test] + fn a_raster_past_the_budget_is_not_decoded() { + let mode = RasterMode { interpolate: false, tag_rgb: false }; + let decodes = || RECOMPRESS_DECODES.with(std::cell::Cell::get); + let start = decodes(); + assert!(recompress(&crate::tests::blank_png(16_385), mode).is_none()); + assert_eq!(decodes(), start); + assert!(recompress(&crate::tests::blank_png(9), mode).is_some()); + assert_eq!(decodes(), start + 1); + } +} diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 8fcc15547..160fa38b2 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -258,7 +258,7 @@ fn cmyk_tiff() -> (Vec, Vec) { } /// A black `side`² 1-bit grey PNG: well under 1 MB, it decodes to `side`² bytes. -fn blank_png(side: u32) -> Vec { +pub(crate) fn blank_png(side: u32) -> Vec { use std::io::Write; let chunk = |out: &mut Vec, kind: &[u8; 4], data: &[u8]| { out.extend((data.len() as u32).to_be_bytes()); @@ -354,17 +354,14 @@ fn a_cut_off_image_is_skipped_not_fatal() { } } -/// A raster past the RGBA budget is skipped undecoded, also when images are recompressed (#1586). +/// A raster past the RGBA budget is skipped with a warning, also when images are recompressed (#1586). #[test] -fn an_image_past_the_decode_budget_is_skipped_undecoded() { +fn an_image_past_the_decode_budget_is_skipped_with_a_warning() { let side = 16_385; let mut d = doc_with_text("still here"); place(&mut d, blank_png(side), (side, side)); for compress_images in [false, true] { - let started = std::time::Instant::now(); let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { compress_images, ..Default::default() }).unwrap(); - let took = started.elapsed(); - assert!(took < std::time::Duration::from_secs(1), "compress_images {compress_images}: took {took:?}"); assert!(r.warnings.iter().any(|w| w.contains("could not be decoded and was skipped")), "compress_images {compress_images}: {:?}", r.warnings); assert!(image_xobjects(&r.bytes).is_empty(), "compress_images {compress_images}"); } From d7bc6633e763656ac53179f7ffb12b5c46eef6b4 Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:26:06 -0600 Subject: [PATCH 272/628] Compose: honor discretionary hyphens in single-line composition Recognize U+00AD independently of automatic hyphenation in the greedy breaker, reusing the existing visible hyphen output. Cover both composers, zero-width unbroken soft hyphens, pattern suppression, and tagged and untagged PDF text extraction. Fixes #1955 Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/compose/src/breaker.rs | 10 +++- crates/compose/src/tests.rs | 100 +++++++++++++++++++++++++++++----- crates/pdf/src/tests.rs | 22 ++++++++ docs/typography-parity.md | 2 +- 5 files changed, 120 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c02176bab..6c49d77e7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -380,6 +380,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-28** · Discretionary hyphens (U+00AD) break in the single-line composer with automatic hyphenation on or off, matching the paragraph composer. Unbroken soft hyphens remain invisible and zero-width, and words containing them keep only their discretionary break points (#1955). No readiness percentage change. - **2026-10-27** · An iconic panel's flyout stays open while you work in the canvas or a text field: Auto-Collapse Iconic Panels is off by default, and preferences saved with it on start with it off. Preferences › Interface › Panels holds the setting next to the column bar's menu item. No readiness percentage change. - **2026-10-24** · Column rules run from the top line's ascent to the bottom line's descent, as InDesign draws them, instead of from the top to the bottom of the columns, on screen, in PDF and in PowerPoint export. The top and bottom insets shorten the rule from those lines. Existing documents draw their column rules at the new length. No readiness percentage change. - **2026-10-20** · Edit › Keyboard Shortcuts is craft-keymap's shared dialog: commands grouped by menu with Show and Filter, recording that asks before taking a key from another command, undo and redo, named shortcut sets (edits of Default become Custom; an imported set becomes a set of its own), an on-screen US keyboard, InDesign Show Set files reviewed before they apply, JSON export and an HTML summary. ui.json keeps the `shortcuts` map earlier versions read, for the active set. No readiness percentage change. diff --git a/crates/compose/src/breaker.rs b/crates/compose/src/breaker.rs index 9b453ff0a..6f62542d7 100644 --- a/crates/compose/src/breaker.rs +++ b/crates/compose/src/breaker.rs @@ -972,7 +972,15 @@ pub fn greedy( x += adv; shrink += sp.box_elastic(g).1.iter().sum::(); if i + 1 < n && !glyphs[i + 1].is_space() && !g.no_break && g.break_after != Some(false) { - if matches!(g.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/') + if g.ch == SOFT_HYPHEN { + if may_hyphenate(hyphens) { + let hw = hyphen_width(g, &mut hy_cache); + let hy = (hw - hyphen_hang(g, hw, sp)).max(0.0); + if x + hy <= w + shrink + FIT_EPS { + last_ok = Some((i + 1, true)); + } + } + } else if matches!(g.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/') || g.break_after.unwrap_or_else(|| cjk_break(g, &glyphs[i + 1], sp)) { last_ok = Some((i + 1, false)); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 3083261fb..3128da3a7 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1103,21 +1103,95 @@ fn hyphenate_across_column_off_keeps_nested_line_styles_on_their_lines() { } #[test] -fn discretionary_hyphen_replaces_automatic_points() { - let text = "aaaa bbbb cccc extraordi\u{AD}narily dddd eeee"; - let para = ParaAttrs { align: Some(Align::Left), ..Default::default() }; - let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 120.0, 1000.0), para); - let cs = compose_story(&d, sid, &ComposeOptions::default()); - let soft = text.find('\u{AD}').unwrap(); - for l in all_lines(&cs) { - if l.hyphenated { - let last = l.glyphs.iter().rfind(|g| g.len > 0).unwrap(); - assert!(last.byte + last.len >= soft && last.byte <= soft + 2, "{:?}", &text[l.range.clone()]); +fn discretionary_hyphen_breaks_with_both_composers_and_hyphenation_settings() { + let text = "Wir möchten uns ganz besonders herzlich bei folgenden Personen bedanken, die auf unterschied\u{ad}lichste Weise zur Entstehung dieses Lehrbuches beigetragen haben:"; + let soft_end = text.find('\u{ad}').unwrap() + '\u{ad}'.len_utf8(); + for composer in [Composer::SingleLine, Composer::Paragraph] { + for hyphenate in [false, true] { + let para = ParaAttrs { composer: Some(composer), hyphenate: Some(hyphenate), ..Default::default() }; + let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 430.0, 1000.0), para); + d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.size = Some(9.5)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let first = all_lines(&cs)[0]; + assert_eq!(first.range.end, soft_end, "{composer:?}, hyphenate={hyphenate}: {:?}", &text[first.range.clone()]); + assert!(first.hyphenated); + let hyphen = first.glyphs.last().unwrap(); + assert!(hyphen.visible && hyphen.len == 0 && hyphen.adv > 0.0); } } - // Hyphenation points of the word itself: none besides the discretionary one. - let lines = all_lines(&cs); - assert!(lines.iter().filter(|l| l.hyphenated).count() <= 1); +} + +#[test] +fn discretionary_hyphen_before_cjk_single_line() { + discretionary_hyphen_before_cjk(Composer::SingleLine); +} + +#[test] +fn discretionary_hyphen_before_cjk_paragraph() { + discretionary_hyphen_before_cjk(Composer::Paragraph); +} + +fn discretionary_hyphen_before_cjk(composer: Composer) { + let text = "abc\u{ad}\u{65e5}\u{672c}\u{8a9e}\u{65e5}\u{672c}\u{8a9e}"; + let soft_end = text.find('\u{ad}').unwrap() + '\u{ad}'.len_utf8(); + for hyphenate in [false, true] { + let para = ParaAttrs { composer: Some(composer), hyphenate: Some(hyphenate), ..Default::default() }; + let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 24.0, 1000.0), para); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let first = all_lines(&cs)[0]; + assert_eq!(first.range.end, soft_end, "{composer:?}, hyphenate={hyphenate}: {:?}", &text[first.range.clone()]); + assert!(first.hyphenated, "{composer:?}, hyphenate={hyphenate}"); + let hyphen = first.glyphs.last().unwrap(); + assert!(hyphen.visible && hyphen.len == 0 && hyphen.adv > 0.0); + } +} + +#[test] +fn discretionary_hyphen_is_invisible_and_zero_width_when_unbroken() { + for composer in [Composer::SingleLine, Composer::Paragraph] { + let mut widths = Vec::new(); + for text in ["unterschiedlichste", "unterschied\u{ad}lichste"] { + let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 1000.0, 100.0), ParaAttrs { composer: Some(composer), ..Default::default() }); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + assert_eq!(lines.len(), 1); + assert!(!lines[0].hyphenated); + if let Some(soft) = text.find('\u{ad}') { + let glyph = lines[0].glyphs.iter().find(|g| g.byte == soft).unwrap(); + assert!(!glyph.visible && glyph.adv == 0.0); + } + widths.push(lines[0].end_x); + } + assert!((widths[0] - widths[1]).abs() < 1e-6, "{composer:?}: {widths:?}"); + } +} + +#[test] +fn discretionary_hyphen_replaces_automatic_points() { + let text = "aaaa bbbb cccc extraordi\u{AD}narily dddd eeee"; + let soft = text.find('\u{AD}').unwrap(); + for composer in [Composer::SingleLine, Composer::Paragraph] { + let mut discretionary_breaks = 0; + let mut automatic_breaks = 0; + for width in (90..181).step_by(10) { + for discretionary in [false, true] { + let source = if discretionary { text.to_string() } else { text.replace('\u{AD}', "") }; + let para = ParaAttrs { composer: Some(composer), hyphenate: Some(true), align: Some(Align::Left), ..Default::default() }; + let (d, sid, _) = doc_with(&source, Rect::new(0.0, 0.0, f64::from(width), 1000.0), para); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + for l in all_lines(&cs).into_iter().filter(|l| l.hyphenated) { + if discretionary { + assert_eq!(l.range.end, soft + '\u{AD}'.len_utf8(), "{composer:?}: {:?}", &source[l.range.clone()]); + discretionary_breaks += 1; + } else { + automatic_breaks += 1; + } + } + } + } + assert!(discretionary_breaks > 0, "{composer:?}: discretionary breaks exercised"); + assert!(automatic_breaks > 0, "{composer:?}: pattern breaks exercised without U+00AD"); + } } #[test] diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 2accc79fa..f913854aa 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -76,6 +76,28 @@ fn exports_valid_pdf_with_one_page_per_page() { assert_eq!(pdf.pages().len(), 2); } +#[test] +fn discretionary_hyphen_pdf_text_follows_visible_breaks() { + let text = "Wir möchten uns ganz besonders herzlich bei folgenden Personen bedanken, die auf unterschied\u{ad}lichste Weise zur Entstehung dieses Lehrbuches beigetragen haben:"; + for composer in [designcraft_doc::Composer::SingleLine, designcraft_doc::Composer::Paragraph] { + for (width, expected) in [(430.0, "unterschied-lichste"), (540.0, "unterschiedlichste")] { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let para = designcraft_doc::ParaAttrs { composer: Some(composer), hyphenate: Some(false), ..Default::default() }; + let (_, sid) = d + .add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 36.0 + width, 400.0), lid, text, ParaFormat { para, ..Default::default() }) + .unwrap(); + d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.size = Some(9.5)); + for tagged in [false, true] { + let bytes = export_pdf(&d, &Cache::new(), &PdfOptions { tagged, ..Default::default() }).unwrap(); + let extracted = extract_text(&bytes).concat(); + assert!(extracted.contains(expected), "{composer:?}, width={width}, tagged={tagged}: {extracted:?}"); + assert!(!extracted.contains('\u{ad}')); + } + } + } +} + #[test] fn text_is_real_text() { let d = doc_with_text("Hello DesignCraft — efficient affine"); diff --git a/docs/typography-parity.md b/docs/typography-parity.md index 8c92e8dfa..9b4c2591a 100644 --- a/docs/typography-parity.md +++ b/docs/typography-parity.md @@ -36,7 +36,7 @@ Fidelity: **unmeasured** unless stated. | Single-line Composer | greedy | greedy with the same ranges | ✓ | | | World-Ready composers | Paragraph / Single-line World-Ready | RTL, Arabic and bidi in the main composer | ~ | Indic scripts (Devanagari etc.) shape through harfrust but are untested | | Justification | word/letter spacing, glyph scaling min/desired/max, single-word, last line | all of these | ✓ | a justified line with a tab justifies after the tab (#284); set per paragraph in Type › Justification… and the Paragraph panel menu (#531) | -| Hyphenation | per-language dictionaries (~40), zone, limits, capitalized, last word, across columns | Liang patterns: English (trained from the public-domain Moby list), Spanish (Bezos), Portuguese (hyph-pt), Norwegian (hyph-no) and Greek (hyph-el-monoton); zone, limits, exceptions in the user dictionary; Hyphenate Across Column off keeps automatic hyphens off the last line of a column or frame | ~ | other languages don't hyphenate (#87 reported "doesn't work") | +| Hyphenation | per-language dictionaries (~40), zone, limits, capitalized, last word, across columns | Liang patterns: English (trained from the public-domain Moby list), Spanish (Bezos), Portuguese (hyph-pt), Norwegian (hyph-no) and Greek (hyph-el-monoton); zone, limits, exceptions in the user dictionary; Hyphenate Across Column off keeps automatic hyphens off the last line of a column or frame; discretionary U+00AD breaks with both composers regardless of automatic hyphenation and replaces pattern points within its word (#1955) | ~ | other languages don't hyphenate (#87 reported "doesn't work") | | Spelling | per-language dictionaries | English (Moby) + document/user dictionary | ~ | | | Kerning | Metrics, Optical, manual, none | Metrics, Optical (outline-based: pairs pulled towards the font's stem gap, `crates/compose/src/optical.rs`), manual, none | ~ | Optical is an approximation of Adobe's algorithm (#181), not a match: imported display text can still break differently | | Tracking, scale, skew, baseline shift | ✓ | ✓ | ✓ | | From 4d88c0899e88a4b05d554985acfdf8ccb2f7ce4e Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:21:20 +0100 Subject: [PATCH 273/628] Keep stroke styles from joining the subpaths of a compound path Found while testing crates/geom/src/stroke_style.rs:24-31 (offset_path: `(MoveTo(p), 0, true) => out.line_to(p)`; also samples/wavy at :58-98) Co-Authored-By: Claude Opus 5.5 --- crates/geom/src/stroke_style.rs | 63 +++++++++++++++++++++++++++------ 1 file changed, 53 insertions(+), 10 deletions(-) diff --git a/crates/geom/src/stroke_style.rs b/crates/geom/src/stroke_style.rs index 63c632ea2..d018667f2 100644 --- a/crates/geom/src/stroke_style.rs +++ b/crates/geom/src/stroke_style.rs @@ -9,6 +9,28 @@ pub fn offset_path(bp: &BezPath, d: f64, tol: f64) -> BezPath { return bp.clone(); } let mut out = BezPath::new(); + for sub in subpaths(bp) { + offset_subpath(&sub, d, tol, &mut out); + } + out +} + +/// `bp` split at each `MoveTo` into its subpaths. +fn subpaths(bp: &BezPath) -> Vec { + let mut out: Vec = Vec::new(); + for el in bp.elements() { + if matches!(el, kurbo::PathEl::MoveTo(_)) || out.is_empty() { + out.push(BezPath::new()); + } + if let Some(sub) = out.last_mut() { + sub.push(*el); + } + } + out +} + +/// One subpath offset by `d`, its segments joined into one line, appended to `out`. +fn offset_subpath(bp: &BezPath, d: f64, tol: f64, out: &mut BezPath) { let mut started = false; for seg in bp.segments() { let c = match seg { @@ -30,7 +52,6 @@ pub fn offset_path(bp: &BezPath, d: f64, tol: f64) -> BezPath { } started = true; } - out } /// Bands (start, width) across a stroke, as fractions of the weight from its left edge. @@ -54,8 +75,13 @@ pub fn stripes(bp: &BezPath, weight: f64, bands: &Bands, tol: f64) -> BezPath { out } -/// Points along `bp` every `step` of arc length, with the unit tangent there. -fn samples(bp: &BezPath, step: f64) -> Vec<(Point, Vec2)> { +/// Points along each subpath of `bp` every `step` of arc length, with the unit tangent there. +fn samples(bp: &BezPath, step: f64) -> Vec> { + subpaths(bp).iter().map(|sub| subpath_samples(sub, step)).collect() +} + +/// Points along one subpath every `step` of arc length, with the unit tangent there. +fn subpath_samples(bp: &BezPath, step: f64) -> Vec<(Point, Vec2)> { let mut out = Vec::new(); let mut carry = 0.0; for seg in bp.segments() { @@ -85,13 +111,14 @@ fn samples(bp: &BezPath, step: f64) -> Vec<(Point, Vec2)> { /// A wavy stroke: the line swings `weight` wide, drawn a third of the weight thick. pub fn wavy(bp: &BezPath, weight: f64, tol: f64) -> BezPath { let wavelength = (weight * 4.0).max(2.0); - let pts = samples(bp, wavelength / 16.0); let mut line = BezPath::new(); - for (i, (p, t)) in pts.iter().enumerate() { - let normal = Vec2::new(-t.y, t.x); - let phase = i as f64 / 16.0 * std::f64::consts::TAU; - let q = *p + normal * (phase.sin() * weight * 0.33); - if i == 0 { line.move_to(q) } else { line.line_to(q) } + for pts in samples(bp, wavelength / 16.0) { + for (i, (p, t)) in pts.iter().enumerate() { + let normal = Vec2::new(-t.y, t.x); + let phase = i as f64 / 16.0 * std::f64::consts::TAU; + let q = *p + normal * (phase.sin() * weight * 0.33); + if i == 0 { line.move_to(q) } else { line.line_to(q) } + } } let s = Stroke::new(weight / 3.0).with_join(kurbo::Join::Round); kurbo::stroke(line.iter(), &s, &kurbo::StrokeOpts::default(), tol) @@ -100,7 +127,7 @@ pub fn wavy(bp: &BezPath, weight: f64, tol: f64) -> BezPath { /// A straight-hash stroke: short ticks across the path, as wide as the weight. pub fn hashed(bp: &BezPath, weight: f64, tol: f64) -> BezPath { let mut ticks = BezPath::new(); - for (p, t) in samples(bp, (weight * 0.6).max(1.0)) { + for (p, t) in samples(bp, (weight * 0.6).max(1.0)).into_iter().flatten() { let normal = Vec2::new(-t.y, t.x) * (weight / 2.0); ticks.move_to(p - normal); ticks.line_to(p + normal); @@ -141,4 +168,20 @@ mod tests { let h = hashed(&line(), 8.0, 0.01).bounding_box(); assert!((h.height() - 8.0).abs() < 0.5 && h.width() > 90.0, "{h:?}"); } + + #[test] + fn styled_strokes_keep_subpaths_apart() { + // Two separate lines, 50 pt apart, in one compound path. + let mut b = line(); + b.move_to((0.0, 50.0)); + b.line_to((100.0, 50.0)); + let moves = |p: &BezPath| p.elements().iter().filter(|e| matches!(e, kurbo::PathEl::MoveTo(_))).count(); + assert_eq!(moves(&offset_path(&b, 3.0, 0.01)), 2, "one offset subpath per input subpath"); + let s = stripes(&b, 12.0, &[(0.0, 0.5), (0.75, 0.25)], 0.01); + assert!(!s.contains(Point::new(50.0, 25.0)), "stripe bridges the subpaths: {:?}", s.bounding_box()); + assert!(s.contains(Point::new(50.0, 46.0)), "second line is still striped"); + let w = wavy(&b, 4.0, 0.01); + let bridged = (0..=2000).map(|i| f64::from(i) * 0.05).filter(|&x| w.contains(Point::new(x, 25.0))).count(); + assert_eq!(bridged, 0, "wavy bridges the subpaths: {:?}", w.bounding_box()); + } } From 5a53a3807c08d156722ba8dce3f034d03b411181 Mon Sep 17 00:00:00 2001 From: Hamza Date: Thu, 29 Oct 2026 14:17:21 +0500 Subject: [PATCH 274/628] Check a parent page's frame on the pages that show it The warnings composed every story once with no page. For a frame on a parent page that is wrong twice over, and I measured both: exportPdf {"pages": [2]} ["the story in frame 9: 76 character(s) do not fit and are not in the export"] Page 2 used no parent at all, so nothing of that frame was in its export to be missing -- and the warning named no page, because a parent frame has no document page of its own. It now resolves the pages that draw the parent page and reports only those: {"pages": [1]} ["page 1: 76 character(s) ... frame 9 ..."] {"pages": [2]} [] The second half is page-dependent text. A parent frame 112 pt wide holding "Continued on page " fits while the number is one digit and does not once it is two, so composing it once could not tell: {"pages": [1]} [] {"pages": [9]} [] {"pages": [10]} ["page 10: 1 character(s) ..."] {} ["pages 10, 11, 12: 1 character(s) ..."] Each page that shows the parent is composed as that page, and the pages are grouped by how much they lose, so a story still gets one line. Also from review: `story.get` now says `overset` is a UTF-8 byte offset into `text`, not a character index. A test measures the difference on French text, where the offset runs ahead of the character count. --- crates/engine/src/cmd/export/pdf.rs | 70 +++++++++++++++++++++++++++++ crates/engine/src/cmd/preflight.rs | 63 +++++++++++++++++++++++++- crates/engine/src/cmd/text.rs | 2 +- 3 files changed, 132 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/export/pdf.rs b/crates/engine/src/cmd/export/pdf.rs index 12d5577ec..b5f16c110 100644 --- a/crates/engine/src/cmd/export/pdf.rs +++ b/crates/engine/src/cmd/export/pdf.rs @@ -541,6 +541,76 @@ mod overset_tests { assert!(story["overset"].is_null() && story["oversetChars"].is_null(), "{story}"); } + /// `overset` indexes `text` in UTF-8 bytes, not characters: a client that slices a JavaScript + /// string at it cuts the wrong place once the text is not ASCII (noted in review). + #[test] + fn overset_is_a_byte_offset_into_the_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 1, "facingPages": false, "margins": 0})).unwrap(); + // Accented letters are two bytes each, so the byte offset runs ahead of the character index. + let text = "Réservé à l'évaluation détaillée des éléments.\nCette phrase dépasse le cadre et n'est pas dans le PDF exporté du tout."; + let frame = s.execute("frame.create", &json!({"rect": [20, 20, 280, 40], "content": "text", "text": text})).unwrap(); + let story = s.execute("story.get", &json!({"story": frame["story"].clone()})).unwrap(); + let at = story["overset"].as_u64().expect("it oversets") as usize; + assert!(text.is_char_boundary(at), "a byte offset that splits no character: {at}"); + assert!(at > text[..at].chars().count(), "ahead of the character index: {at} bytes, {} characters", text[..at].chars().count()); + // And `oversetChars` counts characters, which is why the two numbers differ. + assert_eq!(story["oversetChars"], json!(text[at..].chars().count()), "{story}"); + } + + /// A parent page's frame is composed once per page that shows it: its page number marker is + /// that page's, so the same frame fits on page 1 and oversets on page 10 -- and a parent page + /// no exported page uses puts nothing in the file to be missing. Composing it once with no + /// page reported neither: every selection got the same unpaged warning (noted in review). + #[test] + fn a_parent_frame_is_checked_on_the_pages_that_show_it() { + let about = |r: &serde_json::Value| -> Vec { + r["warnings"] + .as_array() + .map(|w| w.iter().filter_map(|w| w.as_str()).filter(|w| w.contains("not in the export")).map(str::to_string).collect()) + .unwrap_or_default() + }; + + // A parent page that only page 1 uses. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 2, "facingPages": false, "margins": 0})).unwrap(); + s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": null})).unwrap(); + let text = "First line is visible.\nSecond paragraph does not fit: this sentence carries the legal notice and must not vanish from the PDF without a warning."; + let frame = s + .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [20, 20, 280, 60], "content": "text", "text": text})) + .unwrap(); + let id = frame["id"].as_u64().unwrap(); + + let w = about(&s.execute("file.exportPdf", &json!({"pages": [1]})).unwrap()); + assert_eq!(w.len(), 1, "page 1 shows the parent: {w:?}"); + assert!(w[0].contains("page 1") && w[0].contains(&format!("frame {id}")) && w[0].contains("76 character"), "{}", w[0]); + + // Page 2 has no parent, so nothing of that frame is in its export to be missing. + let r = s.execute("file.exportPdf", &json!({"pages": [2]})).unwrap(); + assert!(about(&r).is_empty(), "page 2 does not use the parent: {r}"); + + // Page-dependent text: the marker is one digit up to page 9 and two from page 10, and the + // wider line no longer fits. 112 pt is the width where that is the only difference. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200, "pages": 12, "facingPages": false, "margins": 0})).unwrap(); + let text = format!("Continued on page {}", designcraft_doc::story::PAGE_NUMBER); + s.execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [20, 20, 132, 36], "content": "text", "text": text})) + .unwrap(); + + for page in [1, 9] { + let r = s.execute("file.exportPdf", &json!({"pages": [page]})).unwrap(); + assert!(about(&r).is_empty(), "page {page} fits one digit: {r}"); + } + let w = about(&s.execute("file.exportPdf", &json!({"pages": [10]})).unwrap()); + assert_eq!(w.len(), 1, "two digits do not fit: {w:?}"); + assert!(w[0].contains("page 10"), "{}", w[0]); + + // The whole document: one line naming every page it happens on, not one line per page. + let w = about(&s.execute("file.exportPdf", &json!({})).unwrap()); + assert_eq!(w.len(), 1, "one line for the story: {w:?}"); + assert!(w[0].starts_with("pages 10, 11, 12:"), "{}", w[0]); + } + /// A spread export puts the whole spread on the sheet, so choosing one page of it exports its /// partner too -- and that partner's overset is just as absent from the file. Reporting only /// the pages that were named left it unmentioned (noted in review of #1465). diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index b3a22d6ad..bf98f9ab9 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -121,16 +121,76 @@ fn overset_location(s: &Session, target: designcraft_doc::ItemId) -> Option<(des /// 0-based pages being exported; a story whose overset is on a page that is not being exported is /// not reported, and one with no page of its own always is. A reflowable export (EPUB, HTML) or a /// text export carries the whole story, so neither calls this. +/// Pages a single warning names before "and N more". +const MAX_WARNED_PAGES: usize = 10; + +/// How the pages one warning covers are named: "page 3", "pages 3, 4", "pages 3, 4 and 6 more". +fn page_list(pages: &[usize]) -> String { + let shown: Vec = pages.iter().take(MAX_WARNED_PAGES).map(|p| (p + 1).to_string()).collect(); + let rest = pages.len() - shown.len(); + let list = shown.join(", "); + match (pages.len(), rest) { + (1, _) => format!("page {list}"), + (_, 0) => format!("pages {list}"), + _ => format!("pages {list} and {rest} more"), + } +} + +/// The parent spread and the page of it a frame sits on, or `None` for a frame on a document page. +fn parent_page_of(d: &Document, target: designcraft_doc::ItemId) -> Option<(usize, usize)> { + let loc = d.find(target)?; + let SpreadRef::Parent(pi) = loc.spread else { return None }; + let bounds = d.parent_xf(&loc).transform_rect_bbox(d.item(target)?.bounds()); + Some((pi, d.spread(loc.spread)?.page_at_x(bounds.center().x).unwrap_or(0))) +} + +/// Document pages that draw parent page `pp` of parent spread `pi`, in document order. +fn pages_showing(d: &Document, (pi, pp): (usize, usize)) -> Vec { + (0..d.page_count()) + .filter(|p| d.page_loc(*p).is_some_and(|(si, pgi)| d.shown_parent_pages(SpreadRef::Doc(si), pgi).contains(&(pi, pp)))) + .collect() +} + +/// Characters of `story` that `cs` had no room for. +fn overset_chars(story: &designcraft_doc::Story, cs: &ComposedStory) -> usize { + story.text.get(cs.overset_at.unwrap_or(0)..).unwrap_or("").chars().count() +} + pub fn overset_warnings(s: &Session, pages: Option<&[usize]>) -> Vec { let Some(st) = s.active() else { return vec![] }; let d = &st.doc; let mut out = Vec::new(); for story in d.stories.values() { + let last = story.frames.last().copied(); + // A story on a parent page is composed once per page that shows it: its page number + // markers and variables are that page's, so the same frame can fit on one page and + // overset on another, and a parent page no exported page uses puts nothing in the file + // to be missing. Composing it once with no page reported neither (noted in review). + if let Some(parent) = last.and_then(|f| parent_page_of(d, f)) { + let mut by_count: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + for page in pages_showing(d, parent) { + if pages.is_some_and(|sel| !sel.contains(&page)) { + continue; + } + let cs = s.cache.get(d, story.id, Some(page)); + let n = overset_chars(story, &cs); + if cs.is_overset() && n > 0 { + by_count.entry(n).or_default().push(page); + } + } + let item = last.map_or(0, |f| f.0); + out.extend( + by_count.into_iter().map(|(n, ps)| { + format!("{}: {n} character(s) of the story in frame {item} do not fit it and are not in the export", page_list(&ps)) + }), + ); + continue; + } let cs = s.cache.get(d, story.id, None); if !cs.is_overset() { continue; } - let n = story.text.get(cs.overset_at.unwrap_or(0)..).unwrap_or("").chars().count(); + let n = overset_chars(story, &cs); if n == 0 { continue; } @@ -138,7 +198,6 @@ pub fn overset_warnings(s: &Session, pages: Option<&[usize]>) -> Vec { out.push(format!("a story with no text frame: {n} character(s) of it are not in the export")); continue; } - let last = story.frames.last().copied(); let location = last.and_then(|f| overset_location(s, f)); let page = location.and_then(|(_, page)| page); if let (Some(page), Some(pages)) = (page, pages) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 3e6dce9d5..01abf0e6e 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -191,7 +191,7 @@ pub fn specs() -> Vec { }) } ), - cmd!(query "story.get", "Get Story", [], None, "{story? | frame?} → text, frames, paragraphs, vertical, lines, overset (the index in `text` where it stops fitting its frames, null when it all fits) and oversetChars (how many characters that leaves, as preflight reports them)", has_doc, |s, p| { + cmd!(query "story.get", "Get Story", [], None, "{story? | frame?} → text, frames, paragraphs, vertical, lines, overset (the UTF-8 byte offset into `text` where it stops fitting its frames, not a character index, null when it all fits) and oversetChars (how many characters that leaves, as preflight reports them)", has_doc, |s, p| { let st = s.doc()?; let sid = story_of(s, p).ok_or_else(|| bad("story.get", "no story"))?; let mut v = st.doc.story_summary(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; From fd7d8dfb77c29b4203503196a287130c1550aa4c Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 12:08:10 +0100 Subject: [PATCH 275/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/tools/src/select.rs | 134 ++++++++++++++++++------------------- 1 file changed, 67 insertions(+), 67 deletions(-) diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 096944828..f254cdf33 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -789,51 +789,51 @@ impl SelectionTool { && let Some((id, si, ai, handle)) = anchor_at(cx, p) { let at = anchor_spread_point(cx, id, si, ai); - let mut out = vec![]; - let mut others = vec![]; - if handle.is_none() { - // InDesign's anchor point selection: a click selects the point, Shift+click - // adds it or takes it away, and dragging a selected point moves all of them. - let me = (ItemId(id), si, ai); - let mut points = cx.selection.anchors.clone(); - let was = points.contains(&me); - if ev.mods.shift && was { - // Kept selected so a Shift-drag can constrain it; a click still takes it - // away, on release, as for objects. - self.anchor_release = Some(me); - } else if ev.mods.shift { - points.push(me); - } else if !was { - points = vec![me]; - } - if points != cx.selection.anchors { - out.push(Action::Exec("selection.set".into(), anchor_selection(cx, &points))); - } - others = points.iter().filter(|a| **a != me).map(|(i, s, a)| (i.0, *s, *a)).collect(); - } - let label = if handle.is_some() { - "Move Direction Handle" - } else if others.is_empty() { - "Move Anchor" - } else { - "Move Anchors" - }; - self.drag = Drag::Anchor { id, si, ai, handle, start: p, at, others }; - out.push(Action::Begin(label.into())); - return out; + let mut out = vec![]; + let mut others = vec![]; + if handle.is_none() { + // InDesign's anchor point selection: a click selects the point, Shift+click + // adds it or takes it away, and dragging a selected point moves all of them. + let me = (ItemId(id), si, ai); + let mut points = cx.selection.anchors.clone(); + let was = points.contains(&me); + if ev.mods.shift && was { + // Kept selected so a Shift-drag can constrain it; a click still takes it + // away, on release, as for objects. + self.anchor_release = Some(me); + } else if ev.mods.shift { + points.push(me); + } else if !was { + points = vec![me]; } - // As in InDesign, the Direct Selection tool pressed on a corner or anchor of an object - // that isn't selected selects it and moves just that point (Chris Nielsen's "The - // InDesign Toolbox", 10:10: click outside, then pull a frame's corner). - if self.direct - && !ev.mods.shift - && let Some((_, hit)) = cx.hit(p) - && !cx.selection.contains(hit) - && let Some((id, si, ai, None)) = anchor_at_in(cx, &[hit], p) - { - let at = anchor_spread_point(cx, id, si, ai); - self.drag = Drag::Anchor { id, si, ai, handle: None, start: p, at, others: vec![] }; - return vec![Action::Exec("selection.set".into(), json!({"ids": [id], "content": true})), Action::Begin("Move Anchor".into())]; + if points != cx.selection.anchors { + out.push(Action::Exec("selection.set".into(), anchor_selection(cx, &points))); + } + others = points.iter().filter(|a| **a != me).map(|(i, s, a)| (i.0, *s, *a)).collect(); + } + let label = if handle.is_some() { + "Move Direction Handle" + } else if others.is_empty() { + "Move Anchor" + } else { + "Move Anchors" + }; + self.drag = Drag::Anchor { id, si, ai, handle, start: p, at, others }; + out.push(Action::Begin(label.into())); + return out; + } + // As in InDesign, the Direct Selection tool pressed on a corner or anchor of an object + // that isn't selected selects it and moves just that point (Chris Nielsen's "The + // InDesign Toolbox", 10:10: click outside, then pull a frame's corner). + if self.direct + && !ev.mods.shift + && let Some((_, hit)) = cx.hit(p) + && !cx.selection.contains(hit) + && let Some((id, si, ai, None)) = anchor_at_in(cx, &[hit], p) + { + let at = anchor_spread_point(cx, id, si, ai); + self.drag = Drag::Anchor { id, si, ai, handle: None, start: p, at, others: vec![] }; + return vec![Action::Exec("selection.set".into(), json!({"ids": [id], "content": true})), Action::Begin("Move Anchor".into())]; } // A selected placed graphic's own handles resize it inside its frame (the // frame's anchors, under Direct Selection, come first). @@ -1172,18 +1172,18 @@ impl SelectionTool { // Screen y points down: a positive screen angle is clockwise. vec![Action::Preview("transform.rotate".into(), json!({"angle": -a}))] } - Drag::Anchor { id, si, ai, handle, start, at, others } => { - let mut d = p - start; - if d.hypot() >= cx.tol(3.0) { - self.anchor_release = None; - } else if self.anchor_release.is_some() { - // A Shift-press on a selected point is still a click: nothing moves yet. - return vec![]; - } - // Shift: horizontal, vertical or at 45°. - if ev.mods.shift { - d = constrain_45(d); - } + Drag::Anchor { id, si, ai, handle, start, at, others } => { + let mut d = p - start; + if d.hypot() >= cx.tol(3.0) { + self.anchor_release = None; + } else if self.anchor_release.is_some() { + // A Shift-press on a selected point is still a click: nothing moves yet. + return vec![]; + } + // Shift: horizontal, vertical or at 45°. + if ev.mods.shift { + d = constrain_45(d); + } let mut dx = d.x; let mut dy = d.y; self.guides.clear(); @@ -1211,23 +1211,23 @@ impl SelectionTool { }, ); // dx and dy stay in canvas space. Snap.delta is spread space. - let mut corr = spread_delta_to_canvas(cx.layout.xf(spread), hit.delta); - corr = Vec2::new(if corr.x.is_finite() { corr.x } else { 0.0 }, if corr.y.is_finite() { corr.y } else { 0.0 }); - // Shift: the snap moves the points along the constrained direction only. - if ev.mods.shift && d.hypot() > 1e-9 { - let dir = d / d.hypot(); - corr = dir * corr.dot(dir); + let mut corr = spread_delta_to_canvas(cx.layout.xf(spread), hit.delta); + corr = Vec2::new(if corr.x.is_finite() { corr.x } else { 0.0 }, if corr.y.is_finite() { corr.y } else { 0.0 }); + // Shift: the snap moves the points along the constrained direction only. + if ev.mods.shift && d.hypot() > 1e-9 { + let dir = d / d.hypot(); + corr = dir * corr.dot(dir); } - dx += corr.x; - dy += corr.y; + dx += corr.x; + dy += corr.y; self.guides = hit.guides; } let mut params = json!({"id": id, "anchors": [[si, ai]], "dx": dx, "dy": dy}); if let Some(h) = handle { params["handle"] = json!(h); - } else if !others.is_empty() { - let points: Vec = std::iter::once((id, si, ai)).chain(others).map(|(i, s, a)| json!([i, s, a])).collect(); - params = json!({"points": points, "dx": dx, "dy": dy}); + } else if !others.is_empty() { + let points: Vec = std::iter::once((id, si, ai)).chain(others).map(|(i, s, a)| json!([i, s, a])).collect(); + params = json!({"points": points, "dx": dx, "dy": dy}); } vec![Action::Preview("path.moveAnchors".into(), params)] } From 9114b51de258926b6806de348496a6c4e28a5a31 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 12:13:28 +0100 Subject: [PATCH 276/628] Eyedropper test: the ink limit is the Separations view's on main, not the canvas texture's Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/tests.rs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 71cc94a4a..165727cf8 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3506,15 +3506,6 @@ fn guide_counts(app: &DesignApp) -> Vec { let proofed = sample(&mut h); assert_eq!(proofed, shown(plain, &|img| designcraft_render::proof_view(img, &setup))); assert_ne!(proofed, plain, "the proof changes this green"); - // The composite ink-limit preview: a blue over a 100% limit shows (and samples) the warning. - h.state_mut().ui.proof_colors = false; - h.state_mut().run("object.fill", json!({"ids": [id], "swatch": "C=100 M=90 Y=10 K=0"})).unwrap(); - h.run_steps(3); - let blue = sample(&mut h); - h.state_mut().ui.ink_limit = Some(1.0); - let limited = sample(&mut h); - assert_eq!(limited, shown(blue, &|img| designcraft_render::separation_view(img, None, Some(1.0)))); - assert_eq!(limited, [230, 40, 40]); } #[test] From e96baa7f7f33de500e847047753844106bb3f4e1 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 12:19:19 +0100 Subject: [PATCH 277/628] Table keys: the selection helpers by their paths on main's split modules Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/table.rs | 2 +- crates/engine/src/cmd/table/registry.rs | 2 +- crates/engine/src/cmd/table/selection.rs | 2 +- crates/engine/src/cmd/text/edit.rs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 2a124e7a9..7c2f182ea 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -28,4 +28,4 @@ mod styles; mod target; pub use registry::specs; -pub(crate) use selection::step_cell; +pub(crate) use selection::{one_selected_cell_to_text, step_cell}; diff --git a/crates/engine/src/cmd/table/registry.rs b/crates/engine/src/cmd/table/registry.rs index 19cad9907..ee782609c 100644 --- a/crates/engine/src/cmd/table/registry.rs +++ b/crates/engine/src/cmd/table/registry.rs @@ -1,7 +1,7 @@ use super::cell::{get_cell_stroke, set_cell}; use super::content::{drop_cells, place_graphic, sort_rows}; use super::options::{get, options}; -use super::selection::{move_rc, select, step_cell}; +use super::selection::{move_rc, select, step_cell, toggle_selection}; use super::sizing::{distribute_cols, distribute_rows, resize_border, set_col_width, set_row_height}; use super::structure::{ can_convert_rows, convert_from_text, convert_rows, convert_to_text, delete_cols, delete_rows, delete_table, insert, insert_col, insert_row, diff --git a/crates/engine/src/cmd/table/selection.rs b/crates/engine/src/cmd/table/selection.rs index 00582fb4d..656dfdd78 100644 --- a/crates/engine/src/cmd/table/selection.rs +++ b/crates/engine/src/cmd/table/selection.rs @@ -84,7 +84,7 @@ pub(crate) fn one_selected_cell_to_text(s: &mut Session) -> Result<()> { /// Esc in a table: text in a cell becomes that cell's selection, and selected cells the text of /// the first one (InDesign toggles between the two). -fn toggle_selection(s: &mut Session) -> Result { +pub(super) fn toggle_selection(s: &mut Session) -> Result { const ID: &str = "table.toggleSelection"; let st = s.doc_mut()?; let t = st.selection.text.ok_or_else(|| bad(ID, "no caret"))?; diff --git a/crates/engine/src/cmd/text/edit.rs b/crates/engine/src/cmd/text/edit.rs index 7d896c999..21888cb22 100644 --- a/crates/engine/src/cmd/text/edit.rs +++ b/crates/engine/src/cmd/text/edit.rs @@ -18,7 +18,7 @@ pub(super) fn insert(s: &mut Session, p: &Value) -> Result { return crate::cmd::table::step_cell(s, true); } // Typing with one cell selected replaces its text, as InDesign. - super::table::one_selected_cell_to_text(s)?; + crate::cmd::table::one_selected_cell_to_text(s)?; let raw = p.get("raw").and_then(Value::as_bool).unwrap_or(false); let text = if s.prefs.typographers_quotes && !raw { smart_quotes(s, &text) } else { text }; let tracking = s.doc()?.doc.settings.track_changes; From e779d9eea2a0d34a8502de686c71f505a679766e Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 12:19:27 +0100 Subject: [PATCH 278/628] Hidden characters: the test reaches frame_hidden_marks in its new module Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/canvas/text_overlay.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3a1f8c733..5c0926b44 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -56,7 +56,7 @@ use scroll::{mods, navigation_space, scrollbars, spring_zoom_mode}; pub use text_overlay::selected_text; use text_overlay::{draw_dynamic_spelling, draw_hidden_characters}; #[cfg(test)] -use text_overlay::{draw_text_selection, hidden_marks}; +use text_overlay::{draw_text_selection, frame_hidden_marks, hidden_marks}; use tool_overlays::draw_tool_overlays; pub(crate) use view::page_in_view; use view::present_fit; diff --git a/crates/ui-egui/src/canvas/text_overlay.rs b/crates/ui-egui/src/canvas/text_overlay.rs index 6af92d842..ebf2a6349 100644 --- a/crates/ui-egui/src/canvas/text_overlay.rs +++ b/crates/ui-egui/src/canvas/text_overlay.rs @@ -288,7 +288,7 @@ pub(super) fn draw_hidden_characters(app: &DesignApp, painter: &egui::Painter, x /// the line's ascent): its lines' marks, then its tables' — each text cell's own marks (nested /// tables included) with its # where its text ends, and a table paragraph's ¶ after the last /// row's bottom right corner. A table's own glyph-less line has no marks: its ¶ is that one. -fn frame_hidden_marks(text: &str, ft: &compose::FrameText) -> Vec<(Point, &'static str, bool, f64)> { +pub(super) fn frame_hidden_marks(text: &str, ft: &compose::FrameText) -> Vec<(Point, &'static str, bool, f64)> { let table_lines: Vec = ft.tables.iter().map(|t| t.line).collect(); let mut out: Vec<_> = ft .lines From d4ecb552f5a0aacebf9f428a4f4a1adcc174403a Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 12:38:46 +0100 Subject: [PATCH 279/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/pages/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/pages/tests.rs b/crates/ui-egui/src/panels/pages/tests.rs index 037ef6a89..b0dcafb06 100644 --- a/crates/ui-egui/src/panels/pages/tests.rs +++ b/crates/ui-egui/src/panels/pages/tests.rs @@ -343,7 +343,7 @@ fn the_panel_menu_follows_indesigns_order() { assert_eq!(h.state().session.doc().unwrap().doc.page_count(), 3); h.get_by_label("Insert Pages…").click(); h.run_steps(3); - assert!(h.state().ui.dialog.as_ref().is_some_and(|d| d.id == "insertPages")); + assert!(h.state().ui.dialog.as_ref().is_some_and(|d| d.id == "insertPages")); } #[test] From 08e954515e79fd635ca62791558510aefb067d44 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 12:43:14 +0100 Subject: [PATCH 280/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/chrome/tests.rs | 82 +++++++++++++++--------------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 48f79c819..eb84e74ca 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -180,48 +180,48 @@ fn status_bar_error_count_opens_the_preflight_panel() { assert!(crate::panel_docking::showing(&h.state().app, "preflight")); } - /// The course's chapter 25: while a parent is shown, the status bar's page box names it - /// ("A-Parent"), as InDesign's. Its menu lists the pages, then the parents: a parent shows - /// that parent, a page leaves the parents for that page. - #[test] - fn the_page_box_names_the_parent_in_view_and_lists_the_parents() { - use egui::accesskit::Role; - let mut app = app(); - app.run("file.new", serde_json::json!({"pages": 4, "facingPages": true})).unwrap(); - app.run("layout.parents.new", serde_json::json!({"prefix": "B", "name": "Cover"})).unwrap(); - app.ui.dock_tab = "properties".into(); - let mut h = test_window::open(app, vec2(1200.0, 700.0)); - let shown = |h: &egui_kittest::Harness<'static, test_window::Window>| { - let b = h.get_by(|n| n.role() == Role::ComboBox && n.label().as_deref() == Some("Page")); - b.value().unwrap_or_default() - }; - assert_eq!(shown(&h), "1"); - h.state_mut().app.run("layout.parents.edit", serde_json::json!({"on": true})).unwrap(); - h.run_steps(6); - assert_eq!(shown(&h), "A-Parent"); - // The menu: a parent. - h.get_by(|n| n.role() == Role::ComboBox && n.label().as_deref() == Some("Page")).click(); - h.run_steps(3); - for name in ["1", "2", "3", "4", "A-Parent"] { - assert!(h.query_by_label(name).is_some(), "{name} is listed"); - } - h.get_by_label("B-Cover").click(); - h.run_steps(6); - assert_eq!(shown(&h), "B-Cover"); - assert_eq!(crate::canvas::current_parent_page(&h.state().app).map(|(i, _)| i), Some(1)); - // A page: the parents are left. - h.get_by(|n| n.role() == Role::ComboBox && n.label().as_deref() == Some("Page")).click(); - h.run_steps(3); - h.get_by_label("3").click(); - h.run_steps(6); - assert!(!h.state().app.session.active().unwrap().editing_parents); - assert_eq!(crate::canvas::current_page(&h.state().app), Some(2)); - assert_eq!(shown(&h), "3"); - // Its accessible name is in the interface's language. - h.state_mut().app.ui.language = "de".into(); - h.run_steps(3); - assert!(h.query_by(|n| n.role() == Role::ComboBox && n.label().as_deref() == Some("Seite")).is_some(), "the page box is Seite"); +/// The course's chapter 25: while a parent is shown, the status bar's page box names it +/// ("A-Parent"), as InDesign's. Its menu lists the pages, then the parents: a parent shows +/// that parent, a page leaves the parents for that page. +#[test] +fn the_page_box_names_the_parent_in_view_and_lists_the_parents() { + use egui::accesskit::Role; + let mut app = app(); + app.run("file.new", serde_json::json!({"pages": 4, "facingPages": true})).unwrap(); + app.run("layout.parents.new", serde_json::json!({"prefix": "B", "name": "Cover"})).unwrap(); + app.ui.dock_tab = "properties".into(); + let mut h = test_window::open(app, vec2(1200.0, 700.0)); + let shown = |h: &egui_kittest::Harness<'static, test_window::Window>| { + let b = h.get_by(|n| n.role() == Role::ComboBox && n.label().as_deref() == Some("Page")); + b.value().unwrap_or_default() + }; + assert_eq!(shown(&h), "1"); + h.state_mut().app.run("layout.parents.edit", serde_json::json!({"on": true})).unwrap(); + h.run_steps(6); + assert_eq!(shown(&h), "A-Parent"); + // The menu: a parent. + h.get_by(|n| n.role() == Role::ComboBox && n.label().as_deref() == Some("Page")).click(); + h.run_steps(3); + for name in ["1", "2", "3", "4", "A-Parent"] { + assert!(h.query_by_label(name).is_some(), "{name} is listed"); } + h.get_by_label("B-Cover").click(); + h.run_steps(6); + assert_eq!(shown(&h), "B-Cover"); + assert_eq!(crate::canvas::current_parent_page(&h.state().app).map(|(i, _)| i), Some(1)); + // A page: the parents are left. + h.get_by(|n| n.role() == Role::ComboBox && n.label().as_deref() == Some("Page")).click(); + h.run_steps(3); + h.get_by_label("3").click(); + h.run_steps(6); + assert!(!h.state().app.session.active().unwrap().editing_parents); + assert_eq!(crate::canvas::current_page(&h.state().app), Some(2)); + assert_eq!(shown(&h), "3"); + // Its accessible name is in the interface's language. + h.state_mut().app.ui.language = "de".into(); + h.run_steps(3); + assert!(h.query_by(|n| n.role() == Role::ComboBox && n.label().as_deref() == Some("Seite")).is_some(), "the page box is Seite"); +} /// As in InDesign, double-clicking the preflight count (or its light) shows the panel. #[test] From 18f2dc2bdea4e8d9d028a4c267352240240d8a44 Mon Sep 17 00:00:00 2001 From: Hamza Date: Thu, 29 Oct 2026 16:43:49 +0500 Subject: [PATCH 281/628] A spent cell budget stops reading tables, rather than keeping one each Keeping a 1 x 1 table for every further `
` was unbounded in the one direction the budget exists to close. `
` is fifteen bytes, so the 512 MiB a part may decompress to holds some 36 million of them, and a `Cell` is 1,360 bytes: tens of gigabytes, with every table inside both caps. The test I wrote said as much without my noticing -- its invariant was `reserved <= MAX + tables`, and `tables` had no bound. `budgeted_size` now returns `None` once the budget is spent and the table is not read at all, so the cells one import reserves are at most the budget, exactly. The invariant is now `reserved <= MAX_IMPORT_TABLE_CELLS`, with no term that grows with the file. The warnings needed the same treatment, for the same reason: a file of 400,000 tables each claiming one row and two columns got a line about every one of them. At most 20 are named, then one line saying the rest were the same, and the file is told once that its tables ran out of cells between them. --- crates/idml/src/import.rs | 7 +++ crates/idml/src/import/story.rs | 7 ++- crates/idml/src/import/table.rs | 104 +++++++++++++++++++++----------- 3 files changed, 81 insertions(+), 37 deletions(-) diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index c8e0512b5..ae7364647 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -142,6 +142,11 @@ struct Importer<'r> { /// Cells every table in this import has reserved. One table is capped, but a file chooses /// how many tables it has, so the cap has to be document-wide as well (noted in review). table_cells: usize, + /// Tables whose size the file claimed and did not get, warned about so far. A file chooses + /// how many tables it has, so a line each is its own flood (noted in review). + table_warnings: usize, + /// Whether the file has already been told its tables ran out of cells between them. + table_budget_spent: bool, settings: DocSettings, default_margins: Option<(Margins, Columns)>, swatches: Vec, @@ -229,6 +234,8 @@ impl<'r> Importer<'r> { previews, next_id: 0, table_cells: 0, + table_warnings: 0, + table_budget_spent: false, settings: DocSettings::default(), default_margins: None, swatches: specials, diff --git a/crates/idml/src/import/story.rs b/crates/idml/src/import/story.rs index 694a2dbbd..72cde49b1 100644 --- a/crates/idml/src/import/story.rs +++ b/crates/idml/src/import/story.rs @@ -168,8 +168,11 @@ impl<'r> Importer<'r> { b.fresh = true; } "Table" => { - let t = self.table(c); - b.push_table(t, pf, cf); + // `None` once the file-wide cell budget is spent: the table is not read + // at all rather than kept as a 1 x 1 (noted in review). + if let Some(t) = self.table(c) { + b.push_table(t, pf, cf); + } } "HyperlinkTextDestination" | "ParagraphDestination" => { b.anchors.push((c.get("Self").unwrap_or("").to_string(), c.get("Name").unwrap_or("Anchor").to_string())); diff --git a/crates/idml/src/import/table.rs b/crates/idml/src/import/table.rs index 3b1051b66..91ac287f9 100644 --- a/crates/idml/src/import/table.rs +++ b/crates/idml/src/import/table.rs @@ -7,22 +7,31 @@ use super::*; /// importer collects at most. const MAX_IMPORT_TABLE_CELLS: usize = 400_000; +/// Clamp warnings one import writes before it says the rest were the same. +const MAX_TABLE_WARNINGS: usize = 20; + /// How large a table of `rows` x `cols` may be read when `reserved` cells have already been -/// taken, and whether the file-wide budget is what cut it down. +/// taken, and whether the file-wide budget is what cut it down -- or `None` when the budget is +/// spent and the table is not read at all. /// /// The per-table cap leaves a file free to hold many capped tables, so each is also clamped -/// against what is left. One row and one column are always kept: the table is then still in the -/// document, still says where its text went, and a file pays one cell for a table it really -/// does contain -- which its own size already bounds. -fn budgeted_size(rows: usize, cols: usize, reserved: usize) -> (usize, usize, bool) { +/// against what is left. While there is budget, one row and one column are always kept: the table +/// is then still in the document, still says where its text went, and a file pays one cell for a +/// table it really does contain. +/// +/// Past that there is nothing left to pay with. Keeping a 1 x 1 table for every further `` +/// was unbounded in the one direction the budget was meant to close: `
` is fifteen +/// bytes, so the 512 MiB a part may decompress to holds some 36 million of them, and a `Cell` +/// each is tens of gigabytes with every table inside both caps (noted in review). +fn budgeted_size(rows: usize, cols: usize, reserved: usize) -> Option<(usize, usize, bool)> { let (mut nr, mut nc) = designcraft_doc::table::clamped_size(rows, cols); - let left = MAX_IMPORT_TABLE_CELLS.saturating_sub(reserved); + let left = MAX_IMPORT_TABLE_CELLS.checked_sub(reserved).filter(|left| *left > 0)?; let over_budget = nr.saturating_mul(nc) > left; if over_budget { - nc = nc.min(left.max(1)); + nc = nc.min(left); nr = nr.min((left / nc).max(1)); } - (nr, nc, over_budget) + Some((nr, nc, over_budget)) } /// Preserve the legacy uniform field only when all four sides explicitly specify every @@ -45,8 +54,7 @@ pub(super) fn uniform_cell_stroke(edges: &[designcraft_doc::CellStrokeAttrs; 4]) impl<'r> Importer<'r> { /// ``: rows, columns and cells (cell `Name` is `column:row`). - pub(super) fn table(&mut self, e: &El) -> Table { - let id = self.alloc(); + pub(super) fn table(&mut self, e: &El) -> Option
{ let rows: Vec<&El> = e.find_all("Row").collect(); let cols: Vec<&El> = e.find_all("Column").collect(); // A file's row and column counts are its claim, and a table of them is allocated whole: a @@ -57,12 +65,31 @@ impl<'r> Importer<'r> { // // Reserved before `Table::new`, so a cell's own story cannot spend the budget this table // is counting on. - let (nr, nc, over_budget) = budgeted_size(rows.len().max(1), cols.len().max(1), self.table_cells); + let Some((nr, nc, over_budget)) = budgeted_size(rows.len().max(1), cols.len().max(1), self.table_cells) else { + // Said once: the file that reaches this is the one with millions of tables in it. + if !self.table_budget_spent { + self.table_budget_spent = true; + self.warnings.push(format!( + "the tables in this file claim more than {MAX_IMPORT_TABLE_CELLS} cells between them: the rest of them were not read" + )); + } + return None; + }; self.table_cells = self.table_cells.saturating_add(nr.saturating_mul(nc)); if nr < rows.len() || nc < cols.len() { let why = if over_budget { "more than the tables in one file can hold between them" } else { "more than a table can hold" }; - self.warnings.push(format!("a table of {} rows and {} columns was read as {nr} x {nc}: {why}", rows.len(), cols.len())); + self.table_warnings += 1; + match self.table_warnings.cmp(&MAX_TABLE_WARNINGS) { + std::cmp::Ordering::Less => { + self.warnings.push(format!("a table of {} rows and {} columns was read as {nr} x {nc}: {why}", rows.len(), cols.len())) + } + std::cmp::Ordering::Equal => { + self.warnings.push(format!("more than {MAX_TABLE_WARNINGS} tables were read smaller than this file claims")) + } + std::cmp::Ordering::Greater => {} + } } + let id = self.alloc(); let rows = &rows[..nr.min(rows.len())]; let cols = &cols[..nc.min(cols.len())]; let mut t = Table::new(id, nr, nc, 0, 0, 0.0); @@ -151,7 +178,7 @@ impl<'r> Importer<'r> { for rg in regions { let _ = t.merge(rg); } - t + Some(t) } /// A cell's local fill, insets, alignment, rotation and edge strokes. @@ -207,45 +234,52 @@ mod budget_tests { fn the_tables_in_one_file_are_capped_between_them() { let mut reserved = 0; let mut sizes = Vec::new(); - for _ in 0..3 { - let (nr, nc, over) = budgeted_size(500, 1000, reserved); - reserved += nr * nc; - sizes.push((nr, nc, over)); + for _ in 0..4 { + let size = budgeted_size(500, 1000, reserved); + if let Some((nr, nc, _)) = size { + reserved += nr * nc; + } + sizes.push(size); } - assert_eq!(sizes[0], (250, 1000, false), "the first table is held by the per-table cap alone"); - assert_eq!(sizes[1], (150, 1000, true), "the second gets what is left of the file's budget"); - assert_eq!(sizes[2], (1, 1, true), "and the third is kept, so its text still has somewhere to be"); - assert_eq!(reserved, MAX_IMPORT_TABLE_CELLS + 1, "400,000 cells and the one the last table keeps"); + assert_eq!(sizes[0], Some((250, 1000, false)), "the first table is held by the per-table cap alone"); + assert_eq!(sizes[1], Some((150, 1000, true)), "the second gets what is left of the file's budget"); + assert_eq!(sizes[2], None, "and the third is not read: there is nothing left to pay for it"); + assert_eq!(sizes[3], None, "nor any after it"); + assert_eq!(reserved, MAX_IMPORT_TABLE_CELLS, "the budget is spent exactly, not overspent"); } - /// The budget holds for any sequence of claimed sizes, and a table is never read as nothing: - /// a 0 x n table has no cells to hold the text the file puts in it. + /// The budget is a hard limit, not a floor: keeping one cell for every further table was + /// unbounded in the one direction the budget exists to close, since a `
` is + /// fifteen bytes and a part may decompress to 512 MiB (noted in review). #[test] - fn a_table_is_never_read_as_empty_and_the_budget_always_holds() { + fn a_spent_budget_stops_reading_tables_altogether() { let claims = [(0, 0), (1, 1), (7, 3), (usize::MAX, usize::MAX), (1, usize::MAX), (usize::MAX, 1), (10_000, 1_000), (2, 0)]; let mut reserved = 0usize; - let mut tables = 0usize; + let mut read = 0usize; + let mut skipped = 0usize; // Every claim, several times over, in an order that exhausts the budget part way through. for _ in 0..4 { for (rows, cols) in claims { - let (nr, nc, _) = budgeted_size(rows.max(1), cols.max(1), reserved); - assert!(nr >= 1 && nc >= 1, "{rows} x {cols} was read as {nr} x {nc}"); - reserved += nr * nc; - tables += 1; - assert!( - reserved <= MAX_IMPORT_TABLE_CELLS + tables, - "{tables} tables reserved {reserved} cells, past the budget by more than the one cell each keeps" - ); + match budgeted_size(rows.max(1), cols.max(1), reserved) { + Some((nr, nc, _)) => { + assert!(nr >= 1 && nc >= 1, "{rows} x {cols} was read as {nr} x {nc}: a table has nowhere to put its text"); + reserved += nr * nc; + read += 1; + } + None => skipped += 1, + } + assert!(reserved <= MAX_IMPORT_TABLE_CELLS, "{read} tables reserved {reserved} cells, past the budget"); } } - assert!(reserved >= MAX_IMPORT_TABLE_CELLS, "this sequence does reach the budget: {reserved}"); + assert_eq!(reserved, MAX_IMPORT_TABLE_CELLS, "this sequence does reach the budget"); + assert!(skipped > 0, "and does go on asking after it: {skipped} tables were not read"); } /// Below the budget nothing is clamped by it, so an ordinary file is read exactly as before. #[test] fn an_ordinary_file_is_not_touched_by_the_budget() { for (rows, cols) in [(1, 1), (2, 3), (50, 20), (250, 1000)] { - assert_eq!(budgeted_size(rows, cols, 0), (rows, cols, false), "{rows} x {cols}"); + assert_eq!(budgeted_size(rows, cols, 0), Some((rows, cols, false)), "{rows} x {cols}"); } } } From 3de3efccafdf84725588e4bec78a08f20e6eb9ed Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 12:50:00 +0100 Subject: [PATCH 282/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties/tests.rs | 112 +++++++++--------- 1 file changed, 56 insertions(+), 56 deletions(-) diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 91004afa9..96a1b14b6 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -638,65 +638,65 @@ fn clicking_a_swatch_chip_in_the_appearance_fill_menu_applies_it() { assert_eq!(after, name, "clicking the swatch chip applies it"); } - /// The course's chapter 23: a double-click on Properties › Appearance › Fill opens the Color - /// Picker with the fill's colour, as InDesign's (a click opens the swatches). For selected - /// text, the text's Fill chip opens it for the text. - #[test] - fn double_clicking_a_fill_chip_opens_the_color_picker() { - let double_click = |h: &mut Harness<'_, DesignApp>, label: &str| { - let at = h.get_by_label(label).rect().center(); - h.hover_at(at); +/// The course's chapter 23: a double-click on Properties › Appearance › Fill opens the Color +/// Picker with the fill's colour, as InDesign's (a click opens the swatches). For selected +/// text, the text's Fill chip opens it for the text. +#[test] +fn double_clicking_a_fill_chip_opens_the_color_picker() { + let double_click = |h: &mut Harness<'_, DesignApp>, label: &str| { + let at = h.get_by_label(label).rect().center(); + h.hover_at(at); + h.step(); + for pressed in [true, false, true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); h.step(); - for pressed in [true, false, true, false] { - h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); - h.step(); - } - h.run_steps(3); - }; - // Frames 1 ms apart, so two clicks make a double-click. - let panel = |app: DesignApp| { - let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).with_step_dt(0.001).build_ui_state( - |ui, app: &mut DesignApp| { - let id = egui::Id::new("test_fonts"); - if ui.data(|d| d.get_temp::(id)).is_none() { - crate::theme::install_fonts(ui.ctx(), ""); - ui.data_mut(|d| d.insert_temp(id, true)); - return; - } - show(app, ui); - }, - app, - ); - h.run_steps(3); - h - }; - let frame = || { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); - app.run("selection.set", json!({"ids": [id]})).unwrap(); - app.run("object.fill", json!({"swatch": "C=0 M=0 Y=100 K=0"})).unwrap(); - app - }; - let mut h = panel(frame()); - double_click(&mut h, "Fill swatch"); - assert!(h.query_by_label("Tint:").is_none(), "the swatches popup isn't left open too"); - let d = h.state().ui.dialog.clone().expect("the Color Picker"); - assert_eq!((d.id.as_str(), d.s("target"), d.s("model"), d.b("text")), ("colorPicker", "fill".into(), "cmyk".into(), false)); - assert_eq!(d.fields["cmyk"], json!([0.0, 0.0, 100.0, 0.0])); - let mut h = panel(frame()); - double_click(&mut h, "Stroke swatch"); - assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.s("target")), Some("stroke".into())); - // Selected text: its own Fill chip. + } + h.run_steps(3); + }; + // Frames 1 ms apart, so two clicks make a double-click. + let panel = |app: DesignApp| { + let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).with_step_dt(0.001).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + h + }; + let frame = || { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({})).unwrap(); - let f = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Lemon"})).unwrap(); - app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 5})).unwrap(); - let mut h = panel(app); - double_click(&mut h, "Text fill swatch"); - let d = h.state().ui.dialog.clone().expect("the Color Picker"); - assert_eq!((d.id.as_str(), d.s("target"), d.b("text")), ("colorPicker", "fill".into(), true)); - } + let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + app.run("object.fill", json!({"swatch": "C=0 M=0 Y=100 K=0"})).unwrap(); + app + }; + let mut h = panel(frame()); + double_click(&mut h, "Fill swatch"); + assert!(h.query_by_label("Tint:").is_none(), "the swatches popup isn't left open too"); + let d = h.state().ui.dialog.clone().expect("the Color Picker"); + assert_eq!((d.id.as_str(), d.s("target"), d.s("model"), d.b("text")), ("colorPicker", "fill".into(), "cmyk".into(), false)); + assert_eq!(d.fields["cmyk"], json!([0.0, 0.0, 100.0, 0.0])); + let mut h = panel(frame()); + double_click(&mut h, "Stroke swatch"); + assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.s("target")), Some("stroke".into())); + // Selected text: its own Fill chip. + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Lemon"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 5})).unwrap(); + let mut h = panel(app); + double_click(&mut h, "Text fill swatch"); + let d = h.state().ui.dialog.clone().expect("the Color Picker"); + assert_eq!((d.id.as_str(), d.s("target"), d.b("text")), ("colorPicker", "fill".into(), true)); +} fn panel(app: DesignApp) -> Harness<'static, DesignApp> { let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( From f56aa76ada740497a97e0c524549bd43b9dcde03 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 12:50:08 +0100 Subject: [PATCH 283/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties/tests.rs | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 74fd68ba7..d8b965fb5 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -655,21 +655,21 @@ fn panel(app: DesignApp) -> Harness<'static, DesignApp> { h } - /// The course's chapter 21: Properties › Bullets and Numbering › Options opens the Bullets - /// and Numbering dialog, as InDesign's, not the Paragraph panel. - #[test] - fn bullets_options_open_the_bullets_and_numbering_dialog() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let f = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "4 eggs"})).unwrap(); - app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 6})).unwrap(); - let mut h = panel(app); - h.get_by_label("Options").click(); - h.run_steps(3); - let d = h.state().ui.dialog.clone().expect("a dialog"); - assert_eq!((d.id.as_str(), d.s("section").as_str()), ("paragraphLocal", "bullets")); - assert_ne!(h.state().ui.open_panel.as_deref(), Some("paragraph")); - } +/// The course's chapter 21: Properties › Bullets and Numbering › Options opens the Bullets +/// and Numbering dialog, as InDesign's, not the Paragraph panel. +#[test] +fn bullets_options_open_the_bullets_and_numbering_dialog() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "4 eggs"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 6})).unwrap(); + let mut h = panel(app); + h.get_by_label("Options").click(); + h.run_steps(3); + let d = h.state().ui.dialog.clone().expect("a dialog"); + assert_eq!((d.id.as_str(), d.s("section").as_str()), ("paragraphLocal", "bullets")); + assert_ne!(h.state().ui.open_panel.as_deref(), Some("paragraph")); +} /// The course's chapter 21: Properties' Bulleted List button gives the selected paragraphs /// bullets with InDesign's hanging indent; pressed again, both go. From e9d7a199943d8926370f1f939de6e0109a35a92a Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:58:54 +0000 Subject: [PATCH 284/628] Keep leading and trailing spaces in Tagged Text style names Found while testing crates/textimport/src/tagged.rs:~190-205 (the ParaStyle and CharStyle branches of import: `unescape_value(value.trim())`) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/textimport/src/tagged.rs | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/textimport/src/tagged.rs b/crates/textimport/src/tagged.rs index 74806ae4c..28929c985 100644 --- a/crates/textimport/src/tagged.rs +++ b/crates/textimport/src/tagged.rs @@ -184,7 +184,7 @@ pub fn import(bytes: &[u8]) -> Result { } "ParaStyle" => { started = true; - let v = unescape_value(value.trim()); + let v = unescape_value(value); if !v.is_empty() && !para_styles.contains(&v) { para_styles.push(v.clone()); } @@ -193,7 +193,7 @@ pub fn import(bytes: &[u8]) -> Result { } } "CharStyle" => { - let v = unescape_value(value.trim()); + let v = unescape_value(value); fmt.style = if v.is_empty() { NO_CHAR_STYLE.into() } else { v.clone() }; if !v.is_empty() && !char_styles.contains(&v) { char_styles.push(v); @@ -205,7 +205,7 @@ pub fn import(bytes: &[u8]) -> Result { // keeps its style's size (#1593). "cSize" => fmt.over.size = value.trim().parse().ok().and_then(designcraft_doc::attrs::imported_type_size), "DefineParaStyle" | "DefineCharStyle" => { - let nm = value.split('=').next().unwrap_or("").trim().to_string(); + let nm = value.split('=').next().unwrap_or("").to_string(); let list = if name == "DefineParaStyle" { &mut para_styles } else { &mut char_styles }; if !nm.is_empty() && !list.contains(&nm) { list.push(nm); @@ -278,6 +278,28 @@ mod tests { assert_eq!(import("\r\nA<0xD83D>B<0xDE00>C".as_bytes()).unwrap().story.text, "ABC"); } + /// A style name is read back exactly as the exporter wrote it, spaces included. + #[test] + fn style_names_keep_leading_and_trailing_spaces() { + let i = import("\r\n\r\nHello".as_bytes()).unwrap(); + assert_eq!(i.story.paras[0].style, "Body "); + assert_eq!(i.story.char_format_at(2).style, " Em "); + assert_eq!(i.para_styles[0].name, "Body "); + assert_eq!(i.char_styles[0].name, " Em "); + } + + /// A style defined with spaces in its name keeps them, so a paragraph that uses the name matches. + #[test] + fn defined_style_names_keep_leading_and_trailing_spaces() { + let src = + "\r\n>>Hello"; + let i = import(src.as_bytes()).unwrap(); + assert_eq!(i.para_styles.len(), 1); + assert_eq!(i.para_styles[0].name, "Body "); + assert_eq!(i.char_styles.len(), 1); + assert_eq!(i.char_styles[0].name, " Em "); + } + /// A size does not have to be non-finite to be nonsense: a file can hold `1e300`, `0` or a /// negative, and tagged text took all three and set them on the text (#1593). They are now /// dropped, as RTF and DOCX already dropped them, so the text keeps its style's size. From 299a65c89042e8f0f14fc963c3b3be4984da9827 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 13:16:00 +0100 Subject: [PATCH 285/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menus/tests.rs | 46 +++++++++++++++---------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index d65a99afc..a4d907e7a 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -29,29 +29,29 @@ fn view_items_are_in_the_view_menu_as_in_indesign() { assert_eq!(ui_label("view.togglePreview").and_then(|c| c.1), Some("W")); } - /// Object › Fitting in InDesign's groups: the proportional fits, the frame and content fits, - /// Clear Frame Fitting Options, then the dialog. - #[test] - fn the_fitting_menu_is_grouped_as_indesigns() { - let items = MENUS.iter().find(|(t, _)| *t == "Object").map(|(_, items)| *items).unwrap(); - let start = items.iter().position(|e| *e == ">Fitting").unwrap() + 1; - let end = start + items[start..].iter().position(|e| *e == "<").unwrap(); - assert_eq!( - &items[start..end], - [ - "ui:fit.fillFrameProportionally", - "ui:fit.contentProportionally", - "-", - "ui:fit.frameToContent", - "ui:fit.contentToFrame", - "ui:fit.centerContent", - "-", - "cmd:object.clearFittingOptions", - "-", - "ui:app.fittingOptions", - ] - ); - } +/// Object › Fitting in InDesign's groups: the proportional fits, the frame and content fits, +/// Clear Frame Fitting Options, then the dialog. +#[test] +fn the_fitting_menu_is_grouped_as_indesigns() { + let items = MENUS.iter().find(|(t, _)| *t == "Object").map(|(_, items)| *items).unwrap(); + let start = items.iter().position(|e| *e == ">Fitting").unwrap() + 1; + let end = start + items[start..].iter().position(|e| *e == "<").unwrap(); + assert_eq!( + &items[start..end], + [ + "ui:fit.fillFrameProportionally", + "ui:fit.contentProportionally", + "-", + "ui:fit.frameToContent", + "ui:fit.contentToFrame", + "ui:fit.centerContent", + "-", + "cmd:object.clearFittingOptions", + "-", + "ui:app.fittingOptions", + ] + ); +} /// The submenu paths of a menu bar menu ("View/Screen Mode"), as Edit › Menus keys them. fn submenu_paths(title: &str) -> Vec { From 4a71ee5d83f5a733dec7a36446bc0a93baa5ef15 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 13:22:55 +0100 Subject: [PATCH 286/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/tools/src/select.rs | 140 ++++++++--------- crates/ui-egui/src/panels/properties/tests.rs | 146 +++++++++--------- 2 files changed, 143 insertions(+), 143 deletions(-) diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index d94b72560..e1161fce9 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -782,51 +782,51 @@ impl SelectionTool { && let Some((id, si, ai, handle)) = anchor_at(cx, p) { let at = anchor_spread_point(cx, id, si, ai); - let mut out = vec![]; - let mut others = vec![]; - if handle.is_none() { - // InDesign's anchor point selection: a click selects the point, Shift+click - // adds it or takes it away, and dragging a selected point moves all of them. - let me = (ItemId(id), si, ai); - let mut points = cx.selection.anchors.clone(); - let was = points.contains(&me); - if ev.mods.shift && was { - // Kept selected so a Shift-drag can constrain it; a click still takes it - // away, on release, as for objects. - self.anchor_release = Some(me); - } else if ev.mods.shift { - points.push(me); - } else if !was { - points = vec![me]; - } - if points != cx.selection.anchors { - out.push(Action::Exec("selection.set".into(), anchor_selection(cx, &points, ev.mods.shift))); - } - others = points.iter().filter(|a| **a != me).map(|(i, s, a)| (i.0, *s, *a)).collect(); - } - let label = if handle.is_some() { - "Move Direction Handle" - } else if others.is_empty() { - "Move Anchor" - } else { - "Move Anchors" - }; - self.drag = Drag::Anchor { id, si, ai, handle, start: p, at, others }; - out.push(Action::Begin(label.into())); - return out; + let mut out = vec![]; + let mut others = vec![]; + if handle.is_none() { + // InDesign's anchor point selection: a click selects the point, Shift+click + // adds it or takes it away, and dragging a selected point moves all of them. + let me = (ItemId(id), si, ai); + let mut points = cx.selection.anchors.clone(); + let was = points.contains(&me); + if ev.mods.shift && was { + // Kept selected so a Shift-drag can constrain it; a click still takes it + // away, on release, as for objects. + self.anchor_release = Some(me); + } else if ev.mods.shift { + points.push(me); + } else if !was { + points = vec![me]; } - // As in InDesign, the Direct Selection tool pressed on a corner or anchor of an object - // that isn't selected selects it and moves just that point (Chris Nielsen's "The - // InDesign Toolbox", 10:10: click outside, then pull a frame's corner). - if self.direct - && !ev.mods.shift - && let Some((_, hit)) = cx.hit(p) - && !cx.selection.contains(hit) - && let Some((id, si, ai, None)) = anchor_at_in(cx, &[hit], p) - { - let at = anchor_spread_point(cx, id, si, ai); - self.drag = Drag::Anchor { id, si, ai, handle: None, start: p, at, others: vec![] }; - return vec![Action::Exec("selection.set".into(), json!({"ids": [id], "content": true})), Action::Begin("Move Anchor".into())]; + if points != cx.selection.anchors { + out.push(Action::Exec("selection.set".into(), anchor_selection(cx, &points, ev.mods.shift))); + } + others = points.iter().filter(|a| **a != me).map(|(i, s, a)| (i.0, *s, *a)).collect(); + } + let label = if handle.is_some() { + "Move Direction Handle" + } else if others.is_empty() { + "Move Anchor" + } else { + "Move Anchors" + }; + self.drag = Drag::Anchor { id, si, ai, handle, start: p, at, others }; + out.push(Action::Begin(label.into())); + return out; + } + // As in InDesign, the Direct Selection tool pressed on a corner or anchor of an object + // that isn't selected selects it and moves just that point (Chris Nielsen's "The + // InDesign Toolbox", 10:10: click outside, then pull a frame's corner). + if self.direct + && !ev.mods.shift + && let Some((_, hit)) = cx.hit(p) + && !cx.selection.contains(hit) + && let Some((id, si, ai, None)) = anchor_at_in(cx, &[hit], p) + { + let at = anchor_spread_point(cx, id, si, ai); + self.drag = Drag::Anchor { id, si, ai, handle: None, start: p, at, others: vec![] }; + return vec![Action::Exec("selection.set".into(), json!({"ids": [id], "content": true})), Action::Begin("Move Anchor".into())]; } // A selected placed graphic's own handles resize it inside its frame (the // frame's anchors, under Direct Selection, come first). @@ -1165,18 +1165,18 @@ impl SelectionTool { // Screen y points down: a positive screen angle is clockwise. vec![Action::Preview("transform.rotate".into(), json!({"angle": -a}))] } - Drag::Anchor { id, si, ai, handle, start, at, others } => { - let mut d = p - start; - if d.hypot() >= cx.tol(3.0) { - self.anchor_release = None; - } else if self.anchor_release.is_some() { - // A Shift-press on a selected point is still a click: nothing moves yet. - return vec![]; - } - // Shift: horizontal, vertical or at 45°. - if ev.mods.shift { - d = constrain_45(d); - } + Drag::Anchor { id, si, ai, handle, start, at, others } => { + let mut d = p - start; + if d.hypot() >= cx.tol(3.0) { + self.anchor_release = None; + } else if self.anchor_release.is_some() { + // A Shift-press on a selected point is still a click: nothing moves yet. + return vec![]; + } + // Shift: horizontal, vertical or at 45°. + if ev.mods.shift { + d = constrain_45(d); + } let mut dx = d.x; let mut dy = d.y; self.guides.clear(); @@ -1204,25 +1204,25 @@ impl SelectionTool { }, ); // dx and dy stay in canvas space. Snap.delta is spread space. - let mut corr = spread_delta_to_canvas(cx.layout.xf(spread), hit.delta); - corr = Vec2::new(if corr.x.is_finite() { corr.x } else { 0.0 }, if corr.y.is_finite() { corr.y } else { 0.0 }); - // Shift: the snap moves the points along the constrained direction only. - if ev.mods.shift && d.hypot() > 1e-9 { - let dir = d / d.hypot(); - corr = dir * corr.dot(dir); + let mut corr = spread_delta_to_canvas(cx.layout.xf(spread), hit.delta); + corr = Vec2::new(if corr.x.is_finite() { corr.x } else { 0.0 }, if corr.y.is_finite() { corr.y } else { 0.0 }); + // Shift: the snap moves the points along the constrained direction only. + if ev.mods.shift && d.hypot() > 1e-9 { + let dir = d / d.hypot(); + corr = dir * corr.dot(dir); } - dx += corr.x; - dy += corr.y; + dx += corr.x; + dy += corr.y; self.guides = hit.guides; } - // The command moves in spread space: each point by the drag in its own spread. - let own = at.map_or(Vec2::new(dx, dy), |(spread, _)| cx.layout.delta_to_spread(spread, Vec2::new(dx, dy))); - let mut params = json!({"id": id, "anchors": [[si, ai]], "dx": own.x, "dy": own.y}); + // The command moves in spread space: each point by the drag in its own spread. + let own = at.map_or(Vec2::new(dx, dy), |(spread, _)| cx.layout.delta_to_spread(spread, Vec2::new(dx, dy))); + let mut params = json!({"id": id, "anchors": [[si, ai]], "dx": own.x, "dy": own.y}); if let Some(h) = handle { params["handle"] = json!(h); - } else if !others.is_empty() { - let points = std::iter::once((id, si, ai)).chain(others).map(|(i, s, a)| (ItemId(i), s, a)); - params = json!({"points": moved_points(cx, points, Vec2::new(dx, dy))}); + } else if !others.is_empty() { + let points = std::iter::once((id, si, ai)).chain(others).map(|(i, s, a)| (ItemId(i), s, a)); + params = json!({"points": moved_points(cx, points, Vec2::new(dx, dy))}); } vec![Action::Preview("path.moveAnchors".into(), params)] } diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 025368deb..ef4519688 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -786,82 +786,82 @@ fn apply_to_text_colours_a_text_frames_text_not_the_frame() { assert_eq!(doc.item(designcraft_doc::ids::ItemId(id)).unwrap().fill.swatch, "[None]", "the frame isn't"); } - /// Points selected with Direct Selection: InDesign's "Anchor Point(s)" panel, with Convert - /// Points and the Delete Point(s) quick action, as in the Zero2Graphic tutorial (1:25). - #[test] - fn selected_points_show_convert_points_and_delete_points() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let a = app.run("frame.create", json!({"rect": [72, 72, 172, 172]})).unwrap()["id"].as_u64().unwrap(); - let b = app.run("frame.create", json!({"rect": [200, 72, 300, 172]})).unwrap()["id"].as_u64().unwrap(); - app.run("selection.set", json!({"anchors": [[a, 0, 0], [b, 0, 0]]})).unwrap(); - assert_eq!(selection_title(&app, &super::super::sel_info(&app)), "Anchor Point(s)"); - let mut h = panel(app); - // The four point types (Appearance has a Corner row too). - for tip in ["Plain", "Corner", "Smooth", "Symmetrical"] { - assert!(h.get_all_by_label(tip).next().is_some(), "{tip}"); - } - assert!(h.query_by_label("Align").is_none(), "only the sections InDesign shows for points"); - h.get_by_label("Delete Point(s)").click(); - h.run_steps(3); - let d = &h.state().session.active().unwrap().doc; - for id in [a, b] { - assert_eq!(d.item(designcraft_doc::ids::ItemId(id)).unwrap().path.subpaths[0].anchors.len(), 3, "one point gone from each"); - } +/// Points selected with Direct Selection: InDesign's "Anchor Point(s)" panel, with Convert +/// Points and the Delete Point(s) quick action, as in the Zero2Graphic tutorial (1:25). +#[test] +fn selected_points_show_convert_points_and_delete_points() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let a = app.run("frame.create", json!({"rect": [72, 72, 172, 172]})).unwrap()["id"].as_u64().unwrap(); + let b = app.run("frame.create", json!({"rect": [200, 72, 300, 172]})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"anchors": [[a, 0, 0], [b, 0, 0]]})).unwrap(); + assert_eq!(selection_title(&app, &super::super::sel_info(&app)), "Anchor Point(s)"); + let mut h = panel(app); + // The four point types (Appearance has a Corner row too). + for tip in ["Plain", "Corner", "Smooth", "Symmetrical"] { + assert!(h.get_all_by_label(tip).next().is_some(), "{tip}"); } + assert!(h.query_by_label("Align").is_none(), "only the sections InDesign shows for points"); + h.get_by_label("Delete Point(s)").click(); + h.run_steps(3); + let d = &h.state().session.active().unwrap().doc; + for id in [a, b] { + assert_eq!(d.item(designcraft_doc::ids::ItemId(id)).unwrap().path.subpaths[0].anchors.len(), 3, "one point gone from each"); + } +} - /// With points selected, Transform measures and moves the points: one corner of a frame shows - /// that corner, not the frame, and X moves the corner alone; W spreads two points apart. - #[test] - fn transform_measures_and_moves_the_selected_points() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let a = app.run("frame.create", json!({"rect": [72, 72, 172, 172]})).unwrap()["id"].as_u64().unwrap(); - let corner = |app: &DesignApp, i: usize| { - let d = &app.session.active().unwrap().doc; - let it = d.item(designcraft_doc::ids::ItemId(a)).unwrap(); - it.xf * it.path.subpaths[0].anchors[i].p - }; - let first = corner(&app, 0); - app.run("selection.set", json!({"anchors": [[a, 0, 0]]})).unwrap(); - let i = super::super::sel_info(&app).unwrap(); - assert_eq!((i.page_rect.width(), i.page_rect.height()), (0.0, 0.0), "a point, not the frame"); - app.ui.ref_point = 0; - transform_anchors(&mut app, &json!({"x": i.page_rect.x0 + 10.0})).unwrap(); - assert_eq!(corner(&app, 0), first + designcraft_geom::Vec2::new(10.0, 0.0), "the corner moved"); - assert_eq!(corner(&app, 2), designcraft_geom::Point::new(172.0, 172.0), "the opposite corner stayed"); - // Two points 100 pt apart, W 200 from the left: the right one goes 100 further. - app.run("selection.set", json!({"anchors": [[a, 0, 2], [a, 0, 3]]})).unwrap(); - let (p2, p3) = (corner(&app, 2), corner(&app, 3)); - let w = super::super::sel_info(&app).unwrap().page_rect.width(); - transform_anchors(&mut app, &json!({"width": w * 2.0})).unwrap(); - let (left, right) = if p2.x < p3.x { (2, 3) } else { (3, 2) }; - let (pl, pr) = if left == 2 { (p2, p3) } else { (p3, p2) }; - assert_eq!(corner(&app, left), pl, "the reference side stays"); - assert!((corner(&app, right).x - (pr.x + w)).abs() < 1e-9, "spread to twice the width"); - } +/// With points selected, Transform measures and moves the points: one corner of a frame shows +/// that corner, not the frame, and X moves the corner alone; W spreads two points apart. +#[test] +fn transform_measures_and_moves_the_selected_points() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let a = app.run("frame.create", json!({"rect": [72, 72, 172, 172]})).unwrap()["id"].as_u64().unwrap(); + let corner = |app: &DesignApp, i: usize| { + let d = &app.session.active().unwrap().doc; + let it = d.item(designcraft_doc::ids::ItemId(a)).unwrap(); + it.xf * it.path.subpaths[0].anchors[i].p + }; + let first = corner(&app, 0); + app.run("selection.set", json!({"anchors": [[a, 0, 0]]})).unwrap(); + let i = super::super::sel_info(&app).unwrap(); + assert_eq!((i.page_rect.width(), i.page_rect.height()), (0.0, 0.0), "a point, not the frame"); + app.ui.ref_point = 0; + transform_anchors(&mut app, &json!({"x": i.page_rect.x0 + 10.0})).unwrap(); + assert_eq!(corner(&app, 0), first + designcraft_geom::Vec2::new(10.0, 0.0), "the corner moved"); + assert_eq!(corner(&app, 2), designcraft_geom::Point::new(172.0, 172.0), "the opposite corner stayed"); + // Two points 100 pt apart, W 200 from the left: the right one goes 100 further. + app.run("selection.set", json!({"anchors": [[a, 0, 2], [a, 0, 3]]})).unwrap(); + let (p2, p3) = (corner(&app, 2), corner(&app, 3)); + let w = super::super::sel_info(&app).unwrap().page_rect.width(); + transform_anchors(&mut app, &json!({"width": w * 2.0})).unwrap(); + let (left, right) = if p2.x < p3.x { (2, 3) } else { (3, 2) }; + let (pl, pr) = if left == 2 { (p2, p3) } else { (p3, p2) }; + assert_eq!(corner(&app, left), pl, "the reference side stays"); + assert!((corner(&app, right).x - (pr.x + w)).abs() < 1e-9, "spread to twice the width"); +} - /// One point has no size: W and H show its frame's, greyed out, as InDesign; two points - /// measure (and set) their own. - #[test] - fn one_selected_point_shows_its_frames_size_greyed() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let a = app.run("frame.create", json!({"rect": [72, 72, 172, 222]})).unwrap()["id"].as_u64().unwrap(); - app.run("selection.set", json!({"anchors": [[a, 0, 0]]})).unwrap(); - assert_eq!(super::super::sel_info(&app).unwrap().object_size, (100.0, 150.0)); - let mut h = panel(app); - h.run_steps(2); - let greyed = |h: &Harness<'_, DesignApp>| { - h.query_all_by(|n| n.role() == egui::accesskit::Role::TextInput && n.is_disabled()).filter_map(|n| n.value()).collect::>() - }; - let u = units(h.state()); - let f = |v| designcraft_geom::units::format_measure(v, u); - assert_eq!(greyed(&h), [f(100.0), f(150.0)], "the frame's W and H, greyed"); - h.state_mut().run("selection.set", json!({"anchors": [[a, 0, 0], [a, 0, 2]]})).unwrap(); - h.run_steps(2); - assert!(greyed(&h).is_empty(), "two points have a size of their own"); - } +/// One point has no size: W and H show its frame's, greyed out, as InDesign; two points +/// measure (and set) their own. +#[test] +fn one_selected_point_shows_its_frames_size_greyed() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let a = app.run("frame.create", json!({"rect": [72, 72, 172, 222]})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"anchors": [[a, 0, 0]]})).unwrap(); + assert_eq!(super::super::sel_info(&app).unwrap().object_size, (100.0, 150.0)); + let mut h = panel(app); + h.run_steps(2); + let greyed = |h: &Harness<'_, DesignApp>| { + h.query_all_by(|n| n.role() == egui::accesskit::Role::TextInput && n.is_disabled()).filter_map(|n| n.value()).collect::>() + }; + let u = units(h.state()); + let f = |v| designcraft_geom::units::format_measure(v, u); + assert_eq!(greyed(&h), [f(100.0), f(150.0)], "the frame's W and H, greyed"); + h.state_mut().run("selection.set", json!({"anchors": [[a, 0, 0], [a, 0, 2]]})).unwrap(); + h.run_steps(2); + assert!(greyed(&h).is_empty(), "two points have a size of their own"); +} #[test] fn transform_flips_and_rotates_from_properties() { From 03d6404dcf11d38f5f032dc8403c6b066a3af3f0 Mon Sep 17 00:00:00 2001 From: Youssef Azriouil Date: Thu, 15 Oct 2026 11:38:44 +0200 Subject: [PATCH 287/628] Contributors: credit Y-az --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 79acd86b0..ec7a75f01 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -71,3 +71,7 @@ real_name = "Paul Gemignani" [people.skylartaylor] display_name = "skylartaylor" + +[people.Y-az] +real_name = "Youssef Azriouil" +display_name = "youssef" From 7a3e69e20a345d957311c9b616b6f90b999795ee Mon Sep 17 00:00:00 2001 From: Youssef Azriouil Date: Wed, 14 Oct 2026 18:35:04 +0200 Subject: [PATCH 288/628] Hyphenation: bundle the Dutch patterns Enable hypher's "dutch" feature: Piet Tutelaers's hyph-nl.tex 1.1 from hyph-utf8, MIT licensed (added to NOTICE). Dutch text, including InDesign's nl_NL_2005, now hyphenates instead of never breaking a word. --- NOTICE | 14 ++++++++++++++ crates/compose/Cargo.toml | 2 +- crates/compose/src/hyphen.rs | 26 ++++++++++++++++++++++---- docs/gaps.md | 2 +- docs/localization-parity.md | 2 +- docs/target-app-parity.md | 4 ++-- docs/typography-parity.md | 2 +- 7 files changed, 42 insertions(+), 10 deletions(-) diff --git a/NOTICE b/NOTICE index fc46bcdf3..6f7cb28eb 100644 --- a/NOTICE +++ b/NOTICE @@ -32,6 +32,20 @@ list (author, source, license) is in ASSETS.md. AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +- Dutch hyphenation patterns, embedded through the hypher crate (feature "dutch"): + hyph-nl.tex 1.1 from hyph-utf8, https://github.com/hyphenation/tex-hyphen. + Copyright (C) 1996 Piet Tutelaers. MIT License: + Permission is hereby granted, free of charge, to any person obtaining a copy of this software + and associated documentation files (the "Software"), to deal in the Software without + restriction, including without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: The above copyright notice + and this permission notice shall be included in all copies or substantial portions of the + Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE + AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - Portuguese hyphenation patterns, embedded through the hypher crate (feature "portuguese"): hyph-pt.tex 1.4 from hyph-utf8, https://github.com/hyphenation/tex-hyphen. Copyright (C) 1987, 1994, 1996, 2015 Pedro J. de Rezende, 1996, 2015 J. Joao Dias Almeida, diff --git a/crates/compose/Cargo.toml b/crates/compose/Cargo.toml index b7017f80f..126416a1d 100644 --- a/crates/compose/Cargo.toml +++ b/crates/compose/Cargo.toml @@ -21,7 +21,7 @@ log = { workspace = true } regex = { workspace = true } fancy-regex = { workspace = true } miniz_oxide = "0.8" -hypher = { version = "0.1.8", default-features = false, features = ["alloc", "greek", "portuguese", "spanish", "norwegian"] } +hypher = { version = "0.1.8", default-features = false, features = ["alloc", "greek", "portuguese", "spanish", "norwegian", "dutch"] } [dev-dependencies] designcraft-fonts = { workspace = true, features = ["testing"] } diff --git a/crates/compose/src/hyphen.rs b/crates/compose/src/hyphen.rs index 3f07f4b40..37f7ae598 100644 --- a/crates/compose/src/hyphen.rs +++ b/crates/compose/src/hyphen.rs @@ -2,10 +2,11 @@ //! Liang patterns compiled into `hypher`, one cargo feature per language: Spanish is Javier Bezos's //! `hyph-es.tex` 5.0 from hyph-utf8, MIT; Portuguese is `hyph-pt.tex` 1.4 from hyph-utf8 (Pedro J. //! de Rezende, J. Joao Dias Almeida, Leonardo Araujo, Aline Benevides), BSD-3-Clause; Norwegian is -//! `hyph-no.tex` from hyph-utf8 (Rune Kleveland, Ole Michael Selberg), permissive (see NOTICE); -//! Greek is `hyph-el-monoton.tex` 5.0 from hyph-utf8 (Dimitrios Filippou), LPPL or MIT, taken -//! under MIT. Those are the **monotonic** patterns, which is what `hypher` maps `el` to, so -//! polytonic text hyphenates by them as well. +//! `hyph-no.tex` from hyph-utf8 (Rune Kleveland, Ole Michael Selberg), permissive; Dutch is Piet +//! Tutelaers's `hyph-nl.tex` 1.1 from hyph-utf8, MIT (see NOTICE); Greek is `hyph-el-monoton.tex` +//! 5.0 from hyph-utf8 (Dimitrios Filippou), LPPL or MIT, taken under MIT. Those are the +//! **monotonic** patterns, which is what `hypher` maps `el` to, so polytonic text hyphenates by +//! them as well. //! //! English lookup order for each word: //! 1. the **dictionary** ([`Dictionary::en_us`]) — the public-domain Moby Hyphenator II word list @@ -435,6 +436,23 @@ mod tests { assert_eq!(h("beveger"), "be-ve-ger"); } + #[test] + fn dutch_uses_the_hyph_nl_patterns() { + let nl = Lang::for_language("Dutch").unwrap(); + // InDesign writes the 2005 spelling as `nl_NL_2005`. + assert_eq!(nl, Lang::for_language("nl_NL_2005").unwrap()); + assert_ne!(nl, Lang::for_language("Spanish").unwrap()); + let h = |w: &str| { + let pts = hyphen_points_in(w, &Limits::default(), nl); + w.chars().enumerate().map(|(i, c)| if pts.contains(&i) { format!("-{c}") } else { c.to_string() }).collect::() + }; + assert_eq!(h("uitspraak"), "uit-spraak"); + assert_eq!(h("kinderen"), "kin-de-ren"); + assert_eq!(h("leesvaardigheid"), "lees-vaar-dig-heid"); + assert_eq!(h("zelfvertrouwen"), "zelf-ver-trou-wen"); + assert_eq!(h("Ontwikkelen"), "Ont-wik-ke-len"); + } + #[test] fn heuristic_fallback() { let c: Vec = "happen".chars().collect(); diff --git a/docs/gaps.md b/docs/gaps.md index ea9cafd40..c0cbfa847 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -156,7 +156,7 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · - **Estimate:** 25–40 h. ### 13. Hyphenation and spelling languages (T, L) -- **Missing:** hyphenation patterns for every language but English, Spanish, Portuguese, Norwegian and Greek; spelling for +- **Missing:** hyphenation patterns for every language but English, Spanish, Portuguese, Norwegian, Greek and Dutch; spelling for every language but English. InDesign ships dictionaries for ~40 languages. - **Estimate:** 30–60 h (permissively licensed patterns exist for most; spelling word lists need licence review). **Doc:** [typography-parity.md](typography-parity.md) diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 404cf13a7..599358c61 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -68,7 +68,7 @@ eight of the twelve (cs, da, el, en_GB, fi, hu, it, nb, nl, pl, ro, ru, sq, sv, ## Document languages InDesign 2026 hyphenates and spell-checks ~40 languages (Hunspell and Duden dictionaries). -DesignCraft hyphenates **English, Spanish, Portuguese and Norwegian** and spell-checks **English** only. Text language +DesignCraft hyphenates **English, Spanish, Portuguese, Norwegian and Dutch** and spell-checks **English** only. Text language can be set per paragraph and character (Properties, Character panel) and drives quotes and locale shaping. See [typography-parity.md](typography-parity.md). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3443ca8e6..2bed89817 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -59,7 +59,7 @@ they receive, setting type exactly, and basic editing that never fails. | Dimension | Weight | Ready | Remaining (Opus 5.5 h) | Evidence | Doc | |---|---|---|---|---|---| | Features (depth of each area) | 25% | 55% | 250–400 | 275 catalogue rows exist; many lack options (e.g. Books is 258 lines of engine code, Liquid Layout 329); see the area table | this file | -| Typography and composition fidelity | 15% | 45% | 120–200 | Knuth–Plass composer, keeps, grids, CJK, Arabic all exist; no comparison with InDesign's line breaks; optical kerning falls back to metrics (#181); overset rule differs (#180); hyphenation patterns for English, Spanish, Portuguese, Norwegian and Greek only | [typography-parity.md](typography-parity.md) | +| Typography and composition fidelity | 15% | 45% | 120–200 | Knuth–Plass composer, keeps, grids, CJK, Arabic all exist; no comparison with InDesign's line breaks; optical kerning falls back to metrics (#181); overset rule differs (#180); hyphenation patterns for English, Spanish, Portuguese, Norwegian, Greek and Dutch only | [typography-parity.md](typography-parity.md) | | File formats | 20% | 40% | 150–250 (+10–30 for an INDD corpus) | INDD and INDT open through indd-utils, measured on synthetic bytes only (#21); IDML both ways and verified in InDesign 2026, but real-file bugs open (#193, #338, #339, #340); PDF export strong; PDF/X-4 only | [file-format-parity.md](file-format-parity.md) | | UI/UX fidelity | 15% | 45% | 120–180 | Measured InDesign 2026 look; but users report object copy/paste (#351, #348, #185, #164), right-click menus (#238, #187, #160), out-port threading (#352, #194, #165) failing | [ui-parity.md](ui-parity.md) | | Stability | 10% | 45% | 60–100 | Never-crash rules and last-resort guards; startup failures on some GPUs and machines (#334, #273, #220, #192, #167, #166); no CI runs the tests on pull requests | [gaps.md](gaps.md) | @@ -362,7 +362,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Linked stories (Edit ▸ Place and Link, `story.placeAndLink/links/updateLink/unlink`): child stories copy a parent, show out of date in the Links panel and update. - Edit ▸ Menus (`window.hideMenuItem`): hide menu items; menus with hidden items end in Show All Menu Items. - View ▸ Separations Preview (`view.separations`, Window ▸ Output ▸ Separations Preview, `ink.separations`): the plates the print PDF puts on film, spot plates included. Each visible spread is exported as Export PDF writes it and separated with craft-prepress, so the Ink Manager's aliases and spots to process, overprint and the flattener show as printed. All inks as printed, one plate as film, or an ink limit in red over gray; the panel lists the plates with each ink's percentage under the pointer. -- Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT), Portuguese with hyph-utf8's `hyph-pt.tex` 1.4 (BSD-3-Clause) and Norwegian with hyph-utf8's `hyph-no.tex` (permissive), all through the `hypher` crate; hyphenation for other languages isn't bundled yet. +- Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT), Portuguese with hyph-utf8's `hyph-pt.tex` 1.4 (BSD-3-Clause), Norwegian with hyph-utf8's `hyph-no.tex` (permissive) and Dutch with Piet Tutelaers's `hyph-nl.tex` 1.1 (MIT), all through the `hypher` crate; hyphenation for other languages isn't bundled yet. - Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, chapter numbers resolved in book order (Automatic, Start Chapter Numbering at, Same as Previous Document; `layout.chapterNumbering`, with a style for Chapter Number variables and cross-references), style sync from a style source, one merged PDF. Section prefixes follow InDesign's rule (up to 8 characters, no + or comma). - Numbering & Section Options window as in InDesign (Start Section, automatic or explicit page numbering up to 99999, prefix, style, marker, Include Prefix; the document's chapter numbering and its book); OK applies both in one undo step (`layout.numberingOptions`). - Book numbering/style updates validate and serialize all targets before changing the first chapter. They write only closed native chapters, checking canonical paths for open aliases; IDML remains a read-only style source and PDF input. Snapshots are staged on disk with at most 4,096 chapter records in memory. Each replacement is atomic; a later write failure explicitly lists completed chapters rather than claiming whole-book rollback. Import/serialization memory limits and advanced book TOC/index work remain separate. diff --git a/docs/typography-parity.md b/docs/typography-parity.md index 8c92e8dfa..e9d5d20f5 100644 --- a/docs/typography-parity.md +++ b/docs/typography-parity.md @@ -36,7 +36,7 @@ Fidelity: **unmeasured** unless stated. | Single-line Composer | greedy | greedy with the same ranges | ✓ | | | World-Ready composers | Paragraph / Single-line World-Ready | RTL, Arabic and bidi in the main composer | ~ | Indic scripts (Devanagari etc.) shape through harfrust but are untested | | Justification | word/letter spacing, glyph scaling min/desired/max, single-word, last line | all of these | ✓ | a justified line with a tab justifies after the tab (#284); set per paragraph in Type › Justification… and the Paragraph panel menu (#531) | -| Hyphenation | per-language dictionaries (~40), zone, limits, capitalized, last word, across columns | Liang patterns: English (trained from the public-domain Moby list), Spanish (Bezos), Portuguese (hyph-pt), Norwegian (hyph-no) and Greek (hyph-el-monoton); zone, limits, exceptions in the user dictionary; Hyphenate Across Column off keeps automatic hyphens off the last line of a column or frame | ~ | other languages don't hyphenate (#87 reported "doesn't work") | +| Hyphenation | per-language dictionaries (~40), zone, limits, capitalized, last word, across columns | Liang patterns: English (trained from the public-domain Moby list), Spanish (Bezos), Portuguese (hyph-pt), Norwegian (hyph-no), Greek (hyph-el-monoton) and Dutch (Tutelaers); zone, limits, exceptions in the user dictionary; Hyphenate Across Column off keeps automatic hyphens off the last line of a column or frame | ~ | other languages don't hyphenate (#87 reported "doesn't work") | | Spelling | per-language dictionaries | English (Moby) + document/user dictionary | ~ | | | Kerning | Metrics, Optical, manual, none | Metrics, Optical (outline-based: pairs pulled towards the font's stem gap, `crates/compose/src/optical.rs`), manual, none | ~ | Optical is an approximation of Adobe's algorithm (#181), not a match: imported display text can still break differently | | Tracking, scale, skew, baseline shift | ✓ | ✓ | ✓ | | From 5fc74966297d2bbd579c845d598c5bef0137ddbb Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 13:43:42 +0100 Subject: [PATCH 289/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/tools/src/select.rs | 134 ++++++++++++++++++------------------- 1 file changed, 67 insertions(+), 67 deletions(-) diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 2cc3f0078..3fdf457d3 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -768,51 +768,51 @@ impl SelectionTool { && let Some((id, si, ai, handle)) = anchor_at(cx, p) { let at = anchor_spread_point(cx, id, si, ai); - let mut out = vec![]; - let mut others = vec![]; - if handle.is_none() { - // InDesign's anchor point selection: a click selects the point, Shift+click - // adds it or takes it away, and dragging a selected point moves all of them. - let me = (ItemId(id), si, ai); - let mut points = cx.selection.anchors.clone(); - let was = points.contains(&me); - if ev.mods.shift && was { - // Kept selected so a Shift-drag can constrain it; a click still takes it - // away, on release, as for objects. - self.anchor_release = Some(me); - } else if ev.mods.shift { - points.push(me); - } else if !was { - points = vec![me]; - } - if points != cx.selection.anchors { - out.push(Action::Exec("selection.set".into(), anchor_selection(cx, &points))); - } - others = points.iter().filter(|a| **a != me).map(|(i, s, a)| (i.0, *s, *a)).collect(); - } - let label = if handle.is_some() { - "Move Direction Handle" - } else if others.is_empty() { - "Move Anchor" - } else { - "Move Anchors" - }; - self.drag = Drag::Anchor { id, si, ai, handle, start: p, at, others }; - out.push(Action::Begin(label.into())); - return out; + let mut out = vec![]; + let mut others = vec![]; + if handle.is_none() { + // InDesign's anchor point selection: a click selects the point, Shift+click + // adds it or takes it away, and dragging a selected point moves all of them. + let me = (ItemId(id), si, ai); + let mut points = cx.selection.anchors.clone(); + let was = points.contains(&me); + if ev.mods.shift && was { + // Kept selected so a Shift-drag can constrain it; a click still takes it + // away, on release, as for objects. + self.anchor_release = Some(me); + } else if ev.mods.shift { + points.push(me); + } else if !was { + points = vec![me]; } - // As in InDesign, the Direct Selection tool pressed on a corner or anchor of an object - // that isn't selected selects it and moves just that point (Chris Nielsen's "The - // InDesign Toolbox", 10:10: click outside, then pull a frame's corner). - if self.direct - && !ev.mods.shift - && let Some((_, hit)) = cx.hit(p) - && !cx.selection.contains(hit) - && let Some((id, si, ai, None)) = anchor_at_in(cx, &[hit], p) - { - let at = anchor_spread_point(cx, id, si, ai); - self.drag = Drag::Anchor { id, si, ai, handle: None, start: p, at, others: vec![] }; - return vec![Action::Exec("selection.set".into(), json!({"ids": [id], "content": true})), Action::Begin("Move Anchor".into())]; + if points != cx.selection.anchors { + out.push(Action::Exec("selection.set".into(), anchor_selection(cx, &points))); + } + others = points.iter().filter(|a| **a != me).map(|(i, s, a)| (i.0, *s, *a)).collect(); + } + let label = if handle.is_some() { + "Move Direction Handle" + } else if others.is_empty() { + "Move Anchor" + } else { + "Move Anchors" + }; + self.drag = Drag::Anchor { id, si, ai, handle, start: p, at, others }; + out.push(Action::Begin(label.into())); + return out; + } + // As in InDesign, the Direct Selection tool pressed on a corner or anchor of an object + // that isn't selected selects it and moves just that point (Chris Nielsen's "The + // InDesign Toolbox", 10:10: click outside, then pull a frame's corner). + if self.direct + && !ev.mods.shift + && let Some((_, hit)) = cx.hit(p) + && !cx.selection.contains(hit) + && let Some((id, si, ai, None)) = anchor_at_in(cx, &[hit], p) + { + let at = anchor_spread_point(cx, id, si, ai); + self.drag = Drag::Anchor { id, si, ai, handle: None, start: p, at, others: vec![] }; + return vec![Action::Exec("selection.set".into(), json!({"ids": [id], "content": true})), Action::Begin("Move Anchor".into())]; } // A selected placed graphic's own handles resize it inside its frame (the // frame's anchors, under Direct Selection, come first). @@ -1151,18 +1151,18 @@ impl SelectionTool { // Screen y points down: a positive screen angle is clockwise. vec![Action::Preview("transform.rotate".into(), json!({"angle": -a}))] } - Drag::Anchor { id, si, ai, handle, start, at, others } => { - let mut d = p - start; - if d.hypot() >= cx.tol(3.0) { - self.anchor_release = None; - } else if self.anchor_release.is_some() { - // A Shift-press on a selected point is still a click: nothing moves yet. - return vec![]; - } - // Shift: horizontal, vertical or at 45°. - if ev.mods.shift { - d = constrain_45(d); - } + Drag::Anchor { id, si, ai, handle, start, at, others } => { + let mut d = p - start; + if d.hypot() >= cx.tol(3.0) { + self.anchor_release = None; + } else if self.anchor_release.is_some() { + // A Shift-press on a selected point is still a click: nothing moves yet. + return vec![]; + } + // Shift: horizontal, vertical or at 45°. + if ev.mods.shift { + d = constrain_45(d); + } let mut dx = d.x; let mut dy = d.y; self.guides.clear(); @@ -1190,23 +1190,23 @@ impl SelectionTool { }, ); // dx and dy stay in canvas space. Snap.delta is spread space. - let mut corr = spread_delta_to_canvas(cx.layout.xf(spread), hit.delta); - corr = Vec2::new(if corr.x.is_finite() { corr.x } else { 0.0 }, if corr.y.is_finite() { corr.y } else { 0.0 }); - // Shift: the snap moves the points along the constrained direction only. - if ev.mods.shift && d.hypot() > 1e-9 { - let dir = d / d.hypot(); - corr = dir * corr.dot(dir); + let mut corr = spread_delta_to_canvas(cx.layout.xf(spread), hit.delta); + corr = Vec2::new(if corr.x.is_finite() { corr.x } else { 0.0 }, if corr.y.is_finite() { corr.y } else { 0.0 }); + // Shift: the snap moves the points along the constrained direction only. + if ev.mods.shift && d.hypot() > 1e-9 { + let dir = d / d.hypot(); + corr = dir * corr.dot(dir); } - dx += corr.x; - dy += corr.y; + dx += corr.x; + dy += corr.y; self.guides = hit.guides; } let mut params = json!({"id": id, "anchors": [[si, ai]], "dx": dx, "dy": dy}); if let Some(h) = handle { params["handle"] = json!(h); - } else if !others.is_empty() { - let points: Vec = std::iter::once((id, si, ai)).chain(others).map(|(i, s, a)| json!([i, s, a])).collect(); - params = json!({"points": points, "dx": dx, "dy": dy}); + } else if !others.is_empty() { + let points: Vec = std::iter::once((id, si, ai)).chain(others).map(|(i, s, a)| json!([i, s, a])).collect(); + params = json!({"points": points, "dx": dx, "dy": dy}); } vec![Action::Preview("path.moveAnchors".into(), params)] } From 35e2f8e66c8dfcee1c45516fc6ea95e2553c3488 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 13:51:04 +0100 Subject: [PATCH 290/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menus/tests.rs | 140 +++++++++++++++--------------- 1 file changed, 70 insertions(+), 70 deletions(-) diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 60455a4fc..5f7f5b93d 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -6,80 +6,80 @@ fn menu_ids(title: &str) -> Vec<&'static str> { items.iter().filter_map(|e| e.split_once(':').map(|(_, rest)| rest.split('|').next().unwrap_or(rest))).collect() } - /// The Object menu's top level in InDesign's order and groups (as InDesign 2023 shows it in - /// Learn Skills Daily's course), with Paths holding Join, Open and Close Path, Convert Shape - /// only shapes, and a Convert Point submenu. - #[test] - fn the_object_menu_is_in_indesigns_order() { - let items = MENUS.iter().find(|(t, _)| *t == "Object").map(|(_, items)| *items).unwrap(); - // The top-level entries: submenus by name, separators as "-", items by command id. - let mut top = Vec::new(); - let mut depth = 0; - for e in items { - match *e { - "<" => depth -= 1, - e if e.starts_with('>') => { - if depth == 0 { - top.push(e.trim_start_matches('>')); - } - depth += 1; +/// The Object menu's top level in InDesign's order and groups (as InDesign 2023 shows it in +/// Learn Skills Daily's course), with Paths holding Join, Open and Close Path, Convert Shape +/// only shapes, and a Convert Point submenu. +#[test] +fn the_object_menu_is_in_indesigns_order() { + let items = MENUS.iter().find(|(t, _)| *t == "Object").map(|(_, items)| *items).unwrap(); + // The top-level entries: submenus by name, separators as "-", items by command id. + let mut top = Vec::new(); + let mut depth = 0; + for e in items { + match *e { + "<" => depth -= 1, + e if e.starts_with('>') => { + if depth == 0 { + top.push(e.trim_start_matches('>')); } - e if depth == 0 => top.push(e.split_once(':').map_or(e, |(_, r)| r.split('|').next().unwrap_or(r))), - _ => {} + depth += 1; } + e if depth == 0 => top.push(e.split_once(':').map_or(e, |(_, r)| r.split('|').next().unwrap_or(r))), + _ => {} } - assert_eq!( - top, - [ - "Transform", - "Transform Again", - "Arrange", - "Select", - "-", - "object.group", - "object.ungroup", - "object.lock", - "object.unlockAll", - "object.hide", - "object.showAll", - "-", - "app.qrCode", - "-", - "object.textFrameOptions", - "object.primaryTextFrame", - "Anchored Object", - "-", - "Fitting", - "Content", - "-", - "Effects", - "object.cornerOptions", - "-", - "app.layerOptions", - "object.exportOptions", - "Captions", - "Clipping Path", - "-", - "Flex Layout", - "-", - "Paths", - "Pathfinder", - "Convert Shape", - "Convert Point", - ] - ); - let ids = menu_ids("Object"); - for id in ["path.join", "path.reverse", "path.convertPoints"] { - assert!(ids.contains(&id), "{id}"); - } - let sub = |name: &str| { - let i = items.iter().position(|e| *e == format!(">{name}")).unwrap(); - items[i + 1..].iter().take_while(|e| **e != "<").copied().collect::>() - }; - assert!(sub("Paths").iter().any(|e| e.contains("|Open Path|")) && sub("Paths").iter().any(|e| e.contains("|Close Path|"))); - assert!(!sub("Convert Shape").iter().any(|e| e.contains("openPath") || e.contains("closedPath")), "shapes only"); - assert_eq!(sub("Convert Point").len(), 4); } + assert_eq!( + top, + [ + "Transform", + "Transform Again", + "Arrange", + "Select", + "-", + "object.group", + "object.ungroup", + "object.lock", + "object.unlockAll", + "object.hide", + "object.showAll", + "-", + "app.qrCode", + "-", + "object.textFrameOptions", + "object.primaryTextFrame", + "Anchored Object", + "-", + "Fitting", + "Content", + "-", + "Effects", + "object.cornerOptions", + "-", + "app.layerOptions", + "object.exportOptions", + "Captions", + "Clipping Path", + "-", + "Flex Layout", + "-", + "Paths", + "Pathfinder", + "Convert Shape", + "Convert Point", + ] + ); + let ids = menu_ids("Object"); + for id in ["path.join", "path.reverse", "path.convertPoints"] { + assert!(ids.contains(&id), "{id}"); + } + let sub = |name: &str| { + let i = items.iter().position(|e| *e == format!(">{name}")).unwrap(); + items[i + 1..].iter().take_while(|e| **e != "<").copied().collect::>() + }; + assert!(sub("Paths").iter().any(|e| e.contains("|Open Path|")) && sub("Paths").iter().any(|e| e.contains("|Close Path|"))); + assert!(!sub("Convert Shape").iter().any(|e| e.contains("openPath") || e.contains("closedPath")), "shapes only"); + assert_eq!(sub("Convert Point").len(), 4); +} #[test] fn view_items_are_in_the_view_menu_as_in_indesign() { From e7a29a8ad5968f873b5bbbdb9fa5b26cf0413b6a Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 13:55:33 +0100 Subject: [PATCH 291/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/tests.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 900407f5e..7d1f217b7 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3469,20 +3469,20 @@ fn guide_counts(app: &DesignApp) -> Vec { } #[test] - fn create_guides_dialog_covers_the_spread_in_view() { +fn create_guides_dialog_covers_the_spread_in_view() { let mut app = facing_app(6); - // As in InDesign, both pages of the facing spread in view get the guides. + // As in InDesign, both pages of the facing spread in view get the guides. crate::canvas::go_to_page(&mut app, 1); crate::menus::activate(&mut app, "layout.createGuides", &Value::Null); - assert_eq!(app.ui.dialog.as_ref().unwrap().s("target"), "spread"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("target"), "spread"); ctl(&mut app, "ui.dialog.set", json!({"field": "columns", "value": "2"})); - assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); - assert_eq!(guide_counts(&app), vec![0, 2, 2, 0, 0, 0]); - // "This page" narrows them to the page in view. - crate::canvas::go_to_page(&mut app, 4); - crate::menus::activate(&mut app, "layout.createGuides", &Value::Null); - ctl(&mut app, "ui.dialog.set", json!({"field": "columns", "value": "3"})); - ctl(&mut app, "ui.dialog.set", json!({"field": "target", "value": "page"})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + assert_eq!(guide_counts(&app), vec![0, 2, 2, 0, 0, 0]); + // "This page" narrows them to the page in view. + crate::canvas::go_to_page(&mut app, 4); + crate::menus::activate(&mut app, "layout.createGuides", &Value::Null); + ctl(&mut app, "ui.dialog.set", json!({"field": "columns", "value": "3"})); + ctl(&mut app, "ui.dialog.set", json!({"field": "target", "value": "page"})); assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); assert_eq!(guide_counts(&app), vec![0, 2, 2, 0, 4, 0]); } From 142498f2ca5ed9d1005657d61c0db299c26945a6 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 14:00:45 +0100 Subject: [PATCH 292/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties/tests.rs | 58 +++++++++---------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 2ae3bd3ac..e87d359e0 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -710,35 +710,35 @@ fn several_shapes_show_pathfinder_and_intersect_combines_them() { assert!((r.x0 - 200.0).abs() < 0.5 && (r.y1 - 200.0).abs() < 0.5 && (r.x1 - 300.0).abs() < 0.5, "{r:?}"); } - /// Properties › Frame Fitting has Auto-Fit and an Options button, as InDesign's: ticking - /// Auto-Fit makes the picture refit when its frame is resized. - #[test] - fn properties_frame_fitting_sets_auto_fit_and_opens_its_options() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let id = app.run("frame.create", json!({"rect": [72, 72, 272, 172]})).unwrap()["id"].as_u64().unwrap(); - let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png(); - app.run("file.place", json!({"base64": designcraft_engine::cmd::base64_encode(&png), "name": "p.png", "frame": id})).unwrap(); - app.run("selection.set", json!({"ids": [id]})).unwrap(); - let graphic = |app: &DesignApp| app.session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().graphic().unwrap().clone(); - assert_eq!(graphic(&app).auto_fit, designcraft_doc::Fitting::None); - let mut h = panel(app); - // A checkbox its section disables says so: Invert, with no text wrap. - use egui_kittest::kittest::NodeT; - assert!(h.get_by_label("Invert").accesskit_node().is_disabled()); - assert!(!h.get_by_label("Auto-Fit").accesskit_node().is_disabled()); - h.get_by_label("Auto-Fit").click(); - h.run_steps(3); - assert_ne!(graphic(h.state()).auto_fit, designcraft_doc::Fitting::None, "Auto-Fit is on"); - // The frame resized: the picture follows it. - h.state_mut().run("transform.resize", json!({"from": [72, 72, 272, 172], "to": [72, 72, 372, 272]})).unwrap(); - let g = graphic(h.state()); - let r = g.xf.transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1)); - assert!(r.width() > 250.0, "refitted to the larger frame: {r:?}"); - h.get_by_label("Options").click(); - h.run_steps(3); - assert!(h.state().ui.dialog.is_some(), "Frame Fitting Options opens"); - } +/// Properties › Frame Fitting has Auto-Fit and an Options button, as InDesign's: ticking +/// Auto-Fit makes the picture refit when its frame is resized. +#[test] +fn properties_frame_fitting_sets_auto_fit_and_opens_its_options() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 272, 172]})).unwrap()["id"].as_u64().unwrap(); + let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png(); + app.run("file.place", json!({"base64": designcraft_engine::cmd::base64_encode(&png), "name": "p.png", "frame": id})).unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let graphic = |app: &DesignApp| app.session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().graphic().unwrap().clone(); + assert_eq!(graphic(&app).auto_fit, designcraft_doc::Fitting::None); + let mut h = panel(app); + // A checkbox its section disables says so: Invert, with no text wrap. + use egui_kittest::kittest::NodeT; + assert!(h.get_by_label("Invert").accesskit_node().is_disabled()); + assert!(!h.get_by_label("Auto-Fit").accesskit_node().is_disabled()); + h.get_by_label("Auto-Fit").click(); + h.run_steps(3); + assert_ne!(graphic(h.state()).auto_fit, designcraft_doc::Fitting::None, "Auto-Fit is on"); + // The frame resized: the picture follows it. + h.state_mut().run("transform.resize", json!({"from": [72, 72, 272, 172], "to": [72, 72, 372, 272]})).unwrap(); + let g = graphic(h.state()); + let r = g.xf.transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1)); + assert!(r.width() > 250.0, "refitted to the larger frame: {r:?}"); + h.get_by_label("Options").click(); + h.run_steps(3); + assert!(h.state().ui.dialog.is_some(), "Frame Fitting Options opens"); +} const CYAN: &str = "C=100 M=0 Y=0 K=0"; From 3b86c0ca06b347659f8415255864cb988c51d7da Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 14:06:07 +0100 Subject: [PATCH 293/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/tools/src/snap/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tools/src/snap/tests.rs b/crates/tools/src/snap/tests.rs index 300d5ad05..f97c637c1 100644 --- a/crates/tools/src/snap/tests.rs +++ b/crates/tools/src/snap/tests.rs @@ -64,7 +64,7 @@ fn ctx_on<'a>(doc: &'a Document, sel: &'a Selection, cache: &'a Cache, layout: & snap: SnapView::FACTORY, unit: Unit::Points, stroke_in_front: false, - loaded: None, + loaded: None, } } From 5e2af169ef84d40dd60854c9ab0a8c191cef5147 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 14:08:04 +0100 Subject: [PATCH 294/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties/tests.rs | 130 ++++++++---------- 1 file changed, 61 insertions(+), 69 deletions(-) diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 40226fa84..1ac82ab9f 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -710,78 +710,70 @@ fn several_shapes_show_pathfinder_and_intersect_combines_them() { assert!((r.x0 - 200.0).abs() < 0.5 && (r.y1 - 200.0).abs() < 0.5 && (r.x1 - 300.0).abs() < 0.5, "{r:?}"); } - /// Properties › Text Wrap shows the four offsets under the wrap buttons, as InDesign's - /// Properties panel does: linked at first, so one value sets all four; unlinked, one side. - #[test] - fn properties_text_wrap_shows_and_sets_the_offsets() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); - app.run("selection.set", json!({"ids": [id]})).unwrap(); - app.run("object.textWrap", json!({"mode": "boundingBox", "offset": 0})).unwrap(); - let offsets = |app: &DesignApp| app.session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().wrap.offsets; - let mut h = panel(app); - // The fields between the Text Wrap heading and Quick Actions: Top, Left, Bottom, Right. - let fields = |h: &Harness<'_, DesignApp>| { - let (top, bottom) = (h.get_by_label("Text Wrap").rect().bottom(), h.get_by_label("Quick Actions").rect().top()); - let mut v: Vec = h - .get_all_by_role(egui::accesskit::Role::TextInput) - .map(|n| n.rect()) - .filter(|r| r.center().y > top && r.center().y < bottom) - .collect(); - v.sort_by(|a, b| (a.top(), a.left()).partial_cmp(&(b.top(), b.left())).unwrap()); - v - }; - let f = fields(&h); - assert_eq!(f.len(), 4, "four offsets: {f:?}"); - let type_in = |h: &mut Harness<'_, DesignApp>, at: egui::Rect, text: &str| { - h.hover_at(at.center()); - h.drag_at(at.center()); - h.drop_at(at.center()); - h.run_steps(2); - h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); - h.event(egui::Event::Text(text.into())); - h.key_press(egui::Key::Enter); - h.run_steps(3); - }; - // Linked: the top offset sets all four. - type_in(&mut h, f[0], "12pt"); - assert_eq!(offsets(h.state()), [12.0; 4]); - // Unlinked: the bottom one alone. +/// Properties › Text Wrap shows the four offsets under the wrap buttons, as InDesign's +/// Properties panel does: linked at first, so one value sets all four; unlinked, one side. +#[test] +fn properties_text_wrap_shows_and_sets_the_offsets() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + app.run("object.textWrap", json!({"mode": "boundingBox", "offset": 0})).unwrap(); + let offsets = |app: &DesignApp| app.session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().wrap.offsets; + let mut h = panel(app); + // The fields between the Text Wrap heading and Quick Actions: Top, Left, Bottom, Right. + let fields = |h: &Harness<'_, DesignApp>| { let (top, bottom) = (h.get_by_label("Text Wrap").rect().bottom(), h.get_by_label("Quick Actions").rect().top()); - h.get_all_by_label("Make all settings the same").find(|n| n.rect().center().y > top && n.rect().center().y < bottom).unwrap().click(); + let mut v: Vec = + h.get_all_by_role(egui::accesskit::Role::TextInput).map(|n| n.rect()).filter(|r| r.center().y > top && r.center().y < bottom).collect(); + v.sort_by(|a, b| (a.top(), a.left()).partial_cmp(&(b.top(), b.left())).unwrap()); + v + }; + let f = fields(&h); + assert_eq!(f.len(), 4, "four offsets: {f:?}"); + let type_in = |h: &mut Harness<'_, DesignApp>, at: egui::Rect, text: &str| { + h.hover_at(at.center()); + h.drag_at(at.center()); + h.drop_at(at.center()); h.run_steps(2); - let f = fields(&h); - type_in(&mut h, f[2], "6pt"); - assert_eq!(offsets(h.state()), [12.0, 12.0, 6.0, 12.0], "[top, left, bottom, right]"); - // Two objects with different offsets: a side typed in changes that side on each, and - // each keeps its other three. - let other = h.state_mut().run("frame.create", json!({"rect": [72, 300, 300, 400], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); - h.state_mut().run("object.textWrap", json!({"mode": "contour", "offset": [3, 4, 5, 6], "ids": [other]})).unwrap(); - let of = |h: &Harness<'_, DesignApp>, id: u64| { - h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().wrap.offsets - }; - h.state_mut().run("selection.set", json!({"ids": [id, other]})).unwrap(); + h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); + h.event(egui::Event::Text(text.into())); + h.key_press(egui::Key::Enter); h.run_steps(3); - let f = fields(&h); - type_in(&mut h, f[0], "9pt"); - assert_eq!((of(&h, id), of(&h, other)), ([9.0, 12.0, 6.0, 12.0], [9.0, 4.0, 5.0, 6.0]), "the top alone, on each"); - let mode = - |h: &Harness<'_, DesignApp>, id: u64| h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().wrap.mode; - assert_eq!( - (mode(&h, id), mode(&h, other)), - (designcraft_doc::WrapMode::BoundingBox, designcraft_doc::WrapMode::Contour), - "each keeps its mode" - ); - // An object whose offsets are the same starts linked, though the link was off before. - let third = h.state_mut().run("frame.create", json!({"rect": [320, 72, 500, 200], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); - h.state_mut().run("object.textWrap", json!({"mode": "boundingBox", "offset": 4, "ids": [third]})).unwrap(); - h.state_mut().run("selection.set", json!({"ids": [third]})).unwrap(); - h.run_steps(3); - let f = fields(&h); - type_in(&mut h, f[2], "8pt"); - assert_eq!(of(&h, third), [8.0; 4], "linked again for this selection"); - } + }; + // Linked: the top offset sets all four. + type_in(&mut h, f[0], "12pt"); + assert_eq!(offsets(h.state()), [12.0; 4]); + // Unlinked: the bottom one alone. + let (top, bottom) = (h.get_by_label("Text Wrap").rect().bottom(), h.get_by_label("Quick Actions").rect().top()); + h.get_all_by_label("Make all settings the same").find(|n| n.rect().center().y > top && n.rect().center().y < bottom).unwrap().click(); + h.run_steps(2); + let f = fields(&h); + type_in(&mut h, f[2], "6pt"); + assert_eq!(offsets(h.state()), [12.0, 12.0, 6.0, 12.0], "[top, left, bottom, right]"); + // Two objects with different offsets: a side typed in changes that side on each, and + // each keeps its other three. + let other = h.state_mut().run("frame.create", json!({"rect": [72, 300, 300, 400], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); + h.state_mut().run("object.textWrap", json!({"mode": "contour", "offset": [3, 4, 5, 6], "ids": [other]})).unwrap(); + let of = + |h: &Harness<'_, DesignApp>, id: u64| h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().wrap.offsets; + h.state_mut().run("selection.set", json!({"ids": [id, other]})).unwrap(); + h.run_steps(3); + let f = fields(&h); + type_in(&mut h, f[0], "9pt"); + assert_eq!((of(&h, id), of(&h, other)), ([9.0, 12.0, 6.0, 12.0], [9.0, 4.0, 5.0, 6.0]), "the top alone, on each"); + let mode = + |h: &Harness<'_, DesignApp>, id: u64| h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().wrap.mode; + assert_eq!((mode(&h, id), mode(&h, other)), (designcraft_doc::WrapMode::BoundingBox, designcraft_doc::WrapMode::Contour), "each keeps its mode"); + // An object whose offsets are the same starts linked, though the link was off before. + let third = h.state_mut().run("frame.create", json!({"rect": [320, 72, 500, 200], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); + h.state_mut().run("object.textWrap", json!({"mode": "boundingBox", "offset": 4, "ids": [third]})).unwrap(); + h.state_mut().run("selection.set", json!({"ids": [third]})).unwrap(); + h.run_steps(3); + let f = fields(&h); + type_in(&mut h, f[2], "8pt"); + assert_eq!(of(&h, third), [8.0; 4], "linked again for this selection"); +} const CYAN: &str = "C=100 M=0 Y=0 K=0"; From 8cb63ce5f57d2a36ba2dd8dd290387b3e7dba95d Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 14:12:43 +0100 Subject: [PATCH 295/628] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties/object.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/properties/object.rs b/crates/ui-egui/src/panels/properties/object.rs index ea0fff6aa..3795bcd8a 100644 --- a/crates/ui-egui/src/panels/properties/object.rs +++ b/crates/ui-egui/src/panels/properties/object.rs @@ -555,7 +555,7 @@ fn wrap_offsets(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { ] { let ir = sub(b, x, y + 2.0, 17.0, 17.0); icons::paint(ui.painter(), ir, icon, if enabled { t.icon } else { t.text_dim }); - ui.interact(ir, ui.id().with(("prop_wrap_off", k)), Sense::hover()).on_hover_ui(|ui| { + ui.interact(ir, ui.scope_id().with(("prop_wrap_off", k)), Sense::hover()).on_hover_ui(|ui| { crate::rtl::label(ui, tr(tip)); }); let nv = place(ui, sub(b, x + 20.0, y, 76.0, FIELD_H), |ui| { From f1f7ed1fab6985ada5877f74f69d41cc90f06543 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 14:15:30 +0100 Subject: [PATCH 296/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties/tests.rs | 102 +++++++++--------- 1 file changed, 51 insertions(+), 51 deletions(-) diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 2af713820..2858392f0 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -710,59 +710,59 @@ fn several_shapes_show_pathfinder_and_intersect_combines_them() { assert!((r.x0 - 200.0).abs() < 0.5 && (r.y1 - 200.0).abs() < 0.5 && (r.x1 - 300.0).abs() < 0.5, "{r:?}"); } - /// The arrow beside Opacity opens a slider, as in InDesign: a drag shows the opacity live and - /// is one undo step; the Opacity link still opens the Effects panel. - #[test] - fn the_opacity_arrow_opens_a_slider_and_a_drag_is_one_undo_step() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); - app.run("selection.set", json!({"ids": [id]})).unwrap(); - let opacity = |app: &DesignApp| app.session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().opacity; - let steps = undo_steps(&app); - let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( - |ui, app: &mut DesignApp| { - let id = egui::Id::new("test_fonts"); - if ui.data(|d| d.get_temp::(id)).is_none() { - crate::theme::install_fonts(ui.ctx(), ""); - ui.data_mut(|d| d.insert_temp(id, true)); - return; - } - app.settle_live_preview(); - show(app, ui); - }, - app, - ); - h.run_steps(3); - assert!(h.query_by_role(egui::accesskit::Role::Slider).is_none()); - h.get_by(|n| n.role() == egui::accesskit::Role::Button && n.label().as_deref() == Some("Opacity")).click(); - h.run_steps(3); - assert!( - h.query_by(|n| n.role() == egui::accesskit::Role::Slider && n.label().as_deref() == Some("Opacity")).is_some(), - "assistive technology names the slider" - ); - let track = h.get_by_role(egui::accesskit::Role::Slider).rect(); - let y = track.center().y; - h.hover_at(egui::pos2(track.right() - 1.0, y)); +/// The arrow beside Opacity opens a slider, as in InDesign: a drag shows the opacity live and +/// is one undo step; the Opacity link still opens the Effects panel. +#[test] +fn the_opacity_arrow_opens_a_slider_and_a_drag_is_one_undo_step() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let opacity = |app: &DesignApp| app.session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().opacity; + let steps = undo_steps(&app); + let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + app.settle_live_preview(); + show(app, ui); + }, + app, + ); + h.run_steps(3); + assert!(h.query_by_role(egui::accesskit::Role::Slider).is_none()); + h.get_by(|n| n.role() == egui::accesskit::Role::Button && n.label().as_deref() == Some("Opacity")).click(); + h.run_steps(3); + assert!( + h.query_by(|n| n.role() == egui::accesskit::Role::Slider && n.label().as_deref() == Some("Opacity")).is_some(), + "assistive technology names the slider" + ); + let track = h.get_by_role(egui::accesskit::Role::Slider).rect(); + let y = track.center().y; + h.hover_at(egui::pos2(track.right() - 1.0, y)); + h.run_steps(1); + h.drag_at(egui::pos2(track.right() - 1.0, y)); + h.run_steps(1); + for k in 1..=5 { + h.hover_at(egui::pos2(track.right() - track.width() * 0.05 * k as f32, y)); h.run_steps(1); - h.drag_at(egui::pos2(track.right() - 1.0, y)); - h.run_steps(1); - for k in 1..=5 { - h.hover_at(egui::pos2(track.right() - track.width() * 0.05 * k as f32, y)); - h.run_steps(1); - assert_eq!(undo_steps(h.state()), steps, "no undo step while dragging"); - } - let previewed = opacity(h.state()); - assert!(previewed < 0.9 && previewed > 0.5, "the drag shows the opacity live: {previewed}"); - // Let go further along than the last move: the release frame changes the value too. - h.drop_at(egui::pos2(track.right() - track.width() * 0.3, y)); - h.run_steps(3); - assert_eq!(undo_steps(h.state()), steps + 1, "the whole drag is one undo step"); - assert!(opacity(h.state()) < previewed, "the release position is applied: {previewed} → {}", opacity(h.state())); - assert_ne!(h.state().ui.open_panel.as_deref(), Some("effects"), "the arrow doesn't open Effects"); - h.state_mut().run("edit.undo", json!({})).unwrap(); - assert_eq!(opacity(h.state()), 1.0); + assert_eq!(undo_steps(h.state()), steps, "no undo step while dragging"); } + let previewed = opacity(h.state()); + assert!(previewed < 0.9 && previewed > 0.5, "the drag shows the opacity live: {previewed}"); + // Let go further along than the last move: the release frame changes the value too. + h.drop_at(egui::pos2(track.right() - track.width() * 0.3, y)); + h.run_steps(3); + assert_eq!(undo_steps(h.state()), steps + 1, "the whole drag is one undo step"); + assert!(opacity(h.state()) < previewed, "the release position is applied: {previewed} → {}", opacity(h.state())); + assert_ne!(h.state().ui.open_panel.as_deref(), Some("effects"), "the arrow doesn't open Effects"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(opacity(h.state()), 1.0); +} const CYAN: &str = "C=100 M=0 Y=0 K=0"; From 888b8a2d901ebaab75d5218828918aca77b20f02 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 14:32:08 +0100 Subject: [PATCH 297/628] 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 90185deb8287604d7b87b801dade64b22ab0b793 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 14:35:23 +0100 Subject: [PATCH 298/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/tests.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 408d56ff2..3d1ef0acc 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -2401,7 +2401,7 @@ fn find_scope_dialog_domain_and_object_changes_release_original_range() { #[test] fn dialog_title_and_actions_fit_scaled_viewports() { for language in ["en", "ar"] { - for size in [egui::vec2(1178.0, 814.0), egui::vec2(589.0, 407.0), egui::vec2(392.0, 271.0), egui::vec2(360.0, 280.0)] { + for size in [egui::vec2(1178.0, 814.0), egui::vec2(589.0, 407.0), egui::vec2(392.0, 271.0), egui::vec2(360.0, 280.0)] { for (command, title, ok, cancel) in [("app.preferences", "Preferences", "OK", "Cancel"), ("app.newDocumentDialog", "New Document", "Create", "Close")] { @@ -2528,7 +2528,7 @@ fn preferences_horizontal_scroll_reaches_clipped_options() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.run("app.preferences", json!({})).unwrap(); let ctx = dialog_context("en"); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(360.0, 280.0)); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(360.0, 280.0)); for _ in 0..3 { dialog_frame(&mut app, &ctx, screen, vec![]); } @@ -2587,7 +2587,7 @@ fn new_document_vertical_scroll_reaches_every_field_and_preset() { #[test] fn arabic_new_document_fields_remain_reachable_and_editable() { - for size in [egui::vec2(392.0, 271.0), egui::vec2(360.0, 280.0), egui::vec2(1178.0, 814.0)] { + for size in [egui::vec2(392.0, 271.0), egui::vec2(360.0, 280.0), egui::vec2(1178.0, 814.0)] { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.ui.language = "ar".into(); app.run("app.newDocumentDialog", json!({})).unwrap(); From 68d96f3ec97740be9bee3af96f8fdcb28a4806b6 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 14:35:28 +0100 Subject: [PATCH 299/628] egui 0.37: the renamed APIs in this branch's code 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 32cd4f690..ef4814347 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -6930,7 +6930,7 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { // 200 pt, reserved: a desired width alone is squeezed by the grid to a few characters. // Narrower when the dialog is, so the style selector beside it stays in view. let w = 200.0_f32.min(title_room.max(80.0)); - if ui.add_sized([w, ui.spacing().interact_size.y], egui::TextEdit::singleline(&mut title).horizontal_align(egui::Align::Min)).changed() { + if ui.add_sized([w, ui.spacing().interact_size.y], egui::TextEdit::singleline(&mut title).align(egui::Align2::LEFT_TOP)).changed() { d.fields.insert("title".into(), json!(title)); } crate::rtl::label(ui, tr("Style:")); From 93bbbad231ea12ad39902037a3fed71f67fbe964 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 14:47:56 +0100 Subject: [PATCH 300/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/new_document/tests.rs | 72 ++++++++++++------------ 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/crates/ui-egui/src/new_document/tests.rs b/crates/ui-egui/src/new_document/tests.rs index 0a364c277..bf63b9c87 100644 --- a/crates/ui-egui/src/new_document/tests.rs +++ b/crates/ui-egui/src/new_document/tests.rs @@ -216,42 +216,42 @@ fn enter_in_the_preset_popup_saves_the_preset_and_keeps_the_dialog() { assert!(!egui::Popup::is_any_open(&h.ctx), "the popup closed"); } - #[test] - fn saved_preset_delete_buttons_keep_the_preview_and_never_create_a_document() { - use egui_kittest::kittest::Queryable as _; - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.savePreset", json!({"name":"Portrait draft","pages":3,"facingPages":false})).unwrap(); - app.run("file.savePreset", json!({"name":"Landscape draft","width":792,"height":612,"pages":2})).unwrap(); - app.run("app.newDocumentDialog", json!({})).unwrap(); - let mut h = crate::test_window::open(app, vec2(1440.0, 900.0)); - h.run_steps(4); - let saved = h.get_by_label("Saved").rect().center(); - crate::test_window::click_at(&mut h, saved); - let portrait = h.get_by_label("Portrait draft").rect().center(); - crate::test_window::click_at(&mut h, portrait); - h.get_by_label("Preview").click(); - h.run_steps(20); - assert!(h.query_by_label("Delete Preset: Portrait draft").is_some(), "the selected preset has its action"); - assert!(h.query_by_label("Delete Preset: [Default]").is_none(), "the factory card cannot be deleted"); - let before = h.state().app.session.active().unwrap().doc.clone(); - let preview = h.state().app.session.new_document_preview(); - let count = h.state().app.session.documents().len(); - let fields = params(h.state().app.ui.dialog.as_ref().unwrap()); - let landscape = h.get_by_label("Landscape draft").rect().center(); - h.event(egui::Event::PointerMoved(landscape)); - h.run_steps(3); - let remove = h.get_by_label("Delete Preset: Landscape draft").rect().center(); - crate::test_window::click_at(&mut h, remove); - assert_eq!(h.state().app.session.prefs.document_presets.iter().map(|p| p.title.as_str()).collect::>(), ["Portrait draft"]); - let remove = h.get_by_label("Delete Preset: Portrait draft").rect().center(); - crate::test_window::click_at(&mut h, remove); - let app = &h.state().app; - assert!(app.session.prefs.document_presets.is_empty()); - assert_eq!(app.session.new_document_preview(), preview); - assert_eq!(app.session.documents().len(), count); - assert_eq!(*app.session.active().unwrap().doc, *before); - assert_eq!(params(app.ui.dialog.as_ref().unwrap()), fields, "deletion does not select another preset or create the preview"); - } +#[test] +fn saved_preset_delete_buttons_keep_the_preview_and_never_create_a_document() { + use egui_kittest::kittest::Queryable as _; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.savePreset", json!({"name":"Portrait draft","pages":3,"facingPages":false})).unwrap(); + app.run("file.savePreset", json!({"name":"Landscape draft","width":792,"height":612,"pages":2})).unwrap(); + app.run("app.newDocumentDialog", json!({})).unwrap(); + let mut h = crate::test_window::open(app, vec2(1440.0, 900.0)); + h.run_steps(4); + let saved = h.get_by_label("Saved").rect().center(); + crate::test_window::click_at(&mut h, saved); + let portrait = h.get_by_label("Portrait draft").rect().center(); + crate::test_window::click_at(&mut h, portrait); + h.get_by_label("Preview").click(); + h.run_steps(20); + assert!(h.query_by_label("Delete Preset: Portrait draft").is_some(), "the selected preset has its action"); + assert!(h.query_by_label("Delete Preset: [Default]").is_none(), "the factory card cannot be deleted"); + let before = h.state().app.session.active().unwrap().doc.clone(); + let preview = h.state().app.session.new_document_preview(); + let count = h.state().app.session.documents().len(); + let fields = params(h.state().app.ui.dialog.as_ref().unwrap()); + let landscape = h.get_by_label("Landscape draft").rect().center(); + h.event(egui::Event::PointerMoved(landscape)); + h.run_steps(3); + let remove = h.get_by_label("Delete Preset: Landscape draft").rect().center(); + crate::test_window::click_at(&mut h, remove); + assert_eq!(h.state().app.session.prefs.document_presets.iter().map(|p| p.title.as_str()).collect::>(), ["Portrait draft"]); + let remove = h.get_by_label("Delete Preset: Portrait draft").rect().center(); + crate::test_window::click_at(&mut h, remove); + let app = &h.state().app; + assert!(app.session.prefs.document_presets.is_empty()); + assert_eq!(app.session.new_document_preview(), preview); + assert_eq!(app.session.documents().len(), count); + assert_eq!(*app.session.active().unwrap().doc, *before); + assert_eq!(params(app.ui.dialog.as_ref().unwrap()), fields, "deletion does not select another preset or create the preview"); +} #[test] fn escape_in_the_preset_popup_closes_only_the_popup() { From b92070c4e8746934ed718af66199e1eb541c0181 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 14:50:52 +0100 Subject: [PATCH 301/628] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/new_document.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/new_document.rs b/crates/ui-egui/src/new_document.rs index 1fb2b3807..54fa76a33 100644 --- a/crates/ui-egui/src/new_document.rs +++ b/crates/ui-egui/src/new_document.rs @@ -508,7 +508,7 @@ fn presets(app: &mut DesignApp, ui: &mut Ui, d: &mut Dialog) -> bool { if current == c.key || ui.rect_contains_pointer(resp.rect) { let x = if rtl { resp.rect.left() + 4.0 } else { resp.rect.right() - 26.0 }; let rect = Rect::from_min_size(pos2(x, resp.rect.top() + 4.0), vec2(22.0, 22.0)); - let button = ui.interact(rect, ui.id().with(("delete-preset", &c.key)), Sense::click()); + let button = ui.interact(rect, ui.scope_id().with(("delete-preset", &c.key)), Sense::click()); let label = format!("{}: {}", crate::i18n::tr(&lang, "Delete Preset"), c.label); button.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label.as_str())); if button.hovered() { From 4ebc675164538a7a814553272107a26c24df074b Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 14:53:46 +0100 Subject: [PATCH 302/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/format/src/tests.rs | 56 +++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/crates/format/src/tests.rs b/crates/format/src/tests.rs index 170e8be9a..0e7ba0d3b 100644 --- a/crates/format/src/tests.rs +++ b/crates/format/src/tests.rs @@ -555,14 +555,14 @@ fn r7a_nullable_document(local_clear: bool, style_clear: bool) -> Document { underline_offset: Some(Some(1.0)), strikethrough_weight: Some(Some(2.0)), strikethrough_offset: Some(Some(1.0)), - kenten_size: Some(Some(14.0)), - kenten_fill_tint: Some(Some(0.3)), - kenten_stroke_tint: Some(Some(0.4)), - kenten_stroke_weight: Some(Some(2.0)), - ruby_font_size: Some(Some(14.0)), - ruby_fill_tint: Some(Some(0.3)), - ruby_stroke_tint: Some(Some(0.4)), - ruby_stroke_weight: Some(Some(2.0)), + kenten_size: Some(Some(14.0)), + kenten_fill_tint: Some(Some(0.3)), + kenten_stroke_tint: Some(Some(0.4)), + kenten_stroke_weight: Some(Some(2.0)), + ruby_font_size: Some(Some(14.0)), + ruby_fill_tint: Some(Some(0.3)), + ruby_stroke_tint: Some(Some(0.4)), + ruby_stroke_weight: Some(Some(2.0)), ..Default::default() }; // Legacy JSON loads as format 1, whose upgrade pins Keep Lines Together and Align Left @@ -572,7 +572,7 @@ fn r7a_nullable_document(local_clear: bool, style_clear: bool) -> Document { start_at: Some(Some(7)), paragraph_kashida_width: Some(Some(0.5)), kinsoku: Some(Some(Default::default())), - list_tab: Some(Some(36.0)), + list_tab: Some(Some(36.0)), keep_all_lines: Some(true), drop_cap_align_left: Some(true), ..Default::default() @@ -587,18 +587,18 @@ fn r7a_nullable_document(local_clear: bool, style_clear: bool) -> Document { underline_offset: Some(None), strikethrough_weight: Some(None), strikethrough_offset: Some(None), - kenten_size: Some(None), - kenten_fill_tint: Some(None), - kenten_stroke_tint: Some(None), - kenten_stroke_weight: Some(None), - ruby_font_size: Some(None), - ruby_fill_tint: Some(None), - ruby_stroke_tint: Some(None), - ruby_stroke_weight: Some(None), + kenten_size: Some(None), + kenten_fill_tint: Some(None), + kenten_stroke_tint: Some(None), + kenten_stroke_weight: Some(None), + ruby_font_size: Some(None), + ruby_fill_tint: Some(None), + ruby_stroke_tint: Some(None), + ruby_stroke_weight: Some(None), ..Default::default() }; - let clear_para = - ParaAttrs { start_at: Some(None), paragraph_kashida_width: Some(None), kinsoku: Some(None), list_tab: Some(None), ..Default::default() }; + let clear_para = + ParaAttrs { start_at: Some(None), paragraph_kashida_width: Some(None), kinsoku: Some(None), list_tab: Some(None), ..Default::default() }; let selected = if style_clear { std::sync::Arc::make_mut(&mut d.styles).paragraph.push(designcraft_doc::styles::ParagraphStyle { name: "Defaults".into(), @@ -639,15 +639,15 @@ fn r7a_assert_nullable_defaults_survive(d: &Document) { assert_eq!(expected.0.paragraph_kashida_width, None); assert_eq!(expected.0.kinsoku, None); assert_eq!(expected.1.leading_aki, None); - assert_eq!(expected.0.list_tab, None); - assert_eq!(expected.1.kenten_size, None); - assert_eq!(expected.1.kenten_fill_tint, None); - assert_eq!(expected.1.kenten_stroke_tint, None); - assert_eq!(expected.1.kenten_stroke_weight, None); - assert_eq!(expected.1.ruby_font_size, None); - assert_eq!(expected.1.ruby_fill_tint, None); - assert_eq!(expected.1.ruby_stroke_tint, None); - assert_eq!(expected.1.ruby_stroke_weight, None); + assert_eq!(expected.0.list_tab, None); + assert_eq!(expected.1.kenten_size, None); + assert_eq!(expected.1.kenten_fill_tint, None); + assert_eq!(expected.1.kenten_stroke_tint, None); + assert_eq!(expected.1.kenten_stroke_weight, None); + assert_eq!(expected.1.ruby_font_size, None); + assert_eq!(expected.1.ruby_fill_tint, None); + assert_eq!(expected.1.ruby_stroke_tint, None); + assert_eq!(expected.1.ruby_stroke_weight, None); // Legacy JSON is format 1: its upgrade pins older defaults on the other styles, so only // the native ZIP compares as the whole document. for (bytes, native) in [(save(d).unwrap(), true), (serde_json::to_vec(d).unwrap(), false)] { From 11ef2cc8404c42ff83fd811d84dd8e51f04300f8 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:01:01 +0100 Subject: [PATCH 303/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/tools/src/select.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index cd47a0338..ff5d0320e 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -776,8 +776,8 @@ impl SelectionTool { } else { out.push(Action::Exec("selection.toggle".into(), json!({"id": id.0}))); } - } else if !cx.selection.contains(id) || (self.direct && !cx.selection.content && cx.doc.item(id).is_some_and(holds_content)) { - // Direct Selection on a selected frame selects its content (a graphic or pasted-in items). + } else if !cx.selection.contains(id) || (self.direct && !cx.selection.content && cx.doc.item(id).is_some_and(holds_content)) { + // Direct Selection on a selected frame selects its content (a graphic or pasted-in items). out.push(Action::Exec("selection.set".into(), json!({"ids": [id.0], "content": self.direct}))); } else if !self.direct && cx.selection.items.len() > 1 { // InDesign: a click on one of several selected objects makes it the From 785c49a1d4e4a5669578397289f07e56fe0d26fd Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:08:12 +0100 Subject: [PATCH 304/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/tools/src/select.rs | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index f073d063e..f6b42377d 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -703,19 +703,19 @@ impl SelectionTool { self.drag = Drag::Anchor { id, si, ai, handle, start: p, at }; return vec![Action::Begin(if handle.is_some() { "Move Direction Handle".into() } else { "Move Anchor".into() })]; } - // As in InDesign, the Direct Selection tool pressed on a corner or anchor of an object - // that isn't selected selects it and moves just that point (Chris Nielsen's "The - // InDesign Toolbox", 10:10: click outside, then pull a frame's corner). - if self.direct - && !ev.mods.shift - && let Some((_, hit)) = cx.hit(p) - && !cx.selection.contains(hit) - && let Some((id, si, ai, None)) = anchor_at_in(cx, &[hit], p) - { - let at = anchor_spread_point(cx, id, si, ai); - self.drag = Drag::Anchor { id, si, ai, handle: None, start: p, at }; - return vec![Action::Exec("selection.set".into(), json!({"ids": [id], "content": true})), Action::Begin("Move Anchor".into())]; - } + // As in InDesign, the Direct Selection tool pressed on a corner or anchor of an object + // that isn't selected selects it and moves just that point (Chris Nielsen's "The + // InDesign Toolbox", 10:10: click outside, then pull a frame's corner). + if self.direct + && !ev.mods.shift + && let Some((_, hit)) = cx.hit(p) + && !cx.selection.contains(hit) + && let Some((id, si, ai, None)) = anchor_at_in(cx, &[hit], p) + { + let at = anchor_spread_point(cx, id, si, ai); + self.drag = Drag::Anchor { id, si, ai, handle: None, start: p, at }; + return vec![Action::Exec("selection.set".into(), json!({"ids": [id], "content": true})), Action::Begin("Move Anchor".into())]; + } // A selected placed graphic's own handles resize it inside its frame (the // frame's anchors, under Direct Selection, come first). if let Some((id, h)) = content_handle_at(cx, p) From 5f96d2b3ffa3410695a0fff0bbe0d3029cdc4345 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:14:53 +0100 Subject: [PATCH 305/628] 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 5363ef8a4c40f312802c718c6b5e42983282c993 Mon Sep 17 00:00:00 2001 From: Youssef Azriouil Date: Thu, 29 Oct 2026 15:26:14 +0100 Subject: [PATCH 306/628] Docs: Greek in the localization hyphenation list Greek patterns are bundled on main; the localization parity list now names them next to Dutch. --- docs/localization-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 599358c61..d300a0b22 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -68,7 +68,7 @@ eight of the twelve (cs, da, el, en_GB, fi, hu, it, nb, nl, pl, ro, ru, sq, sv, ## Document languages InDesign 2026 hyphenates and spell-checks ~40 languages (Hunspell and Duden dictionaries). -DesignCraft hyphenates **English, Spanish, Portuguese, Norwegian and Dutch** and spell-checks **English** only. Text language +DesignCraft hyphenates **English, Spanish, Portuguese, Norwegian, Greek and Dutch** and spell-checks **English** only. Text language can be set per paragraph and character (Properties, Character panel) and drives quotes and locale shaping. See [typography-parity.md](typography-parity.md). From 3959cafd12e5cd965a73367f06c23b615faaa25b Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:26:16 +0100 Subject: [PATCH 307/628] 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 | 49 ----------------------------- crates/ui-egui/src/dialogs/tests.rs | 49 +++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 49 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 4801278db..ef6869f95 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -186,55 +186,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - #[test] - fn on_a_page_without_a_section_the_window_is_new_section() { - // Chris Nielsen's "Numbering Page Sections" (4:43): page 3 selected, the Pages panel menu's - // Numbering & Section Options opens New Section with Start Section on and 1 offered. - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({"pages": 6, "facingPages": true})).unwrap(); - app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 800.0))); - crate::canvas::go_to_page(&mut app, 2); - crate::menus::activate(&mut app, "layout.section", &Value::Null); - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!( - (d.n("page"), d.b("newSection"), d.b("startSection"), d.b("automatic"), d.s("startNumber")), - (Some(3.0), true, true, true, "1".into()) - ); - let ctx = dialog_context("en"); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1178.0, 814.0)); - for _ in 0..3 { - dialog_frame(&mut app, &ctx, screen, vec![]); - } - let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - visible_label(&labels, screen, "New Section"); - // Typing a number in the field chooses Start Page Numbering at; OK: page 3 is page 5 (odd, - // so a facing page stays on the right). - let field = visible_label(&labels, screen, "1"); - click_dialog(&mut app, &ctx, screen, field.center()); - dialog_frame( - &mut app, - &ctx, - screen, - vec![ - egui::Event::Key { key: egui::Key::End, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }, - egui::Event::Key { key: egui::Key::Backspace, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }, - egui::Event::Text("5".into()), - ], - ); - assert!(!app.ui.dialog.as_ref().unwrap().b("automatic"), "typing chose Start Page Numbering at"); - let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "OK").center()); - assert!(app.ui.dialog.is_none()); - assert_eq!(app.session.active().unwrap().doc.page_name(2), "5"); - // Opened again on that page, it is Numbering & Section Options, showing the section. - crate::menus::activate(&mut app, "layout.section", &Value::Null); - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!((d.b("newSection"), d.b("startSection"), d.b("automatic"), d.s("startNumber")), (false, true, false, "5".into())); - let labels = (0..4).map(|_| dialog_frame(&mut app, &ctx, screen, vec![])).last().unwrap(); - visible_label(&labels, screen, "Numbering & Section Options"); - assert!(!labels.iter().any(|(t, _, _)| t == "New Section")); - } - #[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 84cbf3bea..23bc23ef9 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3521,3 +3521,52 @@ 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)); } + +#[test] +fn on_a_page_without_a_section_the_window_is_new_section() { + // Chris Nielsen's "Numbering Page Sections" (4:43): page 3 selected, the Pages panel menu's + // Numbering & Section Options opens New Section with Start Section on and 1 offered. + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 6, "facingPages": true})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 800.0))); + crate::canvas::go_to_page(&mut app, 2); + crate::menus::activate(&mut app, "layout.section", &Value::Null); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!( + (d.n("page"), d.b("newSection"), d.b("startSection"), d.b("automatic"), d.s("startNumber")), + (Some(3.0), true, true, true, "1".into()) + ); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1178.0, 814.0)); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&labels, screen, "New Section"); + // Typing a number in the field chooses Start Page Numbering at; OK: page 3 is page 5 (odd, + // so a facing page stays on the right). + let field = visible_label(&labels, screen, "1"); + click_dialog(&mut app, &ctx, screen, field.center()); + dialog_frame( + &mut app, + &ctx, + screen, + vec![ + egui::Event::Key { key: egui::Key::End, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }, + egui::Event::Key { key: egui::Key::Backspace, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }, + egui::Event::Text("5".into()), + ], + ); + assert!(!app.ui.dialog.as_ref().unwrap().b("automatic"), "typing chose Start Page Numbering at"); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "OK").center()); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.active().unwrap().doc.page_name(2), "5"); + // Opened again on that page, it is Numbering & Section Options, showing the section. + crate::menus::activate(&mut app, "layout.section", &Value::Null); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.b("newSection"), d.b("startSection"), d.b("automatic"), d.s("startNumber")), (false, true, false, "5".into())); + let labels = (0..4).map(|_| dialog_frame(&mut app, &ctx, screen, vec![])).last().unwrap(); + visible_label(&labels, screen, "Numbering & Section Options"); + assert!(!labels.iter().any(|(t, _, _)| t == "New Section")); +} From b0c1bc47479a8582163a50c7f693c82aaf23ced5 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:26:17 +0100 Subject: [PATCH 308/628] 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 | 113 ---------------------------- crates/ui-egui/src/dialogs/tests.rs | 113 ++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 113 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1eccf0ac3..4704cc870 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -186,119 +186,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - #[test] - fn corner_options_dialog_sets_each_corner_with_preview_and_one_undo_step() { - use designcraft_geom::corners::CornerShape; - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let id = app.run("frame.create", json!({"rect": [72, 72, 360, 360]})).unwrap()["id"].as_u64().unwrap(); - app.run("selection.set", json!({"ids": [id]})).unwrap(); - let corners = |app: &DesignApp| app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().corners.corners; - let undo = |app: &DesignApp| app.session.doc().unwrap().history.undo.len(); - let set = |app: &mut DesignApp, k: &str, v: Value| { - app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); - }; - let u0 = undo(&app); - let ctx = dialog_context("en"); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); - // Object › Corner Options… opens InDesign's dialog, not a form of the command's params. - crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); - assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some(CORNER_OPTIONS)); - dialog_frame(&mut app, &ctx, screen, vec![]); - let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - for expected in ["Corner Options", "Corner Size and Shape:", "Preview", "OK", "Cancel"] { - visible_label(&labels, screen, expected); - } - assert!(!labels.iter().any(|(t, _, _)| t.trim() == "Ids"), "no raw command fields"); - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!((d.s("size1"), d.s("shape1"), d.b("link")), ("0p0".into(), "none".into(), true), "seeded from the frame, linked"); - // Linked: a corner changed is all four. - set(&mut app, "size0", json!("1p0")); - dialog_frame(&mut app, &ctx, screen, vec![]); - assert_eq!(app.ui.dialog.as_ref().unwrap().s("size2"), "1p0"); - // Unlinked, only the top-right corner rounds, as in the tutorial. - set(&mut app, "link", json!(false)); - for k in 0..4 { - set(&mut app, &format!("size{k}"), json!("0")); - } - set(&mut app, "size1", json!("3 cm")); - set(&mut app, "shape1", json!("rounded")); - set(&mut app, "preview", json!(true)); - dialog_frame(&mut app, &ctx, screen, vec![]); - assert_eq!(corners(&app)[1].shape, CornerShape::Rounded, "Preview shows it"); - assert_eq!(undo(&app), u0, "without an undo step"); - set(&mut app, "preview", json!(false)); - dialog_frame(&mut app, &ctx, screen, vec![]); - assert_eq!(corners(&app)[1].shape, CornerShape::None, "Preview off puts it back"); - set(&mut app, "preview", json!(true)); - dialog_frame(&mut app, &ctx, screen, vec![]); - confirm(&mut app).unwrap(); - let c = corners(&app); - assert_eq!(c.map(|c| c.shape), [CornerShape::None, CornerShape::Rounded, CornerShape::None, CornerShape::None]); - assert!((c[1].size - 3.0 / 2.54 * 72.0).abs() < 1e-6, "{}", c[1].size); - assert_eq!(undo(&app), u0 + 1, "one undo step"); - // Reopened, it shows the frame's corners, no longer linked. - crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); - dialog_frame(&mut app, &ctx, screen, vec![]); - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!((d.s("shape1"), d.s("shape0"), d.b("link")), ("rounded".into(), "none".into(), false)); - // A size that does not parse keeps the dialog open with the error; so does a shape the - // dialog doesn't offer (rather than another shape taking its place). - set(&mut app, "size0", json!("abc")); - assert!(confirm(&mut app).is_err()); - assert!(app.ui.dialog.is_some()); - set(&mut app, "size0", json!("0")); - set(&mut app, "shape0", json!("spiky")); - assert!(confirm(&mut app).is_err()); - assert!(app.ui.dialog.is_some()); - cancel(&mut app); - assert_eq!(corners(&app), c); - } - - /// Several objects: the dialog shows the first one's corners, and OK sets only the corners - /// edited; the others keep each object's own. A linked edit sets all four. - #[test] - fn corner_options_keep_each_objects_untouched_corners() { - use designcraft_geom::corners::CornerShape; - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let a = app.run("frame.create", json!({"rect": [72, 72, 200, 200]})).unwrap()["id"].as_u64().unwrap(); - let b = app.run("frame.create", json!({"rect": [272, 72, 400, 200]})).unwrap()["id"].as_u64().unwrap(); - app.run("object.cornerOptions", json!({"ids": [b], "shape": "bevel", "size": 12})).unwrap(); - app.run("selection.set", json!({"ids": [a, b]})).unwrap(); - let corners = |app: &DesignApp, id: u64| app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().corners.corners; - let set = |app: &mut DesignApp, k: &str, v: Value| { - app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); - }; - let ctx = dialog_context("en"); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); - let b0 = corners(&app, b); - // Unlinked: only the top-right corner rounds, on both. - crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); - dialog_frame(&mut app, &ctx, screen, vec![]); - set(&mut app, "link", json!(false)); - set(&mut app, "size1", json!("1p0")); - set(&mut app, "shape1", json!("rounded")); - dialog_frame(&mut app, &ctx, screen, vec![]); - confirm(&mut app).unwrap(); - let (ca, cb) = (corners(&app, a), corners(&app, b)); - assert_eq!(ca.map(|c| c.shape), [CornerShape::None, CornerShape::Rounded, CornerShape::None, CornerShape::None]); - assert_eq!(cb.map(|c| c.shape), [CornerShape::Bevel, CornerShape::Rounded, CornerShape::Bevel, CornerShape::Bevel], "b keeps its others"); - assert_eq!((cb[0], cb[2], cb[3]), (b0[0], b0[2], b0[3])); - assert_eq!(ca[1], cb[1]); - // Linked: a corner set to the first object's own value still sets all four, on both. - crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); - dialog_frame(&mut app, &ctx, screen, vec![]); - set(&mut app, "link", json!(true)); - dialog_frame(&mut app, &ctx, screen, vec![]); - set(&mut app, "size0", json!("0p6")); - dialog_frame(&mut app, &ctx, screen, vec![]); - confirm(&mut app).unwrap(); - let (ca, cb) = (corners(&app, a), corners(&app, b)); - assert_eq!(ca, cb, "all four, on both"); - assert!(ca.iter().all(|c| c.shape == CornerShape::None && (c.size - 6.0).abs() < 1e-6), "{ca:?}"); - } - #[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 84cbf3bea..7d91c4533 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3521,3 +3521,116 @@ 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)); } + +#[test] +fn corner_options_dialog_sets_each_corner_with_preview_and_one_undo_step() { + use designcraft_geom::corners::CornerShape; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 360, 360]})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let corners = |app: &DesignApp| app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().corners.corners; + let undo = |app: &DesignApp| app.session.doc().unwrap().history.undo.len(); + let set = |app: &mut DesignApp, k: &str, v: Value| { + app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); + }; + let u0 = undo(&app); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); + // Object › Corner Options… opens InDesign's dialog, not a form of the command's params. + crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some(CORNER_OPTIONS)); + dialog_frame(&mut app, &ctx, screen, vec![]); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for expected in ["Corner Options", "Corner Size and Shape:", "Preview", "OK", "Cancel"] { + visible_label(&labels, screen, expected); + } + assert!(!labels.iter().any(|(t, _, _)| t.trim() == "Ids"), "no raw command fields"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.s("size1"), d.s("shape1"), d.b("link")), ("0p0".into(), "none".into(), true), "seeded from the frame, linked"); + // Linked: a corner changed is all four. + set(&mut app, "size0", json!("1p0")); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("size2"), "1p0"); + // Unlinked, only the top-right corner rounds, as in the tutorial. + set(&mut app, "link", json!(false)); + for k in 0..4 { + set(&mut app, &format!("size{k}"), json!("0")); + } + set(&mut app, "size1", json!("3 cm")); + set(&mut app, "shape1", json!("rounded")); + set(&mut app, "preview", json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(corners(&app)[1].shape, CornerShape::Rounded, "Preview shows it"); + assert_eq!(undo(&app), u0, "without an undo step"); + set(&mut app, "preview", json!(false)); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(corners(&app)[1].shape, CornerShape::None, "Preview off puts it back"); + set(&mut app, "preview", json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + confirm(&mut app).unwrap(); + let c = corners(&app); + assert_eq!(c.map(|c| c.shape), [CornerShape::None, CornerShape::Rounded, CornerShape::None, CornerShape::None]); + assert!((c[1].size - 3.0 / 2.54 * 72.0).abs() < 1e-6, "{}", c[1].size); + assert_eq!(undo(&app), u0 + 1, "one undo step"); + // Reopened, it shows the frame's corners, no longer linked. + crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); + dialog_frame(&mut app, &ctx, screen, vec![]); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.s("shape1"), d.s("shape0"), d.b("link")), ("rounded".into(), "none".into(), false)); + // A size that does not parse keeps the dialog open with the error; so does a shape the + // dialog doesn't offer (rather than another shape taking its place). + set(&mut app, "size0", json!("abc")); + assert!(confirm(&mut app).is_err()); + assert!(app.ui.dialog.is_some()); + set(&mut app, "size0", json!("0")); + set(&mut app, "shape0", json!("spiky")); + assert!(confirm(&mut app).is_err()); + assert!(app.ui.dialog.is_some()); + cancel(&mut app); + assert_eq!(corners(&app), c); +} + +/// Several objects: the dialog shows the first one's corners, and OK sets only the corners +/// edited; the others keep each object's own. A linked edit sets all four. +#[test] +fn corner_options_keep_each_objects_untouched_corners() { + use designcraft_geom::corners::CornerShape; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let a = app.run("frame.create", json!({"rect": [72, 72, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let b = app.run("frame.create", json!({"rect": [272, 72, 400, 200]})).unwrap()["id"].as_u64().unwrap(); + app.run("object.cornerOptions", json!({"ids": [b], "shape": "bevel", "size": 12})).unwrap(); + app.run("selection.set", json!({"ids": [a, b]})).unwrap(); + let corners = |app: &DesignApp, id: u64| app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().corners.corners; + let set = |app: &mut DesignApp, k: &str, v: Value| { + app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); + }; + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); + let b0 = corners(&app, b); + // Unlinked: only the top-right corner rounds, on both. + crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); + dialog_frame(&mut app, &ctx, screen, vec![]); + set(&mut app, "link", json!(false)); + set(&mut app, "size1", json!("1p0")); + set(&mut app, "shape1", json!("rounded")); + dialog_frame(&mut app, &ctx, screen, vec![]); + confirm(&mut app).unwrap(); + let (ca, cb) = (corners(&app, a), corners(&app, b)); + assert_eq!(ca.map(|c| c.shape), [CornerShape::None, CornerShape::Rounded, CornerShape::None, CornerShape::None]); + assert_eq!(cb.map(|c| c.shape), [CornerShape::Bevel, CornerShape::Rounded, CornerShape::Bevel, CornerShape::Bevel], "b keeps its others"); + assert_eq!((cb[0], cb[2], cb[3]), (b0[0], b0[2], b0[3])); + assert_eq!(ca[1], cb[1]); + // Linked: a corner set to the first object's own value still sets all four, on both. + crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); + dialog_frame(&mut app, &ctx, screen, vec![]); + set(&mut app, "link", json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + set(&mut app, "size0", json!("0p6")); + dialog_frame(&mut app, &ctx, screen, vec![]); + confirm(&mut app).unwrap(); + let (ca, cb) = (corners(&app, a), corners(&app, b)); + assert_eq!(ca, cb, "all four, on both"); + assert!(ca.iter().all(|c| c.shape == CornerShape::None && (c.size - 6.0).abs() < 1e-6), "{ca:?}"); +} From 9ffc1ae379bdfe5e000b7a60ab92343a74d46fab Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:26:17 +0100 Subject: [PATCH 309/628] 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 | 54 ----------------------------- crates/ui-egui/src/dialogs/tests.rs | 54 +++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 54 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index a2265c288..858f08a22 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -186,60 +186,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - /// With no document open, Preferences has Units & Increments, as InDesign's: OK keeps them - /// for new documents, and New Document opens in them (Lollo's inches, set once). - #[test] - fn preferences_with_no_document_set_the_units_of_new_documents() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - crate::menus::activate(&mut app, "app.preferences", &Value::Null); - let mut d = app.ui.dialog.take().expect("Preferences"); - assert!(d.b("defaults"), "for new documents"); - let ctx = dialog_context("en"); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1178.0, 814.0)); - app.ui.dialog = Some(d.clone()); - let mut labels = vec![]; - for _ in 0..3 { - labels = dialog_frame(&mut app, &ctx, screen, vec![]); - } - assert!(labels.iter().any(|(t, _, _)| t == "Units & Increments"), "listed with no document: {labels:?}"); - d.fields.insert("horizontalUnits".into(), json!("inches")); - d.fields.insert("verticalUnits".into(), json!("inches")); - d.fields.insert("keyboardIncrement".into(), json!("0.5 pt")); - app.ui.dialog = Some(d); - confirm(&mut app).unwrap(); - let du = app.session.prefs.default_units; - assert_eq!((du.horizontal_units, du.vertical_units, du.keyboard_increment), (Unit::Inches, Unit::Inches, 0.5)); - assert!(app.session.active().is_none(), "no document made"); - // A Cursor Key of 0 or text that isn't a measure is refused, keeping the dialog open and - // the saved values, the other preferences' too; a Kerning/Tracking of 0.5 is kept as it is. - let scale = app.session.prefs.scale_strokes; - for bad in [json!({"keyboardIncrement": "0 pt"}), json!({"keyboardIncrement": "wide"}), json!({"kerningIncrement": "lots"})] { - let mut d = Dialog::new("preferences", json!({"defaults": true, "horizontalUnits": "inches", "scaleStrokes": !scale})); - for (k, v) in bad.as_object().unwrap() { - d.fields.insert(k.clone(), v.clone()); - } - app.ui.dialog = Some(d); - assert!(confirm(&mut app).is_err(), "{bad}"); - assert!(app.ui.dialog.as_ref().is_some_and(|d| d.id == "preferences"), "still open: {bad}"); - assert_eq!(app.session.prefs.default_units.keyboard_increment, 0.5, "{bad}"); - assert_eq!(app.session.prefs.scale_strokes, scale, "no other preference saved: {bad}"); - } - app.ui.dialog = Some(Dialog::new("preferences", json!({"defaults": true, "horizontalUnits": "inches", "kerningIncrement": "0.5"}))); - confirm(&mut app).unwrap(); - assert_eq!(app.session.prefs.default_units.kerning_increment, 0.5, "not rounded up to 1"); - // New Document opens in inches, and the document it makes has inch rulers. - crate::menus::activate(&mut app, "app.newDocumentDialog", &Value::Null); - let nd = app.ui.dialog.clone().expect("New Document"); - assert_eq!(nd.fields["units"], json!("inches")); - assert_eq!(nd.s("width"), designcraft_geom::format_measure(612.0, Unit::Inches)); - confirm(&mut app).unwrap(); - let st = &app.session.doc().unwrap().doc.settings; - assert_eq!((st.horizontal_units, st.vertical_units, st.keyboard_increment), (Unit::Inches, Unit::Inches, 0.5)); - // With the document open, Preferences edits that document's units, not the defaults. - crate::menus::activate(&mut app, "app.preferences", &Value::Null); - assert!(!app.ui.dialog.as_ref().unwrap().b("defaults")); - } - #[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 84cbf3bea..7b0c8b5ba 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3521,3 +3521,57 @@ 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)); } + +/// With no document open, Preferences has Units & Increments, as InDesign's: OK keeps them +/// for new documents, and New Document opens in them (Lollo's inches, set once). +#[test] +fn preferences_with_no_document_set_the_units_of_new_documents() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + crate::menus::activate(&mut app, "app.preferences", &Value::Null); + let mut d = app.ui.dialog.take().expect("Preferences"); + assert!(d.b("defaults"), "for new documents"); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1178.0, 814.0)); + app.ui.dialog = Some(d.clone()); + let mut labels = vec![]; + for _ in 0..3 { + labels = dialog_frame(&mut app, &ctx, screen, vec![]); + } + assert!(labels.iter().any(|(t, _, _)| t == "Units & Increments"), "listed with no document: {labels:?}"); + d.fields.insert("horizontalUnits".into(), json!("inches")); + d.fields.insert("verticalUnits".into(), json!("inches")); + d.fields.insert("keyboardIncrement".into(), json!("0.5 pt")); + app.ui.dialog = Some(d); + confirm(&mut app).unwrap(); + let du = app.session.prefs.default_units; + assert_eq!((du.horizontal_units, du.vertical_units, du.keyboard_increment), (Unit::Inches, Unit::Inches, 0.5)); + assert!(app.session.active().is_none(), "no document made"); + // A Cursor Key of 0 or text that isn't a measure is refused, keeping the dialog open and + // the saved values, the other preferences' too; a Kerning/Tracking of 0.5 is kept as it is. + let scale = app.session.prefs.scale_strokes; + for bad in [json!({"keyboardIncrement": "0 pt"}), json!({"keyboardIncrement": "wide"}), json!({"kerningIncrement": "lots"})] { + let mut d = Dialog::new("preferences", json!({"defaults": true, "horizontalUnits": "inches", "scaleStrokes": !scale})); + for (k, v) in bad.as_object().unwrap() { + d.fields.insert(k.clone(), v.clone()); + } + app.ui.dialog = Some(d); + assert!(confirm(&mut app).is_err(), "{bad}"); + assert!(app.ui.dialog.as_ref().is_some_and(|d| d.id == "preferences"), "still open: {bad}"); + assert_eq!(app.session.prefs.default_units.keyboard_increment, 0.5, "{bad}"); + assert_eq!(app.session.prefs.scale_strokes, scale, "no other preference saved: {bad}"); + } + app.ui.dialog = Some(Dialog::new("preferences", json!({"defaults": true, "horizontalUnits": "inches", "kerningIncrement": "0.5"}))); + confirm(&mut app).unwrap(); + assert_eq!(app.session.prefs.default_units.kerning_increment, 0.5, "not rounded up to 1"); + // New Document opens in inches, and the document it makes has inch rulers. + crate::menus::activate(&mut app, "app.newDocumentDialog", &Value::Null); + let nd = app.ui.dialog.clone().expect("New Document"); + assert_eq!(nd.fields["units"], json!("inches")); + assert_eq!(nd.s("width"), designcraft_geom::format_measure(612.0, Unit::Inches)); + confirm(&mut app).unwrap(); + let st = &app.session.doc().unwrap().doc.settings; + assert_eq!((st.horizontal_units, st.vertical_units, st.keyboard_increment), (Unit::Inches, Unit::Inches, 0.5)); + // With the document open, Preferences edits that document's units, not the defaults. + crate::menus::activate(&mut app, "app.preferences", &Value::Null); + assert!(!app.ui.dialog.as_ref().unwrap().b("defaults")); +} From f713e97a7f7b2af46f8d51df75d729f458f4761b Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:26:17 +0100 Subject: [PATCH 310/628] 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 | 19 ------------------- crates/ui-egui/src/dialogs/tests.rs | 19 +++++++++++++++++++ 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0506af15f..45ffc593b 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -186,25 +186,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - /// Preferences › File Handling shows the number of recent items as a whole number, as - /// InDesign's does ("20"; it read "20.0"). - #[test] - fn file_handling_shows_the_recent_items_count_as_a_whole_number() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - app.run("app.preferences", json!({})).unwrap(); - app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!("files")); - let ctx = dialog_context("en"); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 700.0)); - dialog_frame(&mut app, &ctx, screen, vec![]); - let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - visible_label(&labels, screen, "Number of Recent Items to Display:"); - let count = app.ui.dialog.as_ref().unwrap().n("recentFilesMax").unwrap(); - assert_eq!(count, 20.0, "InDesign's default"); - visible_label(&labels, screen, &format!("{count}")); - assert!(!labels.iter().any(|(t, _, _)| t.trim() == format!("{count:.1}")), "no decimal shown"); - } - #[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 84cbf3bea..167158d84 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3521,3 +3521,22 @@ 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)); } + +/// Preferences › File Handling shows the number of recent items as a whole number, as +/// InDesign's does ("20"; it read "20.0"). +#[test] +fn file_handling_shows_the_recent_items_count_as_a_whole_number() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("app.preferences", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!("files")); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 700.0)); + dialog_frame(&mut app, &ctx, screen, vec![]); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&labels, screen, "Number of Recent Items to Display:"); + let count = app.ui.dialog.as_ref().unwrap().n("recentFilesMax").unwrap(); + assert_eq!(count, 20.0, "InDesign's default"); + visible_label(&labels, screen, &format!("{count}")); + assert!(!labels.iter().any(|(t, _, _)| t.trim() == format!("{count:.1}")), "no decimal shown"); +} From 788061a9ae8350c69c45eb5d725272d75b7df5d6 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:26:18 +0100 Subject: [PATCH 311/628] 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 | 58 ----------------------------- crates/ui-egui/src/dialogs/tests.rs | 58 +++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 58 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index cdcccc954..85ad65dce 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -186,64 +186,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - /// Collapsing advanced TOC controls changes visibility without discarding their values. - #[test] - fn toc_more_options_preserves_hidden_settings() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.newSample", json!({})).unwrap(); - open_toc(&mut app); - app.ui.dialog.as_mut().unwrap().fields.insert( - "included".into(), - json!([{ - "style": "Headline", "level": 3, "separator": " ^t", "pageNumber": "before", - "pageNumberStyle": "Accent", "separatorStyle": "Emphasis", "sort": true - }]), - ); - let before = toc_params(&app, app.ui.dialog.as_ref().unwrap()); - let revision = app.session.doc().unwrap().revision; - let ctx = dialog_context("en"); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0)); - for _ in 0..3 { - dialog_frame(&mut app, &ctx, screen, vec![]); - } - let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - visible_label(&labels, screen, "Between Entry and Number:"); - click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "Fewer Options").center()); - let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - assert!(!labels.iter().any(|(text, _, _)| text == "Between Entry and Number:")); - visible_label(&labels, screen, "Entry Style:"); - assert_eq!(toc_params(&app, app.ui.dialog.as_ref().unwrap()), before); - click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "More Options").center()); - for _ in 0..3 { - dialog_frame(&mut app, &ctx, screen, vec![]); - } - let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - visible_label(&labels, screen, "Between Entry and Number:"); - assert_eq!(toc_params(&app, app.ui.dialog.as_ref().unwrap()), before); - assert_eq!(app.session.doc().unwrap().revision, revision); - } - - /// Enter on the focused Fewer Options / More Options button toggles the options; it doesn't - /// also make the table of contents. - #[test] - fn enter_on_the_toc_options_toggle_only_toggles() { - use egui_kittest::{Harness, kittest::Queryable}; - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.newSample", json!({})).unwrap(); - open_toc(&mut app); - let revision = app.session.doc().unwrap().revision; - let mut h = Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(|ui, app: &mut DesignApp| show(app, ui.ctx()), app); - h.run_steps(3); - h.get_by_label("Fewer Options").focus(); - h.run_steps(2); - h.key_press(egui::Key::Enter); - h.run_steps(3); - let d = h.state().ui.dialog.as_ref().expect("the dialog stays open"); - assert_eq!((d.id.as_str(), d.b("moreOptions")), (TOC, false), "the options are fewer"); - assert_eq!(h.state().session.doc().unwrap().revision, revision, "no table of contents made"); - assert!(h.query_by_label("More Options").is_some()); - } - #[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 84cbf3bea..549b9b6dc 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3521,3 +3521,61 @@ 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)); } + +/// Collapsing advanced TOC controls changes visibility without discarding their values. +#[test] +fn toc_more_options_preserves_hidden_settings() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.newSample", json!({})).unwrap(); + open_toc(&mut app); + app.ui.dialog.as_mut().unwrap().fields.insert( + "included".into(), + json!([{ + "style": "Headline", "level": 3, "separator": " ^t", "pageNumber": "before", + "pageNumberStyle": "Accent", "separatorStyle": "Emphasis", "sort": true + }]), + ); + let before = toc_params(&app, app.ui.dialog.as_ref().unwrap()); + let revision = app.session.doc().unwrap().revision; + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0)); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&labels, screen, "Between Entry and Number:"); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "Fewer Options").center()); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + assert!(!labels.iter().any(|(text, _, _)| text == "Between Entry and Number:")); + visible_label(&labels, screen, "Entry Style:"); + assert_eq!(toc_params(&app, app.ui.dialog.as_ref().unwrap()), before); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "More Options").center()); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&labels, screen, "Between Entry and Number:"); + assert_eq!(toc_params(&app, app.ui.dialog.as_ref().unwrap()), before); + assert_eq!(app.session.doc().unwrap().revision, revision); +} + +/// Enter on the focused Fewer Options / More Options button toggles the options; it doesn't +/// also make the table of contents. +#[test] +fn enter_on_the_toc_options_toggle_only_toggles() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.newSample", json!({})).unwrap(); + open_toc(&mut app); + let revision = app.session.doc().unwrap().revision; + let mut h = Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(|ui, app: &mut DesignApp| show(app, ui.ctx()), app); + h.run_steps(3); + h.get_by_label("Fewer Options").focus(); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(3); + let d = h.state().ui.dialog.as_ref().expect("the dialog stays open"); + assert_eq!((d.id.as_str(), d.b("moreOptions")), (TOC, false), "the options are fewer"); + assert_eq!(h.state().session.doc().unwrap().revision, revision, "no table of contents made"); + assert!(h.query_by_label("More Options").is_some()); +} From a769513826a9ef2662994bbf8ce8e066445da7ae Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:26:18 +0100 Subject: [PATCH 312/628] 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 | 34 ----------------------------- crates/ui-egui/src/dialogs/tests.rs | 34 +++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ef4814347..dc13b83ed 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -186,40 +186,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - /// The title stays readable beside the style selector after the grid settles. - #[test] - fn toc_title_field_shows_the_whole_default_title() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.newSample", json!({})).unwrap(); - open_toc(&mut app); - let ctx = dialog_context("en"); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0)); - for _ in 0..3 { - dialog_frame(&mut app, &ctx, screen, vec![]); - } - let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - let title_row = |labels: &[(String, egui::Rect, egui::Rect)]| { - let y = visible_label(labels, screen, "Title:").center().y; - labels.iter().filter(|(_, rect, _)| (rect.center().y - y).abs() < 2.0).cloned().collect::>() - }; - visible_label(&title_row(&labels), screen, "Contents"); - app.ui.dialog.as_mut().unwrap().fields.insert("title".into(), json!("Table of Contents")); - let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - visible_label(&title_row(&labels), screen, "Table of Contents"); - // A small window: the title narrows and the style selector beside it stays in view. - let small = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(450.0, 280.0)); - for _ in 0..3 { - dialog_frame(&mut app, &ctx, small, vec![]); - } - let labels = dialog_frame(&mut app, &ctx, small, vec![]); - let y = visible_label(&labels, small, "Title:").center().y; - let row: Vec<_> = labels.iter().filter(|(_, rect, _)| (rect.center().y - y).abs() < 2.0).cloned().collect(); - let style = visible_label(&row, small, "TOC Title"); - // The selector is 200 pt wide from just before its text: all of it is in the dialog body. - let (_, _, body) = row.iter().find(|(text, _, _)| text == "Style:").unwrap(); - assert!(style.min.x - 4.0 + 200.0 <= body.max.x, "the style selector fits the dialog: {style:?} in {body:?}"); - } - #[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 3d1ef0acc..7415066fb 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3521,3 +3521,37 @@ 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)); } + +/// The title stays readable beside the style selector after the grid settles. +#[test] +fn toc_title_field_shows_the_whole_default_title() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.newSample", json!({})).unwrap(); + open_toc(&mut app); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0)); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + let title_row = |labels: &[(String, egui::Rect, egui::Rect)]| { + let y = visible_label(labels, screen, "Title:").center().y; + labels.iter().filter(|(_, rect, _)| (rect.center().y - y).abs() < 2.0).cloned().collect::>() + }; + visible_label(&title_row(&labels), screen, "Contents"); + app.ui.dialog.as_mut().unwrap().fields.insert("title".into(), json!("Table of Contents")); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&title_row(&labels), screen, "Table of Contents"); + // A small window: the title narrows and the style selector beside it stays in view. + let small = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(450.0, 280.0)); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, small, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, small, vec![]); + let y = visible_label(&labels, small, "Title:").center().y; + let row: Vec<_> = labels.iter().filter(|(_, rect, _)| (rect.center().y - y).abs() < 2.0).cloned().collect(); + let style = visible_label(&row, small, "TOC Title"); + // The selector is 200 pt wide from just before its text: all of it is in the dialog body. + let (_, _, body) = row.iter().find(|(text, _, _)| text == "Style:").unwrap(); + assert!(style.min.x - 4.0 + 200.0 <= body.max.x, "the style selector fits the dialog: {style:?} in {body:?}"); +} From 0d486c91cac11f0d0fefb499eb3dd6bf42675084 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:26:19 +0100 Subject: [PATCH 313/628] 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 | 77 ----------------------------- crates/ui-egui/src/dialogs/tests.rs | 77 +++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 77 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9a607c59e..868d990d9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -186,83 +186,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - /// Replace the text of the field beside the `nth` label `label` (on its row, to its right) with - /// `new`, and leave the field. - fn retype_beside(h: &mut egui_kittest::Harness<'static, crate::test_window::Window>, label: &str, nth: usize, new: &str) { - use egui_kittest::kittest::Queryable; - let l = h.get_all_by_label(label).nth(nth).unwrap_or_else(|| panic!("no label {label} #{nth}")).rect(); - let field = h - .get_all_by(|n| n.role() == egui::accesskit::Role::TextInput) - .map(|n| n.rect()) - .filter(|r| r.center().x > l.max.x && (r.center().y - l.center().y).abs() < l.height().max(8.0)) - .min_by(|a, b| a.min.x.total_cmp(&b.min.x)) - .unwrap_or_else(|| panic!("no field beside {label} #{nth}")) - .center(); - crate::test_window::click_at(h, field); - h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); - h.event(egui::Event::Text(new.into())); - h.run_steps(1); - // Tab leaves the field (Enter would be the dialog's OK). - h.key_press(egui::Key::Tab); - h.run_steps(3); - } - - /// The course's chapter 40: Paragraph Border's four weights are linked as InDesign's, so a - /// side typed in sets all four; with the link off ("Make all settings the same") a side is set - /// alone, as the lesson gives the border only a thick left side. The corners link the same way. - #[test] - fn paragraph_border_sides_and_corners_link_as_indesigns() { - use egui_kittest::kittest::Queryable; - let (app, _) = ruled_paragraph(); - let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); - h.state_mut().app.ui.dialog = - Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "border", "p.borderOn": true}))); - h.run_steps(4); - let field = |h: &egui_kittest::Harness<'static, crate::test_window::Window>, key: &str| { - h.state().app.ui.dialog.as_ref().and_then(|d| d.fields.get(&format!("p.{key}")).cloned()).unwrap_or_default() - }; - // The four weights start the same, so linked: Top sets them all. - retype_beside(&mut h, "Top:", 0, "3"); - assert_eq!(field(&h, "borderWeights"), json!([3.0, 3.0, 3.0, 3.0])); - // Unlinked, Left alone takes 10 pt. - // The link of the weights (0), corners (1) or offsets (2), where it is now: the dialog - // scrolls as fields take focus. - let link = |h: &egui_kittest::Harness<'static, crate::test_window::Window>, n: usize| { - let links: Vec = h.get_all_by_label("Make all settings the same").map(|n| n.rect()).collect(); - assert_eq!(links.len(), 3, "weights, corners and offsets each have a link"); - links[n].center() - }; - let at = link(&h, 0); - crate::test_window::click_at(&mut h, at); - retype_beside(&mut h, "Left:", 0, "10"); - assert_eq!(field(&h, "borderWeights"), json!([3.0, 10.0, 3.0, 3.0])); - // Linked again, the four take Top's weight; unlinked once more for the thick left side. - let at = link(&h, 0); - crate::test_window::click_at(&mut h, at); - assert_eq!(field(&h, "borderWeights"), json!([3.0, 3.0, 3.0, 3.0])); - let at = link(&h, 0); - crate::test_window::click_at(&mut h, at); - retype_beside(&mut h, "Left:", 0, "10"); - assert_eq!(field(&h, "borderWeights"), json!([3.0, 10.0, 3.0, 3.0])); - // The corners start the same too: Top Left's size goes to all four. - retype_beside(&mut h, "Top Left:", 0, "0p4"); - let corners = field(&h, "borderCorners"); - assert!((0..4).all(|i| corners[i]["size"] == json!(4.0)), "{corners}"); - // Unlinked, Top Right alone takes 0p8; linked again, all four take Top Left's 4 pt. - let at = link(&h, 1); - crate::test_window::click_at(&mut h, at); - retype_beside(&mut h, "Top Right:", 0, "0p8"); - assert_eq!(field(&h, "borderCorners")[1]["size"], json!(8.0)); - assert_eq!(field(&h, "borderCorners")[0]["size"], json!(4.0)); - let at = link(&h, 1); - crate::test_window::click_at(&mut h, at); - let corners = field(&h, "borderCorners"); - assert!((0..4).all(|i| corners[i] == corners[0] && corners[i]["size"] == json!(4.0)), "{corners}"); - confirm(&mut h.state_mut().app).unwrap(); - let style = h.state().app.session.doc().unwrap().doc.styles.para("Ruled Child").cloned().unwrap(); - assert_eq!(style.para.border_weights, Some([3.0, 10.0, 3.0, 3.0])); - } - #[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 84cbf3bea..f144327be 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3521,3 +3521,80 @@ 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)); } + +/// Replace the text of the field beside the `nth` label `label` (on its row, to its right) with +/// `new`, and leave the field. +fn retype_beside(h: &mut egui_kittest::Harness<'static, crate::test_window::Window>, label: &str, nth: usize, new: &str) { + use egui_kittest::kittest::Queryable; + let l = h.get_all_by_label(label).nth(nth).unwrap_or_else(|| panic!("no label {label} #{nth}")).rect(); + let field = h + .get_all_by(|n| n.role() == egui::accesskit::Role::TextInput) + .map(|n| n.rect()) + .filter(|r| r.center().x > l.max.x && (r.center().y - l.center().y).abs() < l.height().max(8.0)) + .min_by(|a, b| a.min.x.total_cmp(&b.min.x)) + .unwrap_or_else(|| panic!("no field beside {label} #{nth}")) + .center(); + crate::test_window::click_at(h, field); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text(new.into())); + h.run_steps(1); + // Tab leaves the field (Enter would be the dialog's OK). + h.key_press(egui::Key::Tab); + h.run_steps(3); +} + +/// The course's chapter 40: Paragraph Border's four weights are linked as InDesign's, so a +/// side typed in sets all four; with the link off ("Make all settings the same") a side is set +/// alone, as the lesson gives the border only a thick left side. The corners link the same way. +#[test] +fn paragraph_border_sides_and_corners_link_as_indesigns() { + use egui_kittest::kittest::Queryable; + let (app, _) = ruled_paragraph(); + let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); + h.state_mut().app.ui.dialog = + Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "border", "p.borderOn": true}))); + h.run_steps(4); + let field = |h: &egui_kittest::Harness<'static, crate::test_window::Window>, key: &str| { + h.state().app.ui.dialog.as_ref().and_then(|d| d.fields.get(&format!("p.{key}")).cloned()).unwrap_or_default() + }; + // The four weights start the same, so linked: Top sets them all. + retype_beside(&mut h, "Top:", 0, "3"); + assert_eq!(field(&h, "borderWeights"), json!([3.0, 3.0, 3.0, 3.0])); + // Unlinked, Left alone takes 10 pt. + // The link of the weights (0), corners (1) or offsets (2), where it is now: the dialog + // scrolls as fields take focus. + let link = |h: &egui_kittest::Harness<'static, crate::test_window::Window>, n: usize| { + let links: Vec = h.get_all_by_label("Make all settings the same").map(|n| n.rect()).collect(); + assert_eq!(links.len(), 3, "weights, corners and offsets each have a link"); + links[n].center() + }; + let at = link(&h, 0); + crate::test_window::click_at(&mut h, at); + retype_beside(&mut h, "Left:", 0, "10"); + assert_eq!(field(&h, "borderWeights"), json!([3.0, 10.0, 3.0, 3.0])); + // Linked again, the four take Top's weight; unlinked once more for the thick left side. + let at = link(&h, 0); + crate::test_window::click_at(&mut h, at); + assert_eq!(field(&h, "borderWeights"), json!([3.0, 3.0, 3.0, 3.0])); + let at = link(&h, 0); + crate::test_window::click_at(&mut h, at); + retype_beside(&mut h, "Left:", 0, "10"); + assert_eq!(field(&h, "borderWeights"), json!([3.0, 10.0, 3.0, 3.0])); + // The corners start the same too: Top Left's size goes to all four. + retype_beside(&mut h, "Top Left:", 0, "0p4"); + let corners = field(&h, "borderCorners"); + assert!((0..4).all(|i| corners[i]["size"] == json!(4.0)), "{corners}"); + // Unlinked, Top Right alone takes 0p8; linked again, all four take Top Left's 4 pt. + let at = link(&h, 1); + crate::test_window::click_at(&mut h, at); + retype_beside(&mut h, "Top Right:", 0, "0p8"); + assert_eq!(field(&h, "borderCorners")[1]["size"], json!(8.0)); + assert_eq!(field(&h, "borderCorners")[0]["size"], json!(4.0)); + let at = link(&h, 1); + crate::test_window::click_at(&mut h, at); + let corners = field(&h, "borderCorners"); + assert!((0..4).all(|i| corners[i] == corners[0] && corners[i]["size"] == json!(4.0)), "{corners}"); + confirm(&mut h.state_mut().app).unwrap(); + let style = h.state().app.session.doc().unwrap().doc.styles.para("Ruled Child").cloned().unwrap(); + assert_eq!(style.para.border_weights, Some([3.0, 10.0, 3.0, 3.0])); +} From 1d62a5875c81b8f3cf6d3bdd1e0ef3ba5eede051 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:26:19 +0100 Subject: [PATCH 314/628] 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 | 30 ----------------------------- crates/ui-egui/src/dialogs/tests.rs | 30 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index cba803f4d..9150f8d23 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -186,36 +186,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - /// The bookmarks checkbox persists through generation and reopening the dialog. - #[test] - fn toc_bookmarks_checkbox_sets_and_reopens_the_persisted_option() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.newSample", json!({})).unwrap(); - open_toc(&mut app); - assert!(app.ui.dialog.as_ref().unwrap().b("createBookmarks")); - app.ui.dialog.as_mut().unwrap().fields.insert("included".into(), json!([{"style": "Headline", "level": 1}])); - let ctx = dialog_context("en"); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); - for _ in 0..3 { - dialog_frame(&mut app, &ctx, screen, vec![]); - } - let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - visible_label(&labels, screen, "Create PDF Bookmarks"); - confirm(&mut app).unwrap(); - assert!(app.session.doc().unwrap().doc.toc.as_ref().unwrap().create_bookmarks); - assert!(!app.session.doc().unwrap().doc.bookmarks.is_empty()); - open_toc(&mut app); - for _ in 0..3 { - dialog_frame(&mut app, &ctx, screen, vec![]); - } - let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "Create PDF Bookmarks").center()); - confirm(&mut app).unwrap(); - assert!(app.session.doc().unwrap().doc.bookmarks.is_empty()); - open_toc(&mut app); - assert!(!app.ui.dialog.as_ref().unwrap().b("createBookmarks")); - } - #[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 84cbf3bea..560af66bd 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3521,3 +3521,33 @@ 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)); } + +/// The bookmarks checkbox persists through generation and reopening the dialog. +#[test] +fn toc_bookmarks_checkbox_sets_and_reopens_the_persisted_option() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.newSample", json!({})).unwrap(); + open_toc(&mut app); + assert!(app.ui.dialog.as_ref().unwrap().b("createBookmarks")); + app.ui.dialog.as_mut().unwrap().fields.insert("included".into(), json!([{"style": "Headline", "level": 1}])); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&labels, screen, "Create PDF Bookmarks"); + confirm(&mut app).unwrap(); + assert!(app.session.doc().unwrap().doc.toc.as_ref().unwrap().create_bookmarks); + assert!(!app.session.doc().unwrap().doc.bookmarks.is_empty()); + open_toc(&mut app); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "Create PDF Bookmarks").center()); + confirm(&mut app).unwrap(); + assert!(app.session.doc().unwrap().doc.bookmarks.is_empty()); + open_toc(&mut app); + assert!(!app.ui.dialog.as_ref().unwrap().b("createBookmarks")); +} From b0068ee57b7b18f7c65f46040706413ddb5d8a5a Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:26:52 +0100 Subject: [PATCH 315/628] Parent page box: json! where main's split canvas view has no import Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/view.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas/view.rs b/crates/ui-egui/src/canvas/view.rs index 7f0e7768d..b679923d2 100644 --- a/crates/ui-egui/src/canvas/view.rs +++ b/crates/ui-egui/src/canvas/view.rs @@ -187,7 +187,7 @@ pub fn page_box(app: &DesignApp) -> Option { pub fn page_box_go(app: &mut DesignApp, b: &PageBox, i: usize) { if i < b.pages { if app.session.active().is_some_and(|st| st.editing_parents) { - let _ = app.run("layout.parents.edit", json!({"on": false})); + let _ = app.run("layout.parents.edit", serde_json::json!({"on": false})); // The canvas refits to the page's spread once it is centred. if let Some(v) = app.view_mut() { v.fitted = false; @@ -195,7 +195,7 @@ pub fn page_box_go(app: &mut DesignApp, b: &PageBox, i: usize) { } go_to_page(app, i); } else if let Some(id) = b.parents.get(i - b.pages) { - let _ = app.run("layout.parents.edit", json!({"parent": id.0})); + let _ = app.run("layout.parents.edit", serde_json::json!({"parent": id.0})); } } From a9814cae3995ac610f0ddfb32994ffa436740db9 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:27:13 +0100 Subject: [PATCH 316/628] Fill colour picker: the picker opener shared with the text panel, the swatches module's path, one Fill popup per chip Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties/object.rs | 4 ++-- crates/ui-egui/src/panels/properties/text.rs | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/properties/object.rs b/crates/ui-egui/src/panels/properties/object.rs index 50f8e498e..e9daa0f32 100644 --- a/crates/ui-egui/src/panels/properties/object.rs +++ b/crates/ui-egui/src/panels/properties/object.rs @@ -378,8 +378,8 @@ pub(super) fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) /// The Color Picker for a Fill or Stroke chip (a double-click on it, as in InDesign), with the /// chip's colour: the objects', or with `text` the selected text's. -fn open_color_picker(app: &mut DesignApp, doc: &designcraft_doc::Document, swatch: &str, stroke: bool, text: bool) { - let mut fields = super::swatches::picker_fields(doc.resolve_color(swatch, 1.0), stroke); +pub(super) fn open_color_picker(app: &mut DesignApp, doc: &designcraft_doc::Document, swatch: &str, stroke: bool, text: bool) { + let mut fields = crate::panels::swatches::picker_fields(doc.resolve_color(swatch, 1.0), stroke); fields["text"] = json!(text); app.ui.dialog = Some(crate::dialogs::Dialog::new("colorPicker", fields)); } diff --git a/crates/ui-egui/src/panels/properties/text.rs b/crates/ui-egui/src/panels/properties/text.rs index 05a2175a4..64c6d6890 100644 --- a/crates/ui-egui/src/panels/properties/text.rs +++ b/crates/ui-egui/src/panels/properties/text.rs @@ -1,4 +1,4 @@ -use super::object::swatch_menu; +use super::object::{open_color_picker, swatch_menu}; use super::typography::{para_field_min, show_typography_panel}; use super::{ Align, DesignApp, FIELD_H, NumField, Rect, Sense, Stroke, StrokeKind, Tokens, Ui, Value, block, full_width, icon_toggle, icons, json, link_label, @@ -128,7 +128,6 @@ pub(super) fn text_appearance_section(app: &mut DesignApp, ui: &mut Ui) { ui.painter().text(chip.center(), egui::Align2::CENTER_CENTER, "T", crate::theme::semibold(12.0), tc); } let resp = ui.interact(chip, ui.scope_id().with(("tchip", k)), Sense::click()).on_hover_text(format!("{label}: {sw}")); - swatch_menu(app, &resp, &doc, &sw, k != 0, false, true); resp.widget_info(|| { egui::WidgetInfo::labeled(egui::WidgetType::Button, true, if k == 0 { "Text fill swatch" } else { "Text stroke swatch" }) }); From d3db571e9df937bc4e373369ced7e65d1ca23719 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:27:38 +0100 Subject: [PATCH 317/628] Anchor points: the panels module's helpers from main's split Properties module Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties/object.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/properties/object.rs b/crates/ui-egui/src/panels/properties/object.rs index 4557b7c03..50969f415 100644 --- a/crates/ui-egui/src/panels/properties/object.rs +++ b/crates/ui-egui/src/panels/properties/object.rs @@ -106,7 +106,7 @@ fn anchor_transform_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { } let mut set = serde_json::Map::new(); // A single point has no size: as InDesign, W and H show its object's, greyed out. - let single = super::selected_anchor_points(app).len() == 1; + let single = crate::panels::selected_anchor_points(app).len() == 1; let (w, h) = if single { i.object_size } else { (r.width(), r.height()) }; for (row, (lx, vx, kx, lw, vw, kw)) in [("X:", x, "x", "W:", w, "width"), ("Y:", y, "y", "H:", h, "height")].into_iter().enumerate() { let yy = row as f32 * 25.0; @@ -133,8 +133,8 @@ fn anchor_transform_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { /// in the page coordinates the fields show, from the reference point): one `path.moveAnchors` /// step, each point by its own movement. A width or height of a single point (0) can't scale. pub(crate) fn transform_anchors(app: &mut DesignApp, set: &Value) -> Result { - let points = super::selected_anchor_points(app); - let Some(i) = super::sel_info(app) else { return Ok(Value::Null) }; + let points = crate::panels::selected_anchor_points(app); + let Some(i) = crate::panels::sel_info(app) else { return Ok(Value::Null) }; if points.is_empty() { return Ok(Value::Null); } From d8b2179b5698be2c7c5e64e53374bb1f37a0387b Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:29:22 +0100 Subject: [PATCH 318/628] Loaded cursor thumbnail: its drawing after main's canvas pass, one loaded field per snap context Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 11 +++++------ crates/ui-egui/src/canvas/column_guides.rs | 1 - 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ec1f1716b..c993193de 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -363,6 +363,11 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { draw_loaded_thumbnail(app, &painter, p, &t); } } + // A second viewport has no panels, dialogs or Quick Apply after its canvas: observe here, + // once, as the end of its pass. The root viewport observes at the end of `DesignApp::ui`. + if ui.ctx().viewport_id() != egui::ViewportId::ROOT { + ime_observe(app, ui.ctx()); + } } /// Longest side of the loaded place cursor's thumbnail, in points. @@ -414,12 +419,6 @@ fn loaded_thumb_texture(ctx: &egui::Context, doc: &Document, asset: designcraft_ let t = ctx.load_texture("loaded_thumb", crate::widgets::fit_texture(ci, max), egui::TextureOptions::LINEAR); ctx.data_mut(|d| d.insert_temp(key, t.clone())); Some(t) - } - // A second viewport has no panels, dialogs or Quick Apply after its canvas: observe here, - // once, as the end of its pass. The root viewport observes at the end of `DesignApp::ui`. - if ui.ctx().viewport_id() != egui::ViewportId::ROOT { - ime_observe(app, ui.ctx()); - } } /// Presses in a row on the canvas: the last press's count, and where and when it was released diff --git a/crates/ui-egui/src/canvas/column_guides.rs b/crates/ui-egui/src/canvas/column_guides.rs index 2cc1d4d0e..c15595442 100644 --- a/crates/ui-egui/src/canvas/column_guides.rs +++ b/crates/ui-egui/src/canvas/column_guides.rs @@ -107,7 +107,6 @@ fn snap_gutter(app: &DesignApp, g: &ColumnDrag, want: f64, shift: bool, zoom: f6 unit: app.ui.units, stroke_in_front: false, loaded: None, - loaded: None, }; let rect = DRect::new(want, m.y0, want + width, m.y1); let s = designcraft_tools::snap::snap( From 83aa235cbc85a927e0f0c14ad79ace9a3f0a9053 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:30:31 +0100 Subject: [PATCH 319/628] TOC import: the listed style reference shared from main's split import styles module Co-Authored-By: Claude Opus 5.5 --- crates/idml/src/import/styles.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/idml/src/import/styles.rs b/crates/idml/src/import/styles.rs index a05e7d50b..13b140733 100644 --- a/crates/idml/src/import/styles.rs +++ b/crates/idml/src/import/styles.rs @@ -225,7 +225,7 @@ impl<'r> Importer<'r> { /// A paragraph style reference written as an element's text: as written when it names a /// style, otherwise without surrounding whitespace (see [`Self::listed_char_style_ref`]). - fn listed_para_style_ref(&self, r: &str) -> String { + pub(super) fn listed_para_style_ref(&self, r: &str) -> String { if self.para_names.contains_key(r) { self.para_style_ref(r) } else { self.para_style_ref(r.trim()) } } /// A character style reference written as an element's text (nested, line and GREP styles). From 1698f69d683e58419565a4e241f38bf502223739 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:30:59 +0100 Subject: [PATCH 320/628] Hidden characters test: in canvas/tests.rs, out of the canvas paint function Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 32 ------------------------------ crates/ui-egui/src/canvas/tests.rs | 32 ++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5c0926b44..1b1e10d13 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -194,38 +194,6 @@ fn paint_render(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Documen painter.add(egui::Shape::mesh(mesh)); } } - - /// As InDesign: an end-of-story # in every cell (after its text), a ¶ where a cell paragraph - /// ends, and the table paragraph's ¶ just after the table's bottom right corner. - #[test] - fn table_cells_show_their_hidden_characters() { - let mut d = Document::new(&designcraft_doc::build::NewDocument::default()); - let lid = d.default_layer(); - let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), DRect::new(0.0, 0.0, 300.0, 300.0), lid, "", Default::default()).unwrap(); - let data = vec![vec!["Name:".to_string(), "".into()], vec!["a\nb".into(), "".into()]]; - let t = designcraft_doc::Table::from_strings(d.alloc(), &data, 200.0, &Default::default(), &Default::default()); - d.story_mut(sid).unwrap().insert_table(0, t); - let cs = compose::compose_story(&d, sid, &compose::ComposeOptions::default()); - let frag = &cs.frames[0].tables[0]; - // The whole frame: the table's own line adds no second ¶; the story's last paragraph - // (after the table) has its #. - let story = d.story(sid).unwrap().text.clone(); - let all = frame_hidden_marks(&story, &cs.frames[0]); - let marks: Vec<_> = all.iter().filter(|m| frag.rect.inflate(2.0, 2.0).contains(m.0)).cloned().collect(); - let ends = |m: &str| marks.iter().filter(|x| x.1 == m).count(); - assert_eq!(ends("#"), 4, "one per cell: {marks:?}"); - assert_eq!(ends("¶"), 2, "the cell's first paragraph and the table's, once: {marks:?}"); - assert_eq!(all.iter().filter(|m| m.1 == "¶" && m.3 > 20.0).count(), 0, "no ¶ the size of the table: {all:?}"); - // Each # is inside its cell, after the cell's text. - for c in &frag.cells { - let inside = marks.iter().filter(|m| m.1 == "#" && c.rect.contains(m.0)).count(); - assert_eq!(inside, 1, "cell {},{}: {marks:?}", c.row, c.col); - } - let name = frag.cell(0, 0).unwrap(); - assert!(marks.iter().any(|m| m.1 == "#" && name.rect.contains(m.0) && m.0.x > name.origin.x + 20.0), "after Name:"); - let last = marks.last().unwrap(); - assert_eq!((last.0, last.1), (designcraft_geom::Point::new(frag.rect.x1 + 1.0, frag.rect.y1), "¶")); - } } /// Presentation: a click shows the next spread, Shift-click the previous one. diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 01300bdd0..f36269479 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2837,3 +2837,35 @@ fn trailing_spaces_show_dots_before_the_paragraph_mark() { assert!(para_x >= space.x + space.adv, "{mark} at {para_x} overlaps the last space"); } } + +/// As InDesign: an end-of-story # in every cell (after its text), a ¶ where a cell paragraph +/// ends, and the table paragraph's ¶ just after the table's bottom right corner. +#[test] +fn table_cells_show_their_hidden_characters() { + let mut d = Document::new(&designcraft_doc::build::NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), DRect::new(0.0, 0.0, 300.0, 300.0), lid, "", Default::default()).unwrap(); + let data = vec![vec!["Name:".to_string(), "".into()], vec!["a\nb".into(), "".into()]]; + let t = designcraft_doc::Table::from_strings(d.alloc(), &data, 200.0, &Default::default(), &Default::default()); + d.story_mut(sid).unwrap().insert_table(0, t); + let cs = compose::compose_story(&d, sid, &compose::ComposeOptions::default()); + let frag = &cs.frames[0].tables[0]; + // The whole frame: the table's own line adds no second ¶; the story's last paragraph + // (after the table) has its #. + let story = d.story(sid).unwrap().text.clone(); + let all = frame_hidden_marks(&story, &cs.frames[0]); + let marks: Vec<_> = all.iter().filter(|m| frag.rect.inflate(2.0, 2.0).contains(m.0)).cloned().collect(); + let ends = |m: &str| marks.iter().filter(|x| x.1 == m).count(); + assert_eq!(ends("#"), 4, "one per cell: {marks:?}"); + assert_eq!(ends("¶"), 2, "the cell's first paragraph and the table's, once: {marks:?}"); + assert_eq!(all.iter().filter(|m| m.1 == "¶" && m.3 > 20.0).count(), 0, "no ¶ the size of the table: {all:?}"); + // Each # is inside its cell, after the cell's text. + for c in &frag.cells { + let inside = marks.iter().filter(|m| m.1 == "#" && c.rect.contains(m.0)).count(); + assert_eq!(inside, 1, "cell {},{}: {marks:?}", c.row, c.col); + } + let name = frag.cell(0, 0).unwrap(); + assert!(marks.iter().any(|m| m.1 == "#" && name.rect.contains(m.0) && m.0.x > name.origin.x + 20.0), "after Name:"); + let last = marks.last().unwrap(); + assert_eq!((last.0, last.1), (designcraft_geom::Point::new(frag.rect.x1 + 1.0, frag.rect.y1), "¶")); +} From a04852c8cd4fa756cb9895766f4bef679306f417 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:31:57 +0100 Subject: [PATCH 321/628] Georgian: main's rows for the Object menu's point words, once each Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ka.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index b973aaba1..812b8d9a6 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -32,10 +32,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), - ("Join", "შეერთება"), - ("Convert Point", "წერტილის გარდაქმნა"), - ("Plain", "მარტივი"), - ("Symmetrical", "სიმეტრიული"), ("Restart Numbering", "ნუმერაციის თავიდან დაწყება"), ("Continue Numbering", "ნუმერაციის გაგრძელება"), ("Keep Options…", "შენარჩუნების პარამეტრები…"), From 948482d60fe1878eaf77afac11a5ae204c23d8d2 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:34:27 +0100 Subject: [PATCH 322/628] Tab in footnote text is typed, not taken for the next table cell Footnote text is addressed as a cell too (as 2e44b190 handled for Shift+Tab, Esc and the cell commands); text.insert now types the Tab there instead of failing in table.nextCell. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/text/edit.rs | 5 +++-- crates/engine/src/tests/table.rs | 4 ++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/text/edit.rs b/crates/engine/src/cmd/text/edit.rs index 21888cb22..fd104cbcd 100644 --- a/crates/engine/src/cmd/text/edit.rs +++ b/crates/engine/src/cmd/text/edit.rs @@ -13,8 +13,9 @@ fn reserve_command_text(st: &mut designcraft_doc::Story, additional: usize) -> R pub(super) fn insert(s: &mut Session, p: &Value) -> Result { let text = crate::cmd::text_param(p, "text"); - // Tab in a table cell moves to the next cell (Shift-Tab: `table.prevCell`). - if text == "\t" && s.doc()?.selection.text.is_some_and(|t| t.cell.is_some()) { + // Tab in a table cell moves to the next cell (Shift-Tab: `table.prevCell`). Footnote text is + // addressed as a cell too: a Tab there is typed. + if text == "\t" && s.doc()?.selection.text.is_some_and(|t| t.cell.is_some_and(|c| c.footnote_id().is_none())) { return crate::cmd::table::step_cell(s, true); } // Typing with one cell selected replaces its text, as InDesign. diff --git a/crates/engine/src/tests/table.rs b/crates/engine/src/tests/table.rs index c6b2b8824..45e4b8db8 100644 --- a/crates/engine/src/tests/table.rs +++ b/crates/engine/src/tests/table.rs @@ -140,6 +140,10 @@ fn the_cell_commands_refuse_footnote_text() { assert!(s.execute(cmd, &json!({})).is_err(), "{cmd}"); } assert_eq!(s.doc().unwrap().selection, before); + // A Tab there is typed into the footnote, not taken for the next cell. + s.execute("text.insert", &json!({"text": "\t"})).unwrap(); + let notes = s.execute("footnote.list", &json!({})).unwrap(); + assert!(notes[0]["text"].as_str().is_some_and(|t| t.contains('\t')), "{notes}"); } /// Esc in a table toggles between the text and the cell (InDesign's Esc): the caret in a cell From 963ac447faaf63334d09965e053e30dccaa42220 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 16:26:34 +0100 Subject: [PATCH 323/628] Text Frame Options: the paragraph spacing limit set without another branch in text_frame_options Keeps the function within clippy's cognitive complexity limit. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/object.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index c5c3ce307..474d22ca8 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -2592,9 +2592,7 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { if let Some(v) = reference { o.auto_size_ref = v; } - if let Some(v) = spacing_limit { - o.vj_paragraph_spacing_limit = v; - } + o.vj_paragraph_spacing_limit = spacing_limit.unwrap_or(o.vj_paragraph_spacing_limit); if let Some(v) = p.get("useMinimumHeight").and_then(Value::as_bool) { o.auto_size_use_min_height = v; } From 6c6efe2f7aff534c8eb5d20fdfb8ec2586a545b1 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:43:45 +0000 Subject: [PATCH 324/628] Fix "-0" in Name with Color Value for slightly negative RGB Found while testing crates/color/src/swatch.rs:94-101 (value_name) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/color/src/swatch.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/color/src/swatch.rs b/crates/color/src/swatch.rs index e47bf4eef..d27a76428 100644 --- a/crates/color/src/swatch.rs +++ b/crates/color/src/swatch.rs @@ -96,7 +96,9 @@ pub fn value_name(c: Color) -> String { Color::Cmyk { c, m, y, k } => cmyk_name(c, m, y, k), other => { let [r, g, b] = other.to_rgb(); - format!("R={} G={} B={}", (r * 255.0).round(), (g * 255.0).round(), (b * 255.0).round()) + // `+ 0.0` turns -0.0 into 0.0 so it prints as "0", not "-0". + let p = |v: f32| (v * 255.0).round() + 0.0; + format!("R={} G={} B={}", p(r), p(g), p(b)) } } } @@ -172,6 +174,11 @@ pub fn default_swatches() -> Vec { mod tests { use super::*; + #[test] + fn value_name_has_no_negative_zero() { + assert_eq!(value_name(Color::rgb(-0.001, 0.5, 1.0)), "R=0 G=128 B=255"); + } + #[test] fn defaults_start_with_specials() { let s = default_swatches(); From 9fe094575a0a9a64e75e5885bb08ad3968361a7b Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 17:04:00 +0100 Subject: [PATCH 325/628] egui 0.37: Id::unique for the wrap offsets link state Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties/object.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/properties/object.rs b/crates/ui-egui/src/panels/properties/object.rs index 3795bcd8a..672c0cb31 100644 --- a/crates/ui-egui/src/panels/properties/object.rs +++ b/crates/ui-egui/src/panels/properties/object.rs @@ -541,7 +541,7 @@ fn wrap_offsets(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { // The link's state belongs to the selection: another selection starts linked again when its // four offsets are the same. let selected: Vec = app.session.active().map(|st| st.selection.items.iter().map(|i| i.0).collect()).unwrap_or_default(); - let link_id = egui::Id::new(("prop_wrap_linked", selected)); + let link_id = egui::Id::unique(("prop_wrap_linked", selected)); ui.add_enabled_ui(enabled, |ui| { // Linked while the four are the same, until the link is clicked. let same = offsets.iter().all(|v| (v - offsets[0]).abs() < 1e-9); From 38d84c00814c782ad1c8bdd943c1b5d49daf537a Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 17:31:39 +0100 Subject: [PATCH 326/628] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/color_panel_tests.rs | 2 +- crates/ui-egui/src/panels/swatches.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/color_panel_tests.rs b/crates/ui-egui/src/panels/color_panel_tests.rs index 05d5899b6..153216100 100644 --- a/crates/ui-egui/src/panels/color_panel_tests.rs +++ b/crates/ui-egui/src/panels/color_panel_tests.rs @@ -95,7 +95,7 @@ fn the_color_panel_edits_the_gradient_stop_selected_in_the_gradient_panel() { app.run("object.gradient", json!({})).unwrap(); let mut h = harness(app); h.run_steps(2); - h.ctx.data_mut(|d| d.insert_temp(egui::Id::new(GRAD_SEL), RampSel::Stop(1))); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique(GRAD_SEL), RampSel::Stop(1))); h.run_steps(3); assert!(h.query_by_label("R=0 G=0 B=0").is_some(), "the black stop's values"); // C: the topmost field. diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 92d7ff2ef..854e16a89 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -581,7 +581,7 @@ fn cmyk_ramp(k: usize, x: f32) -> egui::Color32 { fn gradient_stop(app: &mut DesignApp, ui: &mut egui::Ui, g: &designcraft_color::Gradient, target: &str, mode: ColorMode) { use designcraft_color::Color; let t = Tokens::get(ui.ctx()); - let sel: RampSel = ui.data(|d| d.get_temp(egui::Id::new(GRAD_SEL))).unwrap_or(RampSel::Stop(0)); + let sel: RampSel = ui.data(|d| d.get_temp(egui::Id::unique(GRAD_SEL))).unwrap_or(RampSel::Stop(0)); let i = match sel { RampSel::Stop(i) | RampSel::Mid(i) => i, } From 0dab4137eecd8e821c65d36de6a4f8cd0c57e1f4 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 17:49:56 +0100 Subject: [PATCH 327/628] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties/object.rs | 2 +- crates/ui-egui/src/panels/properties/tests.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/properties/object.rs b/crates/ui-egui/src/panels/properties/object.rs index 3d5ce2ab8..36a9fecaa 100644 --- a/crates/ui-egui/src/panels/properties/object.rs +++ b/crates/ui-egui/src/panels/properties/object.rs @@ -355,7 +355,7 @@ pub(super) fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) let mresp = ui.interact(more, ui.scope_id().with("opmore"), Sense::click()); ui.painter().rect(more, 0.0, if mresp.hovered() { t.hover } else { t.input }, Stroke::new(1.0, t.field_border), StrokeKind::Inside); icons::paint(ui.painter(), more.shrink(4.0), "chevron-right", t.icon); - mresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr(&app.ui.language, "Opacity"))); + mresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, crate::i18n::tr(&app.ui.language, "Opacity"))); opacity_slider(app, &mresp, i.opacity); ui.add_space(6.0); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 2858392f0..06d462abe 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -722,7 +722,7 @@ fn the_opacity_arrow_opens_a_slider_and_a_drag_is_one_undo_step() { let steps = undo_steps(&app); let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( |ui, app: &mut DesignApp| { - let id = egui::Id::new("test_fonts"); + let id = egui::Id::unique("test_fonts"); if ui.data(|d| d.get_temp::(id)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(id, true)); From d40bb87def4615e0be94002939182a28ca54489a Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 17:53:41 +0100 Subject: [PATCH 328/628] 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 85991bf13f7aebc286c3f693611491017ef9fbf8 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 17:57:34 +0100 Subject: [PATCH 329/628] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/new_document.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/new_document.rs b/crates/ui-egui/src/new_document.rs index 54fa76a33..1cd90d0a7 100644 --- a/crates/ui-egui/src/new_document.rs +++ b/crates/ui-egui/src/new_document.rs @@ -510,7 +510,7 @@ fn presets(app: &mut DesignApp, ui: &mut Ui, d: &mut Dialog) -> bool { let rect = Rect::from_min_size(pos2(x, resp.rect.top() + 4.0), vec2(22.0, 22.0)); let button = ui.interact(rect, ui.scope_id().with(("delete-preset", &c.key)), Sense::click()); let label = format!("{}: {}", crate::i18n::tr(&lang, "Delete Preset"), c.label); - button.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label.as_str())); + button.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label.as_str())); if button.hovered() { ui.painter().rect_filled(rect, 2.0, t.hover); } From 7a5101d0a85344763c39c7837285867df489e47b Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 18:01:19 +0100 Subject: [PATCH 330/628] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/swatches.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 51643b2fc..8864e75ce 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -575,7 +575,7 @@ fn cmyk_ramp(k: usize, x: f32) -> egui::Color32 { /// The spectrum ramp: hue across, light to dark down; click to apply. fn spectrum(app: &mut DesignApp, ui: &mut egui::Ui, target: &str) { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 36.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr(&app.ui.language, "Color spectrum"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, crate::i18n::tr(&app.ui.language, "Color spectrum"))); let at = |u: f32, v: f32| -> [f32; 3] { let c = egui::ecolor::Hsva::new(u, (v * 2.0).min(1.0), (2.0 - v * 2.0).min(1.0), 1.0).to_rgb(); [c[0], c[1], c[2]] @@ -683,7 +683,7 @@ mod color_panel_defaults_tests { let mut h = egui_kittest::Harness::builder().with_size(vec2(320.0, 480.0)).build_ui_state( |ui, app: &mut DesignApp| { // The panel's fonts are installed on the first frame and used from the next. - let id = egui::Id::new("test_fonts"); + let id = egui::Id::unique("test_fonts"); if ui.data(|d| d.get_temp::(id)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(id, true)); From b348eb4efb9545e8ead9026c6e1ca0a41ef77d72 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 18:06:18 +0100 Subject: [PATCH 331/628] 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 f1a0b1eb29927239abe729f086617c1867ecca03 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 18:27:22 +0100 Subject: [PATCH 332/628] egui 0.37: the renamed APIs in this branch's code 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 4704cc870..7adb3f622 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -7244,7 +7244,7 @@ fn corner_options_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) let key = format!("shape{k}"); let shape: CornerShape = d.fields.get(&key).and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); let resp = crate::panels::properties::corner_shape_button(ui, shape, 46.0); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, format!("{} {}", tr(tip), tr(shape.label())))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, format!("{} {}", tr(tip), tr(shape.label())))); egui::Popup::menu(&resp).show(|ui| { ui.set_min_width(150.0); for s in CornerShape::ALL { @@ -7271,7 +7271,7 @@ fn corner_options_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) crate::icons::button(ui, if linked { "link" } else { "link-broken" }, 20.0, linked, &tip) }) .inner; - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &tip)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &tip)); if resp.clicked() { d.fields.insert("link".into(), json!(!linked)); } From d99434b18a68d15260c12fa29e40867a58aa3f96 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 18:27:45 +0100 Subject: [PATCH 333/628] Corner Options: the corner shape button re-exported from main's split Properties module Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index a05eaccc5..888302763 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -31,7 +31,7 @@ use flex::flex_section; pub(crate) use links::run_update_links; pub use links::{info_panel, links_panel, preflight_panel, update_links}; use object::{align_row, appearance_section, guide_section, quick_actions_object, transform_section, wrap_section}; -pub(crate) use object::{corner_type_dropdown, ref_point_proxy, stroke_type_dropdown}; +pub(crate) use object::{corner_shape_button, corner_type_dropdown, ref_point_proxy, stroke_type_dropdown}; use object_panels::pathfinder_row; pub use object_panels::{align_panel, attributes_panel, effects_panel, pathfinder_panel, stroke_panel, wrap_panel}; pub(crate) use text::{LANGUAGES, kerning_control, language_menu}; From d5b4e11f2d015269efb8670f3bfd650d23248883 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 18:28:05 +0100 Subject: [PATCH 334/628] Paragraph border corners: the corner shape painter re-exported from main's split Properties module Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index ee3b2c80c..554014c0d 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -31,7 +31,7 @@ use flex::flex_section; pub(crate) use links::run_update_links; pub use links::{info_panel, links_panel, preflight_panel, update_links}; use object::{align_row, appearance_section, guide_section, quick_actions_object, transform_section, wrap_section}; -pub(crate) use object::{corner_type_dropdown, preview_dropdown, preview_row, ref_point_proxy, stroke_type_dropdown}; +pub(crate) use object::{corner_type_dropdown, paint_corner_shape, preview_dropdown, preview_row, ref_point_proxy, stroke_type_dropdown}; use object_panels::pathfinder_row; pub use object_panels::{align_panel, attributes_panel, effects_panel, pathfinder_panel, stroke_panel, wrap_panel}; pub(crate) use text::{LANGUAGES, kerning_control, language_menu}; From 176b35aa332502979be0809edae676172a650a0d Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 18:42:51 +0100 Subject: [PATCH 335/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/tests.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index f144327be..299fc6a66 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3551,8 +3551,7 @@ fn paragraph_border_sides_and_corners_link_as_indesigns() { use egui_kittest::kittest::Queryable; let (app, _) = ruled_paragraph(); let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); - h.state_mut().app.ui.dialog = - Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "border", "p.borderOn": true}))); + h.state_mut().app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "border", "p.borderOn": true}))); h.run_steps(4); let field = |h: &egui_kittest::Harness<'static, crate::test_window::Window>, key: &str| { h.state().app.ui.dialog.as_ref().and_then(|d| d.fields.get(&format!("p.{key}")).cloned()).unwrap_or_default() From 25e60b515b908e5e196a85bdf46f72d4804ba43e Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 18:46:31 +0100 Subject: [PATCH 336/628] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/chrome/status_bar.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/chrome/status_bar.rs b/crates/ui-egui/src/chrome/status_bar.rs index 519830620..5fde929be 100644 --- a/crates/ui-egui/src/chrome/status_bar.rs +++ b/crates/ui-egui/src/chrome/status_bar.rs @@ -131,7 +131,7 @@ pub fn status_bar(app: &mut DesignApp, ui: &mut egui::Ui) { ui.painter().text(fr.right_center() - vec2(5.0, 0.0), egui::Align2::RIGHT_CENTER, "▾", small.clone(), t.text); let label = crate::i18n::tr(&app.ui.language, "Page"); fresp.widget_info(|| { - let mut info = egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, label); + let mut info = egui::WidgetInfo::labeled(egui::Role::ComboBox, true, label); info.current_text_value = Some(name.clone()); info }); From 5ef8f3e5b04ad8bbcb8555e6fb0bfc3aed33b6d7 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 18:46:46 +0100 Subject: [PATCH 337/628] Parent page box: re-exported from main's split canvas view module Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index de4331c7d..18aabfae5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -62,7 +62,7 @@ pub(crate) use view::page_in_view; use view::present_fit; pub use view::{ apply_view_request, can_go_through_history, current_page, current_parent_page, current_slot, current_spread, current_spread_page, drop_point, - fit, go_through_history, go_to_page, set_zoom, show_spread, step_spread, view_centre, zoom_at, + PageBox, fit, go_through_history, go_to_page, page_box, page_box_go, set_zoom, show_spread, step_spread, view_centre, zoom_at, }; pub const RULER: f32 = 15.0; From 329bff534fed36bf4cf1f3e4df0ea302818c6f46 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 18:47:05 +0100 Subject: [PATCH 338/628] Eyedropper: the canvas colour sampler re-exported from main's split canvas render module Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index de4331c7d..c29cbdfd9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -44,7 +44,7 @@ use input::{press_count, release_clicks}; #[cfg(test)] use page_decor::{MAX_BASELINES, baseline_grid_lines}; use page_decor::{dashed, draw_flattener_preview, draw_guides, shown_area}; -pub use render::DisplayKey; +pub use render::{DisplayKey, color_at}; use render::render_texture; #[cfg(test)] use render::{c32, invalidate_on_display_change, screen_options}; From 15b2f49342cf648d5216751d027815ab654f67a4 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 18:49:03 +0100 Subject: [PATCH 339/628] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties/tests.rs | 2 +- crates/ui-egui/src/panels/properties/text.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 96a1b14b6..2f6a6f4fb 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -657,7 +657,7 @@ fn double_clicking_a_fill_chip_opens_the_color_picker() { let panel = |app: DesignApp| { let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).with_step_dt(0.001).build_ui_state( |ui, app: &mut DesignApp| { - let id = egui::Id::new("test_fonts"); + let id = egui::Id::unique("test_fonts"); if ui.data(|d| d.get_temp::(id)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(id, true)); diff --git a/crates/ui-egui/src/panels/properties/text.rs b/crates/ui-egui/src/panels/properties/text.rs index 64c6d6890..ad25a5a5a 100644 --- a/crates/ui-egui/src/panels/properties/text.rs +++ b/crates/ui-egui/src/panels/properties/text.rs @@ -129,7 +129,7 @@ pub(super) fn text_appearance_section(app: &mut DesignApp, ui: &mut Ui) { } let resp = ui.interact(chip, ui.scope_id().with(("tchip", k)), Sense::click()).on_hover_text(format!("{label}: {sw}")); resp.widget_info(|| { - egui::WidgetInfo::labeled(egui::WidgetType::Button, true, if k == 0 { "Text fill swatch" } else { "Text stroke swatch" }) + egui::WidgetInfo::labeled(egui::Role::Button, true, if k == 0 { "Text fill swatch" } else { "Text stroke swatch" }) }); swatch_menu(app, &resp, &doc, &sw, k != 0, false, true); if resp.double_clicked() { From 9577b7f09cb0d67846905a05fecd966115b4f087 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 18:51:14 +0100 Subject: [PATCH 340/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties/text.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/properties/text.rs b/crates/ui-egui/src/panels/properties/text.rs index ad25a5a5a..a9d6e9baa 100644 --- a/crates/ui-egui/src/panels/properties/text.rs +++ b/crates/ui-egui/src/panels/properties/text.rs @@ -128,9 +128,7 @@ pub(super) fn text_appearance_section(app: &mut DesignApp, ui: &mut Ui) { ui.painter().text(chip.center(), egui::Align2::CENTER_CENTER, "T", crate::theme::semibold(12.0), tc); } let resp = ui.interact(chip, ui.scope_id().with(("tchip", k)), Sense::click()).on_hover_text(format!("{label}: {sw}")); - resp.widget_info(|| { - egui::WidgetInfo::labeled(egui::Role::Button, true, if k == 0 { "Text fill swatch" } else { "Text stroke swatch" }) - }); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, if k == 0 { "Text fill swatch" } else { "Text stroke swatch" })); swatch_menu(app, &resp, &doc, &sw, k != 0, false, true); if resp.double_clicked() { open_color_picker(app, &doc, &sw, k != 0, true); From 1ca398b0b7ec209b90f4eae6e0842b223a01c93a Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 19:00:00 +0100 Subject: [PATCH 341/628] Anchor point sections: the Properties helpers they use from main's split modules Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties.rs | 2 +- crates/ui-egui/src/panels/properties/object.rs | 2 +- crates/ui-egui/src/panels/properties/object_panels.rs | 7 ++++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 8978edbe2..67c28f601 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -32,7 +32,7 @@ pub(crate) use links::run_update_links; pub use links::{info_panel, links_panel, preflight_panel, update_links}; use object::{align_row, appearance_section, guide_section, quick_actions_object, transform_section, wrap_section}; pub(crate) use object::{corner_type_dropdown, ref_point_proxy, stroke_type_dropdown}; -use object_panels::pathfinder_row; +use object_panels::{anchor_point_sections, pathfinder_row}; pub use object_panels::{align_panel, attributes_panel, effects_panel, pathfinder_panel, stroke_panel, wrap_panel}; pub(crate) use text::{LANGUAGES, kerning_control, language_menu}; use text::{ diff --git a/crates/ui-egui/src/panels/properties/object.rs b/crates/ui-egui/src/panels/properties/object.rs index 50969f415..c025aad89 100644 --- a/crates/ui-egui/src/panels/properties/object.rs +++ b/crates/ui-egui/src/panels/properties/object.rs @@ -94,7 +94,7 @@ pub(super) fn transform_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { /// Properties › Transform for points selected with Direct Selection: X/Y/W/H of the points' /// bounds (from the reference point); X and Y move the points, W and H spread or gather them /// about the reference point. No angles: those turn objects. -fn anchor_transform_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { +pub(super) fn anchor_transform_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { let u = units(app); let rp = app.ui.ref_point; let b = block(ui, 2.0 * FIELD_H + 4.0); diff --git a/crates/ui-egui/src/panels/properties/object_panels.rs b/crates/ui-egui/src/panels/properties/object_panels.rs index 08228023d..8e6c210de 100644 --- a/crates/ui-egui/src/panels/properties/object_panels.rs +++ b/crates/ui-egui/src/panels/properties/object_panels.rs @@ -1,6 +1,7 @@ +use super::object::{anchor_transform_section, appearance_section}; use super::{ - Arrowhead, Cap, DesignApp, Join, Sense, Stroke, StrokeAlign, StrokeType, Tokens, Ui, Value, caption, divider, icon_toggle, icons, json, measure, - number, pos2, sel_info, text_attrs, units, vec2, widgets, + Arrowhead, Cap, DesignApp, Join, SelInfo, Sense, Stroke, StrokeAlign, StrokeType, Tokens, Ui, Value, caption, divider, full_width, icon_toggle, + icons, json, measure, number, outline_button, pos2, rule, section, section_row, sel_info, text_attrs, units, vec2, widgets, }; /// Stroke panel: weight, cap, miter limit, join, alignment, type, arrowheads, gap colour. @@ -366,7 +367,7 @@ const CONVERT_SHAPE: [(&str, &str, &str); 9] = [ /// Points selected with Direct Selection, as InDesign's Properties shows them: Transform, /// Appearance, Convert Points (the point type of the selected points) and Delete Point(s). -fn anchor_point_sections(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { +pub(super) fn anchor_point_sections(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { section(ui, crate::i18n::tr(&app.ui.language, "Transform"), true); anchor_transform_section(app, ui, i); divider(ui); From 793e0fc33770b1f5b48a107e2628fef7a8f7eefe Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 19:07:36 +0100 Subject: [PATCH 342/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/guides.rs | 2 +- crates/ui-egui/src/canvas/tests.rs | 98 ++++++++++++++--------------- 2 files changed, 50 insertions(+), 50 deletions(-) diff --git a/crates/ui-egui/src/canvas/guides.rs b/crates/ui-egui/src/canvas/guides.rs index 540a295ee..4689b5fb6 100644 --- a/crates/ui-egui/src/canvas/guides.rs +++ b/crates/ui-egui/src/canvas/guides.rs @@ -143,7 +143,7 @@ pub(super) fn guide_spot(app: &DesignApp, xf: &Xf, g: &GuideDrag) -> Option Some(r), - _ => None, - }) - .expect("the frame is previewed"); - h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); - h.run_steps(3); - let placed = h.state().session.doc().unwrap().doc.spreads[0].items.last().expect("placed").bounds(); - let shown = slot.inverse().transform_rect_bbox(preview); - for (a, b) in [(placed.x0, shown.x0), (placed.y0, shown.y0), (placed.x1, shown.x1), (placed.y1, shown.y1)] { - assert!((a - b).abs() < 0.01, "placed {placed:?}, previewed {shown:?}"); - } +/// A place-cursor drag past its 3-point threshold but short of egui's drag distance previews +/// a frame, and the release places that frame: what's shown is what lands. +#[test] +fn a_short_place_cursor_drag_places_the_frame_it_previews() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png(); + app.run("place.load", json!({"base64": designcraft_engine::cmd::base64_encode(&png), "name": "a.png"})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut DesignApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(6); + let (from, slot) = { + let app = h.state(); + let layout = CanvasLayout::new(&app.session.doc().unwrap().doc, false); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + (xf.to_screen(layout.slots[0].xf * Point::new(200.0, 200.0)), layout.slots[0].xf) + }; + // About 5.4 points: past the place cursor's 3, short of egui's 6. + let to = from + vec2(4.5, 3.0); + assert!((to - from).length() < h.ctx.options(|o| o.input_options.max_click_dist)); + h.hover_at(from); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerMoved(to)); + h.run_steps(2); + let vi = h.state().view_info(); + let preview = h + .state_mut() + .session + .overlays(vi) + .into_iter() + .find_map(|o| match o { + designcraft_tools::Overlay::Marquee(r) => Some(r), + _ => None, + }) + .expect("the frame is previewed"); + h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); + let placed = h.state().session.doc().unwrap().doc.spreads[0].items.last().expect("placed").bounds(); + let shown = slot.inverse().transform_rect_bbox(preview); + for (a, b) in [(placed.x0, shown.x0), (placed.y0, shown.y0), (placed.x1, shown.x1), (placed.y1, shown.y1)] { + assert!((a - b).abs() < 0.01, "placed {placed:?}, previewed {shown:?}"); } +} #[test] fn a_press_after_a_drag_is_not_a_double_click() { From cdf110d6fc5bbd4f02442b8e640fd065409b9ae2 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 19:10:39 +0100 Subject: [PATCH 343/628] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c993193de..714c75c5d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -403,7 +403,7 @@ fn draw_loaded_thumbnail(app: &DesignApp, painter: &egui::Painter, p: Pos2, t: & /// The loaded graphic's thumbnail texture, cached per image data. fn loaded_thumb_texture(ctx: &egui::Context, doc: &Document, asset: designcraft_doc::AssetId, size: (f64, f64)) -> Option { let a = doc.assets.get(&asset)?; - let key = egui::Id::new(("loaded_thumb", std::sync::Arc::as_ptr(&a.data) as usize, a.data.len(), a.page)); + let key = egui::Id::unique(("loaded_thumb", std::sync::Arc::as_ptr(&a.data) as usize, a.data.len(), a.page)); if let Some(t) = ctx.data(|d| d.get_temp::(key)) { return Some(t); } From f5647c5e081c01f70283afc927dd15d66fd07622 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 19:11:29 +0100 Subject: [PATCH 344/628] Grid snap test: the tool context's loaded field in main's moved canvas test Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 51d5d32d3..ed57126bb 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -1823,6 +1823,7 @@ fn document_grid_is_drawn_where_points_snap_to_it() { snap: SnapView { snap_to_document_grid: true, zone_px: 20.0, ..SnapView::OFF }, unit: Unit::Points, stroke_in_front: false, + loaded: None, }; let (snapped, _) = designcraft_tools::snap::snap_point(&cx, sr, Point::new(page.x0 + 31.3, page.y0 + 47.9)); assert_eq!((snapped.x - page.x0, snapped.y - page.y0), (27.0, 45.0)); From 4321999720af5f226acbff08cf80edf78ee7634a Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 19:39:04 +0100 Subject: [PATCH 345/628] 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 346/628] 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 347/628] 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 62387aeefd46965b79adf6c6d03856f96e508b3d Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 19:44:07 +0100 Subject: [PATCH 348/628] 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 | 128 ---------------------------- crates/ui-egui/src/dialogs/tests.rs | 128 ++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 128 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 4145ba7d2..f4b5730e6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -186,134 +186,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - /// Replace the text of the field beside the `nth` label `label` (on its row, to its right) with - /// `new`, and leave the field. - fn retype_beside(h: &mut egui_kittest::Harness<'static, crate::test_window::Window>, label: &str, nth: usize, new: &str) { - use egui_kittest::kittest::Queryable; - let l = h.get_all_by_label(label).nth(nth).unwrap_or_else(|| panic!("no label {label} #{nth}")).rect(); - let field = h - .get_all_by(|n| n.role() == egui::accesskit::Role::TextInput) - .map(|n| n.rect()) - .filter(|r| r.center().x > l.max.x && (r.center().y - l.center().y).abs() < l.height().max(8.0)) - .min_by(|a, b| a.min.x.total_cmp(&b.min.x)) - .unwrap_or_else(|| panic!("no field beside {label} #{nth}")) - .center(); - crate::test_window::click_at(h, field); - h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); - h.event(egui::Event::Text(new.into())); - h.run_steps(1); - // Tab leaves the field (Enter would be the dialog's OK). - h.key_press(egui::Key::Tab); - h.run_steps(3); - } - - /// The course's chapter 40: Paragraph Border's four weights are linked as InDesign's, so a - /// side typed in sets all four; with the link off ("Make all settings the same") a side is set - /// alone, as the lesson gives the border only a thick left side. The corners link the same way. - #[test] - fn paragraph_border_sides_and_corners_link_as_indesigns() { - use egui_kittest::kittest::Queryable; - let (app, _) = ruled_paragraph(); - let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); - h.state_mut().app.ui.dialog = - Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "border", "p.borderOn": true}))); - h.run_steps(4); - let field = |h: &egui_kittest::Harness<'static, crate::test_window::Window>, key: &str| { - h.state().app.ui.dialog.as_ref().and_then(|d| d.fields.get(&format!("p.{key}")).cloned()).unwrap_or_default() - }; - // The four weights start the same, so linked: Top sets them all. - retype_beside(&mut h, "Top:", 0, "3"); - assert_eq!(field(&h, "borderWeights"), json!([3.0, 3.0, 3.0, 3.0])); - // Unlinked, Left alone takes 10 pt. - // The link of the weights (0), corners (1) or offsets (2), where it is now: the dialog - // scrolls as fields take focus. - let link = |h: &egui_kittest::Harness<'static, crate::test_window::Window>, n: usize| { - let links: Vec = h.get_all_by_label("Make all settings the same").map(|n| n.rect()).collect(); - assert_eq!(links.len(), 3, "weights, corners and offsets each have a link"); - links[n].center() - }; - let at = link(&h, 0); - crate::test_window::click_at(&mut h, at); - retype_beside(&mut h, "Left:", 0, "10"); - assert_eq!(field(&h, "borderWeights"), json!([3.0, 10.0, 3.0, 3.0])); - // Linked again, the four take Top's weight; unlinked once more for the thick left side. - let at = link(&h, 0); - crate::test_window::click_at(&mut h, at); - assert_eq!(field(&h, "borderWeights"), json!([3.0, 3.0, 3.0, 3.0])); - let at = link(&h, 0); - crate::test_window::click_at(&mut h, at); - retype_beside(&mut h, "Left:", 0, "10"); - assert_eq!(field(&h, "borderWeights"), json!([3.0, 10.0, 3.0, 3.0])); - // The corners start the same too: Top Left's size goes to all four. - retype_beside(&mut h, "Top Left Corner", 0, "0p4"); - let corners = field(&h, "borderCorners"); - assert!((0..4).all(|i| corners[i]["size"] == json!(4.0)), "{corners}"); - // Unlinked, Top Right alone takes 0p8; linked again, all four take Top Left's 4 pt. - let at = link(&h, 1); - crate::test_window::click_at(&mut h, at); - retype_beside(&mut h, "Top Right Corner", 0, "0p8"); - assert_eq!(field(&h, "borderCorners")[1]["size"], json!(8.0)); - assert_eq!(field(&h, "borderCorners")[0]["size"], json!(4.0)); - let at = link(&h, 1); - crate::test_window::click_at(&mut h, at); - let corners = field(&h, "borderCorners"); - assert!((0..4).all(|i| corners[i] == corners[0] && corners[i]["size"] == json!(4.0)), "{corners}"); - confirm(&mut h.state_mut().app).unwrap(); - let style = h.state().app.session.doc().unwrap().doc.styles.para("Ruled Child").cloned().unwrap(); - assert_eq!(style.para.border_weights, Some([3.0, 10.0, 3.0, 3.0])); - } - - /// The course's chapter 41: Paragraph Shading as InDesign's. The page is headed with its name; - /// the tint has Overprint beside it; the corners are a two-by-two grid whose shape menus show - /// the shapes. The lesson rounds the top two corners at 10 pt, unlinked, and moves the - /// offsets. - #[test] - fn paragraph_shading_page_is_laid_out_as_indesigns() { - use designcraft_geom::corners::CornerShape; - use egui_kittest::kittest::Queryable; - let (app, _) = ruled_paragraph(); - let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); - h.state_mut().app.ui.dialog = - Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "shading", "p.shadingOn": true}))); - h.run_steps(4); - let field = |h: &egui_kittest::Harness<'static, crate::test_window::Window>, key: &str| { - h.state().app.ui.dialog.as_ref().and_then(|d| d.fields.get(&format!("p.{key}")).cloned()).unwrap_or_default() - }; - assert!(h.get_all_by_label("Paragraph Shading").count() >= 2, "the section list and the page's heading"); - h.get_by_label("Overprint").click(); - h.run_steps(2); - assert_eq!(field(&h, "shadingOverprint"), json!(true)); - // The corners' link (0) and the offsets' (1). - let link = |h: &egui_kittest::Harness<'static, crate::test_window::Window>, n: usize| { - let links: Vec = h.get_all_by_label("Make all settings the same").map(|n| n.rect()).collect(); - assert_eq!(links.len(), 2, "corners and offsets each have a link"); - links[n].center() - }; - let at = link(&h, 0); - crate::test_window::click_at(&mut h, at); - for (corner, nth) in [("Top Left Corner", 0), ("Top Right Corner", 1)] { - retype_beside(&mut h, corner, 0, "10 pt"); - h.get_all_by_label("Corner Shape").nth(nth).unwrap().click(); - h.run_steps(2); - h.get_by_label("Rounded").click(); - h.run_steps(2); - } - let corners: [designcraft_geom::corners::Corner; 4] = serde_json::from_value(field(&h, "shadingCorners")).unwrap(); - assert_eq!( - corners.map(|c| (c.shape, c.size)), - [(CornerShape::Rounded, 10.0), (CornerShape::Rounded, 10.0), (CornerShape::None, 0.0), (CornerShape::None, 0.0)] - ); - // The offsets, unlinked: Top alone. - let at = link(&h, 1); - crate::test_window::click_at(&mut h, at); - retype_beside(&mut h, "Top:", 0, "3 pt"); - assert_eq!(field(&h, "shadingOffsets"), json!([3.0, 0.0, 0.0, 0.0])); - confirm(&mut h.state_mut().app).unwrap(); - let style = h.state().app.session.doc().unwrap().doc.styles.para("Ruled Child").cloned().unwrap(); - assert_eq!((style.para.shading_on, style.para.shading_overprint), (Some(true), Some(true))); - assert_eq!(style.para.shading_corners.map(|c| c[1].shape), Some(CornerShape::Rounded)); - } - #[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 84cbf3bea..1cfd51abb 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3521,3 +3521,131 @@ 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)); } + +/// Replace the text of the field beside the `nth` label `label` (on its row, to its right) with +/// `new`, and leave the field. +fn retype_beside(h: &mut egui_kittest::Harness<'static, crate::test_window::Window>, label: &str, nth: usize, new: &str) { + use egui_kittest::kittest::Queryable; + let l = h.get_all_by_label(label).nth(nth).unwrap_or_else(|| panic!("no label {label} #{nth}")).rect(); + let field = h + .get_all_by(|n| n.role() == egui::accesskit::Role::TextInput) + .map(|n| n.rect()) + .filter(|r| r.center().x > l.max.x && (r.center().y - l.center().y).abs() < l.height().max(8.0)) + .min_by(|a, b| a.min.x.total_cmp(&b.min.x)) + .unwrap_or_else(|| panic!("no field beside {label} #{nth}")) + .center(); + crate::test_window::click_at(h, field); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text(new.into())); + h.run_steps(1); + // Tab leaves the field (Enter would be the dialog's OK). + h.key_press(egui::Key::Tab); + h.run_steps(3); +} + +/// The course's chapter 40: Paragraph Border's four weights are linked as InDesign's, so a +/// side typed in sets all four; with the link off ("Make all settings the same") a side is set +/// alone, as the lesson gives the border only a thick left side. The corners link the same way. +#[test] +fn paragraph_border_sides_and_corners_link_as_indesigns() { + use egui_kittest::kittest::Queryable; + let (app, _) = ruled_paragraph(); + let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); + h.state_mut().app.ui.dialog = + Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "border", "p.borderOn": true}))); + h.run_steps(4); + let field = |h: &egui_kittest::Harness<'static, crate::test_window::Window>, key: &str| { + h.state().app.ui.dialog.as_ref().and_then(|d| d.fields.get(&format!("p.{key}")).cloned()).unwrap_or_default() + }; + // The four weights start the same, so linked: Top sets them all. + retype_beside(&mut h, "Top:", 0, "3"); + assert_eq!(field(&h, "borderWeights"), json!([3.0, 3.0, 3.0, 3.0])); + // Unlinked, Left alone takes 10 pt. + // The link of the weights (0), corners (1) or offsets (2), where it is now: the dialog + // scrolls as fields take focus. + let link = |h: &egui_kittest::Harness<'static, crate::test_window::Window>, n: usize| { + let links: Vec = h.get_all_by_label("Make all settings the same").map(|n| n.rect()).collect(); + assert_eq!(links.len(), 3, "weights, corners and offsets each have a link"); + links[n].center() + }; + let at = link(&h, 0); + crate::test_window::click_at(&mut h, at); + retype_beside(&mut h, "Left:", 0, "10"); + assert_eq!(field(&h, "borderWeights"), json!([3.0, 10.0, 3.0, 3.0])); + // Linked again, the four take Top's weight; unlinked once more for the thick left side. + let at = link(&h, 0); + crate::test_window::click_at(&mut h, at); + assert_eq!(field(&h, "borderWeights"), json!([3.0, 3.0, 3.0, 3.0])); + let at = link(&h, 0); + crate::test_window::click_at(&mut h, at); + retype_beside(&mut h, "Left:", 0, "10"); + assert_eq!(field(&h, "borderWeights"), json!([3.0, 10.0, 3.0, 3.0])); + // The corners start the same too: Top Left's size goes to all four. + retype_beside(&mut h, "Top Left Corner", 0, "0p4"); + let corners = field(&h, "borderCorners"); + assert!((0..4).all(|i| corners[i]["size"] == json!(4.0)), "{corners}"); + // Unlinked, Top Right alone takes 0p8; linked again, all four take Top Left's 4 pt. + let at = link(&h, 1); + crate::test_window::click_at(&mut h, at); + retype_beside(&mut h, "Top Right Corner", 0, "0p8"); + assert_eq!(field(&h, "borderCorners")[1]["size"], json!(8.0)); + assert_eq!(field(&h, "borderCorners")[0]["size"], json!(4.0)); + let at = link(&h, 1); + crate::test_window::click_at(&mut h, at); + let corners = field(&h, "borderCorners"); + assert!((0..4).all(|i| corners[i] == corners[0] && corners[i]["size"] == json!(4.0)), "{corners}"); + confirm(&mut h.state_mut().app).unwrap(); + let style = h.state().app.session.doc().unwrap().doc.styles.para("Ruled Child").cloned().unwrap(); + assert_eq!(style.para.border_weights, Some([3.0, 10.0, 3.0, 3.0])); +} + +/// The course's chapter 41: Paragraph Shading as InDesign's. The page is headed with its name; +/// the tint has Overprint beside it; the corners are a two-by-two grid whose shape menus show +/// the shapes. The lesson rounds the top two corners at 10 pt, unlinked, and moves the +/// offsets. +#[test] +fn paragraph_shading_page_is_laid_out_as_indesigns() { + use designcraft_geom::corners::CornerShape; + use egui_kittest::kittest::Queryable; + let (app, _) = ruled_paragraph(); + let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); + h.state_mut().app.ui.dialog = + Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "shading", "p.shadingOn": true}))); + h.run_steps(4); + let field = |h: &egui_kittest::Harness<'static, crate::test_window::Window>, key: &str| { + h.state().app.ui.dialog.as_ref().and_then(|d| d.fields.get(&format!("p.{key}")).cloned()).unwrap_or_default() + }; + assert!(h.get_all_by_label("Paragraph Shading").count() >= 2, "the section list and the page's heading"); + h.get_by_label("Overprint").click(); + h.run_steps(2); + assert_eq!(field(&h, "shadingOverprint"), json!(true)); + // The corners' link (0) and the offsets' (1). + let link = |h: &egui_kittest::Harness<'static, crate::test_window::Window>, n: usize| { + let links: Vec = h.get_all_by_label("Make all settings the same").map(|n| n.rect()).collect(); + assert_eq!(links.len(), 2, "corners and offsets each have a link"); + links[n].center() + }; + let at = link(&h, 0); + crate::test_window::click_at(&mut h, at); + for (corner, nth) in [("Top Left Corner", 0), ("Top Right Corner", 1)] { + retype_beside(&mut h, corner, 0, "10 pt"); + h.get_all_by_label("Corner Shape").nth(nth).unwrap().click(); + h.run_steps(2); + h.get_by_label("Rounded").click(); + h.run_steps(2); + } + let corners: [designcraft_geom::corners::Corner; 4] = serde_json::from_value(field(&h, "shadingCorners")).unwrap(); + assert_eq!( + corners.map(|c| (c.shape, c.size)), + [(CornerShape::Rounded, 10.0), (CornerShape::Rounded, 10.0), (CornerShape::None, 0.0), (CornerShape::None, 0.0)] + ); + // The offsets, unlinked: Top alone. + let at = link(&h, 1); + crate::test_window::click_at(&mut h, at); + retype_beside(&mut h, "Top:", 0, "3 pt"); + assert_eq!(field(&h, "shadingOffsets"), json!([3.0, 0.0, 0.0, 0.0])); + confirm(&mut h.state_mut().app).unwrap(); + let style = h.state().app.session.doc().unwrap().doc.styles.para("Ruled Child").cloned().unwrap(); + assert_eq!((style.para.shading_on, style.para.shading_overprint), (Some(true), Some(true))); + assert_eq!(style.para.shading_corners.map(|c| c[1].shape), Some(CornerShape::Rounded)); +} From 47204fa0bb090411c7a526fe0e5e687e04ca00d4 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 19:44:08 +0100 Subject: [PATCH 349/628] 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 | 67 ----------------------------- crates/ui-egui/src/dialogs/tests.rs | 67 +++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 67 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5aad7fecd..d9d48e416 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -186,73 +186,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - /// The course's chapter 26: Preferences › Interface as InDesign's. A Color Theme swatch shows - /// its theme at once and Cancel goes back; OK keeps the theme and the Tools panel's columns. - /// UI Size is in a UI Scaling section of its own. - #[test] - fn preferences_interface_sets_the_color_theme_and_tools_panel() { - use crate::theme::Brightness; - use egui::accesskit::Role; - use egui_kittest::{Harness, kittest::Queryable}; - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let opened = app.ui.brightness; - let open = |app: &mut DesignApp| { - app.run("app.preferences", json!({})).unwrap(); - app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!("interface")); - }; - open(&mut app); - let mut h = Harness::builder().with_size(egui::vec2(1100.0, 760.0)).build_ui_state( - |ui, app: &mut DesignApp| { - let key = egui::Id::new("prefs_interface_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.ctx()); - }, - app, - ); - h.run_steps(3); - let swatch = - |h: &Harness<'_, DesignApp>, name: &str| h.get_by(|n| n.role() == Role::RadioButton && n.label().as_deref() == Some(name)).click(); - for l in ["Appearance", "Color Theme:", "Panels", "Tools Panel:"] { - assert!(h.query_by_label(l).is_some(), "{l}"); - } - // A click shows the theme at once; Cancel goes back. - swatch(&h, "Light"); - h.run_steps(3); - assert_eq!(h.state().ui.brightness, Brightness::Light); - h.get_by_label("Cancel").click(); - h.run_steps(3); - assert!(h.state().ui.dialog.is_none()); - assert_eq!(h.state().ui.brightness, opened, "Cancel goes back to the theme Preferences opened with"); - // OK keeps the theme and the Tools panel's columns. - open(h.state_mut()); - h.run_steps(3); - swatch(&h, "Dark"); - h.run_steps(2); - h.get_by(|n| n.role() == Role::ComboBox && n.value().as_deref() == Some("Single Column")).click(); - h.run_steps(2); - h.get_by_label("Double Column").click(); - h.run_steps(2); - h.get_by_label(" OK ").click(); - h.run_steps(3); - assert!(h.state().ui.dialog.is_none()); - assert_eq!(h.state().ui.brightness, Brightness::Dark); - assert!(h.state().ui.tools_double_column); - // The same command the Window menu toggles, with an explicit state for Preferences. - assert_eq!(h.state_mut().run("window.toolsDoubleColumn", json!({"on": false})).unwrap(), json!(false)); - assert_eq!(h.state_mut().run("window.toolsDoubleColumn", json!({"on": false})).unwrap(), json!(false), "set, not toggled"); - assert_eq!(h.state_mut().run("window.toolsDoubleColumn", json!({})).unwrap(), json!(true), "toggled without `on`"); - // UI Size has a section of its own. - open(h.state_mut()); - h.state_mut().ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!("uiScaling")); - h.run_steps(3); - assert!(h.query_by_label("UI Size:").is_some()); - } - #[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 84cbf3bea..9c99c92b4 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3521,3 +3521,70 @@ 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)); } + +/// The course's chapter 26: Preferences › Interface as InDesign's. A Color Theme swatch shows +/// its theme at once and Cancel goes back; OK keeps the theme and the Tools panel's columns. +/// UI Size is in a UI Scaling section of its own. +#[test] +fn preferences_interface_sets_the_color_theme_and_tools_panel() { + use crate::theme::Brightness; + use egui::accesskit::Role; + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let opened = app.ui.brightness; + let open = |app: &mut DesignApp| { + app.run("app.preferences", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!("interface")); + }; + open(&mut app); + let mut h = Harness::builder().with_size(egui::vec2(1100.0, 760.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("prefs_interface_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.ctx()); + }, + app, + ); + h.run_steps(3); + let swatch = + |h: &Harness<'_, DesignApp>, name: &str| h.get_by(|n| n.role() == Role::RadioButton && n.label().as_deref() == Some(name)).click(); + for l in ["Appearance", "Color Theme:", "Panels", "Tools Panel:"] { + assert!(h.query_by_label(l).is_some(), "{l}"); + } + // A click shows the theme at once; Cancel goes back. + swatch(&h, "Light"); + h.run_steps(3); + assert_eq!(h.state().ui.brightness, Brightness::Light); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert!(h.state().ui.dialog.is_none()); + assert_eq!(h.state().ui.brightness, opened, "Cancel goes back to the theme Preferences opened with"); + // OK keeps the theme and the Tools panel's columns. + open(h.state_mut()); + h.run_steps(3); + swatch(&h, "Dark"); + h.run_steps(2); + h.get_by(|n| n.role() == Role::ComboBox && n.value().as_deref() == Some("Single Column")).click(); + h.run_steps(2); + h.get_by_label("Double Column").click(); + h.run_steps(2); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().ui.dialog.is_none()); + assert_eq!(h.state().ui.brightness, Brightness::Dark); + assert!(h.state().ui.tools_double_column); + // The same command the Window menu toggles, with an explicit state for Preferences. + assert_eq!(h.state_mut().run("window.toolsDoubleColumn", json!({"on": false})).unwrap(), json!(false)); + assert_eq!(h.state_mut().run("window.toolsDoubleColumn", json!({"on": false})).unwrap(), json!(false), "set, not toggled"); + assert_eq!(h.state_mut().run("window.toolsDoubleColumn", json!({})).unwrap(), json!(true), "toggled without `on`"); + // UI Size has a section of its own. + open(h.state_mut()); + h.state_mut().ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!("uiScaling")); + h.run_steps(3); + assert!(h.query_by_label("UI Size:").is_some()); +} From 408c7e14b58ded1f672abab2cda1ad08054c2296 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 19:44:18 +0100 Subject: [PATCH 350/628] 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 | 90 ----------------------------- crates/ui-egui/src/dialogs/tests.rs | 90 +++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 90 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 438636154..e0e47e22c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -186,96 +186,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - /// The course's chapter 22: Find/Change › Change Format. "eggs", case sensitive, nothing in - /// Change to; Change Format Settings › Style Options sets Character Style "eggs", and the box - /// shows it (with Change to's ⓘ). Escape leaves the settings, not Find/Change. Change All - /// gives every lowercase "eggs" the style and keeps the words; the trash clears the format. - #[test] - fn find_change_gives_the_found_text_a_change_format() { - use egui_kittest::{Harness, kittest::Queryable}; - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let text = "Poached Eggs\nBreak eggs; cook until eggs are set."; - let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": text})).unwrap(); - let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); - app.run("style.character.create", json!({"name": "eggs", "chars": {"fontStyle": "Bold"}})).unwrap(); - app.run("app.findChange", json!({})).unwrap(); - if let Some(d) = app.ui.dialog.as_mut() { - d.fields.insert("find".into(), json!("eggs")); - d.fields.insert("caseSensitive".into(), json!(true)); - } - let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( - |ui, app: &mut DesignApp| { - let key = egui::Id::new("change_format_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.ctx()); - }, - app, - ); - h.run_steps(4); - const TIP: &str = "With a Change Format and nothing in Change to, the found text stays and is only formatted"; - assert!(h.query_by_label("Change Format:").is_some()); - assert!(h.query_by_label(TIP).is_none(), "no ⓘ without a Change Format"); - h.get_by_label("Specify attributes to change").click(); - h.run_steps(3); - // Style Options is the section's name in the list and its heading. - for l in ["Change Format Settings", "Style Options", "Basic Character Formats", "Character Color"] { - assert!(h.query_all_by_label(l).next().is_some(), "{l}"); - } - // Style Options › Character Style: eggs. - h.get_all_by_value("[Any Style]").next().unwrap().click(); - h.run_steps(3); - h.get_by_label("eggs").click(); - h.run_steps(3); - h.get_all_by_label(" OK ").last().unwrap().click(); - h.run_steps(3); - let field = |h: &Harness<'_, DesignApp>, k: &str| h.state().ui.dialog.as_ref().unwrap().fields.get(k).cloned(); - assert_eq!((field(&h, CHANGE_FORMAT), field(&h, FORMAT_EDIT)), (Some(json!({"charStyle": "eggs"})), None)); - assert!(h.query_by_label("Character Style: eggs").is_some(), "the box shows it"); - assert!(h.query_by_label(TIP).is_some(), "Change to's ⓘ"); - // Escape closes Change Format Settings only, and keeps the Change Format. - h.get_by_label("Specify attributes to change").click(); - h.run_steps(3); - h.key_press(egui::Key::Escape); - h.run_steps(3); - assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.id.as_str()), Some("findChange")); - assert_eq!((field(&h, CHANGE_FORMAT), field(&h, FORMAT_EDIT)), (Some(json!({"charStyle": "eggs"})), None)); - // Change All: the words stay, every lowercase "eggs" has the style. - h.get_by_label("Change All").click(); - h.run_steps(3); - assert_eq!(field(&h, "status"), Some(json!("2 replacement(s) made"))); - let story = h.state().session.doc().unwrap().doc.story(sid).cloned().unwrap(); - assert_eq!(story.text, text); - assert_eq!( - [8, 19, 36].map(|at| story.format_after(at).style.clone()), - [designcraft_doc::NO_CHAR_STYLE.to_string(), "eggs".into(), "eggs".into()] - ); - // The trash clears it. - h.get_by_label("Clear specified attributes").click(); - h.run_steps(3); - assert_eq!(field(&h, CHANGE_FORMAT), None); - assert!(h.query_by_label(TIP).is_none()); - } - - /// What Change Format Settings sets goes to find.change: a paragraph style and character - /// attributes as well as a character style; the box sums them up. - #[test] - fn a_change_format_goes_with_change_all() { - let mut d = Dialog::new("findChange", json!({"find": "cook"})); - d.fields.insert(CHANGE_FORMAT.into(), json!({"charStyle": "eggs", "paraStyle": "Method", "chars": {"underline": true}})); - let p = find_change_params(&d); - assert_eq!( - (p["charStyle"].clone(), p["paraStyle"].clone(), p["attrs"].clone()), - (json!("eggs"), json!("Method"), json!({"underline": true})) - ); - assert_eq!(change_format_summary("en", d.fields.get(CHANGE_FORMAT)), "Character Style: eggs + Paragraph Style: Method + Underline: on"); - assert!(find_change_params(&Dialog::new("findChange", json!({"find": "cook"}))).get("attrs").is_none()); - } - #[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 84cbf3bea..4d9a1ddb6 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)); } + +/// The course's chapter 22: Find/Change › Change Format. "eggs", case sensitive, nothing in +/// Change to; Change Format Settings › Style Options sets Character Style "eggs", and the box +/// shows it (with Change to's ⓘ). Escape leaves the settings, not Find/Change. Change All +/// gives every lowercase "eggs" the style and keeps the words; the trash clears the format. +#[test] +fn find_change_gives_the_found_text_a_change_format() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let text = "Poached Eggs\nBreak eggs; cook until eggs are set."; + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": text})).unwrap(); + let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); + app.run("style.character.create", json!({"name": "eggs", "chars": {"fontStyle": "Bold"}})).unwrap(); + app.run("app.findChange", json!({})).unwrap(); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("find".into(), json!("eggs")); + d.fields.insert("caseSensitive".into(), json!(true)); + } + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("change_format_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.ctx()); + }, + app, + ); + h.run_steps(4); + const TIP: &str = "With a Change Format and nothing in Change to, the found text stays and is only formatted"; + assert!(h.query_by_label("Change Format:").is_some()); + assert!(h.query_by_label(TIP).is_none(), "no ⓘ without a Change Format"); + h.get_by_label("Specify attributes to change").click(); + h.run_steps(3); + // Style Options is the section's name in the list and its heading. + for l in ["Change Format Settings", "Style Options", "Basic Character Formats", "Character Color"] { + assert!(h.query_all_by_label(l).next().is_some(), "{l}"); + } + // Style Options › Character Style: eggs. + h.get_all_by_value("[Any Style]").next().unwrap().click(); + h.run_steps(3); + h.get_by_label("eggs").click(); + h.run_steps(3); + h.get_all_by_label(" OK ").last().unwrap().click(); + h.run_steps(3); + let field = |h: &Harness<'_, DesignApp>, k: &str| h.state().ui.dialog.as_ref().unwrap().fields.get(k).cloned(); + assert_eq!((field(&h, CHANGE_FORMAT), field(&h, FORMAT_EDIT)), (Some(json!({"charStyle": "eggs"})), None)); + assert!(h.query_by_label("Character Style: eggs").is_some(), "the box shows it"); + assert!(h.query_by_label(TIP).is_some(), "Change to's ⓘ"); + // Escape closes Change Format Settings only, and keeps the Change Format. + h.get_by_label("Specify attributes to change").click(); + h.run_steps(3); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.id.as_str()), Some("findChange")); + assert_eq!((field(&h, CHANGE_FORMAT), field(&h, FORMAT_EDIT)), (Some(json!({"charStyle": "eggs"})), None)); + // Change All: the words stay, every lowercase "eggs" has the style. + h.get_by_label("Change All").click(); + h.run_steps(3); + assert_eq!(field(&h, "status"), Some(json!("2 replacement(s) made"))); + let story = h.state().session.doc().unwrap().doc.story(sid).cloned().unwrap(); + assert_eq!(story.text, text); + assert_eq!( + [8, 19, 36].map(|at| story.format_after(at).style.clone()), + [designcraft_doc::NO_CHAR_STYLE.to_string(), "eggs".into(), "eggs".into()] + ); + // The trash clears it. + h.get_by_label("Clear specified attributes").click(); + h.run_steps(3); + assert_eq!(field(&h, CHANGE_FORMAT), None); + assert!(h.query_by_label(TIP).is_none()); +} + +/// What Change Format Settings sets goes to find.change: a paragraph style and character +/// attributes as well as a character style; the box sums them up. +#[test] +fn a_change_format_goes_with_change_all() { + let mut d = Dialog::new("findChange", json!({"find": "cook"})); + d.fields.insert(CHANGE_FORMAT.into(), json!({"charStyle": "eggs", "paraStyle": "Method", "chars": {"underline": true}})); + let p = find_change_params(&d); + assert_eq!( + (p["charStyle"].clone(), p["paraStyle"].clone(), p["attrs"].clone()), + (json!("eggs"), json!("Method"), json!({"underline": true})) + ); + assert_eq!(change_format_summary("en", d.fields.get(CHANGE_FORMAT)), "Character Style: eggs + Paragraph Style: Method + Underline: on"); + assert!(find_change_params(&Dialog::new("findChange", json!({"find": "cook"}))).get("attrs").is_none()); +} From d19f41e54508f29c888178c492e5211456cd2e05 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 19:44:18 +0100 Subject: [PATCH 351/628] 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 | 154 ---------------------------- crates/ui-egui/src/dialogs/tests.rs | 154 ++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 154 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 4d362966f..6de0afa0a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -186,160 +186,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - /// The course's chapter 22: Find/Change › Change Format. "eggs", case sensitive, nothing in - /// Change to; Change Format Settings › Style Options sets Character Style "eggs", and the box - /// shows it (with Change to's ⓘ). Escape leaves the settings, not Find/Change. Change All - /// gives every lowercase "eggs" the style and keeps the words; the trash clears the format. - #[test] - fn find_change_gives_the_found_text_a_change_format() { - use egui_kittest::{Harness, kittest::Queryable}; - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let text = "Poached Eggs\nBreak eggs; cook until eggs are set."; - let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": text})).unwrap(); - let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); - app.run("style.character.create", json!({"name": "eggs", "chars": {"fontStyle": "Bold"}})).unwrap(); - app.run("app.findChange", json!({})).unwrap(); - if let Some(d) = app.ui.dialog.as_mut() { - d.fields.insert("find".into(), json!("eggs")); - d.fields.insert("caseSensitive".into(), json!(true)); - } - let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( - |ui, app: &mut DesignApp| { - let key = egui::Id::new("change_format_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.ctx()); - }, - app, - ); - h.run_steps(4); - const TIP: &str = "With a Change Format and nothing in Change to, the found text stays and is only formatted"; - assert!(h.query_by_label("Change Format:").is_some()); - assert!(h.query_by_label(TIP).is_none(), "no ⓘ without a Change Format"); - h.get_by_label("Specify attributes to change").click(); - h.run_steps(3); - // Style Options is the section's name in the list and its heading. - for l in ["Change Format Settings", "Style Options", "Basic Character Formats", "Character Color"] { - assert!(h.query_all_by_label(l).next().is_some(), "{l}"); - } - // Style Options › Character Style: eggs. - h.get_all_by_value("[Any Style]").next().unwrap().click(); - h.run_steps(3); - h.get_by_label("eggs").click(); - h.run_steps(3); - h.get_all_by_label(" OK ").last().unwrap().click(); - h.run_steps(3); - let field = |h: &Harness<'_, DesignApp>, k: &str| h.state().ui.dialog.as_ref().unwrap().fields.get(k).cloned(); - assert_eq!((field(&h, CHANGE_FORMAT), field(&h, FORMAT_EDIT)), (Some(json!({"charStyle": "eggs"})), None)); - assert!(h.query_by_label("Character Style: eggs").is_some(), "the box shows it"); - assert!(h.query_by_label(TIP).is_some(), "Change to's ⓘ"); - // Escape closes Change Format Settings only, and keeps the Change Format. - h.get_by_label("Specify attributes to change").click(); - h.run_steps(3); - h.key_press(egui::Key::Escape); - h.run_steps(3); - assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.id.as_str()), Some("findChange")); - assert_eq!((field(&h, CHANGE_FORMAT), field(&h, FORMAT_EDIT)), (Some(json!({"charStyle": "eggs"})), None)); - // Change All: the words stay, every lowercase "eggs" has the style. - h.get_by_label("Change All").click(); - h.run_steps(3); - assert_eq!(field(&h, "status"), Some(json!("2 replacement(s) made"))); - let story = h.state().session.doc().unwrap().doc.story(sid).cloned().unwrap(); - assert_eq!(story.text, text); - assert_eq!( - [8, 19, 36].map(|at| story.format_after(at).style.clone()), - [designcraft_doc::NO_CHAR_STYLE.to_string(), "eggs".into(), "eggs".into()] - ); - // The trash clears it (the second: the first is the Find Format's). - h.get_all_by_label("Clear specified attributes").nth(1).unwrap().click(); - h.run_steps(3); - assert_eq!(field(&h, CHANGE_FORMAT), None); - assert!(h.query_by_label(TIP).is_none()); - } - - /// What Change Format Settings sets goes to find.change: a paragraph style and character - /// attributes as well as a character style; the box sums them up. - #[test] - fn a_change_format_goes_with_change_all() { - let mut d = Dialog::new("findChange", json!({"find": "cook"})); - d.fields.insert(CHANGE_FORMAT.into(), json!({"charStyle": "eggs", "paraStyle": "Method", "chars": {"underline": true}})); - let p = find_change_params(&d); - assert_eq!( - (p["charStyle"].clone(), p["paraStyle"].clone(), p["attrs"].clone()), - (json!("eggs"), json!("Method"), json!({"underline": true})) - ); - assert_eq!(format_summary("en", d.fields.get(CHANGE_FORMAT)), "Character Style: eggs + Paragraph Style: Method + Underline: on"); - assert!(find_change_params(&Dialog::new("findChange", json!({"find": "cook"}))).get("attrs").is_none()); - // A Find Format goes with every search. - d.fields.insert(FIND_FORMAT.into(), json!({"paraStyle": "Method"})); - assert_eq!(find_change_params(&d)["findFormat"], json!({"paraStyle": "Method"})); - } - - /// Find/Change › Find Format, as InDesign's: Find Format Settings sets the character style the - /// found text must have, the box shows it with Find what's ⓘ, and Count counts only "eggs" in - /// it; with nothing in Find what, Find Next finds the text in the style; the first trash clears - /// it. - #[test] - fn find_format_finds_only_text_with_it() { - use egui_kittest::{Harness, kittest::Queryable}; - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Break eggs; cook eggs."})).unwrap(); - app.run("style.character.create", json!({"name": "eggs", "chars": {"fontStyle": "Bold"}})).unwrap(); - app.run("text.select", json!({"story": f["story"], "anchor": 6, "focus": 10})).unwrap(); - app.run("style.character.apply", json!({"name": "eggs"})).unwrap(); - app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 0})).unwrap(); - app.run("app.findChange", json!({})).unwrap(); - if let Some(d) = app.ui.dialog.as_mut() { - d.fields.insert("find".into(), json!("eggs")); - } - let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( - |ui, app: &mut DesignApp| { - let key = egui::Id::new("find_format_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.ctx()); - }, - app, - ); - h.run_steps(4); - const TIP: &str = "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it"; - assert!(h.query_by_label("Find Format:").is_some() && h.query_by_label(TIP).is_none()); - h.get_by_label("Specify attributes to find").click(); - h.run_steps(3); - assert!(h.query_by_label("Find Format Settings").is_some()); - h.get_all_by_value("[Any Style]").next().unwrap().click(); - h.run_steps(3); - h.get_by_label("eggs").click(); - h.run_steps(3); - h.get_all_by_label(" OK ").last().unwrap().click(); - h.run_steps(3); - let field = |h: &Harness<'_, DesignApp>, k: &str| h.state().ui.dialog.as_ref().unwrap().fields.get(k).cloned(); - assert_eq!((field(&h, FIND_FORMAT), field(&h, CHANGE_FORMAT)), (Some(json!({"charStyle": "eggs"})), None)); - assert!(h.query_by_label("Character Style: eggs").is_some() && h.query_by_label(TIP).is_some()); - h.get_by_label("Count").click(); - h.run_steps(3); - assert_eq!(field(&h, "status"), Some(json!("1 match(es)")), "only the \"eggs\" in the style"); - // Nothing in Find what: the text in the style. - h.state_mut().ui.dialog.as_mut().unwrap().fields.insert("find".into(), json!("")); - h.run_steps(2); - h.get_by_label("Find Next").click(); - h.run_steps(3); - let sel = h.state().session.doc().unwrap().selection.text.unwrap(); - assert_eq!(sel.range(), 6..10); - // The first trash is the Find Format's. - h.get_all_by_label("Clear specified attributes").next().unwrap().click(); - h.run_steps(3); - assert_eq!(field(&h, FIND_FORMAT), None); - } - #[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 84cbf3bea..8dfe5c8c2 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3521,3 +3521,157 @@ 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)); } + +/// The course's chapter 22: Find/Change › Change Format. "eggs", case sensitive, nothing in +/// Change to; Change Format Settings › Style Options sets Character Style "eggs", and the box +/// shows it (with Change to's ⓘ). Escape leaves the settings, not Find/Change. Change All +/// gives every lowercase "eggs" the style and keeps the words; the trash clears the format. +#[test] +fn find_change_gives_the_found_text_a_change_format() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let text = "Poached Eggs\nBreak eggs; cook until eggs are set."; + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": text})).unwrap(); + let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); + app.run("style.character.create", json!({"name": "eggs", "chars": {"fontStyle": "Bold"}})).unwrap(); + app.run("app.findChange", json!({})).unwrap(); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("find".into(), json!("eggs")); + d.fields.insert("caseSensitive".into(), json!(true)); + } + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("change_format_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.ctx()); + }, + app, + ); + h.run_steps(4); + const TIP: &str = "With a Change Format and nothing in Change to, the found text stays and is only formatted"; + assert!(h.query_by_label("Change Format:").is_some()); + assert!(h.query_by_label(TIP).is_none(), "no ⓘ without a Change Format"); + h.get_by_label("Specify attributes to change").click(); + h.run_steps(3); + // Style Options is the section's name in the list and its heading. + for l in ["Change Format Settings", "Style Options", "Basic Character Formats", "Character Color"] { + assert!(h.query_all_by_label(l).next().is_some(), "{l}"); + } + // Style Options › Character Style: eggs. + h.get_all_by_value("[Any Style]").next().unwrap().click(); + h.run_steps(3); + h.get_by_label("eggs").click(); + h.run_steps(3); + h.get_all_by_label(" OK ").last().unwrap().click(); + h.run_steps(3); + let field = |h: &Harness<'_, DesignApp>, k: &str| h.state().ui.dialog.as_ref().unwrap().fields.get(k).cloned(); + assert_eq!((field(&h, CHANGE_FORMAT), field(&h, FORMAT_EDIT)), (Some(json!({"charStyle": "eggs"})), None)); + assert!(h.query_by_label("Character Style: eggs").is_some(), "the box shows it"); + assert!(h.query_by_label(TIP).is_some(), "Change to's ⓘ"); + // Escape closes Change Format Settings only, and keeps the Change Format. + h.get_by_label("Specify attributes to change").click(); + h.run_steps(3); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.id.as_str()), Some("findChange")); + assert_eq!((field(&h, CHANGE_FORMAT), field(&h, FORMAT_EDIT)), (Some(json!({"charStyle": "eggs"})), None)); + // Change All: the words stay, every lowercase "eggs" has the style. + h.get_by_label("Change All").click(); + h.run_steps(3); + assert_eq!(field(&h, "status"), Some(json!("2 replacement(s) made"))); + let story = h.state().session.doc().unwrap().doc.story(sid).cloned().unwrap(); + assert_eq!(story.text, text); + assert_eq!( + [8, 19, 36].map(|at| story.format_after(at).style.clone()), + [designcraft_doc::NO_CHAR_STYLE.to_string(), "eggs".into(), "eggs".into()] + ); + // The trash clears it (the second: the first is the Find Format's). + h.get_all_by_label("Clear specified attributes").nth(1).unwrap().click(); + h.run_steps(3); + assert_eq!(field(&h, CHANGE_FORMAT), None); + assert!(h.query_by_label(TIP).is_none()); +} + +/// What Change Format Settings sets goes to find.change: a paragraph style and character +/// attributes as well as a character style; the box sums them up. +#[test] +fn a_change_format_goes_with_change_all() { + let mut d = Dialog::new("findChange", json!({"find": "cook"})); + d.fields.insert(CHANGE_FORMAT.into(), json!({"charStyle": "eggs", "paraStyle": "Method", "chars": {"underline": true}})); + let p = find_change_params(&d); + assert_eq!( + (p["charStyle"].clone(), p["paraStyle"].clone(), p["attrs"].clone()), + (json!("eggs"), json!("Method"), json!({"underline": true})) + ); + assert_eq!(format_summary("en", d.fields.get(CHANGE_FORMAT)), "Character Style: eggs + Paragraph Style: Method + Underline: on"); + assert!(find_change_params(&Dialog::new("findChange", json!({"find": "cook"}))).get("attrs").is_none()); + // A Find Format goes with every search. + d.fields.insert(FIND_FORMAT.into(), json!({"paraStyle": "Method"})); + assert_eq!(find_change_params(&d)["findFormat"], json!({"paraStyle": "Method"})); +} + +/// Find/Change › Find Format, as InDesign's: Find Format Settings sets the character style the +/// found text must have, the box shows it with Find what's ⓘ, and Count counts only "eggs" in +/// it; with nothing in Find what, Find Next finds the text in the style; the first trash clears +/// it. +#[test] +fn find_format_finds_only_text_with_it() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Break eggs; cook eggs."})).unwrap(); + app.run("style.character.create", json!({"name": "eggs", "chars": {"fontStyle": "Bold"}})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 6, "focus": 10})).unwrap(); + app.run("style.character.apply", json!({"name": "eggs"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 0})).unwrap(); + app.run("app.findChange", json!({})).unwrap(); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("find".into(), json!("eggs")); + } + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("find_format_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.ctx()); + }, + app, + ); + h.run_steps(4); + const TIP: &str = "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it"; + assert!(h.query_by_label("Find Format:").is_some() && h.query_by_label(TIP).is_none()); + h.get_by_label("Specify attributes to find").click(); + h.run_steps(3); + assert!(h.query_by_label("Find Format Settings").is_some()); + h.get_all_by_value("[Any Style]").next().unwrap().click(); + h.run_steps(3); + h.get_by_label("eggs").click(); + h.run_steps(3); + h.get_all_by_label(" OK ").last().unwrap().click(); + h.run_steps(3); + let field = |h: &Harness<'_, DesignApp>, k: &str| h.state().ui.dialog.as_ref().unwrap().fields.get(k).cloned(); + assert_eq!((field(&h, FIND_FORMAT), field(&h, CHANGE_FORMAT)), (Some(json!({"charStyle": "eggs"})), None)); + assert!(h.query_by_label("Character Style: eggs").is_some() && h.query_by_label(TIP).is_some()); + h.get_by_label("Count").click(); + h.run_steps(3); + assert_eq!(field(&h, "status"), Some(json!("1 match(es)")), "only the \"eggs\" in the style"); + // Nothing in Find what: the text in the style. + h.state_mut().ui.dialog.as_mut().unwrap().fields.insert("find".into(), json!("")); + h.run_steps(2); + h.get_by_label("Find Next").click(); + h.run_steps(3); + let sel = h.state().session.doc().unwrap().selection.text.unwrap(); + assert_eq!(sel.range(), 6..10); + // The first trash is the Find Format's. + h.get_all_by_label("Clear specified attributes").next().unwrap().click(); + h.run_steps(3); + assert_eq!(field(&h, FIND_FORMAT), None); +} From c20579eb99d50bcd193646cb2616df78b43bed5f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 19:44:19 +0100 Subject: [PATCH 352/628] 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 | 200 ---------------------------- crates/ui-egui/src/dialogs/tests.rs | 200 ++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+), 200 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 59d3db6c0..84dc359dc 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -186,206 +186,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - /// The course's chapter 22: Find/Change › Change Format. "eggs", case sensitive, nothing in - /// Change to; Change Format Settings › Style Options sets Character Style "eggs", and the box - /// shows it (with Change to's ⓘ). Escape leaves the settings, not Find/Change. Change All - /// gives every lowercase "eggs" the style and keeps the words; the trash clears the format. - #[test] - fn find_change_gives_the_found_text_a_change_format() { - use egui_kittest::{Harness, kittest::Queryable}; - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let text = "Poached Eggs\nBreak eggs; cook until eggs are set."; - let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": text})).unwrap(); - let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); - app.run("style.character.create", json!({"name": "eggs", "chars": {"fontStyle": "Bold"}})).unwrap(); - app.run("app.findChange", json!({})).unwrap(); - if let Some(d) = app.ui.dialog.as_mut() { - d.fields.insert("find".into(), json!("eggs")); - d.fields.insert("caseSensitive".into(), json!(true)); - } - let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( - |ui, app: &mut DesignApp| { - let key = egui::Id::new("change_format_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.ctx()); - }, - app, - ); - h.run_steps(4); - const TIP: &str = "With a Change Format and nothing in Change to, the found text stays and is only formatted"; - assert!(h.query_by_label("Change Format:").is_some()); - assert!(h.query_by_label(TIP).is_none(), "no ⓘ without a Change Format"); - h.get_by_label("Specify attributes to change").click(); - h.run_steps(3); - // Style Options is the section's name in the list and its heading. - for l in ["Change Format Settings", "Style Options", "Basic Character Formats", "Character Color"] { - assert!(h.query_all_by_label(l).next().is_some(), "{l}"); - } - // Style Options › Character Style: eggs. - h.get_all_by_value("[Any Style]").next().unwrap().click(); - h.run_steps(3); - h.get_by_label("eggs").click(); - h.run_steps(3); - h.get_all_by_label(" OK ").last().unwrap().click(); - h.run_steps(3); - let field = |h: &Harness<'_, DesignApp>, k: &str| h.state().ui.dialog.as_ref().unwrap().fields.get(k).cloned(); - assert_eq!((field(&h, CHANGE_FORMAT), field(&h, FORMAT_EDIT)), (Some(json!({"charStyle": "eggs"})), None)); - assert!(h.query_by_label("Character Style: eggs").is_some(), "the box shows it"); - assert!(h.query_by_label(TIP).is_some(), "Change to's ⓘ"); - // Escape closes Change Format Settings only, and keeps the Change Format. - h.get_by_label("Specify attributes to change").click(); - h.run_steps(3); - h.key_press(egui::Key::Escape); - h.run_steps(3); - assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.id.as_str()), Some("findChange")); - assert_eq!((field(&h, CHANGE_FORMAT), field(&h, FORMAT_EDIT)), (Some(json!({"charStyle": "eggs"})), None)); - // Change All: the words stay, every lowercase "eggs" has the style. - h.get_by_label("Change All").click(); - h.run_steps(3); - assert_eq!(field(&h, "status"), Some(json!("2 replacement(s) made"))); - let story = h.state().session.doc().unwrap().doc.story(sid).cloned().unwrap(); - assert_eq!(story.text, text); - assert_eq!( - [8, 19, 36].map(|at| story.format_after(at).style.clone()), - [designcraft_doc::NO_CHAR_STYLE.to_string(), "eggs".into(), "eggs".into()] - ); - // The trash clears it (the second: the first is the Find Format's). - h.get_all_by_label("Clear specified attributes").nth(1).unwrap().click(); - h.run_steps(3); - assert_eq!(field(&h, CHANGE_FORMAT), None); - assert!(h.query_by_label(TIP).is_none()); - } - - /// What Change Format Settings sets goes to find.change: a paragraph style and character - /// attributes as well as a character style; the box sums them up. - #[test] - fn a_change_format_goes_with_change_all() { - let mut d = Dialog::new("findChange", json!({"find": "cook"})); - d.fields.insert(CHANGE_FORMAT.into(), json!({"charStyle": "eggs", "paraStyle": "Method", "chars": {"underline": true}})); - let p = find_change_params(&d); - assert_eq!( - (p["charStyle"].clone(), p["paraStyle"].clone(), p["attrs"].clone()), - (json!("eggs"), json!("Method"), json!({"underline": true})) - ); - assert_eq!(format_summary("en", d.fields.get(CHANGE_FORMAT)), "Character Style: eggs + Paragraph Style: Method + Underline: on"); - assert!(find_change_params(&Dialog::new("findChange", json!({"find": "cook"}))).get("attrs").is_none()); - // A Find Format goes with every search. - d.fields.insert(FIND_FORMAT.into(), json!({"paraStyle": "Method"})); - assert_eq!(find_change_params(&d)["findFormat"], json!({"paraStyle": "Method"})); - } - - /// Find/Change › Find Format, as InDesign's: Find Format Settings sets the character style the - /// found text must have, the box shows it with Find what's ⓘ, and Count counts only "eggs" in - /// it; with nothing in Find what, Find Next finds the text in the style; the first trash clears - /// it. - #[test] - fn find_format_finds_only_text_with_it() { - use egui_kittest::{Harness, kittest::Queryable}; - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Break eggs; cook eggs."})).unwrap(); - app.run("style.character.create", json!({"name": "eggs", "chars": {"fontStyle": "Bold"}})).unwrap(); - app.run("text.select", json!({"story": f["story"], "anchor": 6, "focus": 10})).unwrap(); - app.run("style.character.apply", json!({"name": "eggs"})).unwrap(); - app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 0})).unwrap(); - app.run("app.findChange", json!({})).unwrap(); - if let Some(d) = app.ui.dialog.as_mut() { - d.fields.insert("find".into(), json!("eggs")); - } - let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( - |ui, app: &mut DesignApp| { - let key = egui::Id::new("find_format_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.ctx()); - }, - app, - ); - h.run_steps(4); - const TIP: &str = "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it"; - assert!(h.query_by_label("Find Format:").is_some() && h.query_by_label(TIP).is_none()); - h.get_by_label("Specify attributes to find").click(); - h.run_steps(3); - assert!(h.query_by_label("Find Format Settings").is_some()); - h.get_all_by_value("[Any Style]").next().unwrap().click(); - h.run_steps(3); - h.get_by_label("eggs").click(); - h.run_steps(3); - h.get_all_by_label(" OK ").last().unwrap().click(); - h.run_steps(3); - let field = |h: &Harness<'_, DesignApp>, k: &str| h.state().ui.dialog.as_ref().unwrap().fields.get(k).cloned(); - assert_eq!((field(&h, FIND_FORMAT), field(&h, CHANGE_FORMAT)), (Some(json!({"charStyle": "eggs"})), None)); - assert!(h.query_by_label("Character Style: eggs").is_some() && h.query_by_label(TIP).is_some()); - h.get_by_label("Count").click(); - h.run_steps(3); - assert_eq!(field(&h, "status"), Some(json!("1 match(es)")), "only the \"eggs\" in the style"); - // Nothing in Find what: the text in the style. - h.state_mut().ui.dialog.as_mut().unwrap().fields.insert("find".into(), json!("")); - h.run_steps(2); - h.get_by_label("Find Next").click(); - h.run_steps(3); - let sel = h.state().session.doc().unwrap().selection.text.unwrap(); - assert_eq!(sel.range(), 6..10); - // The first trash is the Find Format's. - h.get_all_by_label("Clear specified attributes").next().unwrap().click(); - h.run_steps(3); - assert_eq!(field(&h, FIND_FORMAT), None); - } - - /// Find/Change's Change and Change/Find, as InDesign's: Find Next selects a match, Change/Find - /// changes it and selects the next, Change changes that one and keeps it selected. With no - /// text selected they're off. - #[test] - fn change_and_change_find_change_the_found_text() { - use egui_kittest::Harness; - use egui_kittest::kittest::{NodeT, Queryable}; - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "red red | red"})).unwrap(); - let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); - app.run("text.select", json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); - app.run("app.findChange", json!({})).unwrap(); - if let Some(d) = app.ui.dialog.as_mut() { - d.fields.insert("find".into(), json!("red")); - d.fields.insert("change".into(), json!("blue")); - } - let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( - |ui, app: &mut DesignApp| { - let key = egui::Id::new("change_find_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.ctx()); - }, - app, - ); - h.run_steps(4); - assert!(h.query_by_value("Document").is_some(), "the Search choices are InDesign's"); - assert!(h.get_by_label("Change").accesskit_node().is_disabled(), "nothing found yet"); - let text = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().doc.story(sid).unwrap().text.clone(); - let selected = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().selection.text.unwrap().range(); - h.get_by_label("Find Next").click(); - h.run_steps(3); - assert_eq!(selected(&h), 0..3); - h.get_by_label("Change/Find").click(); - h.run_steps(3); - assert_eq!((text(&h), selected(&h)), ("blue red | red".to_string(), 5..8)); - h.get_by_label("Change").click(); - h.run_steps(3); - assert_eq!((text(&h), selected(&h)), ("blue blue | red".to_string(), 5..9)); - assert_eq!(h.state().ui.dialog.as_ref().unwrap().fields["status"], json!("1 replacement(s) made")); - } - #[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 79e33322b..9d392ed90 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3530,3 +3530,203 @@ 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)); } + +/// The course's chapter 22: Find/Change › Change Format. "eggs", case sensitive, nothing in +/// Change to; Change Format Settings › Style Options sets Character Style "eggs", and the box +/// shows it (with Change to's ⓘ). Escape leaves the settings, not Find/Change. Change All +/// gives every lowercase "eggs" the style and keeps the words; the trash clears the format. +#[test] +fn find_change_gives_the_found_text_a_change_format() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let text = "Poached Eggs\nBreak eggs; cook until eggs are set."; + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": text})).unwrap(); + let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); + app.run("style.character.create", json!({"name": "eggs", "chars": {"fontStyle": "Bold"}})).unwrap(); + app.run("app.findChange", json!({})).unwrap(); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("find".into(), json!("eggs")); + d.fields.insert("caseSensitive".into(), json!(true)); + } + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("change_format_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.ctx()); + }, + app, + ); + h.run_steps(4); + const TIP: &str = "With a Change Format and nothing in Change to, the found text stays and is only formatted"; + assert!(h.query_by_label("Change Format:").is_some()); + assert!(h.query_by_label(TIP).is_none(), "no ⓘ without a Change Format"); + h.get_by_label("Specify attributes to change").click(); + h.run_steps(3); + // Style Options is the section's name in the list and its heading. + for l in ["Change Format Settings", "Style Options", "Basic Character Formats", "Character Color"] { + assert!(h.query_all_by_label(l).next().is_some(), "{l}"); + } + // Style Options › Character Style: eggs. + h.get_all_by_value("[Any Style]").next().unwrap().click(); + h.run_steps(3); + h.get_by_label("eggs").click(); + h.run_steps(3); + h.get_all_by_label(" OK ").last().unwrap().click(); + h.run_steps(3); + let field = |h: &Harness<'_, DesignApp>, k: &str| h.state().ui.dialog.as_ref().unwrap().fields.get(k).cloned(); + assert_eq!((field(&h, CHANGE_FORMAT), field(&h, FORMAT_EDIT)), (Some(json!({"charStyle": "eggs"})), None)); + assert!(h.query_by_label("Character Style: eggs").is_some(), "the box shows it"); + assert!(h.query_by_label(TIP).is_some(), "Change to's ⓘ"); + // Escape closes Change Format Settings only, and keeps the Change Format. + h.get_by_label("Specify attributes to change").click(); + h.run_steps(3); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.id.as_str()), Some("findChange")); + assert_eq!((field(&h, CHANGE_FORMAT), field(&h, FORMAT_EDIT)), (Some(json!({"charStyle": "eggs"})), None)); + // Change All: the words stay, every lowercase "eggs" has the style. + h.get_by_label("Change All").click(); + h.run_steps(3); + assert_eq!(field(&h, "status"), Some(json!("2 replacement(s) made"))); + let story = h.state().session.doc().unwrap().doc.story(sid).cloned().unwrap(); + assert_eq!(story.text, text); + assert_eq!( + [8, 19, 36].map(|at| story.format_after(at).style.clone()), + [designcraft_doc::NO_CHAR_STYLE.to_string(), "eggs".into(), "eggs".into()] + ); + // The trash clears it (the second: the first is the Find Format's). + h.get_all_by_label("Clear specified attributes").nth(1).unwrap().click(); + h.run_steps(3); + assert_eq!(field(&h, CHANGE_FORMAT), None); + assert!(h.query_by_label(TIP).is_none()); +} + +/// What Change Format Settings sets goes to find.change: a paragraph style and character +/// attributes as well as a character style; the box sums them up. +#[test] +fn a_change_format_goes_with_change_all() { + let mut d = Dialog::new("findChange", json!({"find": "cook"})); + d.fields.insert(CHANGE_FORMAT.into(), json!({"charStyle": "eggs", "paraStyle": "Method", "chars": {"underline": true}})); + let p = find_change_params(&d); + assert_eq!( + (p["charStyle"].clone(), p["paraStyle"].clone(), p["attrs"].clone()), + (json!("eggs"), json!("Method"), json!({"underline": true})) + ); + assert_eq!(format_summary("en", d.fields.get(CHANGE_FORMAT)), "Character Style: eggs + Paragraph Style: Method + Underline: on"); + assert!(find_change_params(&Dialog::new("findChange", json!({"find": "cook"}))).get("attrs").is_none()); + // A Find Format goes with every search. + d.fields.insert(FIND_FORMAT.into(), json!({"paraStyle": "Method"})); + assert_eq!(find_change_params(&d)["findFormat"], json!({"paraStyle": "Method"})); +} + +/// Find/Change › Find Format, as InDesign's: Find Format Settings sets the character style the +/// found text must have, the box shows it with Find what's ⓘ, and Count counts only "eggs" in +/// it; with nothing in Find what, Find Next finds the text in the style; the first trash clears +/// it. +#[test] +fn find_format_finds_only_text_with_it() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Break eggs; cook eggs."})).unwrap(); + app.run("style.character.create", json!({"name": "eggs", "chars": {"fontStyle": "Bold"}})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 6, "focus": 10})).unwrap(); + app.run("style.character.apply", json!({"name": "eggs"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 0})).unwrap(); + app.run("app.findChange", json!({})).unwrap(); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("find".into(), json!("eggs")); + } + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("find_format_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.ctx()); + }, + app, + ); + h.run_steps(4); + const TIP: &str = "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it"; + assert!(h.query_by_label("Find Format:").is_some() && h.query_by_label(TIP).is_none()); + h.get_by_label("Specify attributes to find").click(); + h.run_steps(3); + assert!(h.query_by_label("Find Format Settings").is_some()); + h.get_all_by_value("[Any Style]").next().unwrap().click(); + h.run_steps(3); + h.get_by_label("eggs").click(); + h.run_steps(3); + h.get_all_by_label(" OK ").last().unwrap().click(); + h.run_steps(3); + let field = |h: &Harness<'_, DesignApp>, k: &str| h.state().ui.dialog.as_ref().unwrap().fields.get(k).cloned(); + assert_eq!((field(&h, FIND_FORMAT), field(&h, CHANGE_FORMAT)), (Some(json!({"charStyle": "eggs"})), None)); + assert!(h.query_by_label("Character Style: eggs").is_some() && h.query_by_label(TIP).is_some()); + h.get_by_label("Count").click(); + h.run_steps(3); + assert_eq!(field(&h, "status"), Some(json!("1 match(es)")), "only the \"eggs\" in the style"); + // Nothing in Find what: the text in the style. + h.state_mut().ui.dialog.as_mut().unwrap().fields.insert("find".into(), json!("")); + h.run_steps(2); + h.get_by_label("Find Next").click(); + h.run_steps(3); + let sel = h.state().session.doc().unwrap().selection.text.unwrap(); + assert_eq!(sel.range(), 6..10); + // The first trash is the Find Format's. + h.get_all_by_label("Clear specified attributes").next().unwrap().click(); + h.run_steps(3); + assert_eq!(field(&h, FIND_FORMAT), None); +} + +/// Find/Change's Change and Change/Find, as InDesign's: Find Next selects a match, Change/Find +/// changes it and selects the next, Change changes that one and keeps it selected. With no +/// text selected they're off. +#[test] +fn change_and_change_find_change_the_found_text() { + use egui_kittest::Harness; + use egui_kittest::kittest::{NodeT, Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "red red | red"})).unwrap(); + let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); + app.run("text.select", json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + app.run("app.findChange", json!({})).unwrap(); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("find".into(), json!("red")); + d.fields.insert("change".into(), json!("blue")); + } + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("change_find_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.ctx()); + }, + app, + ); + h.run_steps(4); + assert!(h.query_by_value("Document").is_some(), "the Search choices are InDesign's"); + assert!(h.get_by_label("Change").accesskit_node().is_disabled(), "nothing found yet"); + let text = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().doc.story(sid).unwrap().text.clone(); + let selected = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().selection.text.unwrap().range(); + h.get_by_label("Find Next").click(); + h.run_steps(3); + assert_eq!(selected(&h), 0..3); + h.get_by_label("Change/Find").click(); + h.run_steps(3); + assert_eq!((text(&h), selected(&h)), ("blue red | red".to_string(), 5..8)); + h.get_by_label("Change").click(); + h.run_steps(3); + assert_eq!((text(&h), selected(&h)), ("blue blue | red".to_string(), 5..9)); + assert_eq!(h.state().ui.dialog.as_ref().unwrap().fields["status"], json!("1 replacement(s) made")); +} From 875c317e184cdad8888aa716f73b35bfa312a5d6 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 19:44:20 +0100 Subject: [PATCH 353/628] 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 | 89 ----------------------------- crates/ui-egui/src/dialogs/tests.rs | 89 +++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 89 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index abb4892c9..cbf7ea6a6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -186,95 +186,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - /// Paragraph Rules › Color offers Text Color above the swatches, so a rule set to a swatch can - /// go back to it; Gap Color lists only swatches. - #[test] - fn the_rule_colour_menu_offers_text_color() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let doc = app.session.doc().unwrap().doc.clone(); - let menu = |ctx: &egui::Context, text: Option<&str>, click: Option| { - let mut picked = None; - let mut labels = Vec::new(); - let mut events = Vec::new(); - if let Some(pos) = click { - events.push(egui::Event::PointerMoved(pos)); - events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); - events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); - } - let input = egui::RawInput { - screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 600.0))), - events, - ..Default::default() - }; - let mut out = ctx.run_ui(input, |ui| { - picked = swatch_menu_with(ui, &doc, "m", "[Black]", 130.0, text); - }); - out.textures_delta.clear(); - for s in out.shapes { - if let egui::Shape::Text(t) = s.shape { - labels.push((t.galley.job.text.clone(), t.visual_bounding_rect().center())); - } - } - (picked, labels) - }; - for text in [Some("Text Color"), None] { - let ctx = egui::Context::default(); - let (_, labels) = menu(&ctx, text, None); - let combo = labels.iter().find(|(t, _)| t == "[Black]").unwrap().1; - menu(&ctx, text, Some(combo)); - let (_, labels) = menu(&ctx, text, None); - let item = labels.iter().find(|(t, _)| t == "Text Color").map(|l| l.1); - assert_eq!(item.is_some(), text.is_some(), "{labels:?}"); - if let Some(pos) = item { - let (picked, _) = menu(&ctx, text, Some(pos)); - assert_eq!(picked.as_deref(), Some(designcraft_doc::TEXT_COLOR)); - } - } - } - - /// The course's chapter 20: a paragraph style's rule below becomes wavy, 3 pt, with a gap - /// colour, from Paragraph Rules › Type and Gap Color. - #[test] - fn paragraph_style_rules_take_a_type_and_a_gap_colour() { - let (mut app, _) = ruled_paragraph(); - let ctx = egui::Context::default(); - app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "rules", "rule": "ruleBelow"}))); - draw(&mut app, &ctx); - if let Some(d) = app.ui.dialog.as_mut() { - d.fields.insert("p.ruleBelow.type".into(), json!({"kind": "wavy"})); - d.fields.insert("p.ruleBelow.weight".into(), json!(3.0)); - d.fields.insert("p.ruleBelow.gapColor".into(), json!("[Paper]")); - } - draw(&mut app, &ctx); - confirm(&mut app).unwrap(); - let r = app.session.doc().unwrap().doc.styles.resolve_para_style("Ruled Child").0.rule_below; - assert_eq!((r.kind, r.weight, r.gap_color.as_str()), (designcraft_doc::StrokeType::Wavy, 3.0, "[Paper]")); - } - - /// A rule's Gap Tint shows 100% at its default (the rule's JSON leaves a full gap tint out). - #[test] - fn a_rules_default_gap_tint_shows_100() { - use egui_kittest::kittest::Queryable; - let (mut app, _) = ruled_paragraph(); - app.run( - "style.paragraph.edit", - json!({"name": "Ruled Child", "para": {"ruleBelow": {"type": {"kind": "dashed", "pattern": [4.0, 2.0]}, "gapColor": "[Paper]"}}}), - ) - .unwrap(); - let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); - h.state_mut().app.ui.dialog = - Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "rules", "rule": "ruleBelow"}))); - h.run_steps(4); - let label = h.get_by_label("Gap Tint:").rect(); - let field = h - .get_all_by(|n| n.role() == egui::accesskit::Role::TextInput) - .filter(|n| n.rect().center().x > label.max.x && (n.rect().center().y - label.center().y).abs() < 8.0) - .min_by(|a, b| a.rect().min.x.total_cmp(&b.rect().min.x)) - .and_then(|n| n.value()); - assert_eq!(field.as_deref(), Some("100%")); - } - #[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 84cbf3bea..37452dd27 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3521,3 +3521,92 @@ 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)); } + +/// Paragraph Rules › Color offers Text Color above the swatches, so a rule set to a swatch can +/// go back to it; Gap Color lists only swatches. +#[test] +fn the_rule_colour_menu_offers_text_color() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let doc = app.session.doc().unwrap().doc.clone(); + let menu = |ctx: &egui::Context, text: Option<&str>, click: Option| { + let mut picked = None; + let mut labels = Vec::new(); + let mut events = Vec::new(); + if let Some(pos) = click { + events.push(egui::Event::PointerMoved(pos)); + events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + } + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 600.0))), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + picked = swatch_menu_with(ui, &doc, "m", "[Black]", 130.0, text); + }); + out.textures_delta.clear(); + for s in out.shapes { + if let egui::Shape::Text(t) = s.shape { + labels.push((t.galley.job.text.clone(), t.visual_bounding_rect().center())); + } + } + (picked, labels) + }; + for text in [Some("Text Color"), None] { + let ctx = egui::Context::default(); + let (_, labels) = menu(&ctx, text, None); + let combo = labels.iter().find(|(t, _)| t == "[Black]").unwrap().1; + menu(&ctx, text, Some(combo)); + let (_, labels) = menu(&ctx, text, None); + let item = labels.iter().find(|(t, _)| t == "Text Color").map(|l| l.1); + assert_eq!(item.is_some(), text.is_some(), "{labels:?}"); + if let Some(pos) = item { + let (picked, _) = menu(&ctx, text, Some(pos)); + assert_eq!(picked.as_deref(), Some(designcraft_doc::TEXT_COLOR)); + } + } +} + +/// The course's chapter 20: a paragraph style's rule below becomes wavy, 3 pt, with a gap +/// colour, from Paragraph Rules › Type and Gap Color. +#[test] +fn paragraph_style_rules_take_a_type_and_a_gap_colour() { + let (mut app, _) = ruled_paragraph(); + let ctx = egui::Context::default(); + app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "rules", "rule": "ruleBelow"}))); + draw(&mut app, &ctx); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("p.ruleBelow.type".into(), json!({"kind": "wavy"})); + d.fields.insert("p.ruleBelow.weight".into(), json!(3.0)); + d.fields.insert("p.ruleBelow.gapColor".into(), json!("[Paper]")); + } + draw(&mut app, &ctx); + confirm(&mut app).unwrap(); + let r = app.session.doc().unwrap().doc.styles.resolve_para_style("Ruled Child").0.rule_below; + assert_eq!((r.kind, r.weight, r.gap_color.as_str()), (designcraft_doc::StrokeType::Wavy, 3.0, "[Paper]")); +} + +/// A rule's Gap Tint shows 100% at its default (the rule's JSON leaves a full gap tint out). +#[test] +fn a_rules_default_gap_tint_shows_100() { + use egui_kittest::kittest::Queryable; + let (mut app, _) = ruled_paragraph(); + app.run( + "style.paragraph.edit", + json!({"name": "Ruled Child", "para": {"ruleBelow": {"type": {"kind": "dashed", "pattern": [4.0, 2.0]}, "gapColor": "[Paper]"}}}), + ) + .unwrap(); + let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); + h.state_mut().app.ui.dialog = + Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "rules", "rule": "ruleBelow"}))); + h.run_steps(4); + let label = h.get_by_label("Gap Tint:").rect(); + let field = h + .get_all_by(|n| n.role() == egui::accesskit::Role::TextInput) + .filter(|n| n.rect().center().x > label.max.x && (n.rect().center().y - label.center().y).abs() < 8.0) + .min_by(|a, b| a.rect().min.x.total_cmp(&b.rect().min.x)) + .and_then(|n| n.value()); + assert_eq!(field.as_deref(), Some("100%")); +} From 954e3158f1e4b8d0c2c7d30cb3fc4f0a0aedc615 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 19:44:29 +0100 Subject: [PATCH 354/628] 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 | 128 ---------------------------- crates/ui-egui/src/dialogs/tests.rs | 128 ++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 128 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 98324fb58..9183444cf 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -186,134 +186,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - /// The course's chapter 20: the "Recipe Categories" title formatted on the page (size, All - /// Caps, tracking, space after), then New Paragraph Style from it: the dialog starts from the - /// text, and with Apply Style to Selection the paragraph takes the new style. Next Style and - /// Reset To Base are set from General. - #[test] - fn a_new_paragraph_style_starts_from_the_selected_text() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Egg Recipes\rBoil them."})).unwrap(); - app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 11})).unwrap(); - app.run("type.char", json!({"attrs": {"size": 20, "capitalization": "allCaps", "tracking": 50}})).unwrap(); - app.run("type.para", json!({"attrs": {"spaceAfter": 6}})).unwrap(); - open_new_style(&mut app, true); - let d = app.ui.dialog.clone().unwrap(); - assert_eq!( - (d.fields["c.size"].clone(), d.fields["c.capitalization"].clone(), d.fields["p.spaceAfter"].clone()), - (json!(20.0), json!("allCaps"), json!(6.0)) - ); - assert!(d.b("apply") && !d.fields.contains_key("c.fontFamily"), "only what differs from [No Paragraph Style]"); - if let Some(d) = app.ui.dialog.as_mut() { - d.fields.insert("rename".into(), json!("Recipe Categories")); - d.fields.insert("nextStyle".into(), json!(designcraft_doc::BASIC_PARAGRAPH)); - } - confirm(&mut app).unwrap(); - let a = app.run("type.selectionAttrs", json!({})).unwrap(); - assert_eq!(a["paragraphStyle"], "Recipe Categories", "applied to the selection"); - assert_eq!(a["paraOverrides"].as_u64().unwrap_or(0) + a["charOverrides"].as_u64().unwrap_or(0), 0); - let style = |app: &DesignApp| app.session.doc().unwrap().doc.styles.para("Recipe Categories").cloned().unwrap(); - assert_eq!(style(&app).next_style.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); - assert_eq!(style(&app).chars.size, Some(20.0)); - // Reset To Base: the style keeps nothing of its own, apart from the edits made then. - app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Recipe Categories", "basedOn": designcraft_doc::BASIC_PARAGRAPH}))); - if let Some(d) = app.ui.dialog.as_mut() { - d.fields.insert("resetToBase".into(), json!(true)); - d.fields.insert("c.tracking".into(), json!(10.0)); - } - confirm(&mut app).unwrap(); - let s = style(&app); - assert_eq!((s.chars.size, s.chars.tracking, s.para.space_after), (None, Some(10.0), None), "{s:?}"); - assert_eq!(s.based_on.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); - } - - /// The course's chapter 22: "eggs" made bold and red on the page, then New Character Style - /// from it. The dialog is titled as InDesign's, starts from what the text has of its own - /// over its paragraph style ("[None] + Bold + color"), and Apply Style to Selection gives the - /// text the style, with no override left. - #[test] - fn a_new_character_style_starts_from_the_selected_text() { - use egui_kittest::{Harness, kittest::Queryable}; - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Drop eggs into boiling water."})).unwrap(); - app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 29})).unwrap(); - app.run("style.paragraph.create", json!({"name": "Recipe", "chars": {"size": 14}})).unwrap(); - app.run("style.paragraph.apply", json!({"name": "Recipe"})).unwrap(); - app.run("text.select", json!({"story": f["story"], "anchor": 5, "focus": 9})).unwrap(); - app.run("type.char", json!({"attrs": {"fontStyle": "Bold", "fill": "C=15 M=100 Y=100 K=0"}})).unwrap(); - open_new_style(&mut app, false); - let d = app.ui.dialog.clone().unwrap(); - assert_eq!((d.fields["c.fontStyle"].clone(), d.fields["c.fill"].clone()), (json!("Bold"), json!("C=15 M=100 Y=100 K=0"))); - assert!(d.b("apply")); - let mut h = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state( - |ui, app: &mut DesignApp| { - let key = egui::Id::new("new_character_style_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.ctx()); - }, - app, - ); - h.run_steps(4); - for l in ["New Character Style", "Apply Style to Selection"] { - assert!(h.query_by_label(l).is_some(), "{l}"); - } - let app = h.state_mut(); - if let Some(d) = app.ui.dialog.as_mut() { - d.fields.insert("rename".into(), json!("eggs")); - } - confirm(app).unwrap(); - let style = app.session.doc().unwrap().doc.styles.char_style("eggs").cloned().unwrap(); - assert_eq!((style.chars.font_style.as_deref(), style.chars.fill.as_deref()), (Some("Bold"), Some("C=15 M=100 Y=100 K=0"))); - assert_eq!(style.chars.size, None, "the size is the paragraph style's, not the text's own"); - let a = app.run("type.selectionAttrs", json!({})).unwrap(); - assert_eq!((a["characterStyle"].clone(), a["charOverrides"].clone()), (json!("eggs"), json!(0)), "applied to the selection"); - } - - /// A Based On changed in the dialog (without Reset To Base) is what the fields are checked - /// against: the style's own settings over the new parent. - #[test] - fn a_changed_based_on_is_what_the_fields_are_checked_against() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - app.run("style.paragraph.create", json!({"name": "Loose", "para": {"wordSpaceMin": 1.2, "wordSpaceDesired": 1.3}})).unwrap(); - app.run("style.paragraph.create", json!({"name": "Child", "basedOn": "Loose"})).unwrap(); - let mut d = Dialog::new("paragraphStyleOptions", json!({"name": "Child"})); - d.fields.insert("p.wordSpaceDesired".into(), json!(1.0)); - assert!(check_paragraph_style_justification(&app, &d).is_err(), "below Loose's minimum"); - d.fields.insert("basedOn".into(), json!(designcraft_doc::BASIC_PARAGRAPH)); - assert_eq!(check_paragraph_style_justification(&app, &d), Ok(()), "[Basic Paragraph]'s minimum is lower"); - } - - /// With Reset To Base, the justification fields are checked against the base style (the - /// style's own minimum no longer applies), and Preview shows the reset on the page. - #[test] - fn reset_to_base_checks_and_previews_against_the_base() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Loose words"})).unwrap(); - app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 5})).unwrap(); - app.run("style.paragraph.create", json!({"name": "Loose", "basedOn": designcraft_doc::BASIC_PARAGRAPH, "para": {"wordSpaceMin": 1.2, "wordSpaceDesired": 1.3}, "chars": {"size": 30}})) - .unwrap(); - app.run("style.paragraph.apply", json!({"name": "Loose"})).unwrap(); - let mut d = Dialog::new("paragraphStyleOptions", json!({"name": "Loose"})); - d.fields.insert("p.wordSpaceDesired".into(), json!(1.1)); - assert!(check_paragraph_style_justification(&app, &d).is_err(), "below the style's own minimum"); - d.fields.insert("resetToBase".into(), json!(true)); - assert_eq!(check_paragraph_style_justification(&app, &d), Ok(()), "the base's minimum is 100%"); - d.fields.insert("preview".into(), json!(true)); - style_options_preview(&mut app, &mut d); - assert_eq!(d.fields[PREVIEWED]["resetToBase"], true); - let size = app.run("type.selectionAttrs", json!({})).unwrap()["chars"]["size"].clone(); - assert_eq!(size, json!(12.0), "the page shows the style reset to its base"); - app.session.cancel_interaction(); - } - #[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 ee94dd4c2..20f6e9aba 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3524,3 +3524,131 @@ 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)); } + +/// The course's chapter 20: the "Recipe Categories" title formatted on the page (size, All +/// Caps, tracking, space after), then New Paragraph Style from it: the dialog starts from the +/// text, and with Apply Style to Selection the paragraph takes the new style. Next Style and +/// Reset To Base are set from General. +#[test] +fn a_new_paragraph_style_starts_from_the_selected_text() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Egg Recipes\rBoil them."})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 11})).unwrap(); + app.run("type.char", json!({"attrs": {"size": 20, "capitalization": "allCaps", "tracking": 50}})).unwrap(); + app.run("type.para", json!({"attrs": {"spaceAfter": 6}})).unwrap(); + open_new_style(&mut app, true); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!( + (d.fields["c.size"].clone(), d.fields["c.capitalization"].clone(), d.fields["p.spaceAfter"].clone()), + (json!(20.0), json!("allCaps"), json!(6.0)) + ); + assert!(d.b("apply") && !d.fields.contains_key("c.fontFamily"), "only what differs from [No Paragraph Style]"); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("rename".into(), json!("Recipe Categories")); + d.fields.insert("nextStyle".into(), json!(designcraft_doc::BASIC_PARAGRAPH)); + } + confirm(&mut app).unwrap(); + let a = app.run("type.selectionAttrs", json!({})).unwrap(); + assert_eq!(a["paragraphStyle"], "Recipe Categories", "applied to the selection"); + assert_eq!(a["paraOverrides"].as_u64().unwrap_or(0) + a["charOverrides"].as_u64().unwrap_or(0), 0); + let style = |app: &DesignApp| app.session.doc().unwrap().doc.styles.para("Recipe Categories").cloned().unwrap(); + assert_eq!(style(&app).next_style.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); + assert_eq!(style(&app).chars.size, Some(20.0)); + // Reset To Base: the style keeps nothing of its own, apart from the edits made then. + app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Recipe Categories", "basedOn": designcraft_doc::BASIC_PARAGRAPH}))); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("resetToBase".into(), json!(true)); + d.fields.insert("c.tracking".into(), json!(10.0)); + } + confirm(&mut app).unwrap(); + let s = style(&app); + assert_eq!((s.chars.size, s.chars.tracking, s.para.space_after), (None, Some(10.0), None), "{s:?}"); + assert_eq!(s.based_on.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); +} + +/// The course's chapter 22: "eggs" made bold and red on the page, then New Character Style +/// from it. The dialog is titled as InDesign's, starts from what the text has of its own +/// over its paragraph style ("[None] + Bold + color"), and Apply Style to Selection gives the +/// text the style, with no override left. +#[test] +fn a_new_character_style_starts_from_the_selected_text() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Drop eggs into boiling water."})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 29})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Recipe", "chars": {"size": 14}})).unwrap(); + app.run("style.paragraph.apply", json!({"name": "Recipe"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 5, "focus": 9})).unwrap(); + app.run("type.char", json!({"attrs": {"fontStyle": "Bold", "fill": "C=15 M=100 Y=100 K=0"}})).unwrap(); + open_new_style(&mut app, false); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!((d.fields["c.fontStyle"].clone(), d.fields["c.fill"].clone()), (json!("Bold"), json!("C=15 M=100 Y=100 K=0"))); + assert!(d.b("apply")); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("new_character_style_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.ctx()); + }, + app, + ); + h.run_steps(4); + for l in ["New Character Style", "Apply Style to Selection"] { + assert!(h.query_by_label(l).is_some(), "{l}"); + } + let app = h.state_mut(); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("rename".into(), json!("eggs")); + } + confirm(app).unwrap(); + let style = app.session.doc().unwrap().doc.styles.char_style("eggs").cloned().unwrap(); + assert_eq!((style.chars.font_style.as_deref(), style.chars.fill.as_deref()), (Some("Bold"), Some("C=15 M=100 Y=100 K=0"))); + assert_eq!(style.chars.size, None, "the size is the paragraph style's, not the text's own"); + let a = app.run("type.selectionAttrs", json!({})).unwrap(); + assert_eq!((a["characterStyle"].clone(), a["charOverrides"].clone()), (json!("eggs"), json!(0)), "applied to the selection"); +} + +/// A Based On changed in the dialog (without Reset To Base) is what the fields are checked +/// against: the style's own settings over the new parent. +#[test] +fn a_changed_based_on_is_what_the_fields_are_checked_against() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Loose", "para": {"wordSpaceMin": 1.2, "wordSpaceDesired": 1.3}})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Child", "basedOn": "Loose"})).unwrap(); + let mut d = Dialog::new("paragraphStyleOptions", json!({"name": "Child"})); + d.fields.insert("p.wordSpaceDesired".into(), json!(1.0)); + assert!(check_paragraph_style_justification(&app, &d).is_err(), "below Loose's minimum"); + d.fields.insert("basedOn".into(), json!(designcraft_doc::BASIC_PARAGRAPH)); + assert_eq!(check_paragraph_style_justification(&app, &d), Ok(()), "[Basic Paragraph]'s minimum is lower"); +} + +/// With Reset To Base, the justification fields are checked against the base style (the +/// style's own minimum no longer applies), and Preview shows the reset on the page. +#[test] +fn reset_to_base_checks_and_previews_against_the_base() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Loose words"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 5})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Loose", "basedOn": designcraft_doc::BASIC_PARAGRAPH, "para": {"wordSpaceMin": 1.2, "wordSpaceDesired": 1.3}, "chars": {"size": 30}})) + .unwrap(); + app.run("style.paragraph.apply", json!({"name": "Loose"})).unwrap(); + let mut d = Dialog::new("paragraphStyleOptions", json!({"name": "Loose"})); + d.fields.insert("p.wordSpaceDesired".into(), json!(1.1)); + assert!(check_paragraph_style_justification(&app, &d).is_err(), "below the style's own minimum"); + d.fields.insert("resetToBase".into(), json!(true)); + assert_eq!(check_paragraph_style_justification(&app, &d), Ok(()), "the base's minimum is 100%"); + d.fields.insert("preview".into(), json!(true)); + style_options_preview(&mut app, &mut d); + assert_eq!(d.fields[PREVIEWED]["resetToBase"], true); + let size = app.run("type.selectionAttrs", json!({})).unwrap()["chars"]["size"].clone(); + assert_eq!(size, json!(12.0), "the page shows the style reset to its base"); + app.session.cancel_interaction(); +} From d4cebe862e30fc0ceb1147763b62d0665bb037a8 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 19:44:30 +0100 Subject: [PATCH 355/628] 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 | 81 ----------------------------- crates/ui-egui/src/dialogs/tests.rs | 81 +++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 81 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0cf58d61d..e286dc94f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -186,87 +186,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - /// The course's chapter 20: the "Recipe Categories" title formatted on the page (size, All - /// Caps, tracking, space after), then New Paragraph Style from it: the dialog starts from the - /// text, and with Apply Style to Selection the paragraph takes the new style. Next Style and - /// Reset To Base are set from General. - #[test] - fn a_new_paragraph_style_starts_from_the_selected_text() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Egg Recipes\rBoil them."})).unwrap(); - app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 11})).unwrap(); - app.run("type.char", json!({"attrs": {"size": 20, "capitalization": "allCaps", "tracking": 50}})).unwrap(); - app.run("type.para", json!({"attrs": {"spaceAfter": 6}})).unwrap(); - open_new_style(&mut app, true); - let d = app.ui.dialog.clone().unwrap(); - assert_eq!( - (d.fields["c.size"].clone(), d.fields["c.capitalization"].clone(), d.fields["p.spaceAfter"].clone()), - (json!(20.0), json!("allCaps"), json!(6.0)) - ); - assert!(d.b("apply") && !d.fields.contains_key("c.fontFamily"), "only what differs from [No Paragraph Style]"); - if let Some(d) = app.ui.dialog.as_mut() { - d.fields.insert("rename".into(), json!("Recipe Categories")); - d.fields.insert("nextStyle".into(), json!(designcraft_doc::BASIC_PARAGRAPH)); - } - confirm(&mut app).unwrap(); - let a = app.run("type.selectionAttrs", json!({})).unwrap(); - assert_eq!(a["paragraphStyle"], "Recipe Categories", "applied to the selection"); - assert_eq!(a["paraOverrides"].as_u64().unwrap_or(0) + a["charOverrides"].as_u64().unwrap_or(0), 0); - let style = |app: &DesignApp| app.session.doc().unwrap().doc.styles.para("Recipe Categories").cloned().unwrap(); - assert_eq!(style(&app).next_style.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); - assert_eq!(style(&app).chars.size, Some(20.0)); - // Reset To Base: the style keeps nothing of its own, apart from the edits made then. - app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Recipe Categories", "basedOn": designcraft_doc::BASIC_PARAGRAPH}))); - if let Some(d) = app.ui.dialog.as_mut() { - d.fields.insert("resetToBase".into(), json!(true)); - d.fields.insert("c.tracking".into(), json!(10.0)); - } - confirm(&mut app).unwrap(); - let s = style(&app); - assert_eq!((s.chars.size, s.chars.tracking, s.para.space_after), (None, Some(10.0), None), "{s:?}"); - assert_eq!(s.based_on.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); - } - - /// A Based On changed in the dialog (without Reset To Base) is what the fields are checked - /// against: the style's own settings over the new parent. - #[test] - fn a_changed_based_on_is_what_the_fields_are_checked_against() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - app.run("style.paragraph.create", json!({"name": "Loose", "para": {"wordSpaceMin": 1.2, "wordSpaceDesired": 1.3}})).unwrap(); - app.run("style.paragraph.create", json!({"name": "Child", "basedOn": "Loose"})).unwrap(); - let mut d = Dialog::new("paragraphStyleOptions", json!({"name": "Child"})); - d.fields.insert("p.wordSpaceDesired".into(), json!(1.0)); - assert!(check_paragraph_style_justification(&app, &d).is_err(), "below Loose's minimum"); - d.fields.insert("basedOn".into(), json!(designcraft_doc::BASIC_PARAGRAPH)); - assert_eq!(check_paragraph_style_justification(&app, &d), Ok(()), "[Basic Paragraph]'s minimum is lower"); - } - - /// With Reset To Base, the justification fields are checked against the base style (the - /// style's own minimum no longer applies), and Preview shows the reset on the page. - #[test] - fn reset_to_base_checks_and_previews_against_the_base() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Loose words"})).unwrap(); - app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 5})).unwrap(); - app.run("style.paragraph.create", json!({"name": "Loose", "basedOn": designcraft_doc::BASIC_PARAGRAPH, "para": {"wordSpaceMin": 1.2, "wordSpaceDesired": 1.3}, "chars": {"size": 30}})) - .unwrap(); - app.run("style.paragraph.apply", json!({"name": "Loose"})).unwrap(); - let mut d = Dialog::new("paragraphStyleOptions", json!({"name": "Loose"})); - d.fields.insert("p.wordSpaceDesired".into(), json!(1.1)); - assert!(check_paragraph_style_justification(&app, &d).is_err(), "below the style's own minimum"); - d.fields.insert("resetToBase".into(), json!(true)); - assert_eq!(check_paragraph_style_justification(&app, &d), Ok(()), "the base's minimum is 100%"); - d.fields.insert("preview".into(), json!(true)); - style_options_preview(&mut app, &mut d); - assert_eq!(d.fields[PREVIEWED]["resetToBase"], true); - let size = app.run("type.selectionAttrs", json!({})).unwrap()["chars"]["size"].clone(); - assert_eq!(size, json!(12.0), "the page shows the style reset to its base"); - app.session.cancel_interaction(); - } - #[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 0d1430061..d221100cd 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3522,3 +3522,84 @@ 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)); } + +/// The course's chapter 20: the "Recipe Categories" title formatted on the page (size, All +/// Caps, tracking, space after), then New Paragraph Style from it: the dialog starts from the +/// text, and with Apply Style to Selection the paragraph takes the new style. Next Style and +/// Reset To Base are set from General. +#[test] +fn a_new_paragraph_style_starts_from_the_selected_text() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Egg Recipes\rBoil them."})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 11})).unwrap(); + app.run("type.char", json!({"attrs": {"size": 20, "capitalization": "allCaps", "tracking": 50}})).unwrap(); + app.run("type.para", json!({"attrs": {"spaceAfter": 6}})).unwrap(); + open_new_style(&mut app, true); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!( + (d.fields["c.size"].clone(), d.fields["c.capitalization"].clone(), d.fields["p.spaceAfter"].clone()), + (json!(20.0), json!("allCaps"), json!(6.0)) + ); + assert!(d.b("apply") && !d.fields.contains_key("c.fontFamily"), "only what differs from [No Paragraph Style]"); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("rename".into(), json!("Recipe Categories")); + d.fields.insert("nextStyle".into(), json!(designcraft_doc::BASIC_PARAGRAPH)); + } + confirm(&mut app).unwrap(); + let a = app.run("type.selectionAttrs", json!({})).unwrap(); + assert_eq!(a["paragraphStyle"], "Recipe Categories", "applied to the selection"); + assert_eq!(a["paraOverrides"].as_u64().unwrap_or(0) + a["charOverrides"].as_u64().unwrap_or(0), 0); + let style = |app: &DesignApp| app.session.doc().unwrap().doc.styles.para("Recipe Categories").cloned().unwrap(); + assert_eq!(style(&app).next_style.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); + assert_eq!(style(&app).chars.size, Some(20.0)); + // Reset To Base: the style keeps nothing of its own, apart from the edits made then. + app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Recipe Categories", "basedOn": designcraft_doc::BASIC_PARAGRAPH}))); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("resetToBase".into(), json!(true)); + d.fields.insert("c.tracking".into(), json!(10.0)); + } + confirm(&mut app).unwrap(); + let s = style(&app); + assert_eq!((s.chars.size, s.chars.tracking, s.para.space_after), (None, Some(10.0), None), "{s:?}"); + assert_eq!(s.based_on.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); +} + +/// A Based On changed in the dialog (without Reset To Base) is what the fields are checked +/// against: the style's own settings over the new parent. +#[test] +fn a_changed_based_on_is_what_the_fields_are_checked_against() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Loose", "para": {"wordSpaceMin": 1.2, "wordSpaceDesired": 1.3}})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Child", "basedOn": "Loose"})).unwrap(); + let mut d = Dialog::new("paragraphStyleOptions", json!({"name": "Child"})); + d.fields.insert("p.wordSpaceDesired".into(), json!(1.0)); + assert!(check_paragraph_style_justification(&app, &d).is_err(), "below Loose's minimum"); + d.fields.insert("basedOn".into(), json!(designcraft_doc::BASIC_PARAGRAPH)); + assert_eq!(check_paragraph_style_justification(&app, &d), Ok(()), "[Basic Paragraph]'s minimum is lower"); +} + +/// With Reset To Base, the justification fields are checked against the base style (the +/// style's own minimum no longer applies), and Preview shows the reset on the page. +#[test] +fn reset_to_base_checks_and_previews_against_the_base() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Loose words"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 5})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Loose", "basedOn": designcraft_doc::BASIC_PARAGRAPH, "para": {"wordSpaceMin": 1.2, "wordSpaceDesired": 1.3}, "chars": {"size": 30}})) + .unwrap(); + app.run("style.paragraph.apply", json!({"name": "Loose"})).unwrap(); + let mut d = Dialog::new("paragraphStyleOptions", json!({"name": "Loose"})); + d.fields.insert("p.wordSpaceDesired".into(), json!(1.1)); + assert!(check_paragraph_style_justification(&app, &d).is_err(), "below the style's own minimum"); + d.fields.insert("resetToBase".into(), json!(true)); + assert_eq!(check_paragraph_style_justification(&app, &d), Ok(()), "the base's minimum is 100%"); + d.fields.insert("preview".into(), json!(true)); + style_options_preview(&mut app, &mut d); + assert_eq!(d.fields[PREVIEWED]["resetToBase"], true); + let size = app.run("type.selectionAttrs", json!({})).unwrap()["chars"]["size"].clone(); + assert_eq!(size, json!(12.0), "the page shows the style reset to its base"); + app.session.cancel_interaction(); +} From 5335e0a92fc8c976bdfa2fb7f03c3a53432b2f75 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 19:51:12 +0100 Subject: [PATCH 356/628] 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 aed32a85058dafd427706905d01b58de1945e465 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 19:52:38 +0100 Subject: [PATCH 357/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/tests.rs | 3 +- crates/ui-egui/src/panels/properties/tests.rs | 92 +++++++++---------- 2 files changed, 47 insertions(+), 48 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 1cfd51abb..333cdab6a 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3551,8 +3551,7 @@ fn paragraph_border_sides_and_corners_link_as_indesigns() { use egui_kittest::kittest::Queryable; let (app, _) = ruled_paragraph(); let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); - h.state_mut().app.ui.dialog = - Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "border", "p.borderOn": true}))); + h.state_mut().app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "border", "p.borderOn": true}))); h.run_steps(4); let field = |h: &egui_kittest::Harness<'static, crate::test_window::Window>, key: &str| { h.state().app.ui.dialog.as_ref().and_then(|d| d.fields.get(&format!("p.{key}")).cloned()).unwrap_or_default() diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 7bb88cc2c..5c9feea47 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -710,55 +710,55 @@ fn several_shapes_show_pathfinder_and_intersect_combines_them() { assert!((r.x0 - 200.0).abs() < 0.5 && (r.y1 - 200.0).abs() < 0.5 && (r.x1 - 300.0).abs() < 0.5, "{r:?}"); } - /// A row is as wide as its translated name needs: a long one isn't cut at a fixed width. - #[test] - fn preview_rows_fit_long_translated_names() { - let ctx = egui::Context::default(); - let long = "Скругленный прямоугольник с обратным углом"; - let mut widths = (0.0, 0.0); - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - egui::Area::new(egui::Id::new("rows")).show(ui.ctx(), |ui| { - ui.set_max_width(10.0); - let r = preview_row(ui, false, long, 40.0, |_, _, _| {}); - let text = ui.fonts_mut(|f| f.layout_no_wrap(long.to_string(), egui::FontId::proportional(12.0), egui::Color32::WHITE).size().x); - widths = (r.rect.width(), 40.0 + 12.0 + text); - }); +/// A row is as wide as its translated name needs: a long one isn't cut at a fixed width. +#[test] +fn preview_rows_fit_long_translated_names() { + let ctx = egui::Context::default(); + let long = "Скругленный прямоугольник с обратным углом"; + let mut widths = (0.0, 0.0); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + egui::Area::new(egui::Id::new("rows")).show(ui.ctx(), |ui| { + ui.set_max_width(10.0); + let r = preview_row(ui, false, long, 40.0, |_, _, _| {}); + let text = ui.fonts_mut(|f| f.layout_no_wrap(long.to_string(), egui::FontId::proportional(12.0), egui::Color32::WHITE).size().x); + widths = (r.rect.width(), 40.0 + 12.0 + text); }); - out.textures_delta.clear(); - assert!(widths.0 >= widths.1, "{widths:?}"); - } + }); + out.textures_delta.clear(); + assert!(widths.0 >= widths.1, "{widths:?}"); +} - /// The corner shape list shows each shape's glyph beside its name, as InDesign's; a row picks - /// the shape and the current one is marked. - #[test] - fn the_corner_shape_list_has_glyph_rows_and_picks_a_shape() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); - app.run("selection.set", json!({"ids": [id]})).unwrap(); - let mut h = panel(app); - h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Corner Shape").click(); - h.run_steps(3); - let rows = ["None", "Fancy", "Bevel", "Inset", "Inverse Rounded", "Rounded"]; - for r in rows { - let _ = h.get_by_role_and_label(egui::accesskit::Role::Button, r); - } - h.get_by_role_and_label(egui::accesskit::Role::Button, "Bevel").click(); - h.run_steps(3); - let it = h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().clone(); - assert!(it.corners.corners.iter().all(|c| c.shape == designcraft_geom::corners::CornerShape::Bevel), "{:?}", it.corners); - h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Corner Shape").click(); - h.run_steps(3); - use egui_kittest::kittest::NodeT; - let n = h.get_by_role_and_label(egui::accesskit::Role::Button, "Bevel"); - assert_eq!(n.accesskit_node().toggled(), Some(egui::accesskit::Toggled::True), "the current shape is marked"); - // The stroke type list has the same rows, by name. - h.key_press(egui::Key::Escape); - h.run_steps(2); - h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Stroke Type").click(); - h.run_steps(3); - let _ = h.get_by_role_and_label(egui::accesskit::Role::Button, "Thick - Thin"); +/// The corner shape list shows each shape's glyph beside its name, as InDesign's; a row picks +/// the shape and the current one is marked. +#[test] +fn the_corner_shape_list_has_glyph_rows_and_picks_a_shape() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let mut h = panel(app); + h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Corner Shape").click(); + h.run_steps(3); + let rows = ["None", "Fancy", "Bevel", "Inset", "Inverse Rounded", "Rounded"]; + for r in rows { + let _ = h.get_by_role_and_label(egui::accesskit::Role::Button, r); } + h.get_by_role_and_label(egui::accesskit::Role::Button, "Bevel").click(); + h.run_steps(3); + let it = h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().clone(); + assert!(it.corners.corners.iter().all(|c| c.shape == designcraft_geom::corners::CornerShape::Bevel), "{:?}", it.corners); + h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Corner Shape").click(); + h.run_steps(3); + use egui_kittest::kittest::NodeT; + let n = h.get_by_role_and_label(egui::accesskit::Role::Button, "Bevel"); + assert_eq!(n.accesskit_node().toggled(), Some(egui::accesskit::Toggled::True), "the current shape is marked"); + // The stroke type list has the same rows, by name. + h.key_press(egui::Key::Escape); + h.run_steps(2); + h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Stroke Type").click(); + h.run_steps(3); + let _ = h.get_by_role_and_label(egui::accesskit::Role::Button, "Thick - Thin"); +} const CYAN: &str = "C=100 M=0 Y=0 K=0"; From 0268dd40e72a4031f81a1234dd4a3c683a7adc10 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 19:57:07 +0100 Subject: [PATCH 358/628] IDML export: paragraph overprint attributes through main's attribute macros Co-Authored-By: Claude Opus 5.5 --- crates/idml/src/export/text_attrs.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/idml/src/export/text_attrs.rs b/crates/idml/src/export/text_attrs.rs index 6fddcfeb2..7071f68ba 100644 --- a/crates/idml/src/export/text_attrs.rs +++ b/crates/idml/src/export/text_attrs.rs @@ -494,7 +494,7 @@ impl<'a> Ex<'a> { props.push(p("ParagraphShadingColor", "object", self.sw(c))); } n!(a, el, shading_tint, "ParagraphShadingTint", pct); - b!(shading_overprint, "ParagraphShadingOverprint"); + b!(a, el, shading_overprint, "ParagraphShadingOverprint"); const SIDES: [&str; 4] = ["Top", "Left", "Bottom", "Right"]; if let Some(o) = a.shading_offsets { for (i, side) in SIDES.iter().enumerate() { @@ -506,7 +506,7 @@ impl<'a> Ex<'a> { props.push(p("ParagraphBorderColor", "object", self.sw(c))); } n!(a, el, border_tint, "ParagraphBorderTint", pct); - b!(border_overprint, "ParagraphBorderOverprint"); + b!(a, el, border_overprint, "ParagraphBorderOverprint"); if let Some(w) = a.border_weights { for (i, side) in SIDES.iter().enumerate() { el.set(&format!("ParagraphBorder{side}LineWeight"), num(w[i])); From be7861364b87d0d5c6245e844b26b32233464e9d Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 19:57:14 +0100 Subject: [PATCH 359/628] IDML export: the border gap overprint through main's attribute macro too Co-Authored-By: Claude Opus 5.5 --- crates/idml/src/export/text_attrs.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/idml/src/export/text_attrs.rs b/crates/idml/src/export/text_attrs.rs index 7071f68ba..557602a18 100644 --- a/crates/idml/src/export/text_attrs.rs +++ b/crates/idml/src/export/text_attrs.rs @@ -542,7 +542,7 @@ impl<'a> Ex<'a> { props.push(p("ParagraphBorderGapColor", "object", self.sw(c))); } n!(a, el, border_gap_tint, "ParagraphBorderGapTint", pct); - b!(border_gap_overprint, "ParagraphBorderGapOverprint"); + b!(a, el, border_gap_overprint, "ParagraphBorderGapOverprint"); if let Some(w) = a.shading_width { el.set("ParagraphShadingWidth", names::box_width_out(w)); } From 42183766680f7716bdc73d0a59f0f7f25a5f821a Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 19:57:22 +0100 Subject: [PATCH 360/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/tests.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 9c99c92b4..339354ac4 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3551,8 +3551,7 @@ fn preferences_interface_sets_the_color_theme_and_tools_panel() { app, ); h.run_steps(3); - let swatch = - |h: &Harness<'_, DesignApp>, name: &str| h.get_by(|n| n.role() == Role::RadioButton && n.label().as_deref() == Some(name)).click(); + let swatch = |h: &Harness<'_, DesignApp>, name: &str| h.get_by(|n| n.role() == Role::RadioButton && n.label().as_deref() == Some(name)).click(); for l in ["Appearance", "Color Theme:", "Panels", "Tools Panel:"] { assert!(h.query_by_label(l).is_some(), "{l}"); } From c20d4864e32e167d619a76269d72907611be669b Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 20:01:16 +0100 Subject: [PATCH 361/628] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/src/dialogs/tests.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d9d48e416..7fb6037dc 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1988,7 +1988,7 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he ui.painter().rect_stroke(r.shrink(1.0), 2.0, egui::Stroke::new(2.0, ring), egui::StrokeKind::Inside); } let name = crate::i18n::tr(&lang, b.label()).to_string(); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, true, on, &name)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, true, on, &name)); if resp.on_hover_text(&name).clicked() && !on { d.fields.insert("brightness".into(), json!(b.id())); d.fields.insert("applyBrightness".into(), json!(b.id())); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 339354ac4..bdcac47a9 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3540,7 +3540,7 @@ fn preferences_interface_sets_the_color_theme_and_tools_panel() { open(&mut app); let mut h = Harness::builder().with_size(egui::vec2(1100.0, 760.0)).build_ui_state( |ui, app: &mut DesignApp| { - let key = egui::Id::new("prefs_interface_test_fonts"); + let key = egui::Id::unique("prefs_interface_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 fcb4b5c544893df314bf784b79d97b80bdf05af9 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 20:03:23 +0100 Subject: [PATCH 362/628] Georgian: main's row for Single Column, once Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ka.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index b5779b321..e328562c2 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -33,7 +33,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Copy", "კოპირება"), ("Paste", "ჩასმა"), ("Double Column", "ორმაგი სვეტი"), - ("Single Column", "ერთმაგი სვეტი"), ("Tools Panel:", "ხელსაწყოების პანელი:"), ("Color Theme:", "ფერთა თემა:"), ("Restart Numbering", "ნუმერაციის თავიდან დაწყება"), From e72193c210c41554ac09def7beee1ef27fbad0b2 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 20:31:27 +0100 Subject: [PATCH 363/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties/tests.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 73c600a55..59694954e 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -786,15 +786,15 @@ fn apply_to_text_colours_a_text_frames_text_not_the_frame() { assert_eq!(doc.item(designcraft_doc::ids::ItemId(id)).unwrap().fill.swatch, "[None]", "the frame isn't"); } - /// The Align To button shows the target picked in its menu. - #[test] - fn the_align_to_button_shows_its_target() { - assert_eq!(align_to_icon("selection"), "align-to"); - assert_eq!(align_to_icon("keyObject"), "align-to-key"); - assert_eq!(align_to_icon("margins"), "align-to-margins"); - assert_eq!(align_to_icon("page"), "align-to-page"); - assert_eq!(align_to_icon("spread"), "align-to-spread"); - } +/// The Align To button shows the target picked in its menu. +#[test] +fn the_align_to_button_shows_its_target() { + assert_eq!(align_to_icon("selection"), "align-to"); + assert_eq!(align_to_icon("keyObject"), "align-to-key"); + assert_eq!(align_to_icon("margins"), "align-to-margins"); + assert_eq!(align_to_icon("page"), "align-to-page"); + assert_eq!(align_to_icon("spread"), "align-to-spread"); +} #[test] fn transform_flips_and_rotates_from_properties() { From e3fd046ecc23595f62031319e28fbbd823177a9f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 20:50:36 +0100 Subject: [PATCH 364/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties/tests.rs | 92 +++++++++---------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 7bb88cc2c..5c9feea47 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -710,55 +710,55 @@ fn several_shapes_show_pathfinder_and_intersect_combines_them() { assert!((r.x0 - 200.0).abs() < 0.5 && (r.y1 - 200.0).abs() < 0.5 && (r.x1 - 300.0).abs() < 0.5, "{r:?}"); } - /// A row is as wide as its translated name needs: a long one isn't cut at a fixed width. - #[test] - fn preview_rows_fit_long_translated_names() { - let ctx = egui::Context::default(); - let long = "Скругленный прямоугольник с обратным углом"; - let mut widths = (0.0, 0.0); - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - egui::Area::new(egui::Id::new("rows")).show(ui.ctx(), |ui| { - ui.set_max_width(10.0); - let r = preview_row(ui, false, long, 40.0, |_, _, _| {}); - let text = ui.fonts_mut(|f| f.layout_no_wrap(long.to_string(), egui::FontId::proportional(12.0), egui::Color32::WHITE).size().x); - widths = (r.rect.width(), 40.0 + 12.0 + text); - }); +/// A row is as wide as its translated name needs: a long one isn't cut at a fixed width. +#[test] +fn preview_rows_fit_long_translated_names() { + let ctx = egui::Context::default(); + let long = "Скругленный прямоугольник с обратным углом"; + let mut widths = (0.0, 0.0); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + egui::Area::new(egui::Id::new("rows")).show(ui.ctx(), |ui| { + ui.set_max_width(10.0); + let r = preview_row(ui, false, long, 40.0, |_, _, _| {}); + let text = ui.fonts_mut(|f| f.layout_no_wrap(long.to_string(), egui::FontId::proportional(12.0), egui::Color32::WHITE).size().x); + widths = (r.rect.width(), 40.0 + 12.0 + text); }); - out.textures_delta.clear(); - assert!(widths.0 >= widths.1, "{widths:?}"); - } + }); + out.textures_delta.clear(); + assert!(widths.0 >= widths.1, "{widths:?}"); +} - /// The corner shape list shows each shape's glyph beside its name, as InDesign's; a row picks - /// the shape and the current one is marked. - #[test] - fn the_corner_shape_list_has_glyph_rows_and_picks_a_shape() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); - app.run("selection.set", json!({"ids": [id]})).unwrap(); - let mut h = panel(app); - h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Corner Shape").click(); - h.run_steps(3); - let rows = ["None", "Fancy", "Bevel", "Inset", "Inverse Rounded", "Rounded"]; - for r in rows { - let _ = h.get_by_role_and_label(egui::accesskit::Role::Button, r); - } - h.get_by_role_and_label(egui::accesskit::Role::Button, "Bevel").click(); - h.run_steps(3); - let it = h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().clone(); - assert!(it.corners.corners.iter().all(|c| c.shape == designcraft_geom::corners::CornerShape::Bevel), "{:?}", it.corners); - h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Corner Shape").click(); - h.run_steps(3); - use egui_kittest::kittest::NodeT; - let n = h.get_by_role_and_label(egui::accesskit::Role::Button, "Bevel"); - assert_eq!(n.accesskit_node().toggled(), Some(egui::accesskit::Toggled::True), "the current shape is marked"); - // The stroke type list has the same rows, by name. - h.key_press(egui::Key::Escape); - h.run_steps(2); - h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Stroke Type").click(); - h.run_steps(3); - let _ = h.get_by_role_and_label(egui::accesskit::Role::Button, "Thick - Thin"); +/// The corner shape list shows each shape's glyph beside its name, as InDesign's; a row picks +/// the shape and the current one is marked. +#[test] +fn the_corner_shape_list_has_glyph_rows_and_picks_a_shape() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "shape": "rectangle"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let mut h = panel(app); + h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Corner Shape").click(); + h.run_steps(3); + let rows = ["None", "Fancy", "Bevel", "Inset", "Inverse Rounded", "Rounded"]; + for r in rows { + let _ = h.get_by_role_and_label(egui::accesskit::Role::Button, r); } + h.get_by_role_and_label(egui::accesskit::Role::Button, "Bevel").click(); + h.run_steps(3); + let it = h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().clone(); + assert!(it.corners.corners.iter().all(|c| c.shape == designcraft_geom::corners::CornerShape::Bevel), "{:?}", it.corners); + h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Corner Shape").click(); + h.run_steps(3); + use egui_kittest::kittest::NodeT; + let n = h.get_by_role_and_label(egui::accesskit::Role::Button, "Bevel"); + assert_eq!(n.accesskit_node().toggled(), Some(egui::accesskit::Toggled::True), "the current shape is marked"); + // The stroke type list has the same rows, by name. + h.key_press(egui::Key::Escape); + h.run_steps(2); + h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Stroke Type").click(); + h.run_steps(3); + let _ = h.get_by_role_and_label(egui::accesskit::Role::Button, "Thick - Thin"); +} const CYAN: &str = "C=100 M=0 Y=0 K=0"; From e5b17c21845f3a224e8b52da7f6f856aadc60322 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 20:56:41 +0100 Subject: [PATCH 365/628] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 5c9feea47..4458dae4f 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -717,7 +717,7 @@ fn preview_rows_fit_long_translated_names() { let long = "Скругленный прямоугольник с обратным углом"; let mut widths = (0.0, 0.0); let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - egui::Area::new(egui::Id::new("rows")).show(ui.ctx(), |ui| { + egui::Area::new(egui::Id::unique("rows")).show(ui.ctx(), |ui| { ui.set_max_width(10.0); let r = preview_row(ui, false, long, 40.0, |_, _, _| {}); let text = ui.fonts_mut(|f| f.layout_no_wrap(long.to_string(), egui::FontId::proportional(12.0), egui::Color32::WHITE).size().x); From 5677b4021cbd23c3121ecd0a3c619c7bfae93824 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 21:00:41 +0100 Subject: [PATCH 366/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties/tests.rs | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index e2ebf6feb..ea3c385bc 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -786,33 +786,33 @@ fn apply_to_text_colours_a_text_frames_text_not_the_frame() { assert_eq!(doc.item(designcraft_doc::ids::ItemId(id)).unwrap().fill.swatch, "[None]", "the frame isn't"); } - #[test] - fn apply_to_text_colours_the_text_of_several_text_frames() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let mut ids = vec![]; - let mut stories = vec![]; - for x in [72, 250, 430] { - let r = app.run("frame.create", json!({"rect": [x, 72, x + 150, 140], "content": "text", "text": "75%"})).unwrap(); - ids.push(r["id"].as_u64().unwrap()); - stories.push(designcraft_doc::StoryId(r["story"].as_u64().unwrap())); - } - app.run("selection.set", json!({"ids": ids})).unwrap(); - let mut h = panel(app); - h.get_by_label("Fill swatch").click(); - h.run_steps(3); - // Three text frames, as one: Apply To is offered. - assert!(h.query_by_label("Apply To:").is_some()); - h.get_by_label("Text").click(); - h.run_steps(3); - h.get_by_label(CYAN).click(); - h.run_steps(3); - let doc = &h.state().session.active().unwrap().doc; - for (id, story) in ids.iter().zip(&stories) { - assert_eq!(doc.story(*story).unwrap().char_format_at(0).over.fill.as_deref(), Some(CYAN), "every frame's text is cyan"); - assert_eq!(doc.item(designcraft_doc::ids::ItemId(*id)).unwrap().fill.swatch, "[None]", "the frames aren't"); - } +#[test] +fn apply_to_text_colours_the_text_of_several_text_frames() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let mut ids = vec![]; + let mut stories = vec![]; + for x in [72, 250, 430] { + let r = app.run("frame.create", json!({"rect": [x, 72, x + 150, 140], "content": "text", "text": "75%"})).unwrap(); + ids.push(r["id"].as_u64().unwrap()); + stories.push(designcraft_doc::StoryId(r["story"].as_u64().unwrap())); } + app.run("selection.set", json!({"ids": ids})).unwrap(); + let mut h = panel(app); + h.get_by_label("Fill swatch").click(); + h.run_steps(3); + // Three text frames, as one: Apply To is offered. + assert!(h.query_by_label("Apply To:").is_some()); + h.get_by_label("Text").click(); + h.run_steps(3); + h.get_by_label(CYAN).click(); + h.run_steps(3); + let doc = &h.state().session.active().unwrap().doc; + for (id, story) in ids.iter().zip(&stories) { + assert_eq!(doc.story(*story).unwrap().char_format_at(0).over.fill.as_deref(), Some(CYAN), "every frame's text is cyan"); + assert_eq!(doc.item(designcraft_doc::ids::ItemId(*id)).unwrap().fill.swatch, "[None]", "the frames aren't"); + } +} #[test] fn transform_flips_and_rotates_from_properties() { From 49da13445a3e4d8118b639b2c45e8ded705a47fc Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 21:30:57 +0100 Subject: [PATCH 367/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/tests.rs | 198 ++++++++++++++-------------- 1 file changed, 99 insertions(+), 99 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 706459679..83527c2ee 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -592,7 +592,7 @@ fn the_hex_field_keeps_typed_text_and_commits_a_valid_code() { dialog_frame(&mut app, &ctx, screen, vec![select_all_key()]); type_slowly(&mut app, &ctx, screen, "zz"); let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "#:").center()); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "#:").center()); let labels = dialog_frame(&mut app, &ctx, screen, vec![]); visible_label(&labels, screen, "f59019"); assert_eq!(app.ui.dialog.as_ref().unwrap().s("hex"), "#f59019"); @@ -602,13 +602,13 @@ fn the_hex_field_keeps_typed_text_and_commits_a_valid_code() { fn typed_channel_values_are_kept_while_typing() { let (mut app, ctx, screen, id) = picker(json!({"target": "fill", "hex": "#010203", "model": "rgb", "rgb": [1, 2, 3]})); let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - let r = visible_label(&labels, screen, "R:"); - click_dialog(&mut app, &ctx, screen, egui::pos2(r.min.x + 46.0, r.center().y)); + let r = visible_label(&labels, screen, "R:"); + click_dialog(&mut app, &ctx, screen, egui::pos2(r.min.x + 46.0, r.center().y)); dialog_frame(&mut app, &ctx, screen, vec![select_all_key()]); type_slowly(&mut app, &ctx, screen, "12.5"); let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - visible_label(&labels, screen, "12.5"); - assert_eq!(app.ui.dialog.as_ref().unwrap().fields["rgb"][1], json!(2.0)); + visible_label(&labels, screen, "12.5"); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["rgb"][1], json!(2.0)); assert_eq!(app.ui.dialog.as_ref().unwrap().s("hex"), "#0d0203"); confirm(&mut app).unwrap(); let designcraft_color::Color::Rgb { r, .. } = fill(&app, id) else { panic!("not RGB") }; @@ -630,8 +630,8 @@ fn a_cmyk_colour_stays_cmyk_unless_rgb_values_are_typed() { // A typed C/M/Y/K percent is a percent: M 1 is 1 %. let (mut app, ctx, screen, id) = picker(cmyk.clone()); let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - let r = visible_label(&labels, screen, "M:"); - click_dialog(&mut app, &ctx, screen, egui::pos2(r.max.x + 29.0, r.center().y)); + let r = visible_label(&labels, screen, "M:"); + click_dialog(&mut app, &ctx, screen, egui::pos2(r.max.x + 29.0, r.center().y)); dialog_frame(&mut app, &ctx, screen, vec![select_all_key()]); type_slowly(&mut app, &ctx, screen, "1"); confirm(&mut app).unwrap(); @@ -641,9 +641,9 @@ fn a_cmyk_colour_stays_cmyk_unless_rgb_values_are_typed() { let (mut app, ctx, screen, id) = picker(cmyk); let r = app.ui.dialog.as_ref().unwrap().fields["rgb"][0].as_f64().unwrap(); let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - let _ = r; - let r = visible_label(&labels, screen, "R:"); - click_dialog(&mut app, &ctx, screen, egui::pos2(r.min.x + 46.0, r.center().y)); + let _ = r; + let r = visible_label(&labels, screen, "R:"); + click_dialog(&mut app, &ctx, screen, egui::pos2(r.min.x + 46.0, r.center().y)); dialog_frame(&mut app, &ctx, screen, vec![select_all_key()]); type_slowly(&mut app, &ctx, screen, "10"); confirm(&mut app).unwrap(); @@ -1644,7 +1644,7 @@ fn dialog_frame(app: &mut DesignApp, ctx: &egui::Context, screen: egui::Rect, ev fn visible_label(labels: &[(String, egui::Rect, egui::Rect)], screen: egui::Rect, expected: &str) -> egui::Rect { let (_, rect, clip) = labels.iter().find(|(text, _, _)| text.trim() == expected).unwrap_or_else(|| panic!("{expected} is painted")); assert!(screen.contains_rect(*rect), "{expected} must stay on screen: {rect:?}"); - assert!(clip.expand(0.5).contains_rect(*rect), "{expected} must not be clipped: {rect:?}, {clip:?}"); + assert!(clip.expand(0.5).contains_rect(*rect), "{expected} must not be clipped: {rect:?}, {clip:?}"); *rect } @@ -3462,96 +3462,96 @@ fn guide_counts(app: &DesignApp) -> Vec { (0..doc.page_count()).map(|abs| doc.page(abs).unwrap().guides.len()).collect() } - /// The Color Picker laid out as InDesign's: a field and a slider for the component whose radio - /// is on, the new colour over the old one, and the values in every space. - #[test] +/// The Color Picker laid out as InDesign's: a field and a slider for the component whose radio +/// is on, the new colour over the old one, and the values in every space. +#[test] - fn the_color_picker_keeps_a_typed_cmyk_value_the_colour_shown_does_not_change_with() { - use egui_kittest::kittest::Queryable; - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - app.ui.dialog = Some(Dialog::new("colorPicker", json!({"target": "fill", "cmyk": [0.0, 0.0, 0.0, 100.0], "text": false}))); - let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); - h.run_steps(3); - // A C typed at 100% K is kept, whether or not the colour shown changes with it. - let rgb = h.state().app.ui.dialog.as_ref().unwrap().fields.get("rgb").cloned(); - h.get_by_label("C").focus(); - h.run_steps(1); - h.event(egui::Event::Text("50".into())); - h.run_steps(2); - let d = h.state().app.ui.dialog.as_ref().unwrap(); - assert_eq!(d.fields["cmyk"], json!([50.0, 0.0, 0.0, 100.0]), "rgb {:?} (was {rgb:?})", d.fields.get("rgb")); - assert!(d.b("edited")); - } +fn the_color_picker_keeps_a_typed_cmyk_value_the_colour_shown_does_not_change_with() { + use egui_kittest::kittest::Queryable; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.ui.dialog = Some(Dialog::new("colorPicker", json!({"target": "fill", "cmyk": [0.0, 0.0, 0.0, 100.0], "text": false}))); + let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); + h.run_steps(3); + // A C typed at 100% K is kept, whether or not the colour shown changes with it. + let rgb = h.state().app.ui.dialog.as_ref().unwrap().fields.get("rgb").cloned(); + h.get_by_label("C").focus(); + h.run_steps(1); + h.event(egui::Event::Text("50".into())); + h.run_steps(2); + let d = h.state().app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.fields["cmyk"], json!([50.0, 0.0, 0.0, 100.0]), "rgb {:?} (was {rgb:?})", d.fields.get("rgb")); + assert!(d.b("edited")); +} - #[test] - fn the_color_picker_picks_on_its_field_and_slider_and_adds_a_cmyk_swatch() { - use egui_kittest::kittest::Queryable; - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - app.run("frame.create", json!({"rect": [72, 72, 200, 200], "shape": "rectangle"})).unwrap(); - app.run("object.color", json!({"color":designcraft_color::Color::from_hex("#7c1315").unwrap(),"target":"fill"})).unwrap(); - app.ui.dialog = Some(Dialog::new("colorPicker", json!({"target": "fill", "hex": "#7c1315", "text": false}))); - let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); - h.run_steps(3); - // R at first: R up the slider, B across and G up the field. - let _ = h.get_by_label("RGB Color Space View"); - let field = h.get_by_label("Color field").rect(); - crate::test_window::click_at(&mut h, egui::pos2(field.right() - 1.0, field.top() + 1.0)); - h.run_steps(2); - let p = picked_of(h.state().app.ui.dialog.as_ref().unwrap()); - assert!((p.rgb[0] - 124.0 / 255.0).abs() < 0.01 && p.rgb[1] > 0.97 && p.rgb[2] > 0.97, "{p:?}: R kept, G and B full"); - // The slider sets R alone. - let slider = h.get_by_label("Color slider").rect(); - crate::test_window::click_at(&mut h, egui::pos2(slider.center().x, slider.bottom() - 1.0)); - h.run_steps(2); - let p = picked_of(h.state().app.ui.dialog.as_ref().unwrap()); - assert!(p.rgb[0] < 0.02 && p.rgb[1] > 0.97, "{p:?}"); - // Hue on the slider: the field is saturation across, brightness up. - store_picked(h.state_mut().app.ui.dialog.as_mut().unwrap(), &Picked::from_rgb([124.0 / 255.0, 19.0 / 255.0, 21.0 / 255.0], 0.0)); - h.state_mut().app.ui.dialog.as_mut().unwrap().fields.insert("component".into(), json!("h")); - h.run_steps(2); - crate::test_window::click_at(&mut h, egui::pos2(field.left() + 1.0, field.top() + 1.0)); - h.run_steps(2); - let p = picked_of(h.state().app.ui.dialog.as_ref().unwrap()); - assert!(p.hsb[1] < 0.02 && p.hsb[2] > 0.98 && (p.hsb[0] - 359.0).abs() < 2.0, "{p:?}: no saturation, full brightness, the hue kept"); - // Lab shows its own view. - h.state_mut().app.ui.dialog.as_mut().unwrap().fields.insert("component".into(), json!("l")); - h.run_steps(2); - let _ = h.get_by_label("Lab Color Space View"); - // Add CMYK Swatch adds the colour as a CMYK swatch; OK applies it. - store_picked(h.state_mut().app.ui.dialog.as_mut().unwrap(), &Picked::from_rgb([124.0 / 255.0, 19.0 / 255.0, 21.0 / 255.0], 0.0)); - h.run_steps(2); - let before = h.state().app.session.active().unwrap().doc.swatches.len(); - h.get_by_label("Add CMYK Swatch").click(); - h.run_steps(3); - let doc = &h.state().app.session.active().unwrap().doc; - assert_eq!(doc.swatches.len(), before + 1); - assert!(matches!( - doc.swatches.last().unwrap().value, - designcraft_color::swatch::SwatchValue::Color { color: designcraft_color::Color::Cmyk { .. }, .. } - )); - // The percentages the C/M/Y/K fields show, exactly (not the colour converted back). - h.state_mut().app.ui.dialog.as_mut().unwrap().fields.insert("cmyk".into(), json!([0.0, 50.0, 100.0, 0.0])); - h.run_steps(2); - h.get_by_label("Add CMYK Swatch").click(); - h.run_steps(3); - let doc = &h.state().app.session.active().unwrap().doc; - let designcraft_color::swatch::SwatchValue::Color { color: designcraft_color::Color::Cmyk { c, m, y, k }, .. } = - doc.swatches.last().unwrap().value - else { - panic!("a CMYK swatch") - }; - assert_eq!([c, m, y, k].map(|v| (v * 100.0).round()), [0.0, 50.0, 100.0, 0.0]); - // Its own OK, at the top right, applies it. - h.get_by_label("OK").click(); - h.run_steps(3); - assert!(h.state().app.ui.dialog.is_none(), "OK closes it"); - let st = h.state().app.session.active().unwrap(); - let id = st.selection.items[0]; - let fill = st.doc.item(id).unwrap().fill.swatch.clone(); - assert_eq!(st.doc.resolve_color(&fill, 1.0).unwrap().to_hex(), "#7c1315"); - } +#[test] +fn the_color_picker_picks_on_its_field_and_slider_and_adds_a_cmyk_swatch() { + use egui_kittest::kittest::Queryable; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [72, 72, 200, 200], "shape": "rectangle"})).unwrap(); + app.run("object.color", json!({"color":designcraft_color::Color::from_hex("#7c1315").unwrap(),"target":"fill"})).unwrap(); + app.ui.dialog = Some(Dialog::new("colorPicker", json!({"target": "fill", "hex": "#7c1315", "text": false}))); + let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); + h.run_steps(3); + // R at first: R up the slider, B across and G up the field. + let _ = h.get_by_label("RGB Color Space View"); + let field = h.get_by_label("Color field").rect(); + crate::test_window::click_at(&mut h, egui::pos2(field.right() - 1.0, field.top() + 1.0)); + h.run_steps(2); + let p = picked_of(h.state().app.ui.dialog.as_ref().unwrap()); + assert!((p.rgb[0] - 124.0 / 255.0).abs() < 0.01 && p.rgb[1] > 0.97 && p.rgb[2] > 0.97, "{p:?}: R kept, G and B full"); + // The slider sets R alone. + let slider = h.get_by_label("Color slider").rect(); + crate::test_window::click_at(&mut h, egui::pos2(slider.center().x, slider.bottom() - 1.0)); + h.run_steps(2); + let p = picked_of(h.state().app.ui.dialog.as_ref().unwrap()); + assert!(p.rgb[0] < 0.02 && p.rgb[1] > 0.97, "{p:?}"); + // Hue on the slider: the field is saturation across, brightness up. + store_picked(h.state_mut().app.ui.dialog.as_mut().unwrap(), &Picked::from_rgb([124.0 / 255.0, 19.0 / 255.0, 21.0 / 255.0], 0.0)); + h.state_mut().app.ui.dialog.as_mut().unwrap().fields.insert("component".into(), json!("h")); + h.run_steps(2); + crate::test_window::click_at(&mut h, egui::pos2(field.left() + 1.0, field.top() + 1.0)); + h.run_steps(2); + let p = picked_of(h.state().app.ui.dialog.as_ref().unwrap()); + assert!(p.hsb[1] < 0.02 && p.hsb[2] > 0.98 && (p.hsb[0] - 359.0).abs() < 2.0, "{p:?}: no saturation, full brightness, the hue kept"); + // Lab shows its own view. + h.state_mut().app.ui.dialog.as_mut().unwrap().fields.insert("component".into(), json!("l")); + h.run_steps(2); + let _ = h.get_by_label("Lab Color Space View"); + // Add CMYK Swatch adds the colour as a CMYK swatch; OK applies it. + store_picked(h.state_mut().app.ui.dialog.as_mut().unwrap(), &Picked::from_rgb([124.0 / 255.0, 19.0 / 255.0, 21.0 / 255.0], 0.0)); + h.run_steps(2); + let before = h.state().app.session.active().unwrap().doc.swatches.len(); + h.get_by_label("Add CMYK Swatch").click(); + h.run_steps(3); + let doc = &h.state().app.session.active().unwrap().doc; + assert_eq!(doc.swatches.len(), before + 1); + assert!(matches!( + doc.swatches.last().unwrap().value, + designcraft_color::swatch::SwatchValue::Color { color: designcraft_color::Color::Cmyk { .. }, .. } + )); + // The percentages the C/M/Y/K fields show, exactly (not the colour converted back). + h.state_mut().app.ui.dialog.as_mut().unwrap().fields.insert("cmyk".into(), json!([0.0, 50.0, 100.0, 0.0])); + h.run_steps(2); + h.get_by_label("Add CMYK Swatch").click(); + h.run_steps(3); + let doc = &h.state().app.session.active().unwrap().doc; + let designcraft_color::swatch::SwatchValue::Color { color: designcraft_color::Color::Cmyk { c, m, y, k }, .. } = + doc.swatches.last().unwrap().value + else { + panic!("a CMYK swatch") + }; + assert_eq!([c, m, y, k].map(|v| (v * 100.0).round()), [0.0, 50.0, 100.0, 0.0]); + // Its own OK, at the top right, applies it. + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(h.state().app.ui.dialog.is_none(), "OK closes it"); + let st = h.state().app.session.active().unwrap(); + let id = st.selection.items[0]; + let fill = st.doc.item(id).unwrap().fill.swatch.clone(); + assert_eq!(st.doc.resolve_color(&fill, 1.0).unwrap().to_hex(), "#7c1315"); +} #[test] From 10e9970cfedaaecfe0fa9e76e2e8c80f92065d89 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 21:44:05 +0100 Subject: [PATCH 368/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/tests.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 4d9a1ddb6..ba5531870 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3604,10 +3604,7 @@ fn a_change_format_goes_with_change_all() { let mut d = Dialog::new("findChange", json!({"find": "cook"})); d.fields.insert(CHANGE_FORMAT.into(), json!({"charStyle": "eggs", "paraStyle": "Method", "chars": {"underline": true}})); let p = find_change_params(&d); - assert_eq!( - (p["charStyle"].clone(), p["paraStyle"].clone(), p["attrs"].clone()), - (json!("eggs"), json!("Method"), json!({"underline": true})) - ); + assert_eq!((p["charStyle"].clone(), p["paraStyle"].clone(), p["attrs"].clone()), (json!("eggs"), json!("Method"), json!({"underline": true}))); assert_eq!(change_format_summary("en", d.fields.get(CHANGE_FORMAT)), "Character Style: eggs + Paragraph Style: Method + Underline: on"); assert!(find_change_params(&Dialog::new("findChange", json!({"find": "cook"}))).get("attrs").is_none()); } From 8fec15f3dcf4152ffbec4ce7014df68c2f4c906e Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 21:44:21 +0100 Subject: [PATCH 369/628] Color Picker: the remembered colours seeded with map entries Keeps color_picker within clippy's cognitive complexity limit. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 608c4fcb6..2f0f0049d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -7225,16 +7225,12 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { use picker_layout as at; let lang = app.ui.language.clone(); let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); - if !d.fields.contains_key("old") { - let old = d.s("hex"); - d.fields.insert("old".into(), json!(old)); - } + let old = d.s("hex"); + d.fields.entry("old").or_insert_with(|| json!(old)); let comp = PickComp::from_key(&d.s("component")); let mut p = picked_of(d); let before = p; - if !d.fields.contains_key("rgb") { - d.fields.insert("rgb".into(), json!(p.rgb.map(|x| x * 255.0))); - } + d.fields.entry("rgb").or_insert_with(|| json!(p.rgb.map(|x| x * 255.0))); if !d.fields.contains_key("cmyk") { d.fields.insert( "cmyk".into(), From 54190bb88612ce19e6a245c63504e5367aa6c8af Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 21:44:29 +0100 Subject: [PATCH 370/628] Color Picker: the remembered colours seeded with map entries Keeps color_picker within clippy's cognitive complexity limit. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d345f1658..e13340a25 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -7225,16 +7225,12 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { use picker_layout as at; let lang = app.ui.language.clone(); let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); - if !d.fields.contains_key("old") { - let old = d.s("hex"); - d.fields.insert("old".into(), json!(old)); - } + let old = d.s("hex"); + d.fields.entry("old").or_insert_with(|| json!(old)); let comp = PickComp::from_key(&d.s("component")); let mut p = picked_of(d); let before = p; - if !d.fields.contains_key("rgb") { - d.fields.insert("rgb".into(), json!(p.rgb.map(|x| x * 255.0))); - } + d.fields.entry("rgb").or_insert_with(|| json!(p.rgb.map(|x| x * 255.0))); if !d.fields.contains_key("cmyk") { d.fields.insert( "cmyk".into(), From 27684daabefec09489f51f323bd709f39727e8c8 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 22:02:59 +0100 Subject: [PATCH 371/628] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 4 ++-- crates/ui-egui/src/dialogs/tests.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e0e47e22c..302a1b069 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3279,7 +3279,7 @@ const FORMAT_SECTIONS: &[(&str, &str)] = &[ fn info_icon(ui: &mut egui::Ui, tip: &str) { let (r, resp) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover()); crate::icons::paint(ui.painter(), r, "info", crate::theme::Tokens::get(ui.ctx()).icon); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, tip)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, tip)); resp.on_hover_text(tip); } @@ -3348,7 +3348,7 @@ fn change_format_settings(app: &mut DesignApp, ctx: &egui::Context, d: &mut Dial let cf = CharFields { base: &base, sparse: true }; let popup_open = egui::Popup::is_any_open(ctx); let mut result = None; - let host = egui::Id::new("change_format_settings"); + let host = egui::Id::unique("change_format_settings"); crate::modal_drag::modal(ctx, host, host).backdrop_color(egui::Color32::TRANSPARENT).show(ctx, |ui| { let title_top = ui.cursor().top(); crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Change Format Settings")).font(semibold(16.0))); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index ba5531870..f6f6f0e77 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3542,7 +3542,7 @@ fn find_change_gives_the_found_text_a_change_format() { } let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( |ui, app: &mut DesignApp| { - let key = egui::Id::new("change_format_test_fonts"); + let key = egui::Id::unique("change_format_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 c43319e0a2425c761e7f9057989d538eedee934d Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 22:07:17 +0100 Subject: [PATCH 372/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/tests.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 8dfe5c8c2..c80127706 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3604,10 +3604,7 @@ fn a_change_format_goes_with_change_all() { let mut d = Dialog::new("findChange", json!({"find": "cook"})); d.fields.insert(CHANGE_FORMAT.into(), json!({"charStyle": "eggs", "paraStyle": "Method", "chars": {"underline": true}})); let p = find_change_params(&d); - assert_eq!( - (p["charStyle"].clone(), p["paraStyle"].clone(), p["attrs"].clone()), - (json!("eggs"), json!("Method"), json!({"underline": true})) - ); + assert_eq!((p["charStyle"].clone(), p["paraStyle"].clone(), p["attrs"].clone()), (json!("eggs"), json!("Method"), json!({"underline": true}))); assert_eq!(format_summary("en", d.fields.get(CHANGE_FORMAT)), "Character Style: eggs + Paragraph Style: Method + Underline: on"); assert!(find_change_params(&Dialog::new("findChange", json!({"find": "cook"}))).get("attrs").is_none()); // A Find Format goes with every search. From 08bab7a4776d85750e709da6fa7035b8015c52e9 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 22:10:30 +0100 Subject: [PATCH 373/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/find/tests.rs | 40 ++++++++++++++--------------- crates/ui-egui/src/dialogs/tests.rs | 23 ++++++++--------- 2 files changed, 30 insertions(+), 33 deletions(-) diff --git a/crates/engine/src/cmd/find/tests.rs b/crates/engine/src/cmd/find/tests.rs index a60b40ebf..ae8742702 100644 --- a/crates/engine/src/cmd/find/tests.rs +++ b/crates/engine/src/cmd/find/tests.rs @@ -621,26 +621,26 @@ fn document_scope_searches_and_changes_table_cells() { assert_eq!(s.execute("find.find", &json!({"find": "13.00", "scope": "document"})).unwrap().as_array().unwrap().len(), 2); } - /// Change (`found`) on text Find Next found in a table cell changes it there, leaves the - /// same text in the story alone, and Find Next goes on after it. - #[test] - fn change_changes_the_text_find_next_found_in_a_cell() { - let (mut s, sid) = scope_session("red here"); - scope_select(&mut s, sid, 0, 0); - let table = s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap()["table"].as_u64().unwrap(); - s.execute("table.setCell", &json!({"row": 0, "col": 1, "text": "red"})).unwrap(); - let p = json!({"find": "red", "change": "blue", "scope": "document"}); - let hit = s.execute("find.next", &p).unwrap(); - assert_eq!(hit["cell"], json!({"table": table, "row": 0, "col": 1}), "{hit}"); - let mut change = p.clone(); - change["found"] = json!(true); - assert_eq!(s.execute("find.change", &change).unwrap()["count"], 1); - let t = s.execute("table.get", &json!({"table": table})).unwrap(); - assert_eq!(t["cells"][1]["text"], "blue"); - assert!(s.doc().unwrap().doc.story(sid).unwrap().text.contains("red here"), "the story's red stays"); - let next = s.execute("find.next", &p).unwrap(); - assert!(next.get("cell").is_none() && next.get("start").is_some(), "on to the story: {next}"); - } +/// Change (`found`) on text Find Next found in a table cell changes it there, leaves the +/// same text in the story alone, and Find Next goes on after it. +#[test] +fn change_changes_the_text_find_next_found_in_a_cell() { + let (mut s, sid) = scope_session("red here"); + scope_select(&mut s, sid, 0, 0); + let table = s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + s.execute("table.setCell", &json!({"row": 0, "col": 1, "text": "red"})).unwrap(); + let p = json!({"find": "red", "change": "blue", "scope": "document"}); + let hit = s.execute("find.next", &p).unwrap(); + assert_eq!(hit["cell"], json!({"table": table, "row": 0, "col": 1}), "{hit}"); + let mut change = p.clone(); + change["found"] = json!(true); + assert_eq!(s.execute("find.change", &change).unwrap()["count"], 1); + let t = s.execute("table.get", &json!({"table": table})).unwrap(); + assert_eq!(t["cells"][1]["text"], "blue"); + assert!(s.doc().unwrap().doc.story(sid).unwrap().text.contains("red here"), "the story's red stays"); + let next = s.execute("find.next", &p).unwrap(); + assert!(next.get("cell").is_none() && next.get("start").is_some(), "on to the story: {next}"); +} fn child_scope_session(note: bool, child: &str) -> (Session, designcraft_doc::StoryId, designcraft_doc::TextSel) { let (mut s, sid) = scope_session("red"); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 9d392ed90..153a83486 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -1647,14 +1647,14 @@ fn dialog_frame(app: &mut DesignApp, ctx: &egui::Context, screen: egui::Rect, ev labels } - /// What Find/Change's Search list shows for a scope. - fn scope_label(scope: &str) -> &'static str { - match scope { - "story" => "Story", - "selection" => "Selection", - _ => "Document", - } +/// What Find/Change's Search list shows for a scope. +fn scope_label(scope: &str) -> &'static str { + match scope { + "story" => "Story", + "selection" => "Selection", + _ => "Document", } +} fn visible_label(labels: &[(String, egui::Rect, egui::Rect)], screen: egui::Rect, expected: &str) -> egui::Rect { let (_, rect, clip) = labels.iter().find(|(text, _, _)| text.trim() == expected).unwrap_or_else(|| panic!("{expected} is painted")); @@ -1739,8 +1739,8 @@ fn scope_dialog_with_tool(text: &str, end: usize, tool: Option<&str>) -> (Design for _ in 0..3 { dialog_frame(&mut app, &ctx, screen, vec![]); } - scope_click(&mut app, &ctx, screen, "Document"); - scope_click(&mut app, &ctx, screen, "Selection"); + scope_click(&mut app, &ctx, screen, "Document"); + scope_click(&mut app, &ctx, screen, "Selection"); assert_eq!(app.ui.dialog.as_ref().unwrap().s("scope"), "selection"); (app, ctx, screen, sid) } @@ -3613,10 +3613,7 @@ fn a_change_format_goes_with_change_all() { let mut d = Dialog::new("findChange", json!({"find": "cook"})); d.fields.insert(CHANGE_FORMAT.into(), json!({"charStyle": "eggs", "paraStyle": "Method", "chars": {"underline": true}})); let p = find_change_params(&d); - assert_eq!( - (p["charStyle"].clone(), p["paraStyle"].clone(), p["attrs"].clone()), - (json!("eggs"), json!("Method"), json!({"underline": true})) - ); + assert_eq!((p["charStyle"].clone(), p["paraStyle"].clone(), p["attrs"].clone()), (json!("eggs"), json!("Method"), json!({"underline": true}))); assert_eq!(format_summary("en", d.fields.get(CHANGE_FORMAT)), "Character Style: eggs + Paragraph Style: Method + Underline: on"); assert!(find_change_params(&Dialog::new("findChange", json!({"find": "cook"}))).get("attrs").is_none()); // A Find Format goes with every search. From 776088c0d559b7fd4edc75aea078cd2e693a4d38 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 22:10:49 +0100 Subject: [PATCH 374/628] Paragraph style apply: the story's paragraph ranges, as main lists them Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/style/paragraph.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/style/paragraph.rs b/crates/engine/src/cmd/style/paragraph.rs index bccb8f6c2..bb70a8cfc 100644 --- a/crates/engine/src/cmd/style/paragraph.rs +++ b/crates/engine/src/cmd/style/paragraph.rs @@ -328,7 +328,7 @@ fn apply_para_in_story( f.chars.retain_differences(style_chars); } }); - for (pi, range) in st.paras_touching(r.clone()).into_iter().filter(|(pi, _)| (first..=last).contains(pi)) { + for (pi, range) in st.para_ranges().into_iter().enumerate().filter(|(pi, _)| (first..=last).contains(pi)) { let Some(pf) = st.paras.get(pi) else { continue }; let (_, base) = styles.resolve_para(pf); let end = range.end.saturating_add(1).min(st.len()); From d42333fb9d82c3757526cde9e3204484a1c0e7bd Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 22:10:58 +0100 Subject: [PATCH 375/628] Story: para_at's doc comment back on para_at Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/story.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/doc/src/story.rs b/crates/doc/src/story.rs index de753891c..fb04f9c9f 100644 --- a/crates/doc/src/story.rs +++ b/crates/doc/src/story.rs @@ -286,7 +286,6 @@ impl Story { v } - /// Index of the paragraph containing byte `pos`. /// The paragraphs `r` touches (at least the one at `r.start`), as (index, byte range without /// its return), found from `r` itself rather than by listing every paragraph of the story. pub fn paras_touching(&self, r: std::ops::Range) -> Vec<(usize, std::ops::Range)> { @@ -305,6 +304,7 @@ impl Story { } } + /// Index of the paragraph containing byte `pos`. pub fn para_at(&self, pos: usize) -> usize { self.text[..floor_char_boundary(&self.text, pos)].bytes().filter(|b| *b == b'\n').count() } From 573566fe658617a8e7bab0f45760272e49880ec3 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 22:12:48 +0100 Subject: [PATCH 376/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/tests.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 37452dd27..3f9bbfc10 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3538,11 +3538,8 @@ fn the_rule_colour_menu_offers_text_color() { events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); } - let input = egui::RawInput { - screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 600.0))), - events, - ..Default::default() - }; + let input = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 600.0))), events, ..Default::default() }; let mut out = ctx.run_ui(input, |ui| { picked = swatch_menu_with(ui, &doc, "m", "[Black]", 130.0, text); }); @@ -3599,8 +3596,7 @@ fn a_rules_default_gap_tint_shows_100() { ) .unwrap(); let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); - h.state_mut().app.ui.dialog = - Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "rules", "rule": "ruleBelow"}))); + h.state_mut().app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "rules", "rule": "ruleBelow"}))); h.run_steps(4); let label = h.get_by_label("Gap Tint:").rect(); let field = h From bca78235a54fb19ea6e7ef4f9e6532f7cfa16f40 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 22:13:13 +0100 Subject: [PATCH 377/628] Find/Change: the paragraph ranges as main lists them; the branch's find tests beside main's scope helpers Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/find.rs | 186 ----------------------- crates/engine/src/cmd/find/tests.rs | 186 +++++++++++++++++++++++ crates/engine/src/cmd/style/paragraph.rs | 2 +- 3 files changed, 187 insertions(+), 187 deletions(-) diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index 0586fee05..ca55b30ad 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -814,192 +814,6 @@ mod object_find_tests { use crate::Session; - #[test] - fn change_format_styles_the_found_text_and_keeps_it() { - // The course's chapter 22: every lowercase "eggs" gets the character style "eggs", with - // Change to empty, so the words stay. - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let text = "Poached Eggs\nBreak eggs; cook until eggs are set."; - let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": text})).unwrap(); - let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); - s.execute("style.character.create", &json!({"name": "eggs", "chars": {"fontStyle": "Bold", "fill": "C=15 M=100 Y=100 K=0"}})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Method"})).unwrap(); - let undo = s.doc().unwrap().history.undo.len(); - let r = s.execute("find.change", &json!({"find": "eggs", "change": "", "caseSensitive": true, "charStyle": "eggs"})).unwrap(); - assert_eq!(r["count"], 2); - assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); - let style_at = |s: &Session, at: usize| s.doc().unwrap().doc.story(sid).unwrap().format_after(at).style.clone(); - let story = s.doc().unwrap().doc.story(sid).unwrap().clone(); - assert_eq!(story.text, text, "the words stay"); - for at in [19, 36] { - assert_eq!(&story.text[at..at + 4], "eggs"); - assert_eq!(style_at(&s, at), "eggs"); - } - assert_eq!(style_at(&s, 8), designcraft_doc::NO_CHAR_STYLE, "case sensitive: not \"Eggs\""); - assert_eq!(style_at(&s, 25), designcraft_doc::NO_CHAR_STYLE, "only the found text"); - // A paragraph style goes to the found text's paragraphs, with attributes over the text. - s.execute("find.change", &json!({"find": "cook", "change": "", "paraStyle": "Method", "attrs": {"underline": true}})).unwrap(); - let story = s.doc().unwrap().doc.story(sid).unwrap().clone(); - assert_eq!((story.paras[0].style.as_str(), story.paras[1].style.as_str()), (designcraft_doc::BASIC_PARAGRAPH, "Method")); - assert_eq!(story.format_after(25).over.underline, Some(true)); - assert_eq!(story.format_after(19).style, "eggs", "the other text keeps its character style"); - // Change to and a Change Format: the new text is formatted. - s.execute("find.change", &json!({"find": "set", "change": "firm", "charStyle": "eggs"})).unwrap(); - assert_eq!(style_at(&s, s.doc().unwrap().doc.story(sid).unwrap().text.find("firm").unwrap()), "eggs"); - // No such style: refused, nothing changed. - let before = s.doc().unwrap().doc.clone(); - assert!(s.execute("find.change", &json!({"find": "eggs", "change": "", "charStyle": "Nope"})).is_err()); - assert!(s.execute("find.change", &json!({"find": "eggs", "change": "", "paraStyle": "Nope"})).is_err()); - assert_eq!(s.doc().unwrap().doc, before); - // With no Change Format, an empty Change to still deletes the found text. - s.execute("find.change", &json!({"find": "Break ", "change": ""})).unwrap(); - assert!(!s.doc().unwrap().doc.story(sid).unwrap().text.contains("Break")); - } - - #[test] - fn a_find_format_finds_only_text_with_it() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s - .execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Break eggs; cook eggs.\nEggs and eggs"})) - .unwrap(); - let sid = r["story"].as_u64().unwrap(); - s.execute("style.character.create", &json!({"name": "eggs", "chars": {"fontStyle": "Bold"}})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Method"})).unwrap(); - for (a, b) in [(6, 10), (12, 16)] { - s.execute("text.select", &json!({"story": sid, "anchor": a, "focus": b})).unwrap(); - s.execute("style.character.apply", &json!({"name": "eggs"})).unwrap(); - } - s.execute("text.select", &json!({"story": sid, "anchor": 25, "focus": 25})).unwrap(); - s.execute("style.paragraph.apply", &json!({"name": "Method"})).unwrap(); - let found = |s: &mut Session, p: serde_json::Value| -> Vec<(u64, u64)> { - let m = s.execute("find.find", &p).unwrap(); - m.as_array().unwrap().iter().map(|m| (m["start"].as_u64().unwrap(), m["end"].as_u64().unwrap())).collect() - }; - // "eggs" where it has the character style: the first only. - assert_eq!(found(&mut s, json!({"find": "eggs", "findFormat": {"charStyle": "eggs"}})), [(6, 10)]); - // Nothing in Find what: each stretch of text in the style. - assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"charStyle": "eggs"}})), [(6, 10), (12, 16)]); - // A paragraph style, and resolved attributes. - assert_eq!(found(&mut s, json!({"find": "eggs", "findFormat": {"paraStyle": "Method"}})), [(23, 27), (32, 36)]); - assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"chars": {"fontStyle": "Bold"}}})), [(6, 10), (12, 16)]); - assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"chars": {"size": 12}}})), [(0, 22), (23, 36)], "each paragraph apart"); - // Find Next walks the stretches. - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); - let next = json!({"find": "", "findFormat": {"charStyle": "eggs"}}); - assert_eq!(s.execute("find.next", &next).unwrap()["start"], 6); - assert_eq!(s.execute("find.next", &next).unwrap()["start"], 12); - // Equivalent spellings the attributes accept find the same text: a snake_case key, and a - // nested number written as an integer (14 pt leading on the second paragraph). - s.execute("text.select", &json!({"story": sid, "anchor": 23, "focus": 36})).unwrap(); - s.execute("type.char", &json!({"leading": {"kind": "points", "value": 14.0}})).unwrap(); - for (a, b) in [ - (json!({"fontStyle": "Bold"}), json!({"font_style": "Bold"})), - (json!({"leading": {"kind": "points", "value": 14.0}}), json!({"leading": {"kind": "points", "value": 14}})), - ] { - let (a, b) = (json!({"find": "", "findFormat": {"chars": a}}), json!({"find": "", "findFormat": {"chars": b}})); - let all = found(&mut s, a.clone()); - assert!(!all.is_empty(), "{a}"); - assert_eq!(found(&mut s, b.clone()), all, "{b}"); - for p in [&a, &b] { - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); - assert_eq!(s.execute("find.next", p).unwrap()["start"].as_u64(), Some(all[0].0), "{p}"); - } - } - // A null attribute asks for nothing. - assert_eq!(found(&mut s, json!({"find": "eggs", "findFormat": {"charStyle": "eggs", "chars": {"size": null}}})), [(6, 10)]); - // With a Change Format: the text in the style goes back to [None], underlined. - let r = s - .execute( - "find.change", - &json!({"find": "", "change": "", "findFormat": {"charStyle": "eggs", "chars": {"font_style": "Bold"}}, "charStyle": "[None]", "attrs": {"underline": true}}), - ) - .unwrap(); - assert_eq!(r["count"], 2, "a snake_case attribute changes the same text"); - assert!(found(&mut s, json!({"find": "", "findFormat": {"charStyle": "eggs"}})).is_empty()); - assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"chars": {"underline": true}}})), [(6, 10), (12, 16)]); - // Unknown styles and attributes are refused; an empty search needs a Find Format. - assert!(s.execute("find.find", &json!({"find": "", "findFormat": {"charStyle": "Nope"}})).is_err()); - assert!(s.execute("find.next", &json!({"find": "eggs", "findFormat": {"paraStyle": "Nope"}})).is_err()); - assert!(s.execute("find.find", &json!({"find": "eggs", "findFormat": {"chars": {"bogus": 1}}})).is_err()); - // Malformed fields are refused, not searched without. - for f in [json!({"chars": [1]}), json!({"chars": 12}), json!({"charStyle": 3}), json!({"paraStyle": ["Body"]})] { - assert!(s.execute("find.find", &json!({"find": "eggs", "findFormat": f})).is_err(), "{f}"); - } - assert!(s.execute("find.find", &json!({"find": ""})).is_err()); - } - - /// Change and Change/Find: only the text Find Next found changes; it stays selected and the - /// search goes on after it in its domain (here the original selection). A selection that - /// isn't a match changes nothing and makes no undo step. - #[test] - fn change_changes_the_found_text_and_the_search_goes_on() { - let (mut s, sid) = scope_session("red red | red"); - scope_select(&mut s, sid, 0, 7); - let p = json!({"find": "red", "change": "blue", "scope": "selection"}); - let mut change = p.clone(); - change["found"] = json!(true); - scope_hit(&mut s, &p, sid, 0, 3); - assert_eq!(s.execute("find.change", &change).unwrap()["count"], 1); - let text = |s: &Session| s.doc().unwrap().doc.story(sid).unwrap().text.clone(); - assert_eq!(text(&s), "blue red | red"); - assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 0..4, "the changed text stays selected"); - scope_hit(&mut s, &p, sid, 5, 8); - s.execute("find.change", &change).unwrap(); - assert_eq!(text(&s), "blue blue | red"); - assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null, "the last red is outside the selection searched"); - // Not a match: nothing changes, no undo step. - scope_select(&mut s, sid, 0, 4); - let history = s.doc().unwrap().history.undo.len(); - assert_eq!(s.execute("find.change", &json!({"find": "red", "change": "blue", "found": true})).unwrap()["count"], 0); - assert_eq!(s.doc().unwrap().history.undo.len(), history); - // A Change Format with nothing in Change to formats the found text only. - scope_select(&mut s, sid, 0, 0); - let fmt = json!({"find": "red", "change": "", "attrs": {"underline": true}}); - let hit = s.execute("find.next", &fmt).unwrap(); - assert_eq!(hit["start"], 12); - let mut found = fmt.clone(); - found["found"] = json!(true); - s.execute("find.change", &found).unwrap(); - assert_eq!(text(&s), "blue blue | red"); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().format_after(12).over.underline, Some(true)); - assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 12..15); - } - - /// Change accepts what Find Next selected even when Change All wouldn't match there: the - /// rest of a Find Format stretch from a caret inside it, and an overlapping match. - #[test] - fn change_takes_any_selection_find_next_made() { - // A caret inside a bold stretch: Find Next with only a Find Format selects the rest of it. - let (mut s, sid) = scope_session("plain bold text"); - s.execute("style.character.create", &json!({"name": "Strong", "chars": {"fontStyle": "Bold"}})).unwrap(); - scope_select(&mut s, sid, 6, 10); - s.execute("style.character.apply", &json!({"name": "Strong"})).unwrap(); - scope_select(&mut s, sid, 8, 8); - let p = json!({"find": "", "change": "", "findFormat": {"charStyle": "Strong"}, "attrs": {"underline": true}, "scope": "story"}); - let hit = s.execute("find.next", &p).unwrap(); - assert_eq!((hit["start"].as_u64(), hit["end"].as_u64()), (Some(8), Some(10))); - let mut change = p.clone(); - change["found"] = json!(true); - assert_eq!(s.execute("find.change", &change).unwrap()["count"], 1); - let st = s.doc().unwrap().doc.story(sid).unwrap().clone(); - assert_eq!((st.format_after(7).over.underline, st.format_after(8).over.underline), (None, Some(true)), "only the selected rest"); - // "aa" in "aaa" from the second a: Find Next selects 1..3, which Change All never finds. - for grep in [false, true] { - let (mut s, sid) = scope_session("aaa"); - scope_select(&mut s, sid, 1, 1); - let (find, change) = if grep { ("(a)a", "<$1>") } else { ("aa", "b") }; - let p = json!({"find": find, "change": change, "grep": grep, "scope": "story"}); - let hit = s.execute("find.next", &p).unwrap(); - assert_eq!((hit["start"].as_u64(), hit["end"].as_u64()), (Some(1), Some(3))); - let mut found = p.clone(); - found["found"] = json!(true); - assert_eq!(s.execute("find.change", &found).unwrap()["count"], 1, "grep: {grep}"); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, if grep { "a" } else { "ab" }); - } - } - #[test] fn find_and_change_objects_by_attributes() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/find/tests.rs b/crates/engine/src/cmd/find/tests.rs index ae8742702..8a679269e 100644 --- a/crates/engine/src/cmd/find/tests.rs +++ b/crates/engine/src/cmd/find/tests.rs @@ -768,3 +768,189 @@ fn find_scope_revert_releases_context_with_same_document_uid() { assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); assert!(s.execute("find.find", &p).is_err(), "revert's empty selection cannot inherit a previous range"); } + +#[test] +fn change_format_styles_the_found_text_and_keeps_it() { + // The course's chapter 22: every lowercase "eggs" gets the character style "eggs", with + // Change to empty, so the words stay. + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let text = "Poached Eggs\nBreak eggs; cook until eggs are set."; + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": text})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + s.execute("style.character.create", &json!({"name": "eggs", "chars": {"fontStyle": "Bold", "fill": "C=15 M=100 Y=100 K=0"}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Method"})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("find.change", &json!({"find": "eggs", "change": "", "caseSensitive": true, "charStyle": "eggs"})).unwrap(); + assert_eq!(r["count"], 2); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + let style_at = |s: &Session, at: usize| s.doc().unwrap().doc.story(sid).unwrap().format_after(at).style.clone(); + let story = s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert_eq!(story.text, text, "the words stay"); + for at in [19, 36] { + assert_eq!(&story.text[at..at + 4], "eggs"); + assert_eq!(style_at(&s, at), "eggs"); + } + assert_eq!(style_at(&s, 8), designcraft_doc::NO_CHAR_STYLE, "case sensitive: not \"Eggs\""); + assert_eq!(style_at(&s, 25), designcraft_doc::NO_CHAR_STYLE, "only the found text"); + // A paragraph style goes to the found text's paragraphs, with attributes over the text. + s.execute("find.change", &json!({"find": "cook", "change": "", "paraStyle": "Method", "attrs": {"underline": true}})).unwrap(); + let story = s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert_eq!((story.paras[0].style.as_str(), story.paras[1].style.as_str()), (designcraft_doc::BASIC_PARAGRAPH, "Method")); + assert_eq!(story.format_after(25).over.underline, Some(true)); + assert_eq!(story.format_after(19).style, "eggs", "the other text keeps its character style"); + // Change to and a Change Format: the new text is formatted. + s.execute("find.change", &json!({"find": "set", "change": "firm", "charStyle": "eggs"})).unwrap(); + assert_eq!(style_at(&s, s.doc().unwrap().doc.story(sid).unwrap().text.find("firm").unwrap()), "eggs"); + // No such style: refused, nothing changed. + let before = s.doc().unwrap().doc.clone(); + assert!(s.execute("find.change", &json!({"find": "eggs", "change": "", "charStyle": "Nope"})).is_err()); + assert!(s.execute("find.change", &json!({"find": "eggs", "change": "", "paraStyle": "Nope"})).is_err()); + assert_eq!(s.doc().unwrap().doc, before); + // With no Change Format, an empty Change to still deletes the found text. + s.execute("find.change", &json!({"find": "Break ", "change": ""})).unwrap(); + assert!(!s.doc().unwrap().doc.story(sid).unwrap().text.contains("Break")); +} + +#[test] +fn a_find_format_finds_only_text_with_it() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s + .execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Break eggs; cook eggs.\nEggs and eggs"})) + .unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("style.character.create", &json!({"name": "eggs", "chars": {"fontStyle": "Bold"}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Method"})).unwrap(); + for (a, b) in [(6, 10), (12, 16)] { + s.execute("text.select", &json!({"story": sid, "anchor": a, "focus": b})).unwrap(); + s.execute("style.character.apply", &json!({"name": "eggs"})).unwrap(); + } + s.execute("text.select", &json!({"story": sid, "anchor": 25, "focus": 25})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Method"})).unwrap(); + let found = |s: &mut Session, p: serde_json::Value| -> Vec<(u64, u64)> { + let m = s.execute("find.find", &p).unwrap(); + m.as_array().unwrap().iter().map(|m| (m["start"].as_u64().unwrap(), m["end"].as_u64().unwrap())).collect() + }; + // "eggs" where it has the character style: the first only. + assert_eq!(found(&mut s, json!({"find": "eggs", "findFormat": {"charStyle": "eggs"}})), [(6, 10)]); + // Nothing in Find what: each stretch of text in the style. + assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"charStyle": "eggs"}})), [(6, 10), (12, 16)]); + // A paragraph style, and resolved attributes. + assert_eq!(found(&mut s, json!({"find": "eggs", "findFormat": {"paraStyle": "Method"}})), [(23, 27), (32, 36)]); + assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"chars": {"fontStyle": "Bold"}}})), [(6, 10), (12, 16)]); + assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"chars": {"size": 12}}})), [(0, 22), (23, 36)], "each paragraph apart"); + // Find Next walks the stretches. + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + let next = json!({"find": "", "findFormat": {"charStyle": "eggs"}}); + assert_eq!(s.execute("find.next", &next).unwrap()["start"], 6); + assert_eq!(s.execute("find.next", &next).unwrap()["start"], 12); + // Equivalent spellings the attributes accept find the same text: a snake_case key, and a + // nested number written as an integer (14 pt leading on the second paragraph). + s.execute("text.select", &json!({"story": sid, "anchor": 23, "focus": 36})).unwrap(); + s.execute("type.char", &json!({"leading": {"kind": "points", "value": 14.0}})).unwrap(); + for (a, b) in [ + (json!({"fontStyle": "Bold"}), json!({"font_style": "Bold"})), + (json!({"leading": {"kind": "points", "value": 14.0}}), json!({"leading": {"kind": "points", "value": 14}})), + ] { + let (a, b) = (json!({"find": "", "findFormat": {"chars": a}}), json!({"find": "", "findFormat": {"chars": b}})); + let all = found(&mut s, a.clone()); + assert!(!all.is_empty(), "{a}"); + assert_eq!(found(&mut s, b.clone()), all, "{b}"); + for p in [&a, &b] { + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + assert_eq!(s.execute("find.next", p).unwrap()["start"].as_u64(), Some(all[0].0), "{p}"); + } + } + // A null attribute asks for nothing. + assert_eq!(found(&mut s, json!({"find": "eggs", "findFormat": {"charStyle": "eggs", "chars": {"size": null}}})), [(6, 10)]); + // With a Change Format: the text in the style goes back to [None], underlined. + let r = s + .execute( + "find.change", + &json!({"find": "", "change": "", "findFormat": {"charStyle": "eggs", "chars": {"font_style": "Bold"}}, "charStyle": "[None]", "attrs": {"underline": true}}), + ) + .unwrap(); + assert_eq!(r["count"], 2, "a snake_case attribute changes the same text"); + assert!(found(&mut s, json!({"find": "", "findFormat": {"charStyle": "eggs"}})).is_empty()); + assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"chars": {"underline": true}}})), [(6, 10), (12, 16)]); + // Unknown styles and attributes are refused; an empty search needs a Find Format. + assert!(s.execute("find.find", &json!({"find": "", "findFormat": {"charStyle": "Nope"}})).is_err()); + assert!(s.execute("find.next", &json!({"find": "eggs", "findFormat": {"paraStyle": "Nope"}})).is_err()); + assert!(s.execute("find.find", &json!({"find": "eggs", "findFormat": {"chars": {"bogus": 1}}})).is_err()); + // Malformed fields are refused, not searched without. + for f in [json!({"chars": [1]}), json!({"chars": 12}), json!({"charStyle": 3}), json!({"paraStyle": ["Body"]})] { + assert!(s.execute("find.find", &json!({"find": "eggs", "findFormat": f})).is_err(), "{f}"); + } + assert!(s.execute("find.find", &json!({"find": ""})).is_err()); +} + +/// Change and Change/Find: only the text Find Next found changes; it stays selected and the +/// search goes on after it in its domain (here the original selection). A selection that +/// isn't a match changes nothing and makes no undo step. +#[test] +fn change_changes_the_found_text_and_the_search_goes_on() { + let (mut s, sid) = scope_session("red red | red"); + scope_select(&mut s, sid, 0, 7); + let p = json!({"find": "red", "change": "blue", "scope": "selection"}); + let mut change = p.clone(); + change["found"] = json!(true); + scope_hit(&mut s, &p, sid, 0, 3); + assert_eq!(s.execute("find.change", &change).unwrap()["count"], 1); + let text = |s: &Session| s.doc().unwrap().doc.story(sid).unwrap().text.clone(); + assert_eq!(text(&s), "blue red | red"); + assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 0..4, "the changed text stays selected"); + scope_hit(&mut s, &p, sid, 5, 8); + s.execute("find.change", &change).unwrap(); + assert_eq!(text(&s), "blue blue | red"); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null, "the last red is outside the selection searched"); + // Not a match: nothing changes, no undo step. + scope_select(&mut s, sid, 0, 4); + let history = s.doc().unwrap().history.undo.len(); + assert_eq!(s.execute("find.change", &json!({"find": "red", "change": "blue", "found": true})).unwrap()["count"], 0); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + // A Change Format with nothing in Change to formats the found text only. + scope_select(&mut s, sid, 0, 0); + let fmt = json!({"find": "red", "change": "", "attrs": {"underline": true}}); + let hit = s.execute("find.next", &fmt).unwrap(); + assert_eq!(hit["start"], 12); + let mut found = fmt.clone(); + found["found"] = json!(true); + s.execute("find.change", &found).unwrap(); + assert_eq!(text(&s), "blue blue | red"); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().format_after(12).over.underline, Some(true)); + assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 12..15); +} + +/// Change accepts what Find Next selected even when Change All wouldn't match there: the +/// rest of a Find Format stretch from a caret inside it, and an overlapping match. +#[test] +fn change_takes_any_selection_find_next_made() { + // A caret inside a bold stretch: Find Next with only a Find Format selects the rest of it. + let (mut s, sid) = scope_session("plain bold text"); + s.execute("style.character.create", &json!({"name": "Strong", "chars": {"fontStyle": "Bold"}})).unwrap(); + scope_select(&mut s, sid, 6, 10); + s.execute("style.character.apply", &json!({"name": "Strong"})).unwrap(); + scope_select(&mut s, sid, 8, 8); + let p = json!({"find": "", "change": "", "findFormat": {"charStyle": "Strong"}, "attrs": {"underline": true}, "scope": "story"}); + let hit = s.execute("find.next", &p).unwrap(); + assert_eq!((hit["start"].as_u64(), hit["end"].as_u64()), (Some(8), Some(10))); + let mut change = p.clone(); + change["found"] = json!(true); + assert_eq!(s.execute("find.change", &change).unwrap()["count"], 1); + let st = s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert_eq!((st.format_after(7).over.underline, st.format_after(8).over.underline), (None, Some(true)), "only the selected rest"); + // "aa" in "aaa" from the second a: Find Next selects 1..3, which Change All never finds. + for grep in [false, true] { + let (mut s, sid) = scope_session("aaa"); + scope_select(&mut s, sid, 1, 1); + let (find, change) = if grep { ("(a)a", "<$1>") } else { ("aa", "b") }; + let p = json!({"find": find, "change": change, "grep": grep, "scope": "story"}); + let hit = s.execute("find.next", &p).unwrap(); + assert_eq!((hit["start"].as_u64(), hit["end"].as_u64()), (Some(1), Some(3))); + let mut found = p.clone(); + found["found"] = json!(true); + assert_eq!(s.execute("find.change", &found).unwrap()["count"], 1, "grep: {grep}"); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, if grep { "a" } else { "ab" }); + } +} diff --git a/crates/engine/src/cmd/style/paragraph.rs b/crates/engine/src/cmd/style/paragraph.rs index bccb8f6c2..bb70a8cfc 100644 --- a/crates/engine/src/cmd/style/paragraph.rs +++ b/crates/engine/src/cmd/style/paragraph.rs @@ -328,7 +328,7 @@ fn apply_para_in_story( f.chars.retain_differences(style_chars); } }); - for (pi, range) in st.paras_touching(r.clone()).into_iter().filter(|(pi, _)| (first..=last).contains(pi)) { + for (pi, range) in st.para_ranges().into_iter().enumerate().filter(|(pi, _)| (first..=last).contains(pi)) { let Some(pf) = st.paras.get(pi) else { continue }; let (_, base) = styles.resolve_para(pf); let end = range.end.saturating_add(1).min(st.len()); From d6210b40ee8916efd9b9d9babe5c3af7cc80285f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 22:13:20 +0100 Subject: [PATCH 378/628] Find/Change: the branch's find tests beside main's scope helpers (find/tests.rs) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/find.rs | 116 ---------------------------- crates/engine/src/cmd/find/tests.rs | 116 ++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 116 deletions(-) diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index af05e45af..849d54232 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -709,122 +709,6 @@ mod object_find_tests { use crate::Session; - #[test] - fn change_format_styles_the_found_text_and_keeps_it() { - // The course's chapter 22: every lowercase "eggs" gets the character style "eggs", with - // Change to empty, so the words stay. - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let text = "Poached Eggs\nBreak eggs; cook until eggs are set."; - let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": text})).unwrap(); - let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); - s.execute("style.character.create", &json!({"name": "eggs", "chars": {"fontStyle": "Bold", "fill": "C=15 M=100 Y=100 K=0"}})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Method"})).unwrap(); - let undo = s.doc().unwrap().history.undo.len(); - let r = s.execute("find.change", &json!({"find": "eggs", "change": "", "caseSensitive": true, "charStyle": "eggs"})).unwrap(); - assert_eq!(r["count"], 2); - assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); - let style_at = |s: &Session, at: usize| s.doc().unwrap().doc.story(sid).unwrap().format_after(at).style.clone(); - let story = s.doc().unwrap().doc.story(sid).unwrap().clone(); - assert_eq!(story.text, text, "the words stay"); - for at in [19, 36] { - assert_eq!(&story.text[at..at + 4], "eggs"); - assert_eq!(style_at(&s, at), "eggs"); - } - assert_eq!(style_at(&s, 8), designcraft_doc::NO_CHAR_STYLE, "case sensitive: not \"Eggs\""); - assert_eq!(style_at(&s, 25), designcraft_doc::NO_CHAR_STYLE, "only the found text"); - // A paragraph style goes to the found text's paragraphs, with attributes over the text. - s.execute("find.change", &json!({"find": "cook", "change": "", "paraStyle": "Method", "attrs": {"underline": true}})).unwrap(); - let story = s.doc().unwrap().doc.story(sid).unwrap().clone(); - assert_eq!((story.paras[0].style.as_str(), story.paras[1].style.as_str()), (designcraft_doc::BASIC_PARAGRAPH, "Method")); - assert_eq!(story.format_after(25).over.underline, Some(true)); - assert_eq!(story.format_after(19).style, "eggs", "the other text keeps its character style"); - // Change to and a Change Format: the new text is formatted. - s.execute("find.change", &json!({"find": "set", "change": "firm", "charStyle": "eggs"})).unwrap(); - assert_eq!(style_at(&s, s.doc().unwrap().doc.story(sid).unwrap().text.find("firm").unwrap()), "eggs"); - // No such style: refused, nothing changed. - let before = s.doc().unwrap().doc.clone(); - assert!(s.execute("find.change", &json!({"find": "eggs", "change": "", "charStyle": "Nope"})).is_err()); - assert!(s.execute("find.change", &json!({"find": "eggs", "change": "", "paraStyle": "Nope"})).is_err()); - assert_eq!(s.doc().unwrap().doc, before); - // With no Change Format, an empty Change to still deletes the found text. - s.execute("find.change", &json!({"find": "Break ", "change": ""})).unwrap(); - assert!(!s.doc().unwrap().doc.story(sid).unwrap().text.contains("Break")); - } - - #[test] - fn a_find_format_finds_only_text_with_it() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s - .execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Break eggs; cook eggs.\nEggs and eggs"})) - .unwrap(); - let sid = r["story"].as_u64().unwrap(); - s.execute("style.character.create", &json!({"name": "eggs", "chars": {"fontStyle": "Bold"}})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Method"})).unwrap(); - for (a, b) in [(6, 10), (12, 16)] { - s.execute("text.select", &json!({"story": sid, "anchor": a, "focus": b})).unwrap(); - s.execute("style.character.apply", &json!({"name": "eggs"})).unwrap(); - } - s.execute("text.select", &json!({"story": sid, "anchor": 25, "focus": 25})).unwrap(); - s.execute("style.paragraph.apply", &json!({"name": "Method"})).unwrap(); - let found = |s: &mut Session, p: serde_json::Value| -> Vec<(u64, u64)> { - let m = s.execute("find.find", &p).unwrap(); - m.as_array().unwrap().iter().map(|m| (m["start"].as_u64().unwrap(), m["end"].as_u64().unwrap())).collect() - }; - // "eggs" where it has the character style: the first only. - assert_eq!(found(&mut s, json!({"find": "eggs", "findFormat": {"charStyle": "eggs"}})), [(6, 10)]); - // Nothing in Find what: each stretch of text in the style. - assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"charStyle": "eggs"}})), [(6, 10), (12, 16)]); - // A paragraph style, and resolved attributes. - assert_eq!(found(&mut s, json!({"find": "eggs", "findFormat": {"paraStyle": "Method"}})), [(23, 27), (32, 36)]); - assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"chars": {"fontStyle": "Bold"}}})), [(6, 10), (12, 16)]); - assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"chars": {"size": 12}}})), [(0, 22), (23, 36)], "each paragraph apart"); - // Find Next walks the stretches. - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); - let next = json!({"find": "", "findFormat": {"charStyle": "eggs"}}); - assert_eq!(s.execute("find.next", &next).unwrap()["start"], 6); - assert_eq!(s.execute("find.next", &next).unwrap()["start"], 12); - // Equivalent spellings the attributes accept find the same text: a snake_case key, and a - // nested number written as an integer (14 pt leading on the second paragraph). - s.execute("text.select", &json!({"story": sid, "anchor": 23, "focus": 36})).unwrap(); - s.execute("type.char", &json!({"leading": {"kind": "points", "value": 14.0}})).unwrap(); - for (a, b) in [ - (json!({"fontStyle": "Bold"}), json!({"font_style": "Bold"})), - (json!({"leading": {"kind": "points", "value": 14.0}}), json!({"leading": {"kind": "points", "value": 14}})), - ] { - let (a, b) = (json!({"find": "", "findFormat": {"chars": a}}), json!({"find": "", "findFormat": {"chars": b}})); - let all = found(&mut s, a.clone()); - assert!(!all.is_empty(), "{a}"); - assert_eq!(found(&mut s, b.clone()), all, "{b}"); - for p in [&a, &b] { - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); - assert_eq!(s.execute("find.next", p).unwrap()["start"].as_u64(), Some(all[0].0), "{p}"); - } - } - // A null attribute asks for nothing. - assert_eq!(found(&mut s, json!({"find": "eggs", "findFormat": {"charStyle": "eggs", "chars": {"size": null}}})), [(6, 10)]); - // With a Change Format: the text in the style goes back to [None], underlined. - let r = s - .execute( - "find.change", - &json!({"find": "", "change": "", "findFormat": {"charStyle": "eggs", "chars": {"font_style": "Bold"}}, "charStyle": "[None]", "attrs": {"underline": true}}), - ) - .unwrap(); - assert_eq!(r["count"], 2, "a snake_case attribute changes the same text"); - assert!(found(&mut s, json!({"find": "", "findFormat": {"charStyle": "eggs"}})).is_empty()); - assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"chars": {"underline": true}}})), [(6, 10), (12, 16)]); - // Unknown styles and attributes are refused; an empty search needs a Find Format. - assert!(s.execute("find.find", &json!({"find": "", "findFormat": {"charStyle": "Nope"}})).is_err()); - assert!(s.execute("find.next", &json!({"find": "eggs", "findFormat": {"paraStyle": "Nope"}})).is_err()); - assert!(s.execute("find.find", &json!({"find": "eggs", "findFormat": {"chars": {"bogus": 1}}})).is_err()); - // Malformed fields are refused, not searched without. - for f in [json!({"chars": [1]}), json!({"chars": 12}), json!({"charStyle": 3}), json!({"paraStyle": ["Body"]})] { - assert!(s.execute("find.find", &json!({"find": "eggs", "findFormat": f})).is_err(), "{f}"); - } - assert!(s.execute("find.find", &json!({"find": ""})).is_err()); - } - #[test] fn find_and_change_objects_by_attributes() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/find/tests.rs b/crates/engine/src/cmd/find/tests.rs index 5914c2e51..e88c81a3a 100644 --- a/crates/engine/src/cmd/find/tests.rs +++ b/crates/engine/src/cmd/find/tests.rs @@ -747,3 +747,119 @@ fn find_scope_revert_releases_context_with_same_document_uid() { assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); assert!(s.execute("find.find", &p).is_err(), "revert's empty selection cannot inherit a previous range"); } + +#[test] +fn change_format_styles_the_found_text_and_keeps_it() { + // The course's chapter 22: every lowercase "eggs" gets the character style "eggs", with + // Change to empty, so the words stay. + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let text = "Poached Eggs\nBreak eggs; cook until eggs are set."; + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": text})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + s.execute("style.character.create", &json!({"name": "eggs", "chars": {"fontStyle": "Bold", "fill": "C=15 M=100 Y=100 K=0"}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Method"})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("find.change", &json!({"find": "eggs", "change": "", "caseSensitive": true, "charStyle": "eggs"})).unwrap(); + assert_eq!(r["count"], 2); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + let style_at = |s: &Session, at: usize| s.doc().unwrap().doc.story(sid).unwrap().format_after(at).style.clone(); + let story = s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert_eq!(story.text, text, "the words stay"); + for at in [19, 36] { + assert_eq!(&story.text[at..at + 4], "eggs"); + assert_eq!(style_at(&s, at), "eggs"); + } + assert_eq!(style_at(&s, 8), designcraft_doc::NO_CHAR_STYLE, "case sensitive: not \"Eggs\""); + assert_eq!(style_at(&s, 25), designcraft_doc::NO_CHAR_STYLE, "only the found text"); + // A paragraph style goes to the found text's paragraphs, with attributes over the text. + s.execute("find.change", &json!({"find": "cook", "change": "", "paraStyle": "Method", "attrs": {"underline": true}})).unwrap(); + let story = s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert_eq!((story.paras[0].style.as_str(), story.paras[1].style.as_str()), (designcraft_doc::BASIC_PARAGRAPH, "Method")); + assert_eq!(story.format_after(25).over.underline, Some(true)); + assert_eq!(story.format_after(19).style, "eggs", "the other text keeps its character style"); + // Change to and a Change Format: the new text is formatted. + s.execute("find.change", &json!({"find": "set", "change": "firm", "charStyle": "eggs"})).unwrap(); + assert_eq!(style_at(&s, s.doc().unwrap().doc.story(sid).unwrap().text.find("firm").unwrap()), "eggs"); + // No such style: refused, nothing changed. + let before = s.doc().unwrap().doc.clone(); + assert!(s.execute("find.change", &json!({"find": "eggs", "change": "", "charStyle": "Nope"})).is_err()); + assert!(s.execute("find.change", &json!({"find": "eggs", "change": "", "paraStyle": "Nope"})).is_err()); + assert_eq!(s.doc().unwrap().doc, before); + // With no Change Format, an empty Change to still deletes the found text. + s.execute("find.change", &json!({"find": "Break ", "change": ""})).unwrap(); + assert!(!s.doc().unwrap().doc.story(sid).unwrap().text.contains("Break")); +} + +#[test] +fn a_find_format_finds_only_text_with_it() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s + .execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Break eggs; cook eggs.\nEggs and eggs"})) + .unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("style.character.create", &json!({"name": "eggs", "chars": {"fontStyle": "Bold"}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Method"})).unwrap(); + for (a, b) in [(6, 10), (12, 16)] { + s.execute("text.select", &json!({"story": sid, "anchor": a, "focus": b})).unwrap(); + s.execute("style.character.apply", &json!({"name": "eggs"})).unwrap(); + } + s.execute("text.select", &json!({"story": sid, "anchor": 25, "focus": 25})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Method"})).unwrap(); + let found = |s: &mut Session, p: serde_json::Value| -> Vec<(u64, u64)> { + let m = s.execute("find.find", &p).unwrap(); + m.as_array().unwrap().iter().map(|m| (m["start"].as_u64().unwrap(), m["end"].as_u64().unwrap())).collect() + }; + // "eggs" where it has the character style: the first only. + assert_eq!(found(&mut s, json!({"find": "eggs", "findFormat": {"charStyle": "eggs"}})), [(6, 10)]); + // Nothing in Find what: each stretch of text in the style. + assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"charStyle": "eggs"}})), [(6, 10), (12, 16)]); + // A paragraph style, and resolved attributes. + assert_eq!(found(&mut s, json!({"find": "eggs", "findFormat": {"paraStyle": "Method"}})), [(23, 27), (32, 36)]); + assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"chars": {"fontStyle": "Bold"}}})), [(6, 10), (12, 16)]); + assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"chars": {"size": 12}}})), [(0, 22), (23, 36)], "each paragraph apart"); + // Find Next walks the stretches. + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + let next = json!({"find": "", "findFormat": {"charStyle": "eggs"}}); + assert_eq!(s.execute("find.next", &next).unwrap()["start"], 6); + assert_eq!(s.execute("find.next", &next).unwrap()["start"], 12); + // Equivalent spellings the attributes accept find the same text: a snake_case key, and a + // nested number written as an integer (14 pt leading on the second paragraph). + s.execute("text.select", &json!({"story": sid, "anchor": 23, "focus": 36})).unwrap(); + s.execute("type.char", &json!({"leading": {"kind": "points", "value": 14.0}})).unwrap(); + for (a, b) in [ + (json!({"fontStyle": "Bold"}), json!({"font_style": "Bold"})), + (json!({"leading": {"kind": "points", "value": 14.0}}), json!({"leading": {"kind": "points", "value": 14}})), + ] { + let (a, b) = (json!({"find": "", "findFormat": {"chars": a}}), json!({"find": "", "findFormat": {"chars": b}})); + let all = found(&mut s, a.clone()); + assert!(!all.is_empty(), "{a}"); + assert_eq!(found(&mut s, b.clone()), all, "{b}"); + for p in [&a, &b] { + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + assert_eq!(s.execute("find.next", p).unwrap()["start"].as_u64(), Some(all[0].0), "{p}"); + } + } + // A null attribute asks for nothing. + assert_eq!(found(&mut s, json!({"find": "eggs", "findFormat": {"charStyle": "eggs", "chars": {"size": null}}})), [(6, 10)]); + // With a Change Format: the text in the style goes back to [None], underlined. + let r = s + .execute( + "find.change", + &json!({"find": "", "change": "", "findFormat": {"charStyle": "eggs", "chars": {"font_style": "Bold"}}, "charStyle": "[None]", "attrs": {"underline": true}}), + ) + .unwrap(); + assert_eq!(r["count"], 2, "a snake_case attribute changes the same text"); + assert!(found(&mut s, json!({"find": "", "findFormat": {"charStyle": "eggs"}})).is_empty()); + assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"chars": {"underline": true}}})), [(6, 10), (12, 16)]); + // Unknown styles and attributes are refused; an empty search needs a Find Format. + assert!(s.execute("find.find", &json!({"find": "", "findFormat": {"charStyle": "Nope"}})).is_err()); + assert!(s.execute("find.next", &json!({"find": "eggs", "findFormat": {"paraStyle": "Nope"}})).is_err()); + assert!(s.execute("find.find", &json!({"find": "eggs", "findFormat": {"chars": {"bogus": 1}}})).is_err()); + // Malformed fields are refused, not searched without. + for f in [json!({"chars": [1]}), json!({"chars": 12}), json!({"charStyle": 3}), json!({"paraStyle": ["Body"]})] { + assert!(s.execute("find.find", &json!({"find": "eggs", "findFormat": f})).is_err(), "{f}"); + } + assert!(s.execute("find.find", &json!({"find": ""})).is_err()); +} From cb29eddb363741ab6209647f0bc245b771266981 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 22:24:22 +0100 Subject: [PATCH 379/628] Georgian: main's row for Gap Tint:, once Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ka.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 2304eb940..92f4843c2 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -33,7 +33,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Copy", "კოპირება"), ("Paste", "ჩასმა"), ("Keep In Frame", "ჩარჩოში შენარჩუნება"), - ("Gap Tint:", "ღრეჩოს ტონი:"), ("Restart Numbering", "ნუმერაციის თავიდან დაწყება"), ("Continue Numbering", "ნუმერაციის გაგრძელება"), ("Keep Options…", "შენარჩუნების პარამეტრები…"), From c6703e81f5769ac5dd1e05a6f8fe5c39a70e6ab8 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 22:25:00 +0100 Subject: [PATCH 380/628] Georgian: main's rows where the branch added the same keys again Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ka.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 8c44a18d7..46dc638d9 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -32,7 +32,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), - ("Packaged {count} files into {dir}", "{count} ფაილი შეიფუთა {dir}-ში"), ("Continue", "გაგრძელება"), ("Printing Instructions", "ბეჭდვის ინსტრუქციები"), ("Show Problems Only", "მხოლოდ პრობლემების ჩვენება"), @@ -41,8 +40,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Create Printing Instructions", "ბეჭდვის ინსტრუქციების შექმნა"), ("Entire Publication", "მთელი პუბლიკაცია"), ("Scope:", "არეალი:"), - ("Spot", "ლაქა"), - ("Process", "პროცესი"), ("Source", "წყარო"), ("Installed", "დაყენებული"), ("Bundled", "ჩაშენებული"), @@ -57,9 +54,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ), ("Fonts: {used} Fonts Used, {missing} Missing", "შრიფტები: გამოყენებულია {used}, აკლია {missing}"), ("Instructions:", "ინსტრუქციები:"), - ("Email:", "ელფოსტა:"), ("Fax:", "ფაქსი:"), - ("Phone:", "ტელეფონი:"), ("Address:", "მისამართი:"), ("Company:", "კომპანია:"), ("Contact:", "საკონტაქტო პირი:"), From c8bc257dcd0b9baa4c931f595e645d3c10a4df5c Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 22:25:01 +0100 Subject: [PATCH 381/628] Georgian: main's rows where the branch added the same keys again Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ka.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 48faae5a2..6a3c28d9e 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -33,7 +33,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Copy", "კოპირება"), ("Paste", "ჩასმა"), ("PDF Preset:", "PDF-ის წინასწარი პარამეტრი:"), - ("Packaged {count} files into {dir}", "{count} ფაილი შეიფუთა {dir}-ში"), ("Continue", "გაგრძელება"), ("Printing Instructions", "ბეჭდვის ინსტრუქციები"), ("Show Problems Only", "მხოლოდ პრობლემების ჩვენება"), @@ -42,8 +41,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Create Printing Instructions", "ბეჭდვის ინსტრუქციების შექმნა"), ("Entire Publication", "მთელი პუბლიკაცია"), ("Scope:", "არეალი:"), - ("Spot", "სპოტ-საღებავი"), - ("Process", "პროცესი"), ("Source", "წყარო"), ("Installed", "დაყენებული"), ("Bundled", "ჩაშენებული"), @@ -58,9 +55,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ), ("Fonts: {used} Fonts Used, {missing} Missing", "შრიფტები: გამოყენებულია {used}, აკლია {missing}"), ("Instructions:", "ინსტრუქციები:"), - ("Email:", "ელფოსტა:"), ("Fax:", "ფაქსი:"), - ("Phone:", "ტელეფონი:"), ("Address:", "მისამართი:"), ("Company:", "კომპანია:"), ("Contact:", "საკონტაქტო პირი:"), From 0591765d36e828a5e845adcab10f9366ae2b9d8f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 22:39:54 +0100 Subject: [PATCH 382/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/dialogs/tests.rs | 374 ++++++++++++++-------------- 2 files changed, 188 insertions(+), 188 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c29cbdfd9..a88f2d180 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -44,8 +44,8 @@ use input::{press_count, release_clicks}; #[cfg(test)] use page_decor::{MAX_BASELINES, baseline_grid_lines}; use page_decor::{dashed, draw_flattener_preview, draw_guides, shown_area}; -pub use render::{DisplayKey, color_at}; use render::render_texture; +pub use render::{DisplayKey, color_at}; #[cfg(test)] use render::{c32, invalidate_on_display_change, screen_options}; pub(crate) use rulers::fmt_tick; diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 165727cf8..a35377864 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -592,7 +592,7 @@ fn the_hex_field_keeps_typed_text_and_commits_a_valid_code() { dialog_frame(&mut app, &ctx, screen, vec![select_all_key()]); type_slowly(&mut app, &ctx, screen, "zz"); let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "#:").center()); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "#:").center()); let labels = dialog_frame(&mut app, &ctx, screen, vec![]); visible_label(&labels, screen, "f59019"); assert_eq!(app.ui.dialog.as_ref().unwrap().s("hex"), "#f59019"); @@ -602,13 +602,13 @@ fn the_hex_field_keeps_typed_text_and_commits_a_valid_code() { fn typed_channel_values_are_kept_while_typing() { let (mut app, ctx, screen, id) = picker(json!({"target": "fill", "hex": "#010203", "model": "rgb", "rgb": [1, 2, 3]})); let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - let r = visible_label(&labels, screen, "R:"); - click_dialog(&mut app, &ctx, screen, egui::pos2(r.min.x + 46.0, r.center().y)); + let r = visible_label(&labels, screen, "R:"); + click_dialog(&mut app, &ctx, screen, egui::pos2(r.min.x + 46.0, r.center().y)); dialog_frame(&mut app, &ctx, screen, vec![select_all_key()]); type_slowly(&mut app, &ctx, screen, "12.5"); let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - visible_label(&labels, screen, "12.5"); - assert_eq!(app.ui.dialog.as_ref().unwrap().fields["rgb"][1], json!(2.0)); + visible_label(&labels, screen, "12.5"); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["rgb"][1], json!(2.0)); assert_eq!(app.ui.dialog.as_ref().unwrap().s("hex"), "#0d0203"); confirm(&mut app).unwrap(); let designcraft_color::Color::Rgb { r, .. } = fill(&app, id) else { panic!("not RGB") }; @@ -630,8 +630,8 @@ fn a_cmyk_colour_stays_cmyk_unless_rgb_values_are_typed() { // A typed C/M/Y/K percent is a percent: M 1 is 1 %. let (mut app, ctx, screen, id) = picker(cmyk.clone()); let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - let r = visible_label(&labels, screen, "M:"); - click_dialog(&mut app, &ctx, screen, egui::pos2(r.max.x + 29.0, r.center().y)); + let r = visible_label(&labels, screen, "M:"); + click_dialog(&mut app, &ctx, screen, egui::pos2(r.max.x + 29.0, r.center().y)); dialog_frame(&mut app, &ctx, screen, vec![select_all_key()]); type_slowly(&mut app, &ctx, screen, "1"); confirm(&mut app).unwrap(); @@ -641,9 +641,9 @@ fn a_cmyk_colour_stays_cmyk_unless_rgb_values_are_typed() { let (mut app, ctx, screen, id) = picker(cmyk); let r = app.ui.dialog.as_ref().unwrap().fields["rgb"][0].as_f64().unwrap(); let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - let _ = r; - let r = visible_label(&labels, screen, "R:"); - click_dialog(&mut app, &ctx, screen, egui::pos2(r.min.x + 46.0, r.center().y)); + let _ = r; + let r = visible_label(&labels, screen, "R:"); + click_dialog(&mut app, &ctx, screen, egui::pos2(r.min.x + 46.0, r.center().y)); dialog_frame(&mut app, &ctx, screen, vec![select_all_key()]); type_slowly(&mut app, &ctx, screen, "10"); confirm(&mut app).unwrap(); @@ -1644,7 +1644,7 @@ fn dialog_frame(app: &mut DesignApp, ctx: &egui::Context, screen: egui::Rect, ev fn visible_label(labels: &[(String, egui::Rect, egui::Rect)], screen: egui::Rect, expected: &str) -> egui::Rect { let (_, rect, clip) = labels.iter().find(|(text, _, _)| text.trim() == expected).unwrap_or_else(|| panic!("{expected} is painted")); assert!(screen.contains_rect(*rect), "{expected} must stay on screen: {rect:?}"); - assert!(clip.expand(0.5).contains_rect(*rect), "{expected} must not be clipped: {rect:?}, {clip:?}"); + assert!(clip.expand(0.5).contains_rect(*rect), "{expected} must not be clipped: {rect:?}, {clip:?}"); *rect } @@ -3462,188 +3462,188 @@ fn guide_counts(app: &DesignApp) -> Vec { (0..doc.page_count()).map(|abs| doc.page(abs).unwrap().guides.len()).collect() } - /// The Color Picker laid out as InDesign's: a field and a slider for the component whose radio - /// is on, the new colour over the old one, and the values in every space. - /// The course's chapter 23: the Color Picker's eyedropper, dragged from the picker onto the - /// page and let go there, takes the colour the page shows. - /// The eyedropper takes the colour the canvas shows: with Proof Colors the proofed colour, and - /// with the ink-limit preview the warning colour over the limit. - #[test] - fn the_eyedropper_samples_what_proof_colors_and_the_ink_limit_show() { - use egui_kittest::Harness; - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - app.run("swatch.create", json!({"name": "Vivid Green", "color": [0, 255, 0]})).unwrap(); - let id = app.run("frame.create", json!({"rect": [36, 36, 560, 700], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); - app.run("object.fill", json!({"ids": [id], "swatch": "Vivid Green"})).unwrap(); - let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state( - |ui, app: &mut DesignApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - app, - ); - h.run_steps(6); - let target = { - let app = h.state(); - let doc = &app.session.doc().unwrap().doc; - let layout = designcraft_tools::CanvasLayout::new(doc, false); - let v = *app.view().unwrap(); - crate::canvas::Xf::new(app.canvas_rect.unwrap(), &v).to_screen(layout.slots[0].xf * designcraft_geom::Point::new(300.0, 600.0)) - }; - let ctx = h.ctx.clone(); - let sample = |h: &mut Harness<'_, DesignApp>| crate::canvas::color_at(h.state_mut(), &ctx, target).expect("on the page"); - // What a view makes of the plain colour, as the canvas texture gets it. - let shown = |c: [u8; 3], view: &dyn Fn(&mut designcraft_render::Rendered)| { - let mut img = designcraft_render::Rendered { width: 1, height: 1, pixels: vec![c[0], c[1], c[2], 255] }; - view(&mut img); - [img.pixels[0], img.pixels[1], img.pixels[2]] - }; - let plain = sample(&mut h); - h.state_mut().ui.proof_colors = true; - let setup = h.state().ui.proof_setup.clone(); - let proofed = sample(&mut h); - assert_eq!(proofed, shown(plain, &|img| designcraft_render::proof_view(img, &setup))); - assert_ne!(proofed, plain, "the proof changes this green"); - } +/// The Color Picker laid out as InDesign's: a field and a slider for the component whose radio +/// is on, the new colour over the old one, and the values in every space. +/// The course's chapter 23: the Color Picker's eyedropper, dragged from the picker onto the +/// page and let go there, takes the colour the page shows. +/// The eyedropper takes the colour the canvas shows: with Proof Colors the proofed colour, and +/// with the ink-limit preview the warning colour over the limit. +#[test] +fn the_eyedropper_samples_what_proof_colors_and_the_ink_limit_show() { + use egui_kittest::Harness; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("swatch.create", json!({"name": "Vivid Green", "color": [0, 255, 0]})).unwrap(); + let id = app.run("frame.create", json!({"rect": [36, 36, 560, 700], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + app.run("object.fill", json!({"ids": [id], "swatch": "Vivid Green"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(6); + let target = { + let app = h.state(); + let doc = &app.session.doc().unwrap().doc; + let layout = designcraft_tools::CanvasLayout::new(doc, false); + let v = *app.view().unwrap(); + crate::canvas::Xf::new(app.canvas_rect.unwrap(), &v).to_screen(layout.slots[0].xf * designcraft_geom::Point::new(300.0, 600.0)) + }; + let ctx = h.ctx.clone(); + let sample = |h: &mut Harness<'_, DesignApp>| crate::canvas::color_at(h.state_mut(), &ctx, target).expect("on the page"); + // What a view makes of the plain colour, as the canvas texture gets it. + let shown = |c: [u8; 3], view: &dyn Fn(&mut designcraft_render::Rendered)| { + let mut img = designcraft_render::Rendered { width: 1, height: 1, pixels: vec![c[0], c[1], c[2], 255] }; + view(&mut img); + [img.pixels[0], img.pixels[1], img.pixels[2]] + }; + let plain = sample(&mut h); + h.state_mut().ui.proof_colors = true; + let setup = h.state().ui.proof_setup.clone(); + let proofed = sample(&mut h); + assert_eq!(proofed, shown(plain, &|img| designcraft_render::proof_view(img, &setup))); + assert_ne!(proofed, plain, "the proof changes this green"); +} #[test] - fn the_color_pickers_eyedropper_takes_a_colour_off_the_page() { - use egui_kittest::{Harness, kittest::Queryable}; - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let id = app.run("frame.create", json!({"rect": [36, 36, 560, 700], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); - app.run("object.fill", json!({"ids": [id], "swatch": "C=0 M=100 Y=100 K=0"})).unwrap(); - app.ui.dialog = Some(Dialog::new("colorPicker", json!({"target": "fill", "hex": "#000000"}))); - let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state( - |ui, app: &mut DesignApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - app, - ); - h.run_steps(6); - // Where the frame shows on screen. - let target = { - let app = h.state(); - let doc = &app.session.doc().unwrap().doc; - let layout = designcraft_tools::CanvasLayout::new(doc, false); - let v = *app.view().unwrap(); - crate::canvas::Xf::new(app.canvas_rect.unwrap(), &v).to_screen(layout.slots[0].xf * designcraft_geom::Point::new(300.0, 600.0)) - }; - let from = h.get_by_label("Eyedropper: drag it onto the page").rect().center(); - h.hover_at(from); - h.drag_at(from); +fn the_color_pickers_eyedropper_takes_a_colour_off_the_page() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [36, 36, 560, 700], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + app.run("object.fill", json!({"ids": [id], "swatch": "C=0 M=100 Y=100 K=0"})).unwrap(); + app.ui.dialog = Some(Dialog::new("colorPicker", json!({"target": "fill", "hex": "#000000"}))); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(6); + // Where the frame shows on screen. + let target = { + let app = h.state(); + let doc = &app.session.doc().unwrap().doc; + let layout = designcraft_tools::CanvasLayout::new(doc, false); + let v = *app.view().unwrap(); + crate::canvas::Xf::new(app.canvas_rect.unwrap(), &v).to_screen(layout.slots[0].xf * designcraft_geom::Point::new(300.0, 600.0)) + }; + let from = h.get_by_label("Eyedropper: drag it onto the page").rect().center(); + h.hover_at(from); + h.drag_at(from); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(from + (target - from) * (k as f32 / 4.0)); h.run_steps(1); - for k in 1..=4 { - h.hover_at(from + (target - from) * (k as f32 / 4.0)); - h.run_steps(1); - } - h.drop_at(target); - h.run_steps(3); - let d = h.state().ui.dialog.as_ref().expect("the picker stays open"); - let [r, g, b, _] = h.state().session.doc().unwrap().doc.resolve_color("C=0 M=100 Y=100 K=0", 1.0).unwrap().to_rgba8(1.0); - let got: Vec = d.fields["rgb"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); - for (a, e) in got.iter().zip([r, g, b]) { - assert!((a - f64::from(e)).abs() <= 3.0, "{got:?} vs {:?}", [r, g, b]); - } - assert!(d.b("edited")); - // Let go off the page (here, on the dialog's own field area): no colour, nothing changes. - let hex = d.s("hex"); - h.hover_at(from); - h.drag_at(from); - h.run_steps(1); - h.hover_at(from + egui::vec2(-40.0, 0.0)); - h.run_steps(1); - h.drop_at(egui::pos2(2.0, 2.0)); - h.run_steps(3); - assert_eq!(h.state().ui.dialog.as_ref().unwrap().s("hex"), hex); - // Let go over the picker's own colour field, with the page under it: nothing either. - let field = h.get_by_label("Color field").rect().center(); - let ctx = h.ctx.clone(); - assert!(crate::canvas::color_at(h.state_mut(), &ctx, field).is_some(), "the page lies under the field"); - h.hover_at(from); - h.drag_at(from); - h.run_steps(1); - h.hover_at(field); - h.run_steps(1); - h.drop_at(field); - h.run_steps(3); - assert_eq!(h.state().ui.dialog.as_ref().unwrap().s("hex"), hex, "the picker isn't sampled"); } - - #[test] - fn the_color_picker_picks_on_its_field_and_slider_and_adds_a_cmyk_swatch() { - use egui_kittest::kittest::Queryable; - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - app.run("frame.create", json!({"rect": [72, 72, 200, 200], "shape": "rectangle"})).unwrap(); - app.run("object.color", json!({"color":designcraft_color::Color::from_hex("#7c1315").unwrap(),"target":"fill"})).unwrap(); - app.ui.dialog = Some(Dialog::new("colorPicker", json!({"target": "fill", "hex": "#7c1315", "text": false}))); - let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); - h.run_steps(3); - // R at first: R up the slider, B across and G up the field. - let _ = h.get_by_label("RGB Color Space View"); - let field = h.get_by_label("Color field").rect(); - crate::test_window::click_at(&mut h, egui::pos2(field.right() - 1.0, field.top() + 1.0)); - h.run_steps(2); - let p = picked_of(h.state().app.ui.dialog.as_ref().unwrap()); - assert!((p.rgb[0] - 124.0 / 255.0).abs() < 0.01 && p.rgb[1] > 0.97 && p.rgb[2] > 0.97, "{p:?}: R kept, G and B full"); - // The slider sets R alone. - let slider = h.get_by_label("Color slider").rect(); - crate::test_window::click_at(&mut h, egui::pos2(slider.center().x, slider.bottom() - 1.0)); - h.run_steps(2); - let p = picked_of(h.state().app.ui.dialog.as_ref().unwrap()); - assert!(p.rgb[0] < 0.02 && p.rgb[1] > 0.97, "{p:?}"); - // Hue on the slider: the field is saturation across, brightness up. - store_picked(h.state_mut().app.ui.dialog.as_mut().unwrap(), &Picked::from_rgb([124.0 / 255.0, 19.0 / 255.0, 21.0 / 255.0], 0.0)); - h.state_mut().app.ui.dialog.as_mut().unwrap().fields.insert("component".into(), json!("h")); - h.run_steps(2); - crate::test_window::click_at(&mut h, egui::pos2(field.left() + 1.0, field.top() + 1.0)); - h.run_steps(2); - let p = picked_of(h.state().app.ui.dialog.as_ref().unwrap()); - assert!(p.hsb[1] < 0.02 && p.hsb[2] > 0.98 && (p.hsb[0] - 359.0).abs() < 2.0, "{p:?}: no saturation, full brightness, the hue kept"); - // Lab shows its own view. - h.state_mut().app.ui.dialog.as_mut().unwrap().fields.insert("component".into(), json!("l")); - h.run_steps(2); - let _ = h.get_by_label("Lab Color Space View"); - // Add CMYK Swatch adds the colour as a CMYK swatch; OK applies it. - store_picked(h.state_mut().app.ui.dialog.as_mut().unwrap(), &Picked::from_rgb([124.0 / 255.0, 19.0 / 255.0, 21.0 / 255.0], 0.0)); - h.run_steps(2); - let before = h.state().app.session.active().unwrap().doc.swatches.len(); - h.get_by_label("Add CMYK Swatch").click(); - h.run_steps(3); - let doc = &h.state().app.session.active().unwrap().doc; - assert_eq!(doc.swatches.len(), before + 1); - assert!(matches!( - doc.swatches.last().unwrap().value, - designcraft_color::swatch::SwatchValue::Color { color: designcraft_color::Color::Cmyk { .. }, .. } - )); - // The percentages the C/M/Y/K fields show, exactly (not the colour converted back). - h.state_mut().app.ui.dialog.as_mut().unwrap().fields.insert("cmyk".into(), json!([0.0, 50.0, 100.0, 0.0])); - h.run_steps(2); - h.get_by_label("Add CMYK Swatch").click(); - h.run_steps(3); - let doc = &h.state().app.session.active().unwrap().doc; - let designcraft_color::swatch::SwatchValue::Color { color: designcraft_color::Color::Cmyk { c, m, y, k }, .. } = - doc.swatches.last().unwrap().value - else { - panic!("a CMYK swatch") - }; - assert_eq!([c, m, y, k].map(|v| (v * 100.0).round()), [0.0, 50.0, 100.0, 0.0]); - // Its own OK, at the top right, applies it. - h.get_by_label("OK").click(); - h.run_steps(3); - assert!(h.state().app.ui.dialog.is_none(), "OK closes it"); - let st = h.state().app.session.active().unwrap(); - let id = st.selection.items[0]; - let fill = st.doc.item(id).unwrap().fill.swatch.clone(); - assert_eq!(st.doc.resolve_color(&fill, 1.0).unwrap().to_hex(), "#7c1315"); + h.drop_at(target); + h.run_steps(3); + let d = h.state().ui.dialog.as_ref().expect("the picker stays open"); + let [r, g, b, _] = h.state().session.doc().unwrap().doc.resolve_color("C=0 M=100 Y=100 K=0", 1.0).unwrap().to_rgba8(1.0); + let got: Vec = d.fields["rgb"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + for (a, e) in got.iter().zip([r, g, b]) { + assert!((a - f64::from(e)).abs() <= 3.0, "{got:?} vs {:?}", [r, g, b]); } + assert!(d.b("edited")); + // Let go off the page (here, on the dialog's own field area): no colour, nothing changes. + let hex = d.s("hex"); + h.hover_at(from); + h.drag_at(from); + h.run_steps(1); + h.hover_at(from + egui::vec2(-40.0, 0.0)); + h.run_steps(1); + h.drop_at(egui::pos2(2.0, 2.0)); + h.run_steps(3); + assert_eq!(h.state().ui.dialog.as_ref().unwrap().s("hex"), hex); + // Let go over the picker's own colour field, with the page under it: nothing either. + let field = h.get_by_label("Color field").rect().center(); + let ctx = h.ctx.clone(); + assert!(crate::canvas::color_at(h.state_mut(), &ctx, field).is_some(), "the page lies under the field"); + h.hover_at(from); + h.drag_at(from); + h.run_steps(1); + h.hover_at(field); + h.run_steps(1); + h.drop_at(field); + h.run_steps(3); + assert_eq!(h.state().ui.dialog.as_ref().unwrap().s("hex"), hex, "the picker isn't sampled"); +} - #[test] +#[test] +fn the_color_picker_picks_on_its_field_and_slider_and_adds_a_cmyk_swatch() { + use egui_kittest::kittest::Queryable; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [72, 72, 200, 200], "shape": "rectangle"})).unwrap(); + app.run("object.color", json!({"color":designcraft_color::Color::from_hex("#7c1315").unwrap(),"target":"fill"})).unwrap(); + app.ui.dialog = Some(Dialog::new("colorPicker", json!({"target": "fill", "hex": "#7c1315", "text": false}))); + let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); + h.run_steps(3); + // R at first: R up the slider, B across and G up the field. + let _ = h.get_by_label("RGB Color Space View"); + let field = h.get_by_label("Color field").rect(); + crate::test_window::click_at(&mut h, egui::pos2(field.right() - 1.0, field.top() + 1.0)); + h.run_steps(2); + let p = picked_of(h.state().app.ui.dialog.as_ref().unwrap()); + assert!((p.rgb[0] - 124.0 / 255.0).abs() < 0.01 && p.rgb[1] > 0.97 && p.rgb[2] > 0.97, "{p:?}: R kept, G and B full"); + // The slider sets R alone. + let slider = h.get_by_label("Color slider").rect(); + crate::test_window::click_at(&mut h, egui::pos2(slider.center().x, slider.bottom() - 1.0)); + h.run_steps(2); + let p = picked_of(h.state().app.ui.dialog.as_ref().unwrap()); + assert!(p.rgb[0] < 0.02 && p.rgb[1] > 0.97, "{p:?}"); + // Hue on the slider: the field is saturation across, brightness up. + store_picked(h.state_mut().app.ui.dialog.as_mut().unwrap(), &Picked::from_rgb([124.0 / 255.0, 19.0 / 255.0, 21.0 / 255.0], 0.0)); + h.state_mut().app.ui.dialog.as_mut().unwrap().fields.insert("component".into(), json!("h")); + h.run_steps(2); + crate::test_window::click_at(&mut h, egui::pos2(field.left() + 1.0, field.top() + 1.0)); + h.run_steps(2); + let p = picked_of(h.state().app.ui.dialog.as_ref().unwrap()); + assert!(p.hsb[1] < 0.02 && p.hsb[2] > 0.98 && (p.hsb[0] - 359.0).abs() < 2.0, "{p:?}: no saturation, full brightness, the hue kept"); + // Lab shows its own view. + h.state_mut().app.ui.dialog.as_mut().unwrap().fields.insert("component".into(), json!("l")); + h.run_steps(2); + let _ = h.get_by_label("Lab Color Space View"); + // Add CMYK Swatch adds the colour as a CMYK swatch; OK applies it. + store_picked(h.state_mut().app.ui.dialog.as_mut().unwrap(), &Picked::from_rgb([124.0 / 255.0, 19.0 / 255.0, 21.0 / 255.0], 0.0)); + h.run_steps(2); + let before = h.state().app.session.active().unwrap().doc.swatches.len(); + h.get_by_label("Add CMYK Swatch").click(); + h.run_steps(3); + let doc = &h.state().app.session.active().unwrap().doc; + assert_eq!(doc.swatches.len(), before + 1); + assert!(matches!( + doc.swatches.last().unwrap().value, + designcraft_color::swatch::SwatchValue::Color { color: designcraft_color::Color::Cmyk { .. }, .. } + )); + // The percentages the C/M/Y/K fields show, exactly (not the colour converted back). + h.state_mut().app.ui.dialog.as_mut().unwrap().fields.insert("cmyk".into(), json!([0.0, 50.0, 100.0, 0.0])); + h.run_steps(2); + h.get_by_label("Add CMYK Swatch").click(); + h.run_steps(3); + let doc = &h.state().app.session.active().unwrap().doc; + let designcraft_color::swatch::SwatchValue::Color { color: designcraft_color::Color::Cmyk { c, m, y, k }, .. } = + doc.swatches.last().unwrap().value + else { + panic!("a CMYK swatch") + }; + assert_eq!([c, m, y, k].map(|v| (v * 100.0).round()), [0.0, 50.0, 100.0, 0.0]); + // Its own OK, at the top right, applies it. + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(h.state().app.ui.dialog.is_none(), "OK closes it"); + let st = h.state().app.session.active().unwrap(); + let id = st.selection.items[0]; + let fill = st.doc.item(id).unwrap().fill.swatch.clone(); + assert_eq!(st.doc.resolve_color(&fill, 1.0).unwrap().to_hex(), "#7c1315"); +} + +#[test] fn create_guides_dialog_targets_the_page_in_view() { let mut app = facing_app(6); From 7e51494eacd9460e9cc3c56942fe530c2c1a7916 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 22:42:42 +0100 Subject: [PATCH 383/628] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e13340a25..2a7567400 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -7262,11 +7262,11 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { }, ); let fr = put(at::field()); - let fresp = ui.interact(fr, ui.id().with("cp_field"), egui::Sense::click_and_drag()); + let fresp = ui.interact(fr, ui.scope_id().with("cp_field"), egui::Sense::click_and_drag()); let (tv, _, _) = p.coords(comp); let cells = if matches!(comp, PickComp::R | PickComp::G | PickComp::Bl | PickComp::H) { 2 } else { 24 }; gradient_mesh(ui.painter(), fr, cells, cells, |x, y| p.at(comp, tv, x, y).rgb); - fresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, "Color field")); + fresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, "Color field")); if let Some(pos) = fresp.interact_pointer_pos().filter(|_| fresp.is_pointer_button_down_on() || fresp.clicked()) { let u = ((pos.x - fr.left()) / fr.width()).clamp(0.0, 1.0); let v = ((fr.bottom() - pos.y) / fr.height()).clamp(0.0, 1.0); @@ -7285,9 +7285,9 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } // The slider, with a marker on each side. let sr = put(at::slider()); - let sresp = ui.interact(sr.expand2(egui::vec2(6.0, 0.0)), ui.id().with("cp_slider"), egui::Sense::click_and_drag()); + let sresp = ui.interact(sr.expand2(egui::vec2(6.0, 0.0)), ui.scope_id().with("cp_slider"), egui::Sense::click_and_drag()); gradient_mesh(ui.painter(), sr, 1, 48, |_, y| p.slider_at(comp, y)); - sresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, true, "Color slider")); + sresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Slider, true, "Color slider")); if let Some(pos) = sresp.interact_pointer_pos().filter(|_| sresp.is_pointer_button_down_on() || sresp.clicked()) { let tn = ((sr.bottom() - pos.y) / sr.height()).clamp(0.0, 1.0); let (_, u, v) = p.coords(comp); @@ -7314,7 +7314,7 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { // OK, Cancel, Add CMYK Swatch: rounded, OK filled. let pill = |ui: &mut egui::Ui, i: usize, label: &str, primary: bool| -> bool { let r = put(at::button(i)); - let resp = ui.interact(r, ui.id().with(("cp_button", i)), egui::Sense::click()); + let resp = ui.interact(r, ui.scope_id().with(("cp_button", i)), egui::Sense::click()); let (fill, fg, stroke) = if primary { (t.accent_strong, egui::Color32::WHITE, egui::Stroke::NONE) } else { @@ -7322,7 +7322,7 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { }; ui.painter().rect(r, r.height() / 2.0, fill, stroke, egui::StrokeKind::Inside); ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, label, egui::FontId::proportional(12.0), fg); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); resp.clicked() }; // The footer's padded OK key, which every catalog has. @@ -7370,12 +7370,12 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { match c { Some(c) => { let rr = egui::Rect::from_center_size(egui::pos2(x, cy), egui::vec2(14.0, 14.0)); - let resp = ui.interact(rr, ui.id().with(("cp_radio", key)), egui::Sense::click()); + let resp = ui.interact(rr, ui.scope_id().with(("cp_radio", key)), egui::Sense::click()); ui.painter().circle(rr.center(), 6.0, t.input, egui::Stroke::new(1.0, t.text_dim)); if comp_now == c { ui.painter().circle_filled(rr.center(), 2.6, t.text_strong); } - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, true, comp_now == c, label)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, true, comp_now == c, label)); if resp.clicked() { comp_now = c; } @@ -7425,11 +7425,11 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let cy = area.min.y + at::ROWS[7]; text(ui, egui::pos2(area.min.x + 321.0, cy), egui::Align2::LEFT_CENTER, "#:"); let hr = egui::Rect::from_min_size(egui::pos2(area.min.x + 341.0, cy - 10.5), egui::vec2(64.0, 21.0)); - let id = egui::Id::new("color_picker_hex"); + let id = egui::Id::unique("color_picker_hex"); let editing = ui.memory(|m| m.has_focus(id)); let mut h = if editing { d.s("hexEdit") } else { p.hex().trim_start_matches('#').to_string() }; if ui - .put(hr, egui::TextEdit::singleline(&mut h).id(id).font(font).vertical_align(egui::Align::Center).margin(egui::Margin::symmetric(4, 2))) + .put(hr, egui::TextEdit::singleline(&mut h).id(id).font(font).align(egui::Align2::LEFT_CENTER).margin(egui::Margin::symmetric(4, 2))) .changed() && (h.len() == 6 || (h.len() == 7 && h.starts_with('#'))) && let Some(c) = designcraft_color::Color::from_hex(&h) @@ -7441,8 +7441,8 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { // pointer when it is let go. let tip = tr("Eyedropper: drag it onto the page"); let er = egui::Rect::from_center_size(egui::pos2(hr.right() + 16.0, cy), egui::vec2(21.0, 21.0)); - let resp = ui.interact(er, egui::Id::new("color_picker_eyedropper"), egui::Sense::click_and_drag()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &tip)); + let resp = ui.interact(er, egui::Id::unique("color_picker_eyedropper"), egui::Sense::click_and_drag()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &tip)); if resp.hovered() || resp.dragged() { ui.painter().rect_filled(er, 3.0, t.hover); } @@ -7853,7 +7853,7 @@ mod picker_layout { /// A value field: the text typed stays while it has the focus; a number that parses applies. fn picker_field(ui: &mut egui::Ui, d: &mut Dialog, r: egui::Rect, key: &str, value: f32, range: (f32, f32)) -> Option { - let id = egui::Id::new(("color_picker_field", key)); + let id = egui::Id::unique(("color_picker_field", key)); let editing = ui.memory(|m| m.has_focus(id)); let shown = format!("{}", value.round() as i64); let edit_key = format!("_edit_{key}"); @@ -7863,7 +7863,7 @@ fn picker_field(ui: &mut egui::Ui, d: &mut Dialog, r: egui::Rect, key: &str, val egui::TextEdit::singleline(&mut text) .id(id) .font(egui::FontId::proportional(11.5)) - .vertical_align(egui::Align::Center) + .align(egui::Align2::LEFT_CENTER) .margin(egui::Margin::symmetric(4, 2)), ); let mut out = None; From 24b923dfc0de855009fa08ced9a4c85e2193a566 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 22:43:09 +0100 Subject: [PATCH 384/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/package/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/package/tests.rs b/crates/engine/src/cmd/package/tests.rs index 3440c68f6..fe559bfb8 100644 --- a/crates/engine/src/cmd/package/tests.rs +++ b/crates/engine/src/cmd/package/tests.rs @@ -169,7 +169,7 @@ fn package_writes_document_links_and_report() { assert!(out.join("Links").join("logo.svg").exists()); let report = std::fs::read_to_string(out.join("Instructions.txt")).unwrap(); assert!(report.contains("Print on matte.") && report.contains("logo.svg"), "{report}"); - assert!(report.find("Print on matte.") < report.find("Package report:"), "the instructions come first: {report}"); + assert!(report.find("Print on matte.") < report.find("Package report:"), "the instructions come first: {report}"); assert!(report.contains("Preflight:"), "{report}"); // The packaged document links to its Links folder. let doc_file = files.iter().find(|f| f.ends_with(".designcraft")).unwrap(); From 12425aafd7d12868ebafc081ceecc49174aa69f5 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 22:43:22 +0100 Subject: [PATCH 385/628] Eyedropper: the canvas render module's imports for the colour sampler; screen_options keeps its doc comment Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/render.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas/render.rs b/crates/ui-egui/src/canvas/render.rs index 691d9c162..5b7358996 100644 --- a/crates/ui-egui/src/canvas/render.rs +++ b/crates/ui-egui/src/canvas/render.rs @@ -1,10 +1,9 @@ -use super::{Affine, CanvasLayout, Color32, DRect, DesignApp, Point, Rect, Xf}; +use super::{Affine, CanvasLayout, Color32, DRect, DesignApp, Point, Pos2, Rect, ScreenMode, Xf}; pub(super) fn c32(c: [u8; 3]) -> Color32 { Color32::from_rgb(c[0], c[1], c[2]) } -/// Render options for the canvas texture. /// The colour the canvas shows at screen point `pos` (the Color Picker's eyedropper): one device /// pixel rendered as the page is, over its paper. None off the pages. pub fn color_at(app: &mut DesignApp, ctx: &egui::Context, pos: Pos2) -> Option<[u8; 3]> { @@ -36,6 +35,7 @@ pub fn color_at(app: &mut DesignApp, ctx: &egui::Context, pos: Pos2) -> Option<[ Some([over(r, paper[0]), over(g, paper[1]), over(b, paper[2])]) } +/// Render options for the canvas texture. pub(super) fn screen_options(app: &DesignApp, layout: &CanvasLayout, preview: bool, ppp: f64) -> designcraft_render::RenderOptions { designcraft_render::RenderOptions { printing_only: preview, From 94209bec7e77f6fdcab3e2511c1617aa0e99a7ed Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 22:51:14 +0100 Subject: [PATCH 386/628] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/src/package_dialog.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 72ea2913e..1e1972ed6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3040,7 +3040,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { style_options_preview(app, &mut d); } // A multi-line field with focus (set by the dialog this frame) takes Enter as a new line. - let multiline = ui.ctx().data_mut(|m| m.remove_temp::(egui::Id::new(MULTILINE_FOCUS))).unwrap_or(false); + let multiline = ui.ctx().data_mut(|m| m.remove_temp::(egui::Id::unique(MULTILINE_FOCUS))).unwrap_or(false); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui .add( diff --git a/crates/ui-egui/src/package_dialog.rs b/crates/ui-egui/src/package_dialog.rs index 423870451..35b041d1f 100644 --- a/crates/ui-egui/src/package_dialog.rs +++ b/crates/ui-egui/src/package_dialog.rs @@ -308,7 +308,7 @@ pub fn show_instructions(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } // Enter there makes a new line: the footer mustn't take it for Continue. if lines > 1 && resp.has_focus() { - ui.ctx().data_mut(|m| m.insert_temp(egui::Id::new(crate::dialogs::MULTILINE_FOCUS), true)); + ui.ctx().data_mut(|m| m.insert_temp(egui::Id::unique(crate::dialogs::MULTILINE_FOCUS), true)); } ui.end_row(); } @@ -392,7 +392,7 @@ mod tests { fn harness(app: DesignApp) -> Harness<'static, DesignApp> { Harness::builder().with_size(egui::vec2(1100.0, 760.0)).build_ui_state( |ui, app: &mut DesignApp| { - let key = egui::Id::new("package_test_fonts"); + let key = egui::Id::unique("package_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 e8b27c91f116515ff21e1ba03d6e88b6ff357e14 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 22:55:15 +0100 Subject: [PATCH 387/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/package/tests.rs | 100 ++++++++++++------------- 1 file changed, 50 insertions(+), 50 deletions(-) diff --git a/crates/engine/src/cmd/package/tests.rs b/crates/engine/src/cmd/package/tests.rs index 124ea19c4..4d6513425 100644 --- a/crates/engine/src/cmd/package/tests.rs +++ b/crates/engine/src/cmd/package/tests.rs @@ -25,57 +25,57 @@ fn relocated_packages_rebind_embedded_image_links_on_open() { std::fs::remove_dir_all(dir).unwrap(); } - /// `pdf` takes file.exportPdf's options (a PDF preset's): a PDF/X-4 package PDF; `true` is the - /// default PDF, and bad options are refused. - #[test] - fn package_pdf_takes_export_options() { - let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); - let dir = std::env::temp_dir().join(format!("dc-package-pdf-options-{}-{nonce}", std::process::id())); - let mut s = Session::new(); - s.execute("file.new", &json!({"title": "Options", "width": 72, "height": 72})).unwrap(); - let x4 = dir.join("x4"); - s.execute("file.package", &json!({"dir": x4.to_string_lossy(), "idml": false, "pdf": {"standard": "x4"}})).unwrap(); - let has = |path: &std::path::Path, what: &[u8]| std::fs::read(path).unwrap().windows(what.len()).any(|w| w == what); - assert!(has(&x4.join("Options.pdf"), b"GTS_PDFX")); - let plain = dir.join("plain"); - s.execute("file.package", &json!({"dir": plain.to_string_lossy(), "idml": false, "pdf": true})).unwrap(); - assert!(!has(&plain.join("Options.pdf"), b"GTS_PDFX")); - // Refused before anything is written: bad options, a bad flattener or view option, and a - // `pdf` that is neither a switch nor options. - for bad in [ - json!({"standard": "nope"}), - json!({"flatten": "extreme"}), - json!({"pageLayout": "spiral"}), - json!({"view": "zoomed"}), - json!("print"), - json!(3), - ] { - let out = dir.join("bad"); - assert!(s.execute("file.package", &json!({"dir": out.to_string_lossy(), "pdf": bad})).is_err(), "{bad}"); - assert!(!out.exists(), "{bad}: bad options write nothing"); - } - std::fs::remove_dir_all(dir).unwrap(); +/// `pdf` takes file.exportPdf's options (a PDF preset's): a PDF/X-4 package PDF; `true` is the +/// default PDF, and bad options are refused. +#[test] +fn package_pdf_takes_export_options() { + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("dc-package-pdf-options-{}-{nonce}", std::process::id())); + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "Options", "width": 72, "height": 72})).unwrap(); + let x4 = dir.join("x4"); + s.execute("file.package", &json!({"dir": x4.to_string_lossy(), "idml": false, "pdf": {"standard": "x4"}})).unwrap(); + let has = |path: &std::path::Path, what: &[u8]| std::fs::read(path).unwrap().windows(what.len()).any(|w| w == what); + assert!(has(&x4.join("Options.pdf"), b"GTS_PDFX")); + let plain = dir.join("plain"); + s.execute("file.package", &json!({"dir": plain.to_string_lossy(), "idml": false, "pdf": true})).unwrap(); + assert!(!has(&plain.join("Options.pdf"), b"GTS_PDFX")); + // Refused before anything is written: bad options, a bad flattener or view option, and a + // `pdf` that is neither a switch nor options. + for bad in [ + json!({"standard": "nope"}), + json!({"flatten": "extreme"}), + json!({"pageLayout": "spiral"}), + json!({"view": "zoomed"}), + json!("print"), + json!(3), + ] { + let out = dir.join("bad"); + assert!(s.execute("file.package", &json!({"dir": out.to_string_lossy(), "pdf": bad})).is_err(), "{bad}"); + assert!(!out.exists(), "{bad}: bad options write nothing"); } + std::fs::remove_dir_all(dir).unwrap(); +} - /// A preset that flattens transparency (Screen and Email's `flatten: low`) gives the packaged - /// PDF the flattening `file.exportPdf` does. - #[test] - fn the_packaged_pdf_is_flattened_as_export_flattens_it() { - let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); - let dir = std::env::temp_dir().join(format!("dc-package-pdf-flatten-{}-{nonce}", std::process::id())); - let mut s = Session::new(); - s.execute("file.new", &json!({"title": "Flat", "width": 144, "height": 144})).unwrap(); - let id = s.execute("frame.create", &json!({"rect": [10, 10, 100, 100], "content": "unassigned"})).unwrap()["id"].clone(); - s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap(); - s.execute("object.opacity", &json!({"ids": [id], "opacity": 0.5})).unwrap(); - let exported = super::super::base64_decode(s.execute("file.exportPdf", &json!({"flatten": "low"})).unwrap()["base64"].as_str().unwrap()); - s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false, "pdf": {"flatten": "low"}})).unwrap(); - let packaged = std::fs::read(dir.join("Flat.pdf")).unwrap(); - let images = |pdf: &[u8]| pdf.windows(b"/Subtype/Image".len()).filter(|w| *w == b"/Subtype/Image").count(); - assert!(images(&exported) > 0, "the export rasterises the transparent object"); - assert_eq!(images(&packaged), images(&exported), "so does the package"); - std::fs::remove_dir_all(dir).unwrap(); - } +/// A preset that flattens transparency (Screen and Email's `flatten: low`) gives the packaged +/// PDF the flattening `file.exportPdf` does. +#[test] +fn the_packaged_pdf_is_flattened_as_export_flattens_it() { + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("dc-package-pdf-flatten-{}-{nonce}", std::process::id())); + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "Flat", "width": 144, "height": 144})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [10, 10, 100, 100], "content": "unassigned"})).unwrap()["id"].clone(); + s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap(); + s.execute("object.opacity", &json!({"ids": [id], "opacity": 0.5})).unwrap(); + let exported = super::super::base64_decode(s.execute("file.exportPdf", &json!({"flatten": "low"})).unwrap()["base64"].as_str().unwrap()); + s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false, "pdf": {"flatten": "low"}})).unwrap(); + let packaged = std::fs::read(dir.join("Flat.pdf")).unwrap(); + let images = |pdf: &[u8]| pdf.windows(b"/Subtype/Image".len()).filter(|w| *w == b"/Subtype/Image").count(); + assert!(images(&exported) > 0, "the export rasterises the transparent object"); + assert_eq!(images(&packaged), images(&exported), "so does the package"); + std::fs::remove_dir_all(dir).unwrap(); +} /// A per-placement PDF layer choice cannot travel in an IDML copy that links its files, so the /// report says the copies differ instead of leaving it to be discovered. @@ -221,7 +221,7 @@ fn package_writes_document_links_and_report() { assert!(out.join("Links").join("logo.svg").exists()); let report = std::fs::read_to_string(out.join("Instructions.txt")).unwrap(); assert!(report.contains("Print on matte.") && report.contains("logo.svg"), "{report}"); - assert!(report.find("Print on matte.") < report.find("Package report:"), "the instructions come first: {report}"); + assert!(report.find("Print on matte.") < report.find("Package report:"), "the instructions come first: {report}"); assert!(report.contains("Preflight:"), "{report}"); // The packaged document links to its Links folder. let doc_file = files.iter().find(|f| f.ends_with(".designcraft")).unwrap(); From 2a01caf0de75908da9830c0da8bf475db7ba2b13 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 22:59:47 +0100 Subject: [PATCH 388/628] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/src/package_dialog.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d8d3f7f5a..175dcd5f4 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3075,7 +3075,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { style_options_preview(app, &mut d); } // A multi-line field with focus (set by the dialog this frame) takes Enter as a new line. - let multiline = ui.ctx().data_mut(|m| m.remove_temp::(egui::Id::new(MULTILINE_FOCUS))).unwrap_or(false); + let multiline = ui.ctx().data_mut(|m| m.remove_temp::(egui::Id::unique(MULTILINE_FOCUS))).unwrap_or(false); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui .add( diff --git a/crates/ui-egui/src/package_dialog.rs b/crates/ui-egui/src/package_dialog.rs index 9583d84fe..eada9a244 100644 --- a/crates/ui-egui/src/package_dialog.rs +++ b/crates/ui-egui/src/package_dialog.rs @@ -335,7 +335,7 @@ pub fn show_instructions(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } // Enter there makes a new line: the footer mustn't take it for Continue. if lines > 1 && resp.has_focus() { - ui.ctx().data_mut(|m| m.insert_temp(egui::Id::new(crate::dialogs::MULTILINE_FOCUS), true)); + ui.ctx().data_mut(|m| m.insert_temp(egui::Id::unique(crate::dialogs::MULTILINE_FOCUS), true)); } ui.end_row(); } @@ -420,7 +420,7 @@ mod tests { fn harness(app: DesignApp) -> Harness<'static, DesignApp> { Harness::builder().with_size(egui::vec2(1100.0, 760.0)).build_ui_state( |ui, app: &mut DesignApp| { - let key = egui::Id::new("package_test_fonts"); + let key = egui::Id::unique("package_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 05a2fb4d3697ecc436129d180de49c4988e42f1d Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 23:05:27 +0100 Subject: [PATCH 389/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/tests.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 20f6e9aba..b0c46f5ad 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3253,9 +3253,9 @@ fn new_character_style_opens_its_options_and_creates_the_style() { ctl(&mut app, "ui.dialog.set", json!({"field": "c.size", "value": 9})); assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); let st = char_style(&app, "Character Style 1").unwrap(); - // The selected text's own Italic (its Emphasis style) comes along, as InDesign's New - // Character Style takes it. - assert_eq!((st.based_on, st.chars), (None, CharAttrs { size: Some(9.0), font_style: Some("Italic".into()), ..Default::default() })); + // The selected text's own Italic (its Emphasis style) comes along, as InDesign's New + // Character Style takes it. + assert_eq!((st.based_on, st.chars), (None, CharAttrs { size: Some(9.0), font_style: Some("Italic".into()), ..Default::default() })); crate::menus::activate(&mut app, "style.character.create", &Value::Null); assert_eq!(app.ui.dialog.as_ref().unwrap().s("rename"), "Character Style 2"); } @@ -3271,8 +3271,8 @@ fn new_paragraph_style_opens_its_options_and_creates_the_style() { assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); let st = app.session.active().unwrap().doc.styles.para("Paragraph Style 1").cloned().unwrap(); assert_eq!(st.based_on.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); - // The selected text's own Italic comes along, as InDesign's New Paragraph Style takes it. - assert_eq!(st.chars, CharAttrs { size: Some(20.0), font_style: Some("Italic".into()), ..Default::default() }); + // The selected text's own Italic comes along, as InDesign's New Paragraph Style takes it. + assert_eq!(st.chars, CharAttrs { size: Some(20.0), font_style: Some("Italic".into()), ..Default::default() }); } #[test] From a05bf962e8d2ce0ec249127c12c57840f0c2264d Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 23:05:46 +0100 Subject: [PATCH 390/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index d221100cd..e2877fb41 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3269,8 +3269,8 @@ fn new_paragraph_style_opens_its_options_and_creates_the_style() { assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); let st = app.session.active().unwrap().doc.styles.para("Paragraph Style 1").cloned().unwrap(); assert_eq!(st.based_on.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); - // The selected text's own Italic comes along, as InDesign's New Paragraph Style takes it. - assert_eq!(st.chars, CharAttrs { size: Some(20.0), font_style: Some("Italic".into()), ..Default::default() }); + // The selected text's own Italic comes along, as InDesign's New Paragraph Style takes it. + assert_eq!(st.chars, CharAttrs { size: Some(20.0), font_style: Some("Italic".into()), ..Default::default() }); } #[test] From 741fbe9fac2f4689e60b611015ab721437a36ada Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 23:06:21 +0100 Subject: [PATCH 391/628] Paragraph styles: create_para whole again, with the style name parameter helper before it Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/style/paragraph.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/style/paragraph.rs b/crates/engine/src/cmd/style/paragraph.rs index b7842daef..1f501cf7a 100644 --- a/crates/engine/src/cmd/style/paragraph.rs +++ b/crates/engine/src/cmd/style/paragraph.rs @@ -403,8 +403,6 @@ fn continue_numbering(p: Option<&Value>, para: &mut ParaAttrs) { } } -pub(super) fn create_para(s: &mut Session, p: &Value) -> Result { - let base = str_param(p, "name").unwrap_or("Paragraph Style 1").to_string(); /// The style `p[key]` names: none when absent or null; anything but a name is refused. fn style_name_param(cmd: &str, p: &Value, key: &str) -> Result> { match p.get(key) { @@ -414,7 +412,7 @@ fn style_name_param(cmd: &str, p: &Value, key: &str) -> Result> { } } -fn create_para(s: &mut Session, p: &Value) -> Result { +pub(super) fn create_para(s: &mut Session, p: &Value) -> Result { let base = str_param(p, "name").unwrap_or("Paragraph Style 1").to_string(); let based_on = style_name_param("style.paragraph.create", p, "basedOn")?; let next = style_name_param("style.paragraph.create", p, "nextStyle")?; From 991f51479fd008640d37551cb99cdebc23af14ec Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 23:19:46 +0100 Subject: [PATCH 392/628] TOC toggle test: the app's fonts installed first, as main's dialog tests do Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/tests.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 549b9b6dc..5dc50bd67 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3568,7 +3568,18 @@ fn enter_on_the_toc_options_toggle_only_toggles() { app.run("file.newSample", json!({})).unwrap(); open_toc(&mut app); let revision = app.session.doc().unwrap().revision; - let mut h = Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(|ui, app: &mut DesignApp| show(app, ui.ctx()), app); + let mut h = Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::unique("toc_toggle_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.ctx()); + }, + app, + ); h.run_steps(3); h.get_by_label("Fewer Options").focus(); h.run_steps(2); From 50647c57204824200b0f71f24ee57de2c071ffa7 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 23:26:17 +0100 Subject: [PATCH 393/628] Printing instructions: each field named by its label, for screen readers Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/package_dialog.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/package_dialog.rs b/crates/ui-egui/src/package_dialog.rs index 35b041d1f..1919e6d9c 100644 --- a/crates/ui-egui/src/package_dialog.rs +++ b/crates/ui-egui/src/package_dialog.rs @@ -299,10 +299,11 @@ pub fn show_instructions(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let lang = app.ui.language.clone(); egui::Grid::new("printing_instructions").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { for (key, label, lines) in INSTRUCTION_FIELDS { - crate::rtl::label(ui, crate::i18n::tr(&lang, label)); + let name = crate::rtl::label(ui, crate::i18n::tr(&lang, label)); let mut text = d.s(key); let edit = if lines > 1 { egui::TextEdit::multiline(&mut text).desired_rows(lines) } else { egui::TextEdit::singleline(&mut text) }; - let resp = ui.add(edit.desired_width(320.0)); + // Named by its label, for screen readers. + let resp = ui.add(edit.desired_width(320.0)).labelled_by(name.id); if resp.changed() { d.fields.insert(key.into(), json!(text)); } From 261f698aa26141d6a03a765e025789ecceb1afe5 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 23:26:19 +0100 Subject: [PATCH 394/628] Printing instructions: each field named by its label, for screen readers Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/package_dialog.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/package_dialog.rs b/crates/ui-egui/src/package_dialog.rs index eada9a244..2225e3d08 100644 --- a/crates/ui-egui/src/package_dialog.rs +++ b/crates/ui-egui/src/package_dialog.rs @@ -326,10 +326,11 @@ pub fn show_instructions(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let lang = app.ui.language.clone(); egui::Grid::new("printing_instructions").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { for (key, label, lines) in INSTRUCTION_FIELDS { - crate::rtl::label(ui, crate::i18n::tr(&lang, label)); + let name = crate::rtl::label(ui, crate::i18n::tr(&lang, label)); let mut text = d.s(key); let edit = if lines > 1 { egui::TextEdit::multiline(&mut text).desired_rows(lines) } else { egui::TextEdit::singleline(&mut text) }; - let resp = ui.add(edit.desired_width(320.0)); + // Named by its label, for screen readers. + let resp = ui.add(edit.desired_width(320.0)).labelled_by(name.id); if resp.changed() { d.fields.insert(key.into(), json!(text)); } From 6557f20c80354f4a3d312d2d90dff73e975b5763 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 23:26:34 +0100 Subject: [PATCH 395/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 18aabfae5..0bd0d39e6 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -61,8 +61,8 @@ use tool_overlays::draw_tool_overlays; pub(crate) use view::page_in_view; use view::present_fit; pub use view::{ - apply_view_request, can_go_through_history, current_page, current_parent_page, current_slot, current_spread, current_spread_page, drop_point, - PageBox, fit, go_through_history, go_to_page, page_box, page_box_go, set_zoom, show_spread, step_spread, view_centre, zoom_at, + PageBox, apply_view_request, can_go_through_history, current_page, current_parent_page, current_slot, current_spread, current_spread_page, + drop_point, fit, go_through_history, go_to_page, page_box, page_box_go, set_zoom, show_spread, step_spread, view_centre, zoom_at, }; pub const RULER: f32 = 15.0; From 840d75b46fc02128ca572ac62531925cf0206108 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 23:36:32 +0100 Subject: [PATCH 396/628] Find/Change: Find what, Change to and Search named by their captions, for screen readers Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 302a1b069..e245b2c5c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2462,7 +2462,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let formatted = d.fields.get(CHANGE_FORMAT).and_then(Value::as_object).is_some_and(|f| !f.is_empty()); egui::Grid::new("fc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Find what:")); - text_field(ui, &mut d, "find", 260.0); + text_field_named(ui, &mut d, "find", 260.0, crate::i18n::tr(&app.ui.language, "Find what:")); ui.end_row(); ui.horizontal(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Change to:")); @@ -2477,9 +2477,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { ); } }); - text_field(ui, &mut d, "change", 260.0); + text_field_named(ui, &mut d, "change", 260.0, crate::i18n::tr(&app.ui.language, "Change to:")); ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Search:")); + let scope_name = crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Search:")); let cur = d.s("scope"); craft_ui::ComboBox::from_id_salt("fcscope") .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &cur))) @@ -2491,7 +2491,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { app.session.reset_find_scope(); } } - }); + }) + .response + .labelled_by(scope_name.id); ui.end_row(); }); ui.horizontal(|ui| { From f61871deae5b570eba619465831c350f72eb1afd Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 23:36:33 +0100 Subject: [PATCH 397/628] Find/Change: Find what, Change to and Search named by their captions, for screen readers Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 84dc359dc..1a740a147 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2474,7 +2474,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { ); } }); - text_field(ui, &mut d, "find", 260.0); + text_field_named(ui, &mut d, "find", 260.0, crate::i18n::tr(&app.ui.language, "Find what:")); ui.end_row(); ui.horizontal(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Change to:")); @@ -2489,9 +2489,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { ); } }); - text_field(ui, &mut d, "change", 260.0); + text_field_named(ui, &mut d, "change", 260.0, crate::i18n::tr(&app.ui.language, "Change to:")); ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Search:")); + let scope_name = crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Search:")); let cur = d.s("scope"); // InDesign's names for the scopes. let scope_name = |v: &str| match v { @@ -2511,7 +2511,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { app.session.reset_find_scope(); } } - }); + }) + .response + .labelled_by(scope_name.id); ui.end_row(); }); ui.horizontal(|ui| { From c9621daa3635056c756a76a9ae4817873b3bf508 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 23:36:33 +0100 Subject: [PATCH 398/628] Find/Change: Find what, Change to and Search named by their captions, for screen readers Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6de0afa0a..fd96522cd 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2474,7 +2474,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { ); } }); - text_field(ui, &mut d, "find", 260.0); + text_field_named(ui, &mut d, "find", 260.0, crate::i18n::tr(&app.ui.language, "Find what:")); ui.end_row(); ui.horizontal(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Change to:")); @@ -2489,9 +2489,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { ); } }); - text_field(ui, &mut d, "change", 260.0); + text_field_named(ui, &mut d, "change", 260.0, crate::i18n::tr(&app.ui.language, "Change to:")); ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Search:")); + let scope_name = crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Search:")); let cur = d.s("scope"); craft_ui::ComboBox::from_id_salt("fcscope") .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &cur))) @@ -2503,7 +2503,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { app.session.reset_find_scope(); } } - }); + }) + .response + .labelled_by(scope_name.id); ui.end_row(); }); ui.horizontal(|ui| { From c35afdf719fce13b42cf2c81571b4a74d767acea Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 23:43:20 +0100 Subject: [PATCH 399/628] Insert Pages: Pages, Insert, its page and Parent named for screen readers; the page list is craft_ui's Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 40 ++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 26f87dbdc..6c3690625 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -166,20 +166,20 @@ fn insert_pages_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let mut parents = vec![(String::new(), crate::i18n::tr(&lang, "[None]").to_string())]; parents.extend(st.doc.parents.iter().map(|p| (p.id.0.to_string(), p.parent.as_ref().map(|i| i.label()).unwrap_or_default()))); egui::Grid::new("insert_pages").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&lang, "Pages:")); + let name = crate::rtl::label(ui, crate::i18n::tr(&lang, "Pages:")); let mut n = d.n("count").unwrap_or(1.0).clamp(1.0, 9999.0) as u32; - if ui.add(egui::DragValue::new(&mut n).range(1..=9999)).changed() { + if ui.add(egui::DragValue::new(&mut n).range(1..=9999)).labelled_by(name.id).changed() { d.fields.insert("count".into(), json!(n)); } ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&lang, "Insert:")); + let name = crate::rtl::label(ui, crate::i18n::tr(&lang, "Insert:")); ui.horizontal(|ui| { let wheres = [("after", "After Page"), ("before", "Before Page"), ("start", "At Start of Document"), ("end", "At End of Document")]; - translated_combo(&lang, ui, d, "insert", &wheres); + translated_combo(&lang, ui, d, "insert", &wheres).labelled_by(name.id); // The page, by name: At Start and At End need none. ui.add_enabled_ui(matches!(d.s("insert").as_str(), "after" | "before"), |ui| { let cur = (d.n("page").unwrap_or(0.0).max(0.0) as usize).min(pages.len().saturating_sub(1)); - egui::ComboBox::from_id_salt("insert_page").selected_text(pages.get(cur).cloned().unwrap_or_default()).width(64.0).show_ui( + craft_ui::ComboBox::from_id_salt("insert_page").selected_text(pages.get(cur).cloned().unwrap_or_default()).width(64.0).show_ui( ui, |ui| { for (i, name) in pages.iter().enumerate() { @@ -188,13 +188,15 @@ fn insert_pages_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } } }, - ); + ) + .response + .accessible_name(crate::i18n::tr(&lang, "Page").to_owned()); }); }); ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&lang, "Parent:")); + let name = crate::rtl::label(ui, crate::i18n::tr(&lang, "Parent:")); let opts: Vec<(&str, &str)> = parents.iter().map(|(v, l)| (v.as_str(), l.as_str())).collect(); - combo(ui, d, "parent", &opts); + combo(ui, d, "parent", &opts).labelled_by(name.id); ui.end_row(); }); } @@ -6226,22 +6228,26 @@ fn delete_character_style(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog }); } -fn combo(ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) { +fn combo(ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) -> egui::Response { let cur = d.s(key); let shown = opts.iter().find(|o| o.0 == cur).map_or(cur.as_str(), |o| o.1).to_string(); - craft_ui::ComboBox::from_id_salt(key).selected_text(crate::rtl::widget(ui, &shown)).width(170.0).show_ui(ui, |ui| { - for (v, label) in opts { - if ui.selectable_label(cur == *v, crate::rtl::widget(ui, *label)).clicked() { - d.fields.insert(key.into(), json!(v)); + craft_ui::ComboBox::from_id_salt(key) + .selected_text(crate::rtl::widget(ui, &shown)) + .width(170.0) + .show_ui(ui, |ui| { + for (v, label) in opts { + if ui.selectable_label(cur == *v, crate::rtl::widget(ui, *label)).clicked() { + d.fields.insert(key.into(), json!(v)); + } } - } - }); + }) + .response } /// Translate enum captions without changing serialized values or user-defined names. -fn translated_combo(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) { +fn translated_combo(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) -> egui::Response { let opts: Vec<_> = opts.iter().map(|(value, label)| (*value, crate::i18n::tr(lang, label))).collect(); - combo(ui, d, key, &opts); + combo(ui, d, key, &opts) } /// Style name picker (paragraph or character styles of the active document). From c6573d95577899826ccd000144df52d351c0b60c Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 23:46:49 +0100 Subject: [PATCH 400/628] Swatch rename test: the app's fonts installed first, as main's panel tests do; egui 0.37 Id::unique Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/swatches.rs | 55 ++++++++++++++++----------- 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 7652251c5..789474bbe 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -14,11 +14,11 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { // Another document (switched to, or moved to this one's place by a close) drops a rename in // place: a swatch of the same name there isn't the one being renamed. let uid = st.uid; - if ui.data(|d| d.get_temp::(egui::Id::new(RENAME_DOC))) != Some(uid) { + if ui.data(|d| d.get_temp::(egui::Id::unique(RENAME_DOC))) != Some(uid) { ui.data_mut(|d| { - d.remove_temp::<(String, String)>(egui::Id::new(RENAMING)); - d.remove_temp::<(String, f64)>(egui::Id::new(RENAME_CLICK)); - d.insert_temp(egui::Id::new(RENAME_DOC), uid); + d.remove_temp::<(String, String)>(egui::Id::unique(RENAMING)); + d.remove_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK)); + d.insert_temp(egui::Id::unique(RENAME_DOC), uid); }); } let target = super::color_target(app); @@ -262,9 +262,9 @@ fn folder_button(ui: &mut egui::Ui, tip: &str) -> egui::Response { /// The name field of a swatch being renamed in place, over `rect`; false when `sw` isn't. Enter or /// a click elsewhere renames it (`swatch.options`), Escape leaves it as it was. fn rename_field(app: &mut DesignApp, ui: &mut egui::Ui, sw: &designcraft_color::swatch::Swatch, rect: egui::Rect) -> bool { - let key = egui::Id::new(RENAMING); + let key = egui::Id::unique(RENAMING); let Some((name, mut text)) = ui.data(|d| d.get_temp::<(String, String)>(key)).filter(|(n, _)| *n == sw.name) else { return false }; - let id = egui::Id::new(("swatch_rename", &sw.name)); + let id = egui::Id::unique(("swatch_rename", &sw.name)); let edit = egui::TextEdit::singleline(&mut text).id(id).font(egui::FontId::proportional(12.5)).margin(vec2(2.0, 1.0)); let resp = ui.put(rect, edit); if !resp.has_focus() && !resp.lost_focus() { @@ -285,8 +285,8 @@ fn rename_field(app: &mut DesignApp, ui: &mut egui::Ui, sw: &designcraft_color:: // Still picked under its new name, unless the click that ended the rename picked // another swatch. Ok(_) => ui.data_mut(|d| { - if d.get_temp::(egui::Id::new(PICKED)).is_none_or(|p| p == name) { - d.insert_temp(egui::Id::new(PICKED), to.to_string()); + if d.get_temp::(egui::Id::unique(PICKED)).is_none_or(|p| p == name) { + d.insert_temp(egui::Id::unique(PICKED), to.to_string()); } }), Err(e) => { @@ -369,30 +369,30 @@ fn swatch_row( if resp.clicked() && !resp.double_clicked() { // A click on the name of the swatch picked already renames it in place, as InDesign's // (a slow double-click), unless it turns into a double-click. - let picked = ui.data(|d| d.get_temp::(egui::Id::new(PICKED))).is_some_and(|p| p == sw.name); + let picked = ui.data(|d| d.get_temp::(egui::Id::unique(PICKED))).is_some_and(|p| p == sw.name); // A click on another swatch cancels a rename still waiting. - ui.data_mut(|d| d.remove_temp::<(String, f64)>(egui::Id::new(RENAME_CLICK))); + ui.data_mut(|d| d.remove_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK))); if picked && !sw.locked && resp.interact_pointer_pos().is_some_and(|p| name_rect.contains(p)) { let now = ui.input(|i| i.time); - ui.data_mut(|d| d.insert_temp(egui::Id::new(RENAME_CLICK), (sw.name.clone(), now))); + ui.data_mut(|d| d.insert_temp(egui::Id::unique(RENAME_CLICK), (sw.name.clone(), now))); } ui.data_mut(|d| d.insert_temp(egui::Id::unique(PICKED), sw.name.clone())); let _ = super::apply_swatch(app, app.ui.stroke_front, &sw.name, None); } // Double-click: Swatch Options (the first click has applied the swatch). if resp.double_clicked() { - ui.data_mut(|d| d.remove_temp::<(String, f64)>(egui::Id::new(RENAME_CLICK))); + ui.data_mut(|d| d.remove_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK))); let _ = app.run("app.swatchOptionsDialog", json!({"name": sw.name})); } - if let Some((name, at)) = ui.data(|d| d.get_temp::<(String, f64)>(egui::Id::new(RENAME_CLICK))).filter(|(n, _)| *n == sw.name) { + if let Some((name, at)) = ui.data(|d| d.get_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK))).filter(|(n, _)| *n == sw.name) { let wait = ui.ctx().options(|o| o.input_options.max_double_click_delay); - if ui.data(|d| d.get_temp::(egui::Id::new(PICKED))).is_none_or(|p| p != name) { + if ui.data(|d| d.get_temp::(egui::Id::unique(PICKED))).is_none_or(|p| p != name) { // Another swatch picked since: no rename. - ui.data_mut(|d| d.remove_temp::<(String, f64)>(egui::Id::new(RENAME_CLICK))); + ui.data_mut(|d| d.remove_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK))); } else if ui.input(|i| i.time) - at > wait { ui.data_mut(|d| { - d.remove_temp::<(String, f64)>(egui::Id::new(RENAME_CLICK)); - d.insert_temp(egui::Id::new(RENAMING), (name.clone(), name)); + d.remove_temp::<(String, f64)>(egui::Id::unique(RENAME_CLICK)); + d.insert_temp(egui::Id::unique(RENAMING), (name.clone(), name)); }); } else { ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(wait)); @@ -1373,10 +1373,21 @@ mod swatches_header_tests { app.run("file.new", json!({})).unwrap(); let color = json!({"color": {"c": 14, "m": 8, "y": 95, "k": 0}}); let name = app.run("swatch.create", color.clone()).unwrap()["name"].as_str().unwrap().to_string(); - let mut h = Harness::builder().with_size(vec2(300.0, 520.0)).build_ui_state(|ui, app: &mut DesignApp| super::show(app, ui), app); - h.run_steps(2); + let mut h = Harness::builder().with_size(vec2(300.0, 520.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::unique("rename_test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + super::show(app, ui); + }, + app, + ); + h.run_steps(3); let fields = h.query_all_by_role(egui::accesskit::Role::TextInput).count(); - let renaming = egui::Id::new(super::RENAMING); + let renaming = egui::Id::unique(super::RENAMING); h.ctx.data_mut(|d| d.insert_temp(renaming, (name.clone(), "Typed".to_string()))); h.run_steps(2); assert!(h.query_all_by_role(egui::accesskit::Role::TextInput).count() > fields, "the name is a field"); @@ -1400,7 +1411,7 @@ mod swatches_header_tests { // Frames 50 ms apart: the double-click time is six of them. let mut h = Harness::builder().with_size(vec2(300.0, 520.0)).with_step_dt(0.05).build_ui_state( |ui, app: &mut DesignApp| { - let id = egui::Id::new("test_fonts"); + let id = egui::Id::unique("test_fonts"); if ui.data(|d| d.get_temp::(id)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(id, true)); @@ -1467,7 +1478,7 @@ mod swatches_header_tests { assert!(!field(&h)); // A click on another swatch while the rename waits: that one is picked, and nothing // renames. - let picked = |h: &Harness<'_, DesignApp>| h.ctx.data(|d| d.get_temp::(egui::Id::new(super::PICKED))); + let picked = |h: &Harness<'_, DesignApp>| h.ctx.data(|d| d.get_temp::(egui::Id::unique(super::PICKED))); let (lemon, paper) = (name_at(&h, "Lemon Yellow Sun"), name_at(&h, "[Paper]")); h.run_steps(20); click(&mut h, lemon); From 9fdaa815849ed4800b1589f86ebc6f35042619d0 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 29 Oct 2026 18:48:35 -0400 Subject: [PATCH 401/628] packaging: Flatpak finds and prints to CUPS printers (#2012) * packaging: Flatpak finds and prints to CUPS printers The Print dialog listed no printers in the Flatpak: the runtime has libcups but not lpstat/lp, the sandbox couldn't reach the CUPS socket, and driverless network printers (lpstat -e) need Avahi. Bundle lpstat/lp/lpoptions from CUPS 2.4.20 (Apache-2.0, statically linked, licence installed) and grant --socket=cups and --system-talk-name=org.freedesktop.Avahi in both manifests. * packaging: Flatpak reads the user's CUPS settings (~/.cups, read-only) Review (CodeRabbit): without it the sandbox's lp/lpstat ignore the user's default printer and saved options (lpoptions) and a print server named in their client.conf. * packaging: Flatpak CUPS tools reach the host socket when client.conf names a custom path Review: Flatpak mounts the host's CUPS socket at /var/run/cups/cups.sock whatever path ~/.cups/client.conf or CUPS_SERVER names, so lp/lpstat/lpoptions now run through a wrapper that points a socket path missing in the sandbox there. Server names (remote servers) and sockets that exist are left alone. Tested in the sandbox: CUPS_SERVER=/run/custom/cups.sock lists the host's printers through the wrapper and none without it; a host name is passed through unchanged. * packaging: redirect a custom CUPS socket only when it is the one Flatpak mounted Review (CodeRabbit): Flatpak picks the socket it mounts at /var/run/cups/cups.sock with stricter rules than CUPS (flatpak-run-cups.c: a socket CUPS_SERVER, else the first line spelled exactly "ServerName /path" in ~/.cups/client.conf, else the default), while CUPS reads the keyword in any case and takes the last ServerName. The wrapper now redirects only when CUPS' socket is the one Flatpak chose, so a job can't reach a different server; otherwise CUPS fails on the missing path. * packaging: never redirect a CUPS_SERVER the sandbox was given Review (CodeRabbit): Flatpak picks the socket it mounts from the host's CUPS_SERVER, so one set by flatpak run --env or an override may not be what it mounted. The wrapper now leaves any CUPS_SERVER alone and redirects only a client.conf socket that matches Flatpak's choice; otherwise CUPS fails on the missing path instead of reaching a different server. * packaging: point CUPS at the mounted socket only when it is the one Flatpak chose, else fail Review (CodeRabbit): with a custom socket listed first and the default last in ~/.cups/client.conf, Flatpak mounts the custom one at /var/run/cups/cups.sock while CUPS picks the last line, finds that path, and talks to the custom server. The wrapper now compares CUPS' socket with Flatpak's choice even when the path exists: equal, CUPS is pointed at the mount; different, the tool fails with an error instead of reaching another server. --- .../ai.storyteller.designcraft.bundle.yml | 52 +++++++++++++++++++ .../flatpak/ai.storyteller.designcraft.yml | 52 +++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml index 755d2e2a3..09ac37d73 100644 --- a/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml @@ -27,6 +27,14 @@ finish-args: - --share=network # GPU access for egui's renderer (Vulkan, or GL as a fallback). - --device=dri + # Printing: lpstat/lp (the cups-client module) reach the host's CUPS through its socket and + # find driverless network printers through Avahi (`lpstat -e`). Without these the Print + # dialog lists no printers. + - --socket=cups + - --system-talk-name=org.freedesktop.Avahi + # The user's CUPS settings, read-only: their default printer and saved options (lpoptions) + # and a print server named in their client.conf, which lp reaches over the network. + - --filesystem=~/.cups:ro # Open/Save, Place and Export go through the file-chooser portal (rfd's xdg-portal backend) # and drag-and-drop through the document portal, so picked files work anywhere without broad # filesystem access. Documents and Pictures are granted so files passed by path (the command @@ -35,6 +43,50 @@ finish-args: - --filesystem=xdg-pictures # Home/host filesystem access is deliberately not granted. modules: + # CUPS client tools for craft-print: the runtime has libcups but not lpstat/lp/lpoptions. + # Linked statically against this release's libcups, so a runtime update can't break them; + # the socket path matches the host's, which --socket=cups mounts. + - name: cups-client + buildsystem: simple + build-commands: + - ./configure --prefix=/app --sysconfdir=/etc --localstatedir=/var --with-rundir=/run/cups + --with-domainsocket=/run/cups/cups.sock --disable-shared --with-components=libcups + --with-dnssd=avahi --with-tls=gnutls --disable-pam --disable-dbus + - make -C cups libs + - make -C systemv lp lpoptions lpstat + - install -Dm755 -t /app/libexec/cups systemv/lp systemv/lpoptions systemv/lpstat + - for tool in lp lpoptions lpstat; do install -Dm755 cups-socket.sh "/app/bin/$tool"; done + - install -Dm644 -t /app/share/licenses/cups LICENSE NOTICE + sources: + - type: archive + url: https://github.com/OpenPrinting/cups/releases/download/v2.4.20/cups-2.4.20-source.tar.gz + sha256: ab4d9cd7f3e58060091d2b24972223d6401675f11c49b65abf4f6ef31dea22ff + # Runs each tool. Flatpak mounts the host's CUPS socket at /var/run/cups/cups.sock, choosing + # it as flatpak-run-cups.c does from the host's CUPS_SERVER, else the first line spelled + # exactly `ServerName /path` in ~/.cups/client.conf, else the default; it mounts nothing + # when that socket is missing. CUPS reads the keyword in any case and takes the last + # ServerName. When CUPS' socket from client.conf is the one Flatpak chose, it is pointed at + # the mount; when it isn't, the tool fails rather than reach a different server. A + # CUPS_SERVER is left alone: one set by `flatpak run --env` may not be what was mounted. + - type: script + dest-filename: cups-socket.sh + commands: + - 'conf="$HOME/.cups/client.conf"' + - 'if [ -z "${CUPS_SERVER:-}" ] && [ -r "$conf" ]; then' + - ' server=$(sed -n ''s/^[[:space:]]*[Ss][Ee][Rr][Vv][Ee][Rr][Nn][Aa][Mm][Ee][[:space:]]\{1,\}\([^[:space:]]*\).*/\1/p'' "$conf" | tail -n 1)' + - ' case "$server" in' + - ' /*)' + - ' mounted=$(sed -n ''s/^[[:space:]]*ServerName \(.*\)$/\1/p'' "$conf" | sed ''s/^[[:space:]]*//'' | grep -m 1 ''^/[^:]*$'')' + - ' if [ "$mounted" = "$server" ]; then' + - ' export CUPS_SERVER=/var/run/cups/cups.sock' + - ' else' + - ' echo "${0##*/}: ~/.cups/client.conf names the CUPS socket $server, but Flatpak gave this app ${mounted:-a different one}; give each socket one ServerName line, spelled ServerName" >&2' + - ' exit 1' + - ' fi' + - ' ;;' + - ' esac' + - 'fi' + - 'exec "/app/libexec/cups/${0##*/}" "$@"' - name: designcraft buildsystem: simple build-commands: diff --git a/packaging/linux/flatpak/ai.storyteller.designcraft.yml b/packaging/linux/flatpak/ai.storyteller.designcraft.yml index 1dc2ee4ed..bd456579f 100644 --- a/packaging/linux/flatpak/ai.storyteller.designcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.designcraft.yml @@ -28,6 +28,14 @@ finish-args: - --share=network # GPU access for egui's renderer (Vulkan, or GL as a fallback). - --device=dri + # Printing: lpstat/lp (the cups-client module) reach the host's CUPS through its socket and + # find driverless network printers through Avahi (`lpstat -e`). Without these the Print + # dialog lists no printers. + - --socket=cups + - --system-talk-name=org.freedesktop.Avahi + # The user's CUPS settings, read-only: their default printer and saved options (lpoptions) + # and a print server named in their client.conf, which lp reaches over the network. + - --filesystem=~/.cups:ro # Open/Save, Place and Export go through the file-chooser portal (rfd's xdg-portal backend) # and drag-and-drop through the document portal, so picked files work anywhere without broad # filesystem access. Documents and Pictures are granted so files passed by path (the command @@ -38,6 +46,50 @@ finish-args: build-options: append-path: /usr/lib/sdk/rust-stable/bin modules: + # CUPS client tools for craft-print: the runtime has libcups but not lpstat/lp/lpoptions. + # Linked statically against this release's libcups, so a runtime update can't break them; + # the socket path matches the host's, which --socket=cups mounts. + - name: cups-client + buildsystem: simple + build-commands: + - ./configure --prefix=/app --sysconfdir=/etc --localstatedir=/var --with-rundir=/run/cups + --with-domainsocket=/run/cups/cups.sock --disable-shared --with-components=libcups + --with-dnssd=avahi --with-tls=gnutls --disable-pam --disable-dbus + - make -C cups libs + - make -C systemv lp lpoptions lpstat + - install -Dm755 -t /app/libexec/cups systemv/lp systemv/lpoptions systemv/lpstat + - for tool in lp lpoptions lpstat; do install -Dm755 cups-socket.sh "/app/bin/$tool"; done + - install -Dm644 -t /app/share/licenses/cups LICENSE NOTICE + sources: + - type: archive + url: https://github.com/OpenPrinting/cups/releases/download/v2.4.20/cups-2.4.20-source.tar.gz + sha256: ab4d9cd7f3e58060091d2b24972223d6401675f11c49b65abf4f6ef31dea22ff + # Runs each tool. Flatpak mounts the host's CUPS socket at /var/run/cups/cups.sock, choosing + # it as flatpak-run-cups.c does from the host's CUPS_SERVER, else the first line spelled + # exactly `ServerName /path` in ~/.cups/client.conf, else the default; it mounts nothing + # when that socket is missing. CUPS reads the keyword in any case and takes the last + # ServerName. When CUPS' socket from client.conf is the one Flatpak chose, it is pointed at + # the mount; when it isn't, the tool fails rather than reach a different server. A + # CUPS_SERVER is left alone: one set by `flatpak run --env` may not be what was mounted. + - type: script + dest-filename: cups-socket.sh + commands: + - 'conf="$HOME/.cups/client.conf"' + - 'if [ -z "${CUPS_SERVER:-}" ] && [ -r "$conf" ]; then' + - ' server=$(sed -n ''s/^[[:space:]]*[Ss][Ee][Rr][Vv][Ee][Rr][Nn][Aa][Mm][Ee][[:space:]]\{1,\}\([^[:space:]]*\).*/\1/p'' "$conf" | tail -n 1)' + - ' case "$server" in' + - ' /*)' + - ' mounted=$(sed -n ''s/^[[:space:]]*ServerName \(.*\)$/\1/p'' "$conf" | sed ''s/^[[:space:]]*//'' | grep -m 1 ''^/[^:]*$'')' + - ' if [ "$mounted" = "$server" ]; then' + - ' export CUPS_SERVER=/var/run/cups/cups.sock' + - ' else' + - ' echo "${0##*/}: ~/.cups/client.conf names the CUPS socket $server, but Flatpak gave this app ${mounted:-a different one}; give each socket one ServerName line, spelled ServerName" >&2' + - ' exit 1' + - ' fi' + - ' ;;' + - ' esac' + - 'fi' + - 'exec "/app/libexec/cups/${0##*/}" "$@"' - name: designcraft buildsystem: simple build-options: From 515daea39311b71eee08a6c553da7dbd6992232a Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 23:54:42 +0100 Subject: [PATCH 402/628] Effects: one Mode row for both glows (main's Inner Glow picker and the branch's Outer Glow), craft_ui's dropdown Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/effects_dialog.rs | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/effects_dialog.rs b/crates/ui-egui/src/effects_dialog.rs index fbf8c185a..cd536487b 100644 --- a/crates/ui-egui/src/effects_dialog.rs +++ b/crates/ui-egui/src/effects_dialog.rs @@ -360,23 +360,7 @@ fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) { let key = page; heading(ui, &tr("Blending")); grid("b").show(ui, |ui| { - // Inner Glow: its blend mode (Screen, as InDesign starts it). - if key == "innerGlow" { - row(ui, "Mode:"); - let cur = d.fields.get("fx").and_then(|f| f["innerGlow"]["mode"].as_str()).unwrap_or("Screen").to_string(); - let label = |m: &designcraft_color::BlendMode| { - serde_json::to_value(m).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default() - }; - let shown = designcraft_color::BlendMode::ALL.iter().find(|m| label(m) == cur).map_or("Screen", |m| m.label()); - craft_ui::ComboBox::from_id_salt("fx_glow_mode").selected_text(tr(shown)).width(150.0).show_ui(ui, |ui| { - for m in designcraft_color::BlendMode::ALL { - if ui.selectable_label(label(&m) == cur, tr(m.label())).clicked() { - set(d, &["innerGlow", "mode"], json!(label(&m))); - } - } - }); - ui.end_row(); - } + mode_row(ui, d, key, &tr); row(ui, "Color:"); swatch(app, ui, d, &[key, "color"]); row(ui, "Opacity:"); @@ -576,7 +560,7 @@ fn mode_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, tr: &dyn Fn(&str) -> S let name = |m: &designcraft_color::BlendMode| serde_json::to_value(m).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); let shown = designcraft_color::BlendMode::ALL.iter().find(|m| name(m) == cur).map_or(fallback, |m| m.label()); let mut picked = None; - egui::ComboBox::from_id_salt(("fx_mode", key.to_string())).selected_text(tr(shown)).width(150.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(("fx_mode", key.to_string())).selected_text(tr(shown)).width(150.0).show_ui(ui, |ui| { for m in designcraft_color::BlendMode::ALL { if ui.selectable_label(name(&m) == cur, tr(m.label())).clicked() { picked = Some(name(&m)); From 3d03258989d29dade45c222d42f422dbb5893cc9 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 23:56:54 +0100 Subject: [PATCH 403/628] Preferences: the Tools Panel dropdown named by its label, for screen readers Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7fb6037dc..1802a1fcf 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1998,8 +1998,8 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he ui.add_space(8.0); crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Panels")).font(semibold(12.0))); ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&lang, "Tools Panel:")); - translated_combo(&lang, ui, d, "toolsColumns", &[("single", "Single Column"), ("double", "Double Column")]); + let name = crate::rtl::label(ui, crate::i18n::tr(&lang, "Tools Panel:")); + translated_combo(&lang, ui, d, "toolsColumns", &[("single", "Single Column"), ("double", "Double Column")]).labelled_by(name.id); }); } "uiScaling" => { @@ -6131,22 +6131,26 @@ fn delete_character_style(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog }); } -fn combo(ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) { +fn combo(ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) -> egui::Response { let cur = d.s(key); let shown = opts.iter().find(|o| o.0 == cur).map_or(cur.as_str(), |o| o.1).to_string(); - craft_ui::ComboBox::from_id_salt(key).selected_text(crate::rtl::widget(ui, &shown)).width(170.0).show_ui(ui, |ui| { - for (v, label) in opts { - if ui.selectable_label(cur == *v, crate::rtl::widget(ui, *label)).clicked() { - d.fields.insert(key.into(), json!(v)); + craft_ui::ComboBox::from_id_salt(key) + .selected_text(crate::rtl::widget(ui, &shown)) + .width(170.0) + .show_ui(ui, |ui| { + for (v, label) in opts { + if ui.selectable_label(cur == *v, crate::rtl::widget(ui, *label)).clicked() { + d.fields.insert(key.into(), json!(v)); + } } - } - }); + }) + .response } /// Translate enum captions without changing serialized values or user-defined names. -fn translated_combo(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) { +fn translated_combo(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) -> egui::Response { let opts: Vec<_> = opts.iter().map(|(value, label)| (*value, crate::i18n::tr(lang, label))).collect(); - combo(ui, d, key, &opts); + combo(ui, d, key, &opts) } /// Style name picker (paragraph or character styles of the active document). From b622e045088fee1d05160a7255ef5b27b48556ea Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 00:00:22 +0100 Subject: [PATCH 404/628] Paragraph Style Options: Next Style named by its label, for screen readers; craft_ui dropdowns Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e286dc94f..13d7e54b3 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4396,7 +4396,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo let _ = crate::widgets::name_combo(combo, crate::i18n::tr(&app.ui.language, "Based On")); ui.end_row(); // Next Style: the style of the paragraph that follows when Enter ends this one. - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Next Style:")); + let next_name = crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Next Style:")); let next = match d.fields.get("nextStyle") { Some(Value::String(n)) => Some(n.clone()), Some(_) => None, @@ -4404,7 +4404,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo }; let same = crate::i18n::tr(&app.ui.language, "[Same style]").to_string(); let shown = next.as_deref().map_or(same.clone(), |n| crate::i18n::style_name(&app.ui.language, n).to_string()); - egui::ComboBox::from_id_salt("psnext").selected_text(crate::rtl::widget(ui, shown)).width(220.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("psnext").selected_text(crate::rtl::widget(ui, shown)).width(220.0).show_ui(ui, |ui| { if ui.selectable_label(next.is_none(), crate::rtl::widget(ui, &same)).clicked() { d.fields.insert("nextStyle".into(), Value::Null); } @@ -4416,7 +4416,9 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo d.fields.insert("nextStyle".into(), json!(n)); } } - }); + }) + .response + .labelled_by(next_name.id); ui.end_row(); }); // Reset To Base: the style keeps nothing of its own (the edits made here stay). @@ -5832,7 +5834,7 @@ fn char_number(ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, su fn char_choice(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, opts: &[(Value, &str)]) { let cur = cf.get(d, key); let shown = opts.iter().find(|o| o.0 == cur).map_or("", |o| crate::i18n::tr(lang, o.1)); - egui::ComboBox::from_id_salt(("cs", key)).selected_text(crate::rtl::widget(ui, shown)).width(140.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(("cs", key)).selected_text(crate::rtl::widget(ui, shown)).width(140.0).show_ui(ui, |ui| { if cf.sparse && ui.selectable_label(cur.is_null(), " ").clicked() { cf.set(d, key, Value::Null); } From 1c3b9280024919ba742c9e663269f979543af4da Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 00:00:52 +0100 Subject: [PATCH 405/628] 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 9183444cf..256cfeaef 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4412,7 +4412,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo }; let same = crate::i18n::tr(&app.ui.language, "[Same style]").to_string(); let shown = next.as_deref().map_or(same.clone(), |n| crate::i18n::style_name(&app.ui.language, n).to_string()); - egui::ComboBox::from_id_salt("psnext").selected_text(crate::rtl::widget(ui, shown)).width(220.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("psnext").selected_text(crate::rtl::widget(ui, shown)).width(220.0).show_ui(ui, |ui| { if ui.selectable_label(next.is_none(), crate::rtl::widget(ui, &same)).clicked() { d.fields.insert("nextStyle".into(), Value::Null); } @@ -5857,7 +5857,7 @@ fn char_number(ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, su fn char_choice(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, opts: &[(Value, &str)]) { let cur = cf.get(d, key); let shown = opts.iter().find(|o| o.0 == cur).map_or("", |o| crate::i18n::tr(lang, o.1)); - egui::ComboBox::from_id_salt(("cs", key)).selected_text(crate::rtl::widget(ui, shown)).width(140.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(("cs", key)).selected_text(crate::rtl::widget(ui, shown)).width(140.0).show_ui(ui, |ui| { if cf.sparse && ui.selectable_label(cur.is_null(), " ").clicked() { cf.set(d, key, Value::Null); } From 9c0ab48b9a4e8c9cb1d8e7bc0e8b8e0fb20372fa Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 00:00:55 +0100 Subject: [PATCH 406/628] 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 1a740a147..f3832a9d7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3511,7 +3511,7 @@ fn format_style_options(lang: &str, ui: &mut egui::Ui, fd: &mut Dialog, styles: crate::rtl::label(ui, crate::i18n::tr(lang, label)); let cur = fd.fields.get(key).and_then(Value::as_str).map(str::to_string); let shown = cur.as_deref().map_or_else(|| any.to_string(), |n| crate::i18n::style_name(lang, n).to_string()); - egui::ComboBox::from_id_salt(("format_style", key)).selected_text(crate::rtl::widget(ui, shown)).width(240.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(("format_style", key)).selected_text(crate::rtl::widget(ui, shown)).width(240.0).show_ui(ui, |ui| { if ui.selectable_label(cur.is_none(), crate::rtl::widget(ui, any)).clicked() { fd.fields.remove(key); } From 5db160f9e8d6353ceac0767038d9cd2539b3ed80 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 00:00:56 +0100 Subject: [PATCH 407/628] 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 fd96522cd..35031149a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3476,7 +3476,7 @@ fn format_style_options(lang: &str, ui: &mut egui::Ui, fd: &mut Dialog, styles: crate::rtl::label(ui, crate::i18n::tr(lang, label)); let cur = fd.fields.get(key).and_then(Value::as_str).map(str::to_string); let shown = cur.as_deref().map_or_else(|| any.to_string(), |n| crate::i18n::style_name(lang, n).to_string()); - egui::ComboBox::from_id_salt(("format_style", key)).selected_text(crate::rtl::widget(ui, shown)).width(240.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(("format_style", key)).selected_text(crate::rtl::widget(ui, shown)).width(240.0).show_ui(ui, |ui| { if ui.selectable_label(cur.is_none(), crate::rtl::widget(ui, any)).clicked() { fd.fields.remove(key); } From d865a7cca15fa7ed81d18e0bf6e2bad41e47a153 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 00:00:58 +0100 Subject: [PATCH 408/628] 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 e245b2c5c..805a5ffc6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3441,7 +3441,7 @@ fn format_style_options(lang: &str, ui: &mut egui::Ui, fd: &mut Dialog, styles: crate::rtl::label(ui, crate::i18n::tr(lang, label)); let cur = fd.fields.get(key).and_then(Value::as_str).map(str::to_string); let shown = cur.as_deref().map_or_else(|| any.to_string(), |n| crate::i18n::style_name(lang, n).to_string()); - egui::ComboBox::from_id_salt(("format_style", key)).selected_text(crate::rtl::widget(ui, shown)).width(240.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(("format_style", key)).selected_text(crate::rtl::widget(ui, shown)).width(240.0).show_ui(ui, |ui| { if ui.selectable_label(cur.is_none(), crate::rtl::widget(ui, any)).clicked() { fd.fields.remove(key); } From 63340f51a55d358773c72c52f03c4e1977e7647e Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 00:01:06 +0100 Subject: [PATCH 409/628] 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 cbf7ea6a6..82221a4d1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4616,7 +4616,7 @@ fn swatch_menu_with( Some(label) if current == designcraft_doc::TEXT_COLOR => label, _ => current, }; - egui::ComboBox::from_id_salt(id).selected_text(shown).width(width).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(id).selected_text(shown).width(width).show_ui(ui, |ui| { if let Some(label) = text_color && ui.selectable_label(current == designcraft_doc::TEXT_COLOR, label).clicked() { @@ -6476,7 +6476,7 @@ fn paragraph_rules(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, prefix: & designcraft_doc::StrokeType::Style { name } => name.clone(), k => crate::i18n::tr(lang, k.label()).to_string(), }; - egui::ComboBox::from_id_salt(("rule_type", which)).selected_text(crate::rtl::widget(ui, shown)).width(130.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(("rule_type", which)).selected_text(crate::rtl::widget(ui, shown)).width(130.0).show_ui(ui, |ui| { for k in rule_types().into_iter().chain(custom.into_iter().map(|name| designcraft_doc::StrokeType::Style { name })) { let label = match &k { designcraft_doc::StrokeType::Style { name } => name.clone(), From 454d5ca10cbb9111d5a41d6bcf33939577880052 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 00:01:08 +0100 Subject: [PATCH 410/628] 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 f4b5730e6..5896fdfee 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4572,7 +4572,7 @@ fn para_choice(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: & fn para_choice_width(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &str, opts: &[(&str, &str)], width: f32) { let cur = para_value(d, pv, key); let shown = opts.iter().find(|o| cur == o.0).map_or("", |o| crate::i18n::tr(lang, o.1)); - egui::ComboBox::from_id_salt(("pschoice", key)).selected_text(crate::rtl::widget(ui, shown)).width(width).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(("pschoice", key)).selected_text(crate::rtl::widget(ui, shown)).width(width).show_ui(ui, |ui| { for (v, label) in opts { if ui.selectable_label(cur == *v, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).clicked() { para_set(d, key, json!(v)); From 693e9b13a888f1664c3d6ba342eca7744d2abb86 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 00:01:11 +0100 Subject: [PATCH 411/628] 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 412/628] 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 cd612b1836a79153f5b6de432092ffbe924cee37 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 00:04:27 +0100 Subject: [PATCH 413/628] Package: the PDF Preset dropdown named by its label, for screen readers; craft_ui's dropdown Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/package_dialog.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/package_dialog.rs b/crates/ui-egui/src/package_dialog.rs index 2225e3d08..536c5e4ac 100644 --- a/crates/ui-egui/src/package_dialog.rs +++ b/crates/ui-egui/src/package_dialog.rs @@ -278,9 +278,9 @@ pub fn show(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.add_enabled_ui(d.b("pdf"), |ui| { ui.horizontal(|ui| { ui.add_space(24.0); - crate::rtl::label(ui, tr("PDF Preset:")); + let name = crate::rtl::label(ui, tr("PDF Preset:")); let cur = d.s("pdfPreset"); - egui::ComboBox::from_id_salt("package_pdf_preset").selected_text(crate::rtl::widget(ui, tr(&cur))).width(180.0).show_ui( + craft_ui::ComboBox::from_id_salt("package_pdf_preset").selected_text(crate::rtl::widget(ui, tr(&cur))).width(180.0).show_ui( ui, |ui| { for p in crate::dialogs::pdf_presets() { @@ -289,7 +289,9 @@ pub fn show(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } } }, - ); + ) + .response + .labelled_by(name.id); }); }); } From 9d30ad7e7bdfdcd6906628c3e219972adb342145 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 00:19:51 +0100 Subject: [PATCH 414/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 67c28f601..c27a3618b 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -32,8 +32,8 @@ pub(crate) use links::run_update_links; pub use links::{info_panel, links_panel, preflight_panel, update_links}; use object::{align_row, appearance_section, guide_section, quick_actions_object, transform_section, wrap_section}; pub(crate) use object::{corner_type_dropdown, ref_point_proxy, stroke_type_dropdown}; -use object_panels::{anchor_point_sections, pathfinder_row}; pub use object_panels::{align_panel, attributes_panel, effects_panel, pathfinder_panel, stroke_panel, wrap_panel}; +use object_panels::{anchor_point_sections, pathfinder_row}; pub(crate) use text::{LANGUAGES, kerning_control, language_menu}; use text::{ bullets_section, character_section, paragraph_section, quick_actions_text, text_appearance_section, text_frame_section, text_style_section, From 34d97eae377e27f7141ade69f4e999ed1b0bce2f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 00:20:02 +0100 Subject: [PATCH 415/628] Page box test: the menu's entries found among the names the window shows elsewhere too Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/chrome/tests.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index eb84e74ca..742aba403 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -203,16 +203,18 @@ fn the_page_box_names_the_parent_in_view_and_lists_the_parents() { h.get_by(|n| n.role() == Role::ComboBox && n.label().as_deref() == Some("Page")).click(); h.run_steps(3); for name in ["1", "2", "3", "4", "A-Parent"] { - assert!(h.query_by_label(name).is_some(), "{name} is listed"); + // The canvas or a panel may show the name too: the menu lists it. + assert!(h.query_all_by_label(name).next().is_some(), "{name} is listed"); } - h.get_by_label("B-Cover").click(); + // The menu, drawn last, holds the last match. + h.get_all_by_label("B-Cover").last().unwrap().click(); h.run_steps(6); assert_eq!(shown(&h), "B-Cover"); assert_eq!(crate::canvas::current_parent_page(&h.state().app).map(|(i, _)| i), Some(1)); // A page: the parents are left. h.get_by(|n| n.role() == Role::ComboBox && n.label().as_deref() == Some("Page")).click(); h.run_steps(3); - h.get_by_label("3").click(); + h.get_all_by_label("3").last().unwrap().click(); h.run_steps(6); assert!(!h.state().app.session.active().unwrap().editing_parents); assert_eq!(crate::canvas::current_page(&h.state().app), Some(2)); From d0fce7e9a3cc93340a3be02cef9e3681848b7c51 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 29 Oct 2026 21:38:46 -0400 Subject: [PATCH 416/628] Declare Spanish and Simplified Chinese in CFBundleLocalizations Both are interface languages in LANGUAGES but were missing from the list. Co-Authored-By: Claude Opus 5.5 --- packaging/macos/Info.plist.in | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index 1fbabdee2..5fdf8a278 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -14,6 +14,7 @@ ar cs de + es fr it ja @@ -22,6 +23,7 @@ pt-BR ru uk + zh-Hans CFBundleDisplayName DesignCraft From 84801932f2897a4b355e634132f58c7d2ae01996 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 29 Oct 2026 21:39:23 -0400 Subject: [PATCH 417/628] Renumbering duplicate ids stops at the top of the id range instead of overflowing Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/lib.rs | 5 ++++- crates/format/src/tests.rs | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 08ba34ca0..fecb2c3b4 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -1007,7 +1007,10 @@ impl Document { return; } if !seen.insert(old) { - next += 1; + // An id at the top of the range has no successor: leave the duplicate for + // `check()` to refuse rather than overflow. + let Some(n) = next.checked_add(1) else { return }; + next = n; it.id = ItemId(next); } if let Some(t) = it.text_frame() { diff --git a/crates/format/src/tests.rs b/crates/format/src/tests.rs index 702428d14..882f5e6b8 100644 --- a/crates/format/src/tests.rs +++ b/crates/format/src/tests.rs @@ -822,3 +822,17 @@ fn duplicate_anchored_item_ids_are_renumbered() { assert_eq!(anchored[0].1.shape, Shape::Oval); assert_ne!(anchored[0].1.id, g); } + +#[test] +fn duplicate_ids_at_the_top_of_the_range_do_not_overflow() { + let mut d = Document::new(&NewDocument::default()); + let one = png_asset(&mut d, &[1]); + let two = png_asset(&mut d, &[2]); + place(&mut d, one, 0.0); + place(&mut d, two, 0.0); + set_item_id(&mut d, 0, designcraft_doc::ItemId(u64::MAX)); + set_item_id(&mut d, 1, designcraft_doc::ItemId(u64::MAX)); + d.renumber_duplicate_items(); + assert_eq!(all_item_ids(&d), vec![u64::MAX, u64::MAX]); + assert!(d.check().is_err()); +} From a9256308e32ee8cadd2051226986003ff80fbcb9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 29 Oct 2026 21:39:35 -0400 Subject: [PATCH 418/628] Keep at least four wrap passes for a story with few anchored objects A single wrapping object moved by keep options or vertical justification had four passes to settle before; one-per-object would have given it two. Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/wrap.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/compose/src/wrap.rs b/crates/compose/src/wrap.rs index 7d38f0ef8..724310b00 100644 --- a/crates/compose/src/wrap.rs +++ b/crates/compose/src/wrap.rs @@ -15,7 +15,8 @@ //! moving. An object's wrap can push the anchors of the objects after it, so a chain of n such //! objects needs n + 1 more compositions: the k-th pass places the (k + 1)-th object where the //! wraps before it leave its anchor, and the last pass wraps around the last object. The story -//! is composed at most that many more times, and never more than [`MAX_PASSES`]. +//! is composed at most that many more times (at least [`MIN_PASSES`]), and never more than +//! [`MAX_PASSES`]. use designcraft_doc::{AnchorPosition, AnchoredObject, Document, Item, OBJECT_MARK, PageSide, SpreadRef, Story, StoryId, WrapMode}; use designcraft_geom::Affine; @@ -24,9 +25,13 @@ use crate::{ComposeOptions, ComposedStory, Exclusion, FrameSpec}; /// Most extra compositions of a story with its own anchored objects' wrap, however many objects /// it has; a layout that keeps moving its objects (keep options, vertical justification) stops -/// sooner, after one per object and one more. +/// sooner, after one per object and one more (but never fewer than [`MIN_PASSES`]). pub(crate) const MAX_PASSES: usize = 64; +/// Fewest extra compositions allowed before giving up on a layout that keeps moving: a single +/// object that keep options or vertical justification move still gets the room it had before. +const MIN_PASSES: usize = 4; + /// How far an obstacle may move between passes and still count as settled (points). const SETTLED: f64 = 0.01; @@ -205,7 +210,7 @@ pub(crate) fn compose_among(doc: &Document, sid: StoryId, opts: &ComposeOptions, } let own = st.objects.iter().filter(|o| wraps(o) && !matches!(o.position, AnchorPosition::Inline { .. })).count(); let mut prev = vec![Vec::new(); specs.len()]; - for _ in 0..(own + 1).min(MAX_PASSES) { + for _ in 0..(own + 1).max(MIN_PASSES).min(MAX_PASSES) { let added = exclusions(doc, &specs, &obstacles(doc, st, &out, true)); if settled(&added, &prev) { break; From e6e478bccba5eb3fc46a77d16909afaaa4eaa77e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 29 Oct 2026 21:39:41 -0400 Subject: [PATCH 419/628] Leave the Windows print test to #1840 The expectation change is unrelated to hyphenation and lands with #1840. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/print.rs | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index dfa0a410d..e71c230de 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -86,15 +86,7 @@ mod tests { } else { assert_eq!( command, - [ - "xps-print", - "printer=Office", - "copies=2", - "collate=true", - "duplex=(queue default)", - "scaling=shrink-to-fit", - "color=(driver default)" - ] + ["xps-print", "printer=Office", "copies=2", "collate=true", "duplex=(queue default)", "scaling=shrink-to-fit", "color=color"] ); } assert!(r["bytes"].as_u64().unwrap() > 500); From 794b7a29de1c754f73ec415f4187061e856cf1a7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 29 Oct 2026 21:39:56 -0400 Subject: [PATCH 420/628] Hold the guide-drag cursor only while the primary button is down Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 19bcb87e3..bf8a88563 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -744,8 +744,10 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { None => c, }; // A ruler guide being dragged keeps the cursor across it until it is dropped, over the - // canvas too (the tool's cursor took over as soon as the pointer left the ruler). - let c = match ui.data(|d| d.get_temp::(egui::Id::unique(GUIDE_DRAG))).map(|g| g.orientation) { + // canvas too (the tool's cursor took over as soon as the pointer left the ruler). Only + // while the button is down: a drag state left behind by a skipped release doesn't stick. + let dragging = ui.input(|i| i.pointer.primary_down()); + let c = match ui.data(|d| d.get_temp::(egui::Id::unique(GUIDE_DRAG))).filter(|_| dragging).map(|g| g.orientation) { Some(designcraft_doc::Orientation::Horizontal) => Cursor::ResizeV, Some(designcraft_doc::Orientation::Vertical) => Cursor::ResizeH, None => c, From 44b07403b1502cd676108f75f81b895dda4a2348 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 29 Oct 2026 21:40:04 -0400 Subject: [PATCH 421/628] Draw a checkbox's border at the item's stroke weight Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/forms.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/pdf/src/forms.rs b/crates/pdf/src/forms.rs index bf0d955fc..73165dc7a 100644 --- a/crates/pdf/src/forms.rs +++ b/crates/pdf/src/forms.rs @@ -179,7 +179,7 @@ pub(crate) fn add_fields(pdf: &[u8], fields: &[Placed], media: &[PlacedMedia]) - FieldKind::CheckBox => { // Appearances: an empty box, and a box with a check mark. let border = - if *bw > 0.0 { format!("0.45 G 1 w 0.5 0.5 {:.2} {:.2} re S", (w - 1.0).max(0.0), (h - 1.0).max(0.0)) } else { String::new() }; + if *bw > 0.0 { format!("0.45 G {bw:.2} w {i:.2} {i:.2} {:.2} {:.2} re S", (w - bw).max(0.0), (h - bw).max(0.0), i = bw / 2.0) } else { String::new() }; let check = format!( "{border} 0 g {:.2} w 1 J 1 j {:.2} {:.2} m {:.2} {:.2} l {:.2} {:.2} l S", (w.min(h) * 0.12).max(0.8), From 56eebe9556b55eda9fc624968f764dafb3c062ab Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 29 Oct 2026 21:40:24 -0400 Subject: [PATCH 422/628] Keep the host frame's text area across anchored text frames in PDF export Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/export/walk.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/pdf/src/export/walk.rs b/crates/pdf/src/export/walk.rs index d8a4d2739..cf96dac25 100644 --- a/crates/pdf/src/export/walk.rs +++ b/crates/pdf/src/export/walk.rs @@ -396,9 +396,12 @@ impl Exporter<'_> { if local != Affine::IDENTITY { s.push_transform(&tf(local)); } - self.text_area = (local.determinant().abs() > 1e-12).then(|| local.inverse().transform_rect_bbox(it.inner_bounds())); + // Anchored text frames now draw inside `frame_text`: restore the host's area + // after, so an object drawn there doesn't clear it for the host's glyphs. + let area = (local.determinant().abs() > 1e-12).then(|| local.inverse().transform_rect_bbox(it.inner_bounds())); + let outer = std::mem::replace(&mut self.text_area, area); drawn = self.frame_text(s, &cs, ft, text, Some((xf * local, page))); - self.text_area = None; + self.text_area = outer; if local != Affine::IDENTITY { s.pop(); } From 680dfdc964b3dd86858326fc44c1f963f3f9fc4c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 29 Oct 2026 21:41:41 -0400 Subject: [PATCH 423/628] Format the checkbox border Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/forms.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/pdf/src/forms.rs b/crates/pdf/src/forms.rs index 73165dc7a..90234859d 100644 --- a/crates/pdf/src/forms.rs +++ b/crates/pdf/src/forms.rs @@ -178,8 +178,11 @@ pub(crate) fn add_fields(pdf: &[u8], fields: &[Placed], media: &[PlacedMedia]) - } FieldKind::CheckBox => { // Appearances: an empty box, and a box with a check mark. - let border = - if *bw > 0.0 { format!("0.45 G {bw:.2} w {i:.2} {i:.2} {:.2} {:.2} re S", (w - bw).max(0.0), (h - bw).max(0.0), i = bw / 2.0) } else { String::new() }; + let border = if *bw > 0.0 { + format!("0.45 G {bw:.2} w {i:.2} {i:.2} {:.2} {:.2} re S", (w - bw).max(0.0), (h - bw).max(0.0), i = bw / 2.0) + } else { + String::new() + }; let check = format!( "{border} 0 g {:.2} w 1 J 1 j {:.2} {:.2} m {:.2} {:.2} l {:.2} {:.2} l S", (w.min(h) * 0.12).max(0.8), From cc47975960edb8ff3993b35f160dcbf9664a06c5 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 29 Oct 2026 21:43:16 -0400 Subject: [PATCH 424/628] Use clamp for the wrap pass bounds Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/wrap.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/compose/src/wrap.rs b/crates/compose/src/wrap.rs index 724310b00..f28bba424 100644 --- a/crates/compose/src/wrap.rs +++ b/crates/compose/src/wrap.rs @@ -31,6 +31,8 @@ pub(crate) const MAX_PASSES: usize = 64; /// Fewest extra compositions allowed before giving up on a layout that keeps moving: a single /// object that keep options or vertical justification move still gets the room it had before. const MIN_PASSES: usize = 4; +// `clamp` needs min <= max. +const _: () = assert!(MIN_PASSES <= MAX_PASSES); /// How far an obstacle may move between passes and still count as settled (points). const SETTLED: f64 = 0.01; @@ -210,7 +212,7 @@ pub(crate) fn compose_among(doc: &Document, sid: StoryId, opts: &ComposeOptions, } let own = st.objects.iter().filter(|o| wraps(o) && !matches!(o.position, AnchorPosition::Inline { .. })).count(); let mut prev = vec![Vec::new(); specs.len()]; - for _ in 0..(own + 1).max(MIN_PASSES).min(MAX_PASSES) { + for _ in 0..(own + 1).clamp(MIN_PASSES, MAX_PASSES) { let added = exclusions(doc, &specs, &obstacles(doc, st, &out, true)); if settled(&added, &prev) { break; From eace4735dac32df6785e82718991f391bdc5ac97 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 29 Oct 2026 21:43:49 -0400 Subject: [PATCH 425/628] Draw the checkbox border in its own function Keeps add_fields under the cognitive complexity limit. Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/forms.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/pdf/src/forms.rs b/crates/pdf/src/forms.rs index 90234859d..e36478971 100644 --- a/crates/pdf/src/forms.rs +++ b/crates/pdf/src/forms.rs @@ -79,6 +79,16 @@ fn pdf_string(s: &str) -> String { o } +/// A checkbox appearance's border: the item's stroke weight, inset by half of it so it stays +/// inside the box; nothing for an unstroked item. +fn checkbox_border(bw: f64, w: f64, h: f64) -> String { + if bw > 0.0 { + format!("0.45 G {bw:.2} w {i:.2} {i:.2} {:.2} {:.2} re S", (w - bw).max(0.0), (h - bw).max(0.0), i = bw / 2.0) + } else { + String::new() + } +} + /// Add `fields` and `media` to `pdf` (page indices in output order). `None` when the file's /// structure isn't the simple kind this understands. pub(crate) fn add_fields(pdf: &[u8], fields: &[Placed], media: &[PlacedMedia]) -> Option> { @@ -178,11 +188,7 @@ pub(crate) fn add_fields(pdf: &[u8], fields: &[Placed], media: &[PlacedMedia]) - } FieldKind::CheckBox => { // Appearances: an empty box, and a box with a check mark. - let border = if *bw > 0.0 { - format!("0.45 G {bw:.2} w {i:.2} {i:.2} {:.2} {:.2} re S", (w - bw).max(0.0), (h - bw).max(0.0), i = bw / 2.0) - } else { - String::new() - }; + let border = checkbox_border(*bw, w, h); let check = format!( "{border} 0 g {:.2} w 1 J 1 j {:.2} {:.2} m {:.2} {:.2} l {:.2} {:.2} l S", (w.min(h) * 0.12).max(0.8), From e7e780ec74a953f1de1a53834fb7bc95dbb0b051 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 29 Oct 2026 21:56:27 -0400 Subject: [PATCH 426/628] docs: keep ROADMAP progress log newest first after batch landing Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8307b65ed..ad3f52a14 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -384,12 +384,12 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. -- **2026-10-26** · Inner Shadow, Inner Glow (from the edges) and Bevel and Emboss on a frame holding an image follow what the image paints: a transparent part is outside the object, so the effect lines the image's opaque edge, not the frame (#1622). Satin and a centred Inner Glow still follow the frame. No readiness percentage change. - **2026-10-29** · Inserting text with many returns no longer takes time that grows with their square (part of #1588): each return copied its paragraph's format with its own insert, moving every format after it. Splitting 20,000 returns into paragraphs took 15.8 s; placing a text file of them now takes 0.2 s, and one of a million returns 5.5 s. No readiness percentage change. - **2026-10-28** · Discretionary hyphens (U+00AD) break in the single-line composer with automatic hyphenation on or off, matching the paragraph composer. Unbroken soft hyphens remain invisible and zero-width, and words containing them keep only their discretionary break points (#1955). No readiness percentage change. - **2026-10-28** · Autoflow no longer sizes the rest of a story by a frame that a Start Paragraph option (next column, frame, page, or odd or even page) ends early (#1209). With a title before a chapter that starts on the next odd page, it estimated a page per few characters, added up to 2000 pages (the autoflow limit) and removed them again: placing 2000 words took 36 s in the app, now 0.4 s, with the same pages. It looks past tracked-deleted paragraphs for the option, as composing does, and asks for every frame that start options and breaks force in one round: 200 one-line chapters on their own pages take 9 ms (58 ms before). A break and the start option it meets count once, and breaks that tracked changes delete or a hidden condition hides don't count. No readiness percentage change. - **2026-10-28** · IDML export writes the document's default `StoryPreference` in `Resources/Preferences.xml`, as InDesign does. Only each story carried one, and IDML readers that take the spread reading order from the document's (Okapi's IDML filter) failed on DesignCraft's files. No readiness percentage change. - **2026-10-27** · An iconic panel's flyout stays open while you work in the canvas or a text field: Auto-Collapse Iconic Panels is off by default, and preferences saved with it on start with it off. Preferences › Interface › Panels holds the setting next to the column bar's menu item. No readiness percentage change. +- **2026-10-26** · Inner Shadow, Inner Glow (from the edges) and Bevel and Emboss on a frame holding an image follow what the image paints: a transparent part is outside the object, so the effect lines the image's opaque edge, not the frame (#1622). Satin and a centred Inner Glow still follow the frame. No readiness percentage change. - **2026-10-24** · Column rules run from the top line's ascent to the bottom line's descent, as InDesign draws them, instead of from the top to the bottom of the columns, on screen, in PDF and in PowerPoint export. The top and bottom insets shorten the rule from those lines. Existing documents draw their column rules at the new length. No readiness percentage change. - **2026-10-20** · Edit › Keyboard Shortcuts is craft-keymap's shared dialog: commands grouped by menu with Show and Filter, recording that asks before taking a key from another command, undo and redo, named shortcut sets (edits of Default become Custom; an imported set becomes a set of its own), an on-screen US keyboard, InDesign Show Set files reviewed before they apply, JSON export and an HTML summary. ui.json keeps the `shortcuts` map earlier versions read, for the active set. No readiness percentage change. - **2026-10-19** · Import InDesign Set… runs on craft-keymap's importer: a set's Window menu panel items (Layers F7, Pages F12, Swatches F5) and Tools items set the panel and tool shortcuts, all of an item's Default keys apply, and the report adds numeric keypad keys read as main keys and likely commands for unmatched items (`window.importShortcuts`). On InDesign's Windows defaults it matches 366 of 1,629 items (was 233) and changes 23 commands. No readiness percentage change. From ec059c042b34dcac6db4452a27169e441968e73e Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Sat, 24 Oct 2026 23:11:57 +0200 Subject: [PATCH 427/628] Linux: file managers show a .designcraft file's first page through a freedesktop thumbnailer (#1782) --- Cargo.lock | 1 + ROADMAP.md | 1 + apps/designcraft-cli/Cargo.toml | 1 + apps/designcraft-cli/src/main.rs | 40 ++++++++++++- apps/designcraft-cli/tests/it/cli.rs | 60 +++++++++++++++++++ .../ai.storyteller.designcraft.thumbnailer | 8 +++ packaging/linux/package.sh | 1 + 7 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 packaging/linux/ai.storyteller.designcraft.thumbnailer diff --git a/Cargo.lock b/Cargo.lock index 1c361aef2..13a009d2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1340,6 +1340,7 @@ dependencies = [ "designcraft-mcp", "designcraft-pdf", "designcraft-render", + "image", "serde_json", ] diff --git a/ROADMAP.md b/ROADMAP.md index ad3f52a14..807aa5dbf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -405,6 +405,7 @@ Newest first. - **2026-10-24** · Renaming a paragraph or character style, or moving it into a style group, keeps its Export Tagging under the new name, and undo puts it back under the old one. Deleting a tagged style drops its tagging and leaves the replacement style's own tagging as it was. No readiness percentage change. - **2026-10-24** · Quick Apply and the application bar's search leave out the interface commands that failed or reset a setting when chosen there: those that need a workspace, a command and shortcut, a menu item, a panel, a window edge, an app page, a recent file or a swatch they can't give them, a Contextual Task Bar place, and Interface Color Theme, UI Scaling, Reset Shortcuts, the flattener preset, Screen Mode and Zoom To, which without a value switched to the dark theme, set 100%, cleared every custom shortcut or the preset, or went to Normal mode or 100% zoom. Each stays in the menu, panel or dialog that gives it the value. No readiness percentage change. - **2026-10-24** · EPUB Export Options › Split Document, a dialog before File › Export EPUB… saves (`document.epubSplit`, stored with the document): off, a single paragraph style, or based on paragraph style export tags, with Paragraph Style Options › Export Tagging's Split Document flag marking those styles. A reflowable EPUB is one content file unless Split Document is on; the split before each top-level h1 from #791 is gone. A split before a list item or preformatted line no longer leaves an unclosed list or block in a content file. IDML import and export keep the setting and the flag, and Load Styles the flag. No readiness percentage change. +- **2026-10-24** · Linux file managers show a `.designcraft` file's first page instead of a blank icon: the packages install a freedesktop thumbnailer (`share/thumbnailers/ai.storyteller.designcraft.thumbnailer`) that runs `designcraft-cli thumbnail FILE OUT.png SIZE`, which writes the `preview.png` saving already puts in the file, fitted to the size asked for and never enlarged. A file without a preview fails, so the file manager keeps the file type's icon (#1782). No readiness percentage change. - **2026-10-18** · Repeated placed-PDF layer variants are prepared once per asset and hidden-name set, with recovery warnings retained. All-visible named layers keep their original source bytes, including PDFs whose cross-reference tables the reader repairs, in plain, PDF/X-4, booklet and book-merge output. Layer changes that require writing still reject an unusable cross-reference table with a file-named damage error; the first placement error is retained (#906). No readiness percentage change. - **2026-10-18** · Placed PDF optional-content defaults and per-frame overrides survive plain and PDF/X-4 vector export, including repeated placements, booklet output and book merging (#906). Source usage-application arrays are accepted; groups without a catalog configuration stay visible. Missing or unreadable default configurations and unsupported base states use all-ON defaults with a file-named export warning, retaining explicit frame overrides. Readable groups omitted from the catalog list are added with a distinct file-named warning, preserving their default ON/OFF states. Concurrent recovery warnings are combined. Malformed ON data is recoverable when a known ON base and empty OFF list prove it cannot hide artwork; missing display names are generated without changing known visibility when no override needs name matching. Contradictory states, unresolved references and ambiguous name overrides still fail export. Print-usage adjustments and IDML layer overrides remain follow-ups. No readiness percentage change. diff --git a/apps/designcraft-cli/Cargo.toml b/apps/designcraft-cli/Cargo.toml index 7aaa454cb..ab2464147 100644 --- a/apps/designcraft-cli/Cargo.toml +++ b/apps/designcraft-cli/Cargo.toml @@ -22,4 +22,5 @@ designcraft-compose = { workspace = true } designcraft-format = { workspace = true } designcraft-pdf = { workspace = true } designcraft-mcp = { workspace = true } +image = { workspace = true } serde_json = { workspace = true } diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index 9ce15674c..bf7b6b351 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -16,6 +16,8 @@ //! designcraft-cli bench FILE [--runs N] # the same measurements on one document //! designcraft-cli validate-pdf FILE.pdf [--standard x4|x1a] # the built-in PDF/X-4 or PDF/X-1a checks (default: the //! # file's own GTS_PDFXVersion) +//! designcraft-cli thumbnail FILE.designcraft OUT.png [SIZE] # the file's first-page preview, fitted to SIZE +//! # (the freedesktop thumbnailer for Linux file managers) //! designcraft-cli links # Discord, website, app page and GitHub links //! designcraft-cli --version # print the version //! ``` @@ -40,7 +42,7 @@ use designcraft_engine::Session; use serde_json::{Value, json}; /// The usage block. Shared by `--help` (stdout, success) and an unknown command (stderr, failure). -const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR]... [--automation-write-root DIR]...\n the roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. Each may be given more than once; a kind\n left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli validate-pdf FILE.pdf # built-in PDF/X-4 checks (including visible unembedded fonts)\n designcraft-cli links\n designcraft-cli --version"; +const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR]... [--automation-write-root DIR]...\n the roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. Each may be given more than once; a kind\n left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli validate-pdf FILE.pdf # built-in PDF/X-4 checks (including visible unembedded fonts)\n designcraft-cli thumbnail FILE.designcraft OUT.png [SIZE] # the saved first-page preview, at most SIZE px\n designcraft-cli links\n designcraft-cli --version"; /// `script --help`: the script syntax, otherwise only in `describe script.run` and docs/agents.md. const SCRIPT_HELP: &str = r#"usage: designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going] @@ -124,6 +126,7 @@ fn main() -> ExitCode { Some("perf") => report(perf::perf(&args[1..])), Some("bench") => report(perf::bench(&args[1..])), Some("validate-pdf") => report(validate_pdf(args.get(1..).unwrap_or_default())), + Some("thumbnail") => report(thumbnail(&args[1..])), Some("links") => { use designcraft_engine::links::{APP_PAGE, DISCORD, GITHUB, ISSUES, WEBSITE}; outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}\nIssues {ISSUES}"); @@ -188,6 +191,41 @@ fn validate_pdf(args: &[String]) -> Result<(), String> { if valid { Ok(()) } else { Err(format!("{path} failed {name} validation")) } } +/// `thumbnail FILE OUT [SIZE]`: the first-page preview saved in a `.designcraft` file, as a PNG +/// whose longer side is at most SIZE pixels (never enlarged). It is the freedesktop thumbnailer +/// (`packaging/linux/ai.storyteller.designcraft.thumbnailer`), so it only reads the preview: a file +/// without one fails, and the file manager keeps the file type's icon. +fn thumbnail(args: &[String]) -> Result<(), String> { + const USAGE: &str = "usage: designcraft-cli thumbnail FILE.designcraft OUT.png [SIZE]"; + let [input, out, rest @ ..] = args else { return Err(USAGE.into()) }; + let size = match rest { + [] => None, + [s] => Some(s.parse::().ok().filter(|s| *s > 0).ok_or_else(|| format!("bad SIZE `{s}`: expected a positive number of pixels"))?), + _ => return Err(USAGE.into()), + }; + let file = std::fs::File::open(input).map_err(|e| format!("{input}: {e}"))?; + let preview = designcraft_format::read_preview(std::io::BufReader::new(file)) + .ok_or_else(|| format!("{input} has no preview (a DesignCraft file saved before they had one, or not a DesignCraft file)"))?; + let png = fit_png(preview, size).map_err(|e| format!("{input}: the preview can't be read: {e}"))?; + designcraft_engine::atomic::write_atomic(std::path::Path::new(out), &png).map_err(|e| format!("{out}: {e}")) +} + +/// `png` with its longer side brought down to `size`. A picture already that small is returned +/// as it is; it is decoded anyway, so a broken one is an error rather than an unreadable thumbnail. +fn fit_png(png: Vec, size: Option) -> Result, String> { + // The preview comes from the file: it may claim any size. + let mut limits = image::Limits::default(); + limits.max_image_width = Some(4096); + limits.max_image_height = Some(4096); + let mut reader = image::ImageReader::with_format(std::io::Cursor::new(&png), image::ImageFormat::Png); + reader.limits(limits); + let img = reader.decode().map_err(|e| e.to_string())?; + let Some(size) = size.filter(|s| img.width().max(img.height()) > *s) else { return Ok(png) }; + let mut out = std::io::Cursor::new(Vec::new()); + img.resize(size, size, image::imageops::FilterType::Triangle).write_to(&mut out, image::ImageFormat::Png).map_err(|e| e.to_string())?; + Ok(out.into_inner()) +} + /// `mcp` (headless, in-process engine) or `mcp --connect PORT|HOST:PORT` (drive a running app /// started with `designcraft --control PORT`). JSON-RPC on stdin/stdout; logs on stderr. fn mcp(args: &[String]) -> Result<(), String> { diff --git a/apps/designcraft-cli/tests/it/cli.rs b/apps/designcraft-cli/tests/it/cli.rs index 861cb514f..98b4f5edf 100644 --- a/apps/designcraft-cli/tests/it/cli.rs +++ b/apps/designcraft-cli/tests/it/cli.rs @@ -556,3 +556,63 @@ fn usage_lists_the_automation_root_flags() { let usage = String::from_utf8_lossy(&out.stdout); assert!(usage.contains("--automation-read-root DIR") && usage.contains("--automation-write-root DIR"), "{usage}"); } + +/// Width and height from a PNG's IHDR chunk. +fn png_size(png: &[u8]) -> (u32, u32) { + assert!(png.starts_with(b"\x89PNG\r\n\x1a\n"), "not a PNG"); + let be = |at: usize| u32::from_be_bytes(png[at..at + 4].try_into().unwrap()); + (be(16), be(20)) +} + +/// Linux file managers (Dolphin, Nautilus, Thunar) draw a `.designcraft` file's icon through the +/// freedesktop thumbnailer, which runs `designcraft-cli thumbnail %i %o %s` (#1782). The picture is +/// the first-page preview the file already holds, fitted to the size asked for and never enlarged. +#[test] +fn thumbnail_writes_the_saved_preview_fitted_to_the_size() { + let dir = std::env::temp_dir().join(format!("dc-cli-thumb-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let doc = dir.join("a.designcraft"); + make_document(&doc); + let preview = designcraft_format::read_preview(std::fs::File::open(&doc).unwrap()).unwrap(); + let (pw, ph) = png_size(&preview); + assert!(pw.max(ph) > 128, "the saved preview is {pw}×{ph}"); + + let small = dir.join("small.png"); + let out = cli().arg("thumbnail").arg(&doc).arg(&small).arg("128").output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let (w, h) = png_size(&std::fs::read(&small).unwrap()); + assert_eq!(w.max(h), 128, "{w}×{h} from a {pw}×{ph} preview"); + // The page's proportions stay. + let ratio = |a: u32, b: u32| f64::from(a) / f64::from(b); + assert!((ratio(w, h) - ratio(pw, ph)).abs() < 0.02, "{w}×{h} from a {pw}×{ph} preview"); + + let large = dir.join("large.png"); + let out = cli().arg("thumbnail").arg(&doc).arg(&large).arg("1024").output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + assert_eq!(std::fs::read(&large).unwrap(), preview, "a preview smaller than the size is written as saved"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A file saved without a preview (by an older version, or not by DesignCraft) has no picture: the +/// thumbnailer fails and writes nothing, so the file manager keeps the file type's icon. +#[test] +fn thumbnail_of_a_file_without_a_preview_fails_and_writes_nothing() { + let dir = std::env::temp_dir().join(format!("dc-cli-thumb-none-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let with = dir.join("with.designcraft"); + make_document(&with); + let doc = designcraft_format::load(&std::fs::read(&with).unwrap()).unwrap(); + let without = dir.join("without.designcraft"); + std::fs::write(&without, designcraft_format::save(&doc).unwrap()).unwrap(); + let text = dir.join("notes.designcraft"); + std::fs::write(&text, "not a document").unwrap(); + for input in [&without, &text] { + let png = dir.join("out.png"); + let out = cli().arg("thumbnail").arg(input).arg(&png).arg("256").output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success(), "{}", input.display()); + assert!(err.contains("no preview"), "{}: {err}", input.display()); + assert!(!png.exists(), "{}", input.display()); + } + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/packaging/linux/ai.storyteller.designcraft.thumbnailer b/packaging/linux/ai.storyteller.designcraft.thumbnailer new file mode 100644 index 000000000..4642996b3 --- /dev/null +++ b/packaging/linux/ai.storyteller.designcraft.thumbnailer @@ -0,0 +1,8 @@ +# Installed as share/thumbnailers/ai.storyteller.designcraft.thumbnailer (package.sh). File managers +# that read freedesktop thumbnailers (Nautilus, Thunar, Dolphin through KIO) run it for .designcraft +# files: %i is the file, %o the PNG to write, %s the size in pixels. It writes the first-page preview +# saved in the file; a file without one keeps the file type's icon. +[Thumbnailer Entry] +TryExec=designcraft-cli +Exec=designcraft-cli thumbnail %i %o %s +MimeType=application/x-designcraft; diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index fa24385d5..7b120c525 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -61,6 +61,7 @@ install -Dm755 "$BIN/designcraft-cli" "$STAGE/usr/bin/designcraft-cli" "${CROSS_COMPILE:-}strip" "$STAGE/usr/bin/designcraft" "$STAGE/usr/bin/designcraft-cli" 2>/dev/null || true install -Dm644 "$HERE/$APP_ID.desktop" "$STAGE/usr/share/applications/$APP_ID.desktop" install -Dm644 "$HERE/$APP_ID.mime.xml" "$STAGE/usr/share/mime/packages/$APP_ID.xml" +install -Dm644 "$HERE/$APP_ID.thumbnailer" "$STAGE/usr/share/thumbnailers/$APP_ID.thumbnailer" mkdir -p "$STAGE/usr/share/metainfo" sed -e "s/@VERSION@/$VERSION/g" -e "s/@DATE@/$DESIGNCRAFT_BUILD_DATE/g" \ "$HERE/$APP_ID.metainfo.xml.in" >"$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml" From 8e8865bba18922e29dd57ed9c62fa910ab701c41 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 30 Oct 2026 03:08:39 +0100 Subject: [PATCH 428/628] thumbnail: refuse an output that names the input document (#1782) designcraft-cli thumbnail a.designcraft a.designcraft replaced the document with PNG bytes. The output is now refused before anything is read or written when it is the input by the same path, through a symlink, or on Unix as a hard link (same device and inode). --- apps/designcraft-cli/src/main.rs | 20 ++++++++++++ apps/designcraft-cli/tests/it/cli.rs | 47 ++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index bf7b6b351..a26c3d977 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -203,6 +203,9 @@ fn thumbnail(args: &[String]) -> Result<(), String> { [s] => Some(s.parse::().ok().filter(|s| *s > 0).ok_or_else(|| format!("bad SIZE `{s}`: expected a positive number of pixels"))?), _ => return Err(USAGE.into()), }; + if names_the_same_file(input.as_ref(), out.as_ref()) { + return Err(format!("{out} is the document {input}: the thumbnail would replace it")); + } let file = std::fs::File::open(input).map_err(|e| format!("{input}: {e}"))?; let preview = designcraft_format::read_preview(std::io::BufReader::new(file)) .ok_or_else(|| format!("{input} has no preview (a DesignCraft file saved before they had one, or not a DesignCraft file)"))?; @@ -210,6 +213,23 @@ fn thumbnail(args: &[String]) -> Result<(), String> { designcraft_engine::atomic::write_atomic(std::path::Path::new(out), &png).map_err(|e| format!("{out}: {e}")) } +/// Whether `out` is the file `input` names: the same path, a symlink to it or, on Unix, a hard +/// link (the same device and inode). An `out` that doesn't exist yet can't be the input. +fn names_the_same_file(input: &std::path::Path, out: &std::path::Path) -> bool { + let (Ok(a), Ok(b)) = (std::fs::canonicalize(input), std::fs::canonicalize(out)) else { return false }; + if a == b { + return true; + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if let (Ok(a), Ok(b)) = (std::fs::metadata(&a), std::fs::metadata(&b)) { + return (a.dev(), a.ino()) == (b.dev(), b.ino()); + } + } + false +} + /// `png` with its longer side brought down to `size`. A picture already that small is returned /// as it is; it is decoded anyway, so a broken one is an error rather than an unreadable thumbnail. fn fit_png(png: Vec, size: Option) -> Result, String> { diff --git a/apps/designcraft-cli/tests/it/cli.rs b/apps/designcraft-cli/tests/it/cli.rs index 98b4f5edf..f19551a71 100644 --- a/apps/designcraft-cli/tests/it/cli.rs +++ b/apps/designcraft-cli/tests/it/cli.rs @@ -616,3 +616,50 @@ fn thumbnail_of_a_file_without_a_preview_fails_and_writes_nothing() { } let _ = std::fs::remove_dir_all(&dir); } + +/// `thumbnail a.designcraft a.designcraft` would replace the document with a PNG. An output that +/// names the input, by the same path, through a symlink or (on Unix) as a hard link, is refused +/// before anything is read or written, and the document keeps its bytes. +#[test] +fn thumbnail_refuses_an_output_that_names_the_input() { + let dir = std::env::temp_dir().join(format!("dc-cli-thumb-self-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let doc = dir.join("a.designcraft"); + make_document(&doc); + let saved = std::fs::read(&doc).unwrap(); + let mut outs = vec![doc.clone(), dir.join(".").join("a.designcraft")]; + #[cfg(unix)] + { + let link = dir.join("link.png"); + std::os::unix::fs::symlink(&doc, &link).unwrap(); + outs.push(link); + let hard = dir.join("hard.png"); + std::fs::hard_link(&doc, &hard).unwrap(); + outs.push(hard); + } + for out_path in &outs { + let out = cli().arg("thumbnail").arg(&doc).arg(out_path).arg("128").output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(1), "{}: {err}", out_path.display()); + assert!(err.contains("is the document"), "{}: {err}", out_path.display()); + assert!(std::fs::read(&doc).unwrap() == saved, "{} changed the document", out_path.display()); + assert!(std::fs::read(out_path).unwrap() == saved, "{} was written", out_path.display()); + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// Another existing file at the output path is still replaced by the thumbnail. +#[test] +fn thumbnail_replaces_another_existing_file() { + let dir = std::env::temp_dir().join(format!("dc-cli-thumb-other-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let doc = dir.join("a.designcraft"); + make_document(&doc); + let png = dir.join("old.png"); + std::fs::write(&png, b"an older thumbnail").unwrap(); + let out = cli().arg("thumbnail").arg(&doc).arg(&png).arg("128").output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let (w, h) = png_size(&std::fs::read(&png).unwrap()); + assert_eq!(w.max(h), 128, "{w}×{h}"); + let _ = std::fs::remove_dir_all(&dir); +} From faccd43153d41f674a3384e9c5b099a3c3cbb891 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 29 Oct 2026 22:20:23 -0400 Subject: [PATCH 429/628] CI: workflows only call craft-common-actions at @main (#2018) AGENTS.md (and CLAUDE.md where present) gets a CI workflows section: workflows only call craft-common-actions at @main, with no other jobs; feature and fix PRs don't touch CI and link or open a craft-common-actions issue (a PR only for a small fix that is safe for every app and does not increase CI time); legacy jobs aren't added to and move only as their own task, staying until a shared workflow runs them. windows-arm64.yml moves from its SHA pin to @main. The pinned revision is on craft-common-actions main and the workflow (and the actions it calls) is identical there, so nothing changes in behaviour. --- .github/workflows/windows-arm64.yml | 4 ++-- AGENTS.md | 5 +++++ CLAUDE.md | 5 +++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 211ed988d..668e904d2 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -26,8 +26,8 @@ permissions: jobs: windows-arm64: - # Immutable supporting workflow revision until the new install contract lands upstream. - uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main with: app-name: designcraft app-dir: DesignCraft diff --git a/AGENTS.md b/AGENTS.md index 0be7fa87b..152f6bb06 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,3 +58,8 @@ People trust DesignCraft with their layouts; a crash loses their work. **This ou GitHub profiles). Never hand-edit `contributors.json`. - Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also re-verifies who wrote each `people.toml` entry; `--check` only verifies). + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. diff --git a/CLAUDE.md b/CLAUDE.md index bed556afb..ff1d0c61c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,3 +39,8 @@ People trust DesignCraft with their layouts; a crash loses their work. **This ou ## Roadmap `ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It follows craftrules' [`standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md). Details: `docs/target-app-parity.md` (assessment), `docs/gaps.md` (ranked work list: pick from the top), `docs/roadmap.md` (milestones, current focus), `docs/parity-checklist.md` (presence checklist, `cargo xtask parity`), and the typography, file-format, UI, hardware and localization parity docs in `docs/`. When work lands, update the affected docs and their status lines, delete or shrink the closed gap, and add a progress-log line to ROADMAP.md. + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. From be81d5db0e458998e18eeb64a635b41a4471f0a2 Mon Sep 17 00:00:00 2001 From: VoiceLessQ Date: Fri, 30 Oct 2026 00:40:00 -0200 Subject: [PATCH 430/628] Images: check the RGBA budget before GIF tone and CMYK TIFF decodes (#1586) tone() decoded a GIF to RGBA and decode_cmyk_tiff() called read_image() without the header check decode_rgba() runs. Both now call too_large_to_decode() first. The decoders' own limits (image 512 MiB, tiff 256 MiB) already refused these files; the guard refuses them from the header instead. --- crates/images/src/lib.rs | 3 +++ crates/images/src/tests.rs | 54 ++++++++++++++++++++++++++++++++++++++ crates/images/src/tone.rs | 3 +++ 3 files changed, 60 insertions(+) diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index dfe4166b7..97067ac41 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -532,6 +532,9 @@ pub fn is_cmyk_tiff(bytes: &[u8]) -> bool { /// over the decoder's 256 MiB limit; [`is_cmyk_tiff`] tells those apart from RGB files. pub fn decode_cmyk_tiff(bytes: &[u8]) -> Option { use tiff::tags::Tag; + if too_large_to_decode(bytes) { + return None; + } let mut d = tiff_decoder(bytes)?; let has_alpha = match d.colortype().ok()? { tiff::ColorType::CMYK(8 | 16) => false, diff --git a/crates/images/src/tests.rs b/crates/images/src/tests.rs index 9ca9fe14f..dd09f2577 100644 --- a/crates/images/src/tests.rs +++ b/crates/images/src/tests.rs @@ -441,3 +441,57 @@ fn rasters_past_the_rgba_budget_are_refused_before_decoding() { let small = decode_rgba(&blank_png(9)).expect("decodes"); assert_eq!(small.get_pixel(8, 8).0, [0, 0, 0, 255]); } + +/// A GIF whose logical screen is `side` x `side`, holding one black 1x1 frame. +fn gif_screen(side: u16) -> Vec { + let mut gif = b"GIF89a".to_vec(); + gif.extend(side.to_le_bytes()); + gif.extend(side.to_le_bytes()); + // Two-colour global table: black, white. + gif.extend([0x80, 0, 0, 0, 0, 0, 255, 255, 255]); + // Frame at the origin; LZW minimum code size 2, codes clear, 0, end; trailer. + gif.extend([0x2C, 0, 0, 0, 0, 1, 0, 1, 0, 0, 2, 2, 0x44, 0x01, 0, 0x3B]); + gif +} + +/// The header of an uncompressed 8-bit CMYK TIFF of `side` x `side` pixels, without the pixels. +fn cmyk_tiff_header(side: u32) -> Vec { + let entries: [(u16, u16, u32, u32); 9] = [ + (256, 4, 1, side), // width + (257, 4, 1, side), // height + (258, 3, 4, 122), // bits per sample, stored after the IFD + (259, 3, 1, 1), // no compression + (262, 3, 1, 5), // separated (CMYK) + (273, 4, 1, 130), // strip offset + (277, 3, 1, 4), // samples per pixel + (278, 4, 1, side), // rows per strip + (279, 4, 1, side * side * 4), // strip byte count + ]; + let mut t = b"II*\0".to_vec(); + t.extend(8u32.to_le_bytes()); + t.extend(9u16.to_le_bytes()); + for (tag, kind, count, value) in entries { + t.extend(tag.to_le_bytes()); + t.extend(kind.to_le_bytes()); + t.extend(count.to_le_bytes()); + t.extend(value.to_le_bytes()); + } + t.extend(0u32.to_le_bytes()); + assert_eq!(t.len(), 122); + t.extend([8u16; 4].iter().flat_map(|b| b.to_le_bytes())); + t +} + +/// GIF tones and CMYK inks check the RGBA budget too (#1586). +#[test] +fn gif_tone_and_cmyk_inks_check_the_rgba_budget() { + let gif = gif_screen(16_385); + assert!(too_large_to_decode(&gif)); + assert_eq!(tone(&gif), None); + assert_eq!(tone(&gif_screen(2)), Some(Tone::Bitmap)); + let tiff = cmyk_tiff_header(16_385); + assert!(is_cmyk_tiff(&tiff)); + assert_eq!(pixel_size(&tiff), Some((16_385, 16_385))); + assert!(too_large_to_decode(&tiff)); + assert_eq!(decode_cmyk_tiff(&tiff), None); +} diff --git a/crates/images/src/tone.rs b/crates/images/src/tone.rs index 10be68445..3d0b945ce 100644 --- a/crates/images/src/tone.rs +++ b/crates/images/src/tone.rs @@ -39,6 +39,9 @@ pub fn tone(bytes: &[u8]) -> Option { }, // Indexed: what the palette holds decides. Transparent pixels are left out of a bitmap. F::Gif => { + if crate::too_large_to_decode(bytes) { + return None; + } let img = image::load_from_memory_with_format(bytes, F::Gif).ok()?.into_rgba8(); let mut opaque = true; let mut two_level = true; From 15d13dde2083496b60f3e5a9bfaf89dd2efb9b1b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 30 Oct 2026 01:28:06 -0400 Subject: [PATCH 431/628] Update prompt shows the app icon and accent color instead of a letter (#2023) UPDATE_APP (native builds; craft-update is a native-only dependency) carries our 128 px icon and accent (#7bb51c, its colour in ArtCraft Launcher). The updater, the self-update helper and craft-update's startup prompt all get them through crate::UPDATE_APP; the prompt draws the icon, the accent and "by ArtCraft" (craft-update's default developer). craft-update is pinned to storytold/craft-libs main at 1065653e7 (#434). Clippy fixes for Rust 1.99 in untouched code (same logic): - crates/engine/src/cmd/export/text_tests.rs - crates/pdf/src/gradient_inks.rs --- Cargo.lock | 3 ++- Cargo.toml | 3 ++- apps/designcraft/src/main.rs | 2 +- crates/engine/src/cmd/export/text_tests.rs | 4 ++-- crates/pdf/src/gradient_inks.rs | 2 +- crates/ui-egui/src/lib.rs | 9 +++++++++ crates/ui-egui/src/updates_ui.rs | 2 +- 7 files changed, 18 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1c361aef2..6344685d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1138,13 +1138,14 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=1065653e75854e9c17e1ff563acdd344c1b185f0#1065653e75854e9c17e1ff563acdd344c1b185f0" dependencies = [ "chrono", "dunce", "egui", "flate2", "getrandom 0.3.4", + "image", "log", "plist", "semver", diff --git a/Cargo.toml b/Cargo.toml index 139d06f01..fd8629f62 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,8 @@ craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff1 craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", features = ["egui", "ui"] } # Separations, ink coverage and the ink manager; same revision as craft-pdf and the krilla patch. craft-prepress = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", features = ["flatten"] } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } +# craft-update with storytold/craft-libs#434: the update prompt shows the app's icon, accent and developer. +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1065653e75854e9c17e1ff563acdd344c1b185f0", features = ["egui"] } # internal designcraft-geom = { path = "crates/geom" } designcraft-color = { path = "crates/color" } diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index 5f056aab3..4b4a1ab7f 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -255,7 +255,7 @@ fn control_port_from(source: &str, value: Option) -> Option { fn main() -> std::process::ExitCode { // Windows: the update helper is a copy of this program started with `--finish-update`. #[cfg(windows)] - if let Some(result) = craft_update::self_update::finish(&craft_update::suite::DESIGNCRAFT, &std::env::args_os().skip(1).collect::>()) { + if let Some(result) = craft_update::self_update::finish(&designcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::>()) { std::process::exit(i32::from(result.is_err())); } // First, so every start-up record (and the engine's panic hook, installed with the first diff --git a/crates/engine/src/cmd/export/text_tests.rs b/crates/engine/src/cmd/export/text_tests.rs index e533320f8..2efcb2f92 100644 --- a/crates/engine/src/cmd/export/text_tests.rs +++ b/crates/engine/src/cmd/export/text_tests.rs @@ -204,12 +204,12 @@ fn black_and_gold(pdf: &[u8]) -> Vec<(u8, u8)> { let mut out = Vec::new(); for st in file.objects().into_iter().filter_map(|o| o.into_stream()) { let d = st.dict(); - if !d.get::>(b"Subtype").is_some_and(|n| n.as_ref() == b"Image") || d.get::(b"BitsPerComponent") != Some(8) { + if d.get::>(b"Subtype").is_none_or(|n| n.as_ref() != b"Image") || d.get::(b"BitsPerComponent") != Some(8) { continue; } let Some(cs) = d.get::>(b"ColorSpace") else { continue }; let mut it = cs.iter::>(); - if !it.next().and_then(|o| o.into_name()).is_some_and(|n| n.as_ref() == b"DeviceN") { + if it.next().and_then(|o| o.into_name()).is_none_or(|n| n.as_ref() != b"DeviceN") { continue; } let Some(Object::Array(names)) = it.next() else { continue }; diff --git a/crates/pdf/src/gradient_inks.rs b/crates/pdf/src/gradient_inks.rs index e889ee1d2..c92b80158 100644 --- a/crates/pdf/src/gradient_inks.rs +++ b/crates/pdf/src/gradient_inks.rs @@ -87,7 +87,7 @@ impl Exporter<'_> { let inks: Vec> = g.stops.iter().map(|s| self.stop_ink(s)).collect(); let has_spot = inks.iter().any(|i| matches!(i, Some(Ink::Spot { .. }))); let on_paper = g.stops.iter().any(|s| s.swatch.as_deref() == Some(designcraft_color::swatch::PAPER)); - if !has_spot && !overprint && !(on_paper && self.for_print() && !self.rgb_only) { + if !(has_spot || overprint || on_paper && self.for_print() && !self.rgb_only) { return None; } let Some(inks) = inks.into_iter().collect::>>() else { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0221aa257..a53cffcfa 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -6,6 +6,15 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +/// DesignCraft as craft-update knows it, with its 128 px icon and accent colour: the update +/// prompt shows both, and an AppImage's menu entry gets the icon. +#[cfg(not(target_arch = "wasm32"))] +pub const UPDATE_APP: craft_update::app::App = craft_update::app::App { + icon_png: Some(include_bytes!("../../../assets/app-icon/hicolor/128x128/apps/ai.storyteller.designcraft.png")), + accent: Some([0x7b, 0xb5, 0x1c]), + ..craft_update::suite::DESIGNCRAFT +}; + pub mod about; pub mod canvas; pub mod chrome; diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index 43803c9a5..10d3b83a4 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -26,7 +26,7 @@ impl Default for Runtime { fn default() -> Self { Self { #[cfg(not(target_arch = "wasm32"))] - updater: Updater::new(&craft_update::suite::DESIGNCRAFT, env!("CARGO_PKG_VERSION")), + updater: Updater::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")), #[cfg(not(target_arch = "wasm32"))] startup_check: false, #[cfg(not(target_arch = "wasm32"))] From e5621e16e0fd9f122e18e0e06540d4d39dff8977 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:18:21 +0000 Subject: [PATCH 432/628] fix: combine inline decoration overrides with the character style in EPUB export Co-Authored-By: Claude Sonnet 5.5 --- crates/epub/src/css.rs | 64 ++++++++++++++++++--------------- crates/epub/src/story_writer.rs | 2 +- crates/epub/src/tests.rs | 34 +++++++++++++++++- 3 files changed, 70 insertions(+), 30 deletions(-) diff --git a/crates/epub/src/css.rs b/crates/epub/src/css.rs index 3a8639cec..f066d6b3d 100644 --- a/crates/epub/src/css.rs +++ b/crates/epub/src/css.rs @@ -40,23 +40,7 @@ pub(crate) fn char_css(doc: &Document, c: &CharProps, base_size: f64, generics: let (weight, style) = css_weight_style(&c.font_style); let _ = write!(out, "font-weight: {weight}; font-style: {style}; "); let _ = write!(out, "font-size: {:.3}em; ", c.size / base_size); - if c.tracking != 0.0 { - let _ = write!(out, "letter-spacing: {:.3}em; ", c.tracking / 1000.0); - } - match c.capitalization { - designcraft_doc::Capitalization::AllCaps => out.push_str("text-transform: uppercase; "), - designcraft_doc::Capitalization::SmallCaps | designcraft_doc::Capitalization::OpenTypeAllSmallCaps => { - out.push_str("font-variant: small-caps; ") - } - _ => {} - } - // One declaration: a later `text-decoration` would replace the earlier one. - match (c.underline, c.strikethrough) { - (true, true) => out.push_str("text-decoration: underline line-through; "), - (true, false) => out.push_str("text-decoration: underline; "), - (false, true) => out.push_str("text-decoration: line-through; "), - (false, false) => {} - } + decor_css(c.tracking, c.capitalization, c.underline, c.strikethrough, out); if let Some(h) = hex(doc, &c.fill, c.fill_tint) { let _ = write!(out, "color: {h}; "); } @@ -150,7 +134,7 @@ pub(crate) fn stylesheet_with(doc: &Document, generics: &BTreeMap out.push_str("text-transform: uppercase; "), - Some(designcraft_doc::Capitalization::SmallCaps | designcraft_doc::Capitalization::OpenTypeAllSmallCaps) => { + match caps { + designcraft_doc::Capitalization::AllCaps => out.push_str("text-transform: uppercase; "), + designcraft_doc::Capitalization::SmallCaps | designcraft_doc::Capitalization::OpenTypeAllSmallCaps => { out.push_str("font-variant: small-caps; ") } _ => {} } // One declaration: a later `text-decoration` would replace the earlier one. - match (c.underline == Some(true), c.strikethrough == Some(true)) { + match (underline, strikethrough) { (true, true) => out.push_str("text-decoration: underline line-through; "), (true, false) => out.push_str("text-decoration: underline; "), (false, true) => out.push_str("text-decoration: line-through; "), @@ -182,7 +169,11 @@ fn attrs_decor_css(c: &CharAttrs, out: &mut String) { } } -pub(crate) fn override_css(doc: &Document, o: &CharAttrs) -> String { +/// Inline CSS for the local override `o` of a run in character style `style`. Tracking, caps and +/// line decorations are resolved against the style (and its Based On chain), so an override that +/// sets only one of underline/strikethrough keeps the other, and one that clears a value the +/// style sets writes the reset. +pub(crate) fn override_css(doc: &Document, style: &str, o: &CharAttrs) -> String { let mut s = String::new(); if let Some(f) = &o.font_style { let f = f.to_lowercase(); @@ -198,7 +189,24 @@ pub(crate) fn override_css(doc: &Document, o: &CharAttrs) -> String { { let _ = write!(s, "color: {h}; "); } - attrs_decor_css(o, &mut s); + let base = doc.styles.char_style_attrs(style); + let mut merged = base.clone(); + merged.merge(o); + let tracking = if o.tracking.is_some() { merged.tracking.unwrap_or(0.0) } else { 0.0 }; + let caps = if o.capitalization.is_some() { merged.capitalization.unwrap_or_default() } else { Default::default() }; + let lines = o.underline.is_some() || o.strikethrough.is_some(); + let (underline, strikethrough) = (lines && merged.underline == Some(true), lines && merged.strikethrough == Some(true)); + if o.tracking.is_some() && tracking == 0.0 && base.tracking.is_some_and(|t| t != 0.0) { + s.push_str("letter-spacing: 0em; "); + } + // The class rule's other caps property would still apply, so clear both before the new mode. + if o.capitalization.is_some() && caps != base.capitalization.unwrap_or_default() && base.capitalization.is_some_and(|c| c != Default::default()) { + s.push_str("text-transform: none; font-variant: normal; "); + } + decor_css(tracking, caps, underline, strikethrough, &mut s); + if lines && !underline && !strikethrough && (base.underline == Some(true) || base.strikethrough == Some(true)) { + s.push_str("text-decoration: none; "); + } s.trim_end().to_string() } diff --git a/crates/epub/src/story_writer.rs b/crates/epub/src/story_writer.rs index aed443d0b..40c95f135 100644 --- a/crates/epub/src/story_writer.rs +++ b/crates/epub/src/story_writer.rs @@ -223,7 +223,7 @@ impl<'a, 'f> StoryWriter<'a, 'f> { } else { ("span", String::new()) }; - let style = override_css(doc, &f.over); + let style = override_css(doc, &f.style, &f.over); let style_attr = if style.is_empty() { String::new() } else { format!(" style=\"{}\"", xml_attr(&style)) }; if cls.is_empty() && style_attr.is_empty() { out.push_str(&t); diff --git a/crates/epub/src/tests.rs b/crates/epub/src/tests.rs index 6cae6159f..4b0531967 100644 --- a/crates/epub/src/tests.rs +++ b/crates/epub/src/tests.rs @@ -943,5 +943,37 @@ fn character_styles_keep_decorations_caps_and_tracking() { assert!(css.contains("text-transform: uppercase;"), "{css}"); assert!(css.contains("letter-spacing: 0.100em;"), "{css}"); let o = CharAttrs { strikethrough: Some(true), ..Default::default() }; - assert_eq!(css::override_css(&d, &o), "text-decoration: line-through;"); + assert_eq!(css::override_css(&d, story::NO_CHAR_STYLE, &o), "text-decoration: line-through;"); +} + +fn char_style(name: &str, based_on: Option<&str>, chars: CharAttrs) -> designcraft_doc::CharacterStyle { + designcraft_doc::CharacterStyle { name: name.into(), based_on: based_on.map(Into::into), chars, shortcut: String::new() } +} + +#[test] +fn override_keeps_style_decorations_and_resets_tracking_and_caps() { + use designcraft_doc::Capitalization; + let mut d = Document::new(&NewDocument::default()); + let styles = d.styles_mut(); + styles.character.push(char_style( + "Base", + None, + CharAttrs { underline: Some(true), tracking: Some(100.0), capitalization: Some(Capitalization::AllCaps), ..Default::default() }, + )); + styles.character.push(char_style("Child", Some("Base"), CharAttrs::default())); + let strike = CharAttrs { strikethrough: Some(true), ..Default::default() }; + for style in ["Base", "Child"] { + assert_eq!(css::override_css(&d, style, &strike), "text-decoration: underline line-through;", "{style}"); + } + let off = CharAttrs { underline: Some(false), ..Default::default() }; + assert_eq!(css::override_css(&d, "Child", &off), "text-decoration: none;"); + let reset = CharAttrs { tracking: Some(0.0), capitalization: Some(Capitalization::Normal), ..Default::default() }; + assert_eq!(css::override_css(&d, "Child", &reset), "letter-spacing: 0em; text-transform: none; font-variant: normal;"); + assert_eq!(css::override_css(&d, story::NO_CHAR_STYLE, &reset), ""); + let styles = d.styles_mut(); + styles.character.push(char_style("Small", None, CharAttrs { capitalization: Some(Capitalization::SmallCaps), ..Default::default() })); + let small = CharAttrs { capitalization: Some(Capitalization::SmallCaps), ..Default::default() }; + let all = CharAttrs { capitalization: Some(Capitalization::AllCaps), ..Default::default() }; + assert_eq!(css::override_css(&d, "Base", &small), "text-transform: none; font-variant: normal; font-variant: small-caps;"); + assert_eq!(css::override_css(&d, "Small", &all), "text-transform: none; font-variant: normal; text-transform: uppercase;"); } From ea08e4c2ba290f092063c99d70c4bdd5afc45887 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 30 Oct 2026 02:37:30 -0400 Subject: [PATCH 433/628] UPD.1: honor app pretend versions in update checks (#2025) --- Cargo.lock | 2 +- Cargo.toml | 4 ++-- contributors/people.toml | 3 +++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6344685d7..935d51547 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1138,7 +1138,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1065653e75854e9c17e1ff563acdd344c1b185f0#1065653e75854e9c17e1ff563acdd344c1b185f0" +source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116" dependencies = [ "chrono", "dunce", diff --git a/Cargo.toml b/Cargo.toml index fd8629f62..22ab1e32d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,8 +17,8 @@ craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff1 craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", features = ["egui", "ui"] } # Separations, ink coverage and the ink manager; same revision as craft-pdf and the krilla patch. craft-prepress = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", features = ["flatten"] } -# craft-update with storytold/craft-libs#434: the update prompt shows the app's icon, accent and developer. -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1065653e75854e9c17e1ff563acdd344c1b185f0", features = ["egui"] } +# craft-update: app-specific pretend versions apply to startup and manual checks. +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] } # internal designcraft-geom = { path = "crates/geom" } designcraft-color = { path = "crates/color" } diff --git a/contributors/people.toml b/contributors/people.toml index 79acd86b0..e5b22d268 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -71,3 +71,6 @@ real_name = "Paul Gemignani" [people.skylartaylor] display_name = "skylartaylor" + +[people.dylanmtaylor] +sync_github_name = true From 7366589464ddf31e8c398af60966c3c8fdd28bea Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 11:41:28 +0100 Subject: [PATCH 434/628] Anchor point tests: transform_anchors from main's split Properties object module Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index ef4519688..6aa5b5c07 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -4,6 +4,7 @@ use egui_kittest::{ }; use super::*; +use super::object::transform_anchors; /// A link to a panel whose flyout is open leaves the flyout open. #[test] From 0403b988c160e374f6476273fb13545a1fbe081f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 11:46:38 +0100 Subject: [PATCH 435/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 6aa5b5c07..65e4bc793 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -3,8 +3,8 @@ use egui_kittest::{ kittest::{NodeT, Queryable}, }; -use super::*; use super::object::transform_anchors; +use super::*; /// A link to a panel whose flyout is open leaves the flyout open. #[test] From fd3d2f4e4922cc1bf9c891848304bdb747de0e85 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 11:46:43 +0100 Subject: [PATCH 436/628] Document properties: the page box field in its own function Keeps document_sections within clippy's cognitive complexity limit. Co-Authored-By: Claude Opus 5.5 --- .../ui-egui/src/panels/properties/document.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/panels/properties/document.rs b/crates/ui-egui/src/panels/properties/document.rs index f9ac002cb..d68d4531c 100644 --- a/crates/ui-egui/src/panels/properties/document.rs +++ b/crates/ui-egui/src/panels/properties/document.rs @@ -172,6 +172,15 @@ fn page_preset_menu(ui: &mut Ui, pw: f64, ph: f64) -> Option<(f64, f64)> { preset } +/// As the status bar's page box: the page, or the parent shown ("A-Parent"); then the parents. +fn page_box_field(app: &mut DesignApp, ui: &mut Ui, b: Rect) { + let Some(pb) = crate::canvas::page_box(app) else { return }; + let picked = place(ui, sub(b, 0.0, 0.0, 93.0, FIELD_H), |ui| widgets::dropdown_list(ui, pb.current_name(), 93.0, &pb.names, Some(pb.current))); + if let Some(i) = picked { + crate::canvas::page_box_go(app, &pb, i); + } +} + pub(super) fn document_sections(app: &mut DesignApp, ui: &mut Ui) { let u = units(app); let Some(st) = app.session.active() else { return }; @@ -287,14 +296,7 @@ pub(super) fn document_sections(app: &mut DesignApp, ui: &mut Ui) { // Page. section(ui, crate::i18n::tr(&app.ui.language, "Page"), true); let b = block(ui, FIELD_H); - // As the status bar's page box: the page, or the parent shown ("A-Parent"); then the parents. - if let Some(pb) = crate::canvas::page_box(app) { - let picked = - place(ui, sub(b, 0.0, 0.0, 93.0, FIELD_H), |ui| widgets::dropdown_list(ui, pb.current_name(), 93.0, &pb.names, Some(pb.current))); - if let Some(i) = picked { - crate::canvas::page_box_go(app, &pb, i); - } - } + page_box_field(app, ui, b); let (add, del) = place(ui, sub(b, 172.0, 0.0, 50.0, FIELD_H), |ui| { ui.spacing_mut().item_spacing.x = 4.0; let a = widgets::icon_toggle_sized(ui, "style-new", false, "Insert Page", vec2(22.0, 21.0)).clicked(); From 4652ad2bbcd4afafe98db36e92d41509bcf295bb Mon Sep 17 00:00:00 2001 From: tareq Date: Fri, 30 Oct 2026 12:00:47 +0100 Subject: [PATCH 437/628] Toolbar: Alt/Option-click cycles the flyout tool group. Matches InDesign/Photoshop: plain click keeps the shown tool; Alt-click advances the slot without opening the menu. --- crates/ui-egui/src/toolbar.rs | 14 ++++++++++++-- crates/ui-egui/src/toolbar/tests.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 4c6965ffc..ea5034219 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -103,19 +103,29 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { // on the tool (egui reports releases within 0.8 s as clicks). let hold_id = egui::Id::unique(("flyout_hold", gi)); let ended_hold = resp.clicked() && ui.data_mut(|d| d.remove_temp::(hold_id)).unwrap_or(false); + // Plain click selects the shown tool; Alt/Option-click cycles the flyout + // group (InDesign / Photoshop parity with the letter-key cycle). + let alt = ui.input(|i| i.modifiers.alt); if resp.clicked() && !ended_hold { - chosen = Some(shown.id); + chosen = Some(if g.len() > 1 && alt { + let from = g.iter().position(|tl| tl.id == current).unwrap_or(0); + g[(from + 1) % g.len()].id + } else { + shown.id + }); } if !resp.is_pointer_button_down_on() { ui.data_mut(|d| d.remove::(hold_id)); } // Double-click a polygon tool: Polygon Settings. - if resp.double_clicked() && matches!(shown.id, "polygon" | "polygonFrame") { + // Alt-clicks only cycle the group: exclude them from double-click dialogs. + if resp.double_clicked() && !alt && matches!(shown.id, "polygon" | "polygonFrame") { let cur = app.session.execute("tool.polygonSettings", &json!({})).unwrap_or_default(); app.ui.dialog = Some(crate::dialogs::Dialog::new("polygonSettings", cur)); } // Double-click a transform tool: its Object › Transform dialog. if resp.double_clicked() + && !alt && let Some(cmd) = transform_dialog_of(shown.id) { crate::menus::activate(app, cmd, &Value::Null); diff --git a/crates/ui-egui/src/toolbar/tests.rs b/crates/ui-egui/src/toolbar/tests.rs index 6a8ea752c..97a3bf6c3 100644 --- a/crates/ui-egui/src/toolbar/tests.rs +++ b/crates/ui-egui/src/toolbar/tests.rs @@ -378,3 +378,32 @@ fn fill_stroke_proxy_keys() { press(&mut app, &ctx, Key::X, Modifiers::NONE); assert_eq!(app.ui.stroke_front, front, "X while typing leaves the proxy alone"); } + +/// Alt/Option-click cycles a tool group's flyout without opening the menu. +#[test] +fn alt_click_cycles_the_tool_flyout_group() { + use egui::{Event, Modifiers, PointerButton}; + use egui_kittest::kittest::Queryable; + let app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let mut h = crate::test_window::open(app, vec2(1400.0, 900.0)); + h.state_mut().app.select_tool("rectangleFrame"); + h.run_steps(2); + let at = h.get_by_label("Rectangle Frame Tool (F)").rect().center(); + let alt = Modifiers { alt: true, ..Default::default() }; + let click = |h: &mut egui_kittest::Harness<'static, crate::test_window::Window>, modifiers: Modifiers| { + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.input_mut().events.push(Event::PointerMoved(at)); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers }); + } + h.run_steps(3); + }; + click(&mut h, Modifiers::NONE); + assert_eq!(h.state().app.session.tool_id(), "rectangleFrame"); + assert!(h.state().app.ui.flyout.is_none()); + click(&mut h, alt); + assert_eq!(h.state().app.session.tool_id(), "ellipseFrame", "Alt-click advances the frame group"); + assert!(h.state().app.ui.flyout.is_none(), "Alt-click must not open the flyout"); + click(&mut h, alt); + assert_eq!(h.state().app.session.tool_id(), "polygonFrame"); +} From 9e1e9d079564170379c92c0c331f10de90b2bb54 Mon Sep 17 00:00:00 2001 From: macadminHU <51995175+macrobi@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:04:03 +0100 Subject: [PATCH 438/628] HU1: retain upstream Georgian captions without duplicates Removed Georgian translations for 'File Info...' and 'Check for Updates...'. --- crates/ui-egui/src/i18n/ka.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 7cb8699f7..14a9e6203 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -5,7 +5,6 @@ //! the code or the command table use. pub(super) const TABLE: &[(&str, &str)] = &[ ("File", "ფაილი"), - ("File Info…", "ფაილის ინფორმაცია…"), ("Edit", "რედაქტირება"), ("Layout", "განლაგება"), ("Type", "ტექსტი"), @@ -433,7 +432,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Ungroup Color Group", "ფერების ჯგუფის დაშლა"), ("Unmerge", "შერწყმის გაუქმება"), ("Update", "განახლება"), - ("Check for Updates…", "განახლებების შემოწმება…"), ("Update Numbering", "დანომვრის განახლება"), ("Use Global Light", "გლობალური განათების გამოყენება"), ("User Dictionary", "მომხმარებლის ლექსიკონი"), From 453a3256f9cde17384c31bf77a6dfc818da39248 Mon Sep 17 00:00:00 2001 From: macadminHU <51995175+macrobi@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:06:41 +0100 Subject: [PATCH 439/628] HU1: translate the Story Editor depth-ruler caption --- crates/ui-egui/src/i18n/hu.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/src/i18n/hu.rs b/crates/ui-egui/src/i18n/hu.rs index 29406978c..890959761 100644 --- a/crates/ui-egui/src/i18n/hu.rs +++ b/crates/ui-egui/src/i18n/hu.rs @@ -1493,6 +1493,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Show in the text", "Megjelenítés a szövegben"), ("Show the Typography panel", "Tipográfia panel megjelenítése"), ("Show/Hide Baseline Grid", "Alapvonalrács megjelenítése/elrejtése"), + ("Show/Hide Depth Ruler", "Mélységmérő megjelenítése/elrejtése"), ("Show/Hide Frame Edges", "Keretélek megjelenítése/elrejtése"), ("Show/Hide Guides", "Segédvonalak megjelenítése/elrejtése"), ("Show/Hide Hidden Characters", "Rejtett karakterek megjelenítése/elrejtése"), From 4cb6ad4f8abf693035508f52cac6238d268afd53 Mon Sep 17 00:00:00 2001 From: macadminHU <51995175+macrobi@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:07:19 +0100 Subject: [PATCH 440/628] HU1: retain menu assertions under the upstream complexity limit Introduced a new test function to check Georgian menu label translations and replaced the previous inline implementation with a call to this new function. --- crates/ui-egui/src/menus/tests.rs | 45 +++++++++++++++++-------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 2f5cdaf09..9fd0247c2 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -587,6 +587,30 @@ fn show_document_grid_toggles_from_the_grids_and_guides_menu() { assert!(!app.ui.document_grid); } +/// Checks every Georgian menu title, item and submenu, allowing language-neutral labels. +fn assert_georgian_menu_labels_are_translated() { + // Every menu title, item and submenu is translated into Georgian. + let mut untranslated = Vec::new(); + for (title, entries) in menu_tree() { + let mut labels = vec![title.to_string()]; + let mut stack: Vec<&Item> = entries.iter().collect(); + while let Some(entry) = stack.pop() { + match entry { + Item::Sub(label, children) => { + labels.push(label.clone()); + stack.extend(children.iter()); + } + Item::Cmd { label, id, .. } if id != "app.language" => labels.push(label.clone()), + _ => {} + } + } + // Labels without letters ("180°") or point sizes ("72 pt") read the same in every language. + let size = |l: &str| l.strip_suffix(" pt").is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit())); + untranslated.extend(labels.into_iter().filter(|l| l.chars().any(char::is_alphabetic) && !size(l) && crate::i18n::tr("ka", l) == l.as_str())); + } + assert!(untranslated.is_empty(), "untranslated Georgian menu labels: {untranslated:?}"); +} + /// Checks language selection and rejection of unknown language codes, Preferences dispatch, /// translated menu labels and preservation of document data when switching to Arabic. #[test] @@ -651,26 +675,7 @@ fn interface_language_switches_and_validates() { run_ui(&mut app, "app.language", &json!({"lang": "ka"})).unwrap().unwrap(); assert_eq!(app.ui.language, "ka"); assert_eq!(checked(&app, "app.language", &json!({"lang": "ka"})), Some(true)); - // Every menu title, item and submenu is translated into Georgian. - let mut untranslated = Vec::new(); - for (title, entries) in menu_tree() { - let mut labels = vec![title.to_string()]; - let mut stack: Vec<&Item> = entries.iter().collect(); - while let Some(entry) = stack.pop() { - match entry { - Item::Sub(label, children) => { - labels.push(label.clone()); - stack.extend(children.iter()); - } - Item::Cmd { label, id, .. } if id != "app.language" => labels.push(label.clone()), - _ => {} - } - } - // Labels without letters ("180°") or point sizes ("72 pt") read the same in every language. - let size = |l: &str| l.strip_suffix(" pt").is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit())); - untranslated.extend(labels.into_iter().filter(|l| l.chars().any(char::is_alphabetic) && !size(l) && crate::i18n::tr("ka", l) == l.as_str())); - } - assert!(untranslated.is_empty(), "untranslated Georgian menu labels: {untranslated:?}"); + assert_georgian_menu_labels_are_translated(); } /// Checks Hungarian selection, menu captions and saved UI preferences without changing From 745ba77fd00d254511ddc08e7321ec158cbfb065 Mon Sep 17 00:00:00 2001 From: macadminHU <51995175+macrobi@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:08:20 +0100 Subject: [PATCH 441/628] HU1: document current-main integration and the depth-ruler caption Updated Hungarian localization entries and added Depth Ruler caption. --- docs/localization-parity.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/localization-parity.md b/docs/localization-parity.md index a42ecb0c3..32a362ae4 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-27 · **Change:** minor (shared-module integration and retained localization regressions; older catalog counts retain their dated measurement) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-30 · **Change:** minor (current-main integration with retained catalogs and regression tests; older catalog counts retain their dated measurement) · **Target:** Adobe InDesign 2026 (21.6) Per-language status of the DesignCraft interface and of document typesetting in each script. Script-specific typesetting detail: [cjk.md](cjk.md), [cjk-typography.md](cjk-typography.md), @@ -87,11 +87,22 @@ Hungarian catalog contents and the dated coverage measurements are unchanged. To coverage follows the current platform-specific keymap captions and checks a user-assigned chord alongside translated flyout text; row sizing measures the displayed shortcut. +Main integration (2026-10-30): retain every prior Hungarian entry and both branches’ +localization, menu and toolbar regressions while adopting upstream’s current file-dialog test +harness and toolbar proxy refactor. Add `Show/Hide Depth Ruler` as `Mélységmérő +megjelenítése/elrejtése`, bringing the catalog to **1,906 entries and four context captions**. +The upstream all-language depth-ruler assertion is retained. The unchanged complete Georgian +menu assertions run through a helper so the existing language-switch test meets upstream’s +new complexity limit. The two Georgian menu captions now also exist in main; retain each +identical translation once, keeping the direct lookup and complete-menu assertions. The +progress log keeps both branches’ dated entries; this targeted addition is not a new coverage +measurement. + ## Other shipped languages | Language | Code | UI strings translated | Status | |---|---|---|---| -| Hungarian | hu | 1,905 entries + 4 context captions (2026-10-25); initial native review | partial | +| Hungarian | hu | 1,906 entries + 4 context captions (2026-10-30); initial catalog native-reviewed | partial | | Czech | cs | 1,722 (99%), plural categories handled | partial | | Italian | it | 1,464 (85%) | partial | | Ukrainian | uk | 1,432 (83%), plural categories handled | partial | @@ -128,6 +139,7 @@ German variants have no automatic hyphenation; their German shaping and quotes a | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Integrate current main; retain both branches’ entries/tests; add Hungarian Depth Ruler caption (1,906 entries + four context captions) | | 2026-10-27 | minor | Adopt shared module layout; preserve both parents' tests and all Hungarian catalog entries | | 2026-10-25 | minor | Hungarian Data Merge arrangement caption added; 1,905 entries and four context captions; both merge parents' tests retained | | 2026-10-25 | minor | Georgian File Info and Check for Updates captions added; complete-menu regression retained | From b7f4612aa1d19f8f89488a21a33b1c0b2406c5dc Mon Sep 17 00:00:00 2001 From: macadminHU <51995175+macrobi@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:09:00 +0100 Subject: [PATCH 442/628] HU1: preserve the verified roadmap without editor whitespace --- ROADMAP.md | 1 - 1 file changed, 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index cc59b2f01..113cab376 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -767,4 +767,3 @@ The shared craft-installer UI preserves this revision's install options and prov - **2026-10-15** · Windows recovery folders validate the identity of the live locked file without reading it through a second handle. Missing or replaced locks disable writes and cleanup; regression tests cover retained exclusive locking and replacement-file protection. No readiness percentage change. - PDF reader infrastructure: pinned shared parsing/rasterization, transformed page-box geometry and explicit failures for known incomplete layered-PDF exports. Application ownership and parity targets are unchanged. - \ No newline at end of file From d4b7e6659242b77150ad9596cbf494095b715fd8 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 12:33:53 +0100 Subject: [PATCH 443/628] 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 5ec44d213e503544c12d3549d89155b4fe47908a Mon Sep 17 00:00:00 2001 From: tareq Date: Fri, 30 Oct 2026 12:43:59 +0100 Subject: [PATCH 444/628] Toolbar: ignore double-click dialogs after an Alt-click. egui pairs a plain click with a prior Alt-click as a double-click; remember the previous click's Alt so polygon/transform dialogs only open for two plain clicks. --- crates/ui-egui/src/toolbar.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index ea5034219..ea7b4d287 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -106,7 +106,12 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { // Plain click selects the shown tool; Alt/Option-click cycles the flyout // group (InDesign / Photoshop parity with the letter-key cycle). let alt = ui.input(|i| i.modifiers.alt); - if resp.clicked() && !ended_hold { + // egui's double-click ignores modifiers: remember the previous click's + // Alt so dialogs only open for two plain clicks (same as PhotoCraft). + let prev_alt_id = egui::Id::unique(("tool-click-alt", gi)); + let prev_alt = ui.data(|d| d.get_temp::(prev_alt_id)).unwrap_or(false); + let slot_click = resp.clicked() && !ended_hold; + if slot_click { chosen = Some(if g.len() > 1 && alt { let from = g.iter().position(|tl| tl.id == current).unwrap_or(0); g[(from + 1) % g.len()].id @@ -119,17 +124,20 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } // Double-click a polygon tool: Polygon Settings. // Alt-clicks only cycle the group: exclude them from double-click dialogs. - if resp.double_clicked() && !alt && matches!(shown.id, "polygon" | "polygonFrame") { + let plain_double = resp.double_clicked() && !alt && !prev_alt; + if plain_double && matches!(shown.id, "polygon" | "polygonFrame") { let cur = app.session.execute("tool.polygonSettings", &json!({})).unwrap_or_default(); app.ui.dialog = Some(crate::dialogs::Dialog::new("polygonSettings", cur)); } // Double-click a transform tool: its Object › Transform dialog. - if resp.double_clicked() - && !alt + if plain_double && let Some(cmd) = transform_dialog_of(shown.id) { crate::menus::activate(app, cmd, &Value::Null); } + if slot_click { + ui.data_mut(|d| d.insert_temp(prev_alt_id, alt)); + } let held = resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.press_start_time().is_some_and(|s| i.time - s > 0.35)); if g.len() > 1 && held { From 4880b917ee53fb63941a9d4533887c2d7c746a7c Mon Sep 17 00:00:00 2001 From: tareq Date: Fri, 30 Oct 2026 13:02:19 +0100 Subject: [PATCH 445/628] Toolbar: read click modifiers from the pointer release. Prefer the primary-release event's modifiers for Alt-cycle, and suppress polygon/transform dialogs when either click of a double was modified. --- crates/ui-egui/src/toolbar.rs | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index ea7b4d287..8362d2083 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -105,11 +105,29 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let ended_hold = resp.clicked() && ui.data_mut(|d| d.remove_temp::(hold_id)).unwrap_or(false); // Plain click selects the shown tool; Alt/Option-click cycles the flyout // group (InDesign / Photoshop parity with the letter-key cycle). - let alt = ui.input(|i| i.modifiers.alt); - // egui's double-click ignores modifiers: remember the previous click's - // Alt so dialogs only open for two plain clicks (same as PhotoCraft). - let prev_alt_id = egui::Id::unique(("tool-click-alt", gi)); - let prev_alt = ui.data(|d| d.get_temp::(prev_alt_id)).unwrap_or(false); + // Prefer the primary-release event's modifiers: Alt may already be up + // by the time this frame reads i.modifiers. + let click_mods = ui.input(|i| { + i.events + .iter() + .rev() + .find_map(|e| match e { + egui::Event::PointerButton { + button: egui::PointerButton::Primary, + pressed: false, + modifiers, + .. + } => Some(*modifiers), + _ => None, + }) + .unwrap_or(i.modifiers) + }); + let alt = click_mods.alt; + let modified = click_mods.alt || click_mods.shift || click_mods.ctrl || click_mods.command; + // egui's double-click ignores modifiers: remember whether the previous + // click was modified so dialogs only open for two plain clicks. + let prev_mod_id = egui::Id::unique(("tool-click-mod", gi)); + let prev_modified = ui.data(|d| d.get_temp::(prev_mod_id)).unwrap_or(false); let slot_click = resp.clicked() && !ended_hold; if slot_click { chosen = Some(if g.len() > 1 && alt { @@ -123,8 +141,8 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.data_mut(|d| d.remove::(hold_id)); } // Double-click a polygon tool: Polygon Settings. - // Alt-clicks only cycle the group: exclude them from double-click dialogs. - let plain_double = resp.double_clicked() && !alt && !prev_alt; + // Modified clicks only cycle / select: exclude them from dialogs. + let plain_double = resp.double_clicked() && !modified && !prev_modified; if plain_double && matches!(shown.id, "polygon" | "polygonFrame") { let cur = app.session.execute("tool.polygonSettings", &json!({})).unwrap_or_default(); app.ui.dialog = Some(crate::dialogs::Dialog::new("polygonSettings", cur)); @@ -136,7 +154,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { crate::menus::activate(app, cmd, &Value::Null); } if slot_click { - ui.data_mut(|d| d.insert_temp(prev_alt_id, alt)); + ui.data_mut(|d| d.insert_temp(prev_mod_id, modified)); } let held = resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.press_start_time().is_some_and(|s| i.time - s > 0.35)); From d1b647ac8ca21059ee9779a721ef26996ddec27f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 13:05:20 +0100 Subject: [PATCH 446/628] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/src/panels/properties/tests.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5896fdfee..2beb7fe17 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4700,7 +4700,7 @@ fn para_corners(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: let tip = crate::i18n::tr(lang, tip); let (r, resp) = ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::hover()); crate::icons::paint(ui.painter(), r, icon, crate::theme::Tokens::get(ui.ctx()).icon); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, tip)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, tip)); resp.on_hover_text(tip); let id = format!("ps{key}{i}"); if let Some(v) = crate::widgets::NumField::measure(&id, Some(c.size), units).width(84.0).spinner().range(0.0, 10_000.0).show(ui) { diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 5c9feea47..4458dae4f 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -717,7 +717,7 @@ fn preview_rows_fit_long_translated_names() { let long = "Скругленный прямоугольник с обратным углом"; let mut widths = (0.0, 0.0); let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - egui::Area::new(egui::Id::new("rows")).show(ui.ctx(), |ui| { + egui::Area::new(egui::Id::unique("rows")).show(ui.ctx(), |ui| { ui.set_max_width(10.0); let r = preview_row(ui, false, long, 40.0, |_, _, _| {}); let text = ui.fonts_mut(|f| f.layout_no_wrap(long.to_string(), egui::FontId::proportional(12.0), egui::Color32::WHITE).size().x); From 5b52b23b00f55a06f6f787063a990f4b8561130b Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 13:07:37 +0100 Subject: [PATCH 447/628] IDML export: the paragraph box overprints in their own function Keeps para_box_attrs within clippy's cognitive complexity limit. Co-Authored-By: Claude Opus 5.5 --- crates/idml/src/export/text_attrs.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/idml/src/export/text_attrs.rs b/crates/idml/src/export/text_attrs.rs index 557602a18..1c0def755 100644 --- a/crates/idml/src/export/text_attrs.rs +++ b/crates/idml/src/export/text_attrs.rs @@ -22,6 +22,13 @@ macro_rules! b { }; } +/// The Overprint switches of a paragraph's shading, border and border gap. +fn para_box_overprints(el: &mut El, a: &ParaAttrs) { + b!(a, el, shading_overprint, "ParagraphShadingOverprint"); + b!(a, el, border_overprint, "ParagraphBorderOverprint"); + b!(a, el, border_gap_overprint, "ParagraphBorderGapOverprint"); +} + impl<'a> Ex<'a> { pub(super) fn char_attrs(&mut self, el: &mut El, props: &mut Vec, a: &CharAttrs) { if let Some(v) = a.leading_aki { @@ -494,7 +501,7 @@ impl<'a> Ex<'a> { props.push(p("ParagraphShadingColor", "object", self.sw(c))); } n!(a, el, shading_tint, "ParagraphShadingTint", pct); - b!(a, el, shading_overprint, "ParagraphShadingOverprint"); + para_box_overprints(el, a); const SIDES: [&str; 4] = ["Top", "Left", "Bottom", "Right"]; if let Some(o) = a.shading_offsets { for (i, side) in SIDES.iter().enumerate() { @@ -506,7 +513,6 @@ impl<'a> Ex<'a> { props.push(p("ParagraphBorderColor", "object", self.sw(c))); } n!(a, el, border_tint, "ParagraphBorderTint", pct); - b!(a, el, border_overprint, "ParagraphBorderOverprint"); if let Some(w) = a.border_weights { for (i, side) in SIDES.iter().enumerate() { el.set(&format!("ParagraphBorder{side}LineWeight"), num(w[i])); @@ -542,7 +548,6 @@ impl<'a> Ex<'a> { props.push(p("ParagraphBorderGapColor", "object", self.sw(c))); } n!(a, el, border_gap_tint, "ParagraphBorderGapTint", pct); - b!(a, el, border_gap_overprint, "ParagraphBorderGapOverprint"); if let Some(w) = a.shading_width { el.set("ParagraphShadingWidth", names::box_width_out(w)); } From db4f8a4d6da05b548a1ed7447d8f42e1342c98b5 Mon Sep 17 00:00:00 2001 From: tareq Date: Fri, 30 Oct 2026 13:21:01 +0100 Subject: [PATCH 448/628] Toolbar: assert Alt-click wrap-around in the flyout cycle test. CodeRabbit nitpick: after the last group tool, the next Alt-click must return to the first and keep the flyout closed. --- crates/ui-egui/src/toolbar/tests.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/ui-egui/src/toolbar/tests.rs b/crates/ui-egui/src/toolbar/tests.rs index 97a3bf6c3..5db130f63 100644 --- a/crates/ui-egui/src/toolbar/tests.rs +++ b/crates/ui-egui/src/toolbar/tests.rs @@ -406,4 +406,7 @@ fn alt_click_cycles_the_tool_flyout_group() { assert!(h.state().app.ui.flyout.is_none(), "Alt-click must not open the flyout"); click(&mut h, alt); assert_eq!(h.state().app.session.tool_id(), "polygonFrame"); + click(&mut h, alt); + assert_eq!(h.state().app.session.tool_id(), "rectangleFrame", "Alt-click after the last group tool wraps to the first"); + assert!(h.state().app.ui.flyout.is_none(), "wrap-around Alt-click must not open the flyout"); } From 278b8e8bf4529c84adf3e3f8baae3223dc0be584 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 13:21:04 +0100 Subject: [PATCH 449/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1802a1fcf..eeb67869a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1999,7 +1999,8 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Panels")).font(semibold(12.0))); ui.horizontal(|ui| { let name = crate::rtl::label(ui, crate::i18n::tr(&lang, "Tools Panel:")); - translated_combo(&lang, ui, d, "toolsColumns", &[("single", "Single Column"), ("double", "Double Column")]).labelled_by(name.id); + translated_combo(&lang, ui, d, "toolsColumns", &[("single", "Single Column"), ("double", "Double Column")]) + .labelled_by(name.id); }); } "uiScaling" => { From 9476b39e89db970b9b3bbae5a2b4f1da68c2a836 Mon Sep 17 00:00:00 2001 From: tareq Date: Fri, 30 Oct 2026 13:56:25 +0100 Subject: [PATCH 450/628] Toolbar: bind click modifiers to the slot rect. Ignore primary releases outside this tool button so another widget cannot steer Alt-cycle or suppress dialogs; harden the Alt-click test to clear i.modifiers before release. --- crates/ui-egui/src/toolbar.rs | 5 ++++- crates/ui-egui/src/toolbar/tests.rs | 31 ++++++++++++++++++++--------- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 8362d2083..1ba172004 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -107,17 +107,20 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { // group (InDesign / Photoshop parity with the letter-key cycle). // Prefer the primary-release event's modifiers: Alt may already be up // by the time this frame reads i.modifiers. + // Only a release on this slot: the frame's event list is shared, + // so a later release elsewhere must not steer Alt-cycle / dialogs. let click_mods = ui.input(|i| { i.events .iter() .rev() .find_map(|e| match e { egui::Event::PointerButton { + pos, button: egui::PointerButton::Primary, pressed: false, modifiers, .. - } => Some(*modifiers), + } if resp.rect.contains(*pos) => Some(*modifiers), _ => None, }) .unwrap_or(i.modifiers) diff --git a/crates/ui-egui/src/toolbar/tests.rs b/crates/ui-egui/src/toolbar/tests.rs index 5db130f63..5551ba2dd 100644 --- a/crates/ui-egui/src/toolbar/tests.rs +++ b/crates/ui-egui/src/toolbar/tests.rs @@ -390,23 +390,36 @@ fn alt_click_cycles_the_tool_flyout_group() { h.run_steps(2); let at = h.get_by_label("Rectangle Frame Tool (F)").rect().center(); let alt = Modifiers { alt: true, ..Default::default() }; - let click = |h: &mut egui_kittest::Harness<'static, crate::test_window::Window>, modifiers: Modifiers| { - h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + // Clear i.modifiers before the release while keeping Alt on the release event, so a + // regression that only reads i.modifiers (and ignores the pointer-release modifiers) fails. + let click = |h: &mut egui_kittest::Harness<'static, crate::test_window::Window>, press_modifiers: Modifiers, current_modifiers: Modifiers| { + h.input_mut().events.push(Event::ModifiersChanged(press_modifiers)); h.input_mut().events.push(Event::PointerMoved(at)); - for pressed in [true, false] { - h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers }); - } + h.input_mut().events.push(Event::PointerButton { + pos: at, + button: PointerButton::Primary, + pressed: true, + modifiers: press_modifiers, + }); + h.input_mut().events.push(Event::ModifiersChanged(current_modifiers)); + h.input_mut().events.push(Event::PointerButton { + pos: at, + button: PointerButton::Primary, + pressed: false, + modifiers: press_modifiers, + }); h.run_steps(3); }; - click(&mut h, Modifiers::NONE); + click(&mut h, Modifiers::NONE, Modifiers::NONE); assert_eq!(h.state().app.session.tool_id(), "rectangleFrame"); assert!(h.state().app.ui.flyout.is_none()); - click(&mut h, alt); + click(&mut h, alt, Modifiers::NONE); assert_eq!(h.state().app.session.tool_id(), "ellipseFrame", "Alt-click advances the frame group"); assert!(h.state().app.ui.flyout.is_none(), "Alt-click must not open the flyout"); - click(&mut h, alt); + click(&mut h, alt, Modifiers::NONE); assert_eq!(h.state().app.session.tool_id(), "polygonFrame"); - click(&mut h, alt); + assert!(h.state().app.ui.dialog.is_none(), "modified double-click must not open a dialog"); + click(&mut h, alt, Modifiers::NONE); assert_eq!(h.state().app.session.tool_id(), "rectangleFrame", "Alt-click after the last group tool wraps to the first"); assert!(h.state().app.ui.flyout.is_none(), "wrap-around Alt-click must not open the flyout"); } From 9eb11f3eeed58f38b1ef5122f2876a008bed385d Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 14:24:57 +0100 Subject: [PATCH 451/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/find/tests.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/find/tests.rs b/crates/engine/src/cmd/find/tests.rs index e88c81a3a..7de29f8ea 100644 --- a/crates/engine/src/cmd/find/tests.rs +++ b/crates/engine/src/cmd/find/tests.rs @@ -795,9 +795,8 @@ fn change_format_styles_the_found_text_and_keeps_it() { fn a_find_format_finds_only_text_with_it() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); - let r = s - .execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Break eggs; cook eggs.\nEggs and eggs"})) - .unwrap(); + let r = + s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Break eggs; cook eggs.\nEggs and eggs"})).unwrap(); let sid = r["story"].as_u64().unwrap(); s.execute("style.character.create", &json!({"name": "eggs", "chars": {"fontStyle": "Bold"}})).unwrap(); s.execute("style.paragraph.create", &json!({"name": "Method"})).unwrap(); From 00604b74f27a84ebcb0d8d0c1af37977de0bb95c Mon Sep 17 00:00:00 2001 From: tareq Date: Fri, 30 Oct 2026 14:31:53 +0100 Subject: [PATCH 452/628] Toolbar: accept slot releases that drift outside the button. egui still counts a click when the pointer leaves the tool rect; keep the release modifiers when the press started on the slot. The Alt-cycle test now uses 1 ms frames so an Alt-then-plain pair is a real double-click that must not open Polygon Settings. --- crates/ui-egui/src/toolbar.rs | 8 +++++--- crates/ui-egui/src/toolbar/tests.rs | 20 ++++++++++++++++++-- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 1ba172004..de06cc26c 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -107,9 +107,11 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { // group (InDesign / Photoshop parity with the letter-key cycle). // Prefer the primary-release event's modifiers: Alt may already be up // by the time this frame reads i.modifiers. - // Only a release on this slot: the frame's event list is shared, - // so a later release elsewhere must not steer Alt-cycle / dialogs. + // Prefer a release on this slot; egui still counts a click when the + // pointer drifts a little outside, so also accept a release whose + // press started here. Ignore releases whose press was elsewhere. let click_mods = ui.input(|i| { + let press_here = i.pointer.press_origin().is_some_and(|o| resp.rect.contains(o)); i.events .iter() .rev() @@ -120,7 +122,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { pressed: false, modifiers, .. - } if resp.rect.contains(*pos) => Some(*modifiers), + } if resp.rect.contains(*pos) || press_here => Some(*modifiers), _ => None, }) .unwrap_or(i.modifiers) diff --git a/crates/ui-egui/src/toolbar/tests.rs b/crates/ui-egui/src/toolbar/tests.rs index 5551ba2dd..899f4855b 100644 --- a/crates/ui-egui/src/toolbar/tests.rs +++ b/crates/ui-egui/src/toolbar/tests.rs @@ -385,7 +385,9 @@ fn alt_click_cycles_the_tool_flyout_group() { use egui::{Event, Modifiers, PointerButton}; use egui_kittest::kittest::Queryable; let app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - let mut h = crate::test_window::open(app, vec2(1400.0, 900.0)); + // 1 ms frames so an Alt-click and a following plain click can form a real double-click + // (default open() spaces frames by 0.25 s, outside egui's double-click window). + let mut h = crate::test_window::open_stepped(app, vec2(1400.0, 900.0), 1.0, 0.001); h.state_mut().app.select_tool("rectangleFrame"); h.run_steps(2); let at = h.get_by_label("Rectangle Frame Tool (F)").rect().center(); @@ -410,16 +412,30 @@ fn alt_click_cycles_the_tool_flyout_group() { }); h.run_steps(3); }; + // Break the double-click window between independent clicks (~0.4 s at 1 ms/step). + let settle = |h: &mut egui_kittest::Harness<'static, crate::test_window::Window>| h.run_steps(400); click(&mut h, Modifiers::NONE, Modifiers::NONE); assert_eq!(h.state().app.session.tool_id(), "rectangleFrame"); assert!(h.state().app.ui.flyout.is_none()); + settle(&mut h); click(&mut h, alt, Modifiers::NONE); assert_eq!(h.state().app.session.tool_id(), "ellipseFrame", "Alt-click advances the frame group"); assert!(h.state().app.ui.flyout.is_none(), "Alt-click must not open the flyout"); + settle(&mut h); click(&mut h, alt, Modifiers::NONE); assert_eq!(h.state().app.session.tool_id(), "polygonFrame"); - assert!(h.state().app.ui.dialog.is_none(), "modified double-click must not open a dialog"); + settle(&mut h); click(&mut h, alt, Modifiers::NONE); assert_eq!(h.state().app.session.tool_id(), "rectangleFrame", "Alt-click after the last group tool wraps to the first"); assert!(h.state().app.ui.flyout.is_none(), "wrap-around Alt-click must not open the flyout"); + settle(&mut h); + // Alt-click onto polygonFrame, then a plain click inside the double-click window: egui + // reports double_clicked, but prev_modified must keep Polygon Settings closed. + h.state_mut().app.select_tool("ellipseFrame"); + h.run_steps(2); + click(&mut h, alt, Modifiers::NONE); + assert_eq!(h.state().app.session.tool_id(), "polygonFrame"); + click(&mut h, Modifiers::NONE, Modifiers::NONE); + assert!(h.state().app.ui.dialog.is_none(), "plain click after Alt-click must not open Polygon Settings"); + assert_eq!(h.state().app.session.tool_id(), "polygonFrame"); } From cd84bd903ae92530eb0ce6cf248629d491b51e20 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 14:36:10 +0100 Subject: [PATCH 453/628] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 4 ++-- crates/ui-egui/src/dialogs/tests.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 35031149a..d6da8e43e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3305,7 +3305,7 @@ const FORMAT_SECTIONS: &[(&str, &str)] = &[ fn info_icon(ui: &mut egui::Ui, tip: &str) { let (r, resp) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover()); crate::icons::paint(ui.painter(), r, "info", crate::theme::Tokens::get(ui.ctx()).icon); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, tip)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, tip)); resp.on_hover_text(tip); } @@ -3383,7 +3383,7 @@ fn format_settings(app: &mut DesignApp, ctx: &egui::Context, d: &mut Dialog) { let find = fd.s("for") == "find"; let popup_open = egui::Popup::is_any_open(ctx); let mut result = None; - let host = egui::Id::new("format_settings"); + let host = egui::Id::unique("format_settings"); crate::modal_drag::modal(ctx, host, host).backdrop_color(egui::Color32::TRANSPARENT).show(ctx, |ui| { let title_top = ui.cursor().top(); let title = if find { "Find Format Settings" } else { "Change Format Settings" }; diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index c80127706..e1ce3f2dd 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3542,7 +3542,7 @@ fn find_change_gives_the_found_text_a_change_format() { } let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( |ui, app: &mut DesignApp| { - let key = egui::Id::new("change_format_test_fonts"); + let key = egui::Id::unique("change_format_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)); @@ -3632,7 +3632,7 @@ fn find_format_finds_only_text_with_it() { } let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( |ui, app: &mut DesignApp| { - let key = egui::Id::new("find_format_test_fonts"); + let key = egui::Id::unique("find_format_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 930d96dc0e41f3126a15143c70c4de6d1c030e7b Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 14:40:58 +0100 Subject: [PATCH 454/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/find/tests.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/find/tests.rs b/crates/engine/src/cmd/find/tests.rs index 8a679269e..65dad887b 100644 --- a/crates/engine/src/cmd/find/tests.rs +++ b/crates/engine/src/cmd/find/tests.rs @@ -816,9 +816,8 @@ fn change_format_styles_the_found_text_and_keeps_it() { fn a_find_format_finds_only_text_with_it() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); - let r = s - .execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Break eggs; cook eggs.\nEggs and eggs"})) - .unwrap(); + let r = + s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Break eggs; cook eggs.\nEggs and eggs"})).unwrap(); let sid = r["story"].as_u64().unwrap(); s.execute("style.character.create", &json!({"name": "eggs", "chars": {"fontStyle": "Bold"}})).unwrap(); s.execute("style.paragraph.create", &json!({"name": "Method"})).unwrap(); From 9f591ab6faab85c57161abba7231e741ec38224b Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 14:45:50 +0100 Subject: [PATCH 455/628] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 4 ++-- crates/ui-egui/src/dialogs/tests.rs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f3832a9d7..49932fc70 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3340,7 +3340,7 @@ const FORMAT_SECTIONS: &[(&str, &str)] = &[ fn info_icon(ui: &mut egui::Ui, tip: &str) { let (r, resp) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover()); crate::icons::paint(ui.painter(), r, "info", crate::theme::Tokens::get(ui.ctx()).icon); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, tip)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, tip)); resp.on_hover_text(tip); } @@ -3418,7 +3418,7 @@ fn format_settings(app: &mut DesignApp, ctx: &egui::Context, d: &mut Dialog) { let find = fd.s("for") == "find"; let popup_open = egui::Popup::is_any_open(ctx); let mut result = None; - let host = egui::Id::new("format_settings"); + let host = egui::Id::unique("format_settings"); crate::modal_drag::modal(ctx, host, host).backdrop_color(egui::Color32::TRANSPARENT).show(ctx, |ui| { let title_top = ui.cursor().top(); let title = if find { "Find Format Settings" } else { "Change Format Settings" }; diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 153a83486..8bb581d14 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3551,7 +3551,7 @@ fn find_change_gives_the_found_text_a_change_format() { } let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( |ui, app: &mut DesignApp| { - let key = egui::Id::new("change_format_test_fonts"); + let key = egui::Id::unique("change_format_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)); @@ -3641,7 +3641,7 @@ fn find_format_finds_only_text_with_it() { } let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( |ui, app: &mut DesignApp| { - let key = egui::Id::new("find_format_test_fonts"); + let key = egui::Id::unique("find_format_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)); @@ -3701,7 +3701,7 @@ fn change_and_change_find_change_the_found_text() { } let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( |ui, app: &mut DesignApp| { - let key = egui::Id::new("change_find_test_fonts"); + let key = egui::Id::unique("change_find_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 620f83d44a08c0b21fcbc1352a8440f71182c3df Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 14:46:10 +0100 Subject: [PATCH 456/628] Find/Change: the Search label's name apart from the branch's scope_name closure 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 49932fc70..5752703ac 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2491,7 +2491,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }); text_field_named(ui, &mut d, "change", 260.0, crate::i18n::tr(&app.ui.language, "Change to:")); ui.end_row(); - let scope_name = crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Search:")); + let search_label = crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Search:")); let cur = d.s("scope"); // InDesign's names for the scopes. let scope_name = |v: &str| match v { @@ -2513,7 +2513,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } }) .response - .labelled_by(scope_name.id); + .labelled_by(search_label.id); ui.end_row(); }); ui.horizontal(|ui| { From 00e26ce428a10ea1a3cd9a8f183301a661cc4c07 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 15:11:34 +0100 Subject: [PATCH 457/628] Paragraph Style Options: Next Style named by its label, for screen readers; craft_ui dropdown Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 256cfeaef..6a48de653 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4404,7 +4404,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo let _ = crate::widgets::name_combo(combo, crate::i18n::tr(&app.ui.language, "Based On")); ui.end_row(); // Next Style: the style of the paragraph that follows when Enter ends this one. - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Next Style:")); + let next_name = crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Next Style:")); let next = match d.fields.get("nextStyle") { Some(Value::String(n)) => Some(n.clone()), Some(_) => None, @@ -4424,7 +4424,9 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo d.fields.insert("nextStyle".into(), json!(n)); } } - }); + }) + .response + .labelled_by(next_name.id); ui.end_row(); }); // Reset To Base: the style keeps nothing of its own (the edits made here stay). From b77a1c29bde398920f978b7491b6d13e95bb30ce Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 15:33:46 +0100 Subject: [PATCH 458/628] Eyedropper: the renderer's size as one (width, height) argument, as main's render takes it Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/render.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas/render.rs b/crates/ui-egui/src/canvas/render.rs index 5b7358996..07983944e 100644 --- a/crates/ui-egui/src/canvas/render.rs +++ b/crates/ui-egui/src/canvas/render.rs @@ -25,7 +25,7 @@ pub fn color_at(app: &mut DesignApp, ctx: &egui::Context, pos: Pos2) -> Option<[ let ppp = ctx.pixels_per_point() as f64; let view = Affine::scale(ppp) * Affine::scale(xf.zoom) * Affine::translate((-at.x, -at.y)); let opts = screen_options(app, &layout, app.ui.screen_mode != ScreenMode::Normal, ppp); - let mut img = app.canvas.patcher.render(&doc, &app.session.cache, &placed, 1, 1, view, &opts); + let mut img = app.canvas.patcher.render(&doc, &app.session.cache, &placed, (1, 1), view, &opts); // The colour as the canvas shows it (proofed). screen_post(app, &mut img); let [r, g, b, a] = <[u8; 4]>::try_from(img.pixels.get(..4)?).ok()?; From dd0b891ef0c8e058fb72eb7d84cf49a7e9548240 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 15:35:16 +0100 Subject: [PATCH 459/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6a48de653..3044479cf 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4412,21 +4412,27 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo }; let same = crate::i18n::tr(&app.ui.language, "[Same style]").to_string(); let shown = next.as_deref().map_or(same.clone(), |n| crate::i18n::style_name(&app.ui.language, n).to_string()); - craft_ui::ComboBox::from_id_salt("psnext").selected_text(crate::rtl::widget(ui, shown)).width(220.0).show_ui(ui, |ui| { - if ui.selectable_label(next.is_none(), crate::rtl::widget(ui, &same)).clicked() { - d.fields.insert("nextStyle".into(), Value::Null); - } - for n in names.iter().filter(|n| *n != designcraft_doc::NO_PARA_STYLE) { - if ui - .selectable_label(next.as_deref() == Some(n), crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n))) - .clicked() - { - d.fields.insert("nextStyle".into(), json!(n)); + craft_ui::ComboBox::from_id_salt("psnext") + .selected_text(crate::rtl::widget(ui, shown)) + .width(220.0) + .show_ui(ui, |ui| { + if ui.selectable_label(next.is_none(), crate::rtl::widget(ui, &same)).clicked() { + d.fields.insert("nextStyle".into(), Value::Null); } - } - }) - .response - .labelled_by(next_name.id); + for n in names.iter().filter(|n| *n != designcraft_doc::NO_PARA_STYLE) { + if ui + .selectable_label( + next.as_deref() == Some(n), + crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n)), + ) + .clicked() + { + d.fields.insert("nextStyle".into(), json!(n)); + } + } + }) + .response + .labelled_by(next_name.id); ui.end_row(); }); // Reset To Base: the style keeps nothing of its own (the edits made here stay). From f4e12a5b5048d1aac24342fb06209e86709cf27d Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 15:42:16 +0100 Subject: [PATCH 460/628] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index b0c46f5ad..4997453b4 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3588,7 +3588,7 @@ fn a_new_character_style_starts_from_the_selected_text() { assert!(d.b("apply")); let mut h = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state( |ui, app: &mut DesignApp| { - let key = egui::Id::new("new_character_style_test_fonts"); + let key = egui::Id::unique("new_character_style_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 e73209a4e59cc73716f6d128d8d4a4bf890bce9a Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 15:50:46 +0100 Subject: [PATCH 461/628] Table of Contents: every field named by its label, for screen readers Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 40 ++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 85ad65dce..773aab2c7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -6858,7 +6858,7 @@ fn toc_params(app: &DesignApp, d: &Dialog) -> Value { /// A style menu whose first choice, stored as "", is `auto` (the generated paragraph style, or /// [None] for a character style). -fn toc_style_combo(ui: &mut egui::Ui, salt: &str, styles: &[String], auto: &str, cur: &mut String) -> bool { +fn toc_style_combo(ui: &mut egui::Ui, salt: &str, styles: &[String], auto: &str, cur: &mut String, name: egui::Id) -> bool { let shown = if cur.is_empty() { auto.to_string() } else { cur.clone() }; let mut changed = false; craft_ui::ComboBox::from_id_salt(("toc_style", salt)).selected_text(shown).width(200.0).show_ui(ui, |ui| { @@ -6872,7 +6872,9 @@ fn toc_style_combo(ui: &mut egui::Ui, salt: &str, styles: &[String], auto: &str, changed = true; } } - }); + }) + .response + .labelled_by(name); changed } @@ -6891,9 +6893,9 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let mut title_style = d.s("titleStyle"); egui::Grid::new("toc_title").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { crate::rtl::label(ui, tr("Title:")); - text_field(ui, d, "title", 200.0); - crate::rtl::label(ui, tr("Style:")); - toc_style_combo(ui, "title", &styles, "TOC Title", &mut title_style); + text_field_named(ui, d, "title", 200.0, &tr("Title:")); + let name = crate::rtl::label(ui, tr("Style:")).id; + toc_style_combo(ui, "title", &styles, "TOC Title", &mut title_style, name); ui.end_row(); }); ui.add_space(8.0); @@ -6957,14 +6959,14 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { crate::rtl::label(ui, egui::RichText::new(format!("{} {}", tr("Style:"), r.style)).font(semibold(12.0))); let none = designcraft_doc::NO_CHAR_STYLE; egui::Grid::new("toc_entry").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, tr("Entry Style:")); + let name = crate::rtl::label(ui, tr("Entry Style:")).id; let auto = format!("TOC Level {}", r.level.clamp(1, 9)); - toc_style_combo(ui, "entry", &styles, &auto, &mut r.entry_style); + toc_style_combo(ui, "entry", &styles, &auto, &mut r.entry_style, name); ui.end_row(); if !expanded { return; } - crate::rtl::label(ui, tr("Page Number:")); + let page_number = crate::rtl::label(ui, tr("Page Number:")).id; let choices = [("after", "After Entry"), ("before", "Before Entry"), ("none", "No Page Number")]; let cur = choices.iter().find(|(v, _)| *v == r.page_number).map_or("After Entry", |(_, l)| l); craft_ui::ComboBox::from_id_salt("toc_page_number").selected_text(tr(cur)).width(200.0).show_ui(ui, |ui| { @@ -6973,26 +6975,30 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { r.page_number = v.into(); } } - }); - crate::rtl::label(ui, tr("Style:")); - toc_style_combo(ui, "page_number", &char_styles, none, &mut r.page_number_style); + }) + .response + .labelled_by(page_number); + let name = crate::rtl::label(ui, tr("Style:")).id; + toc_style_combo(ui, "page_number", &char_styles, none, &mut r.page_number_style, name); ui.end_row(); - crate::rtl::label(ui, tr("Between Entry and Number:")); - ui.add(egui::TextEdit::singleline(&mut r.separator).desired_width(80.0).align(egui::Align2::LEFT_TOP)); - crate::rtl::label(ui, tr("Style:")); - toc_style_combo(ui, "separator", &char_styles, none, &mut r.separator_style); + let between = crate::rtl::label(ui, tr("Between Entry and Number:")).id; + ui.add(egui::TextEdit::singleline(&mut r.separator).desired_width(80.0).align(egui::Align2::LEFT_TOP)).labelled_by(between); + let name = crate::rtl::label(ui, tr("Style:")).id; + toc_style_combo(ui, "separator", &char_styles, none, &mut r.separator_style, name); ui.end_row(); crate::rtl::label(ui, ""); ui.checkbox(&mut r.sort, tr("Sort Entries in Alphabetical Order")); ui.end_row(); - crate::rtl::label(ui, tr("Level:")); + let level = crate::rtl::label(ui, tr("Level:")).id; craft_ui::ComboBox::from_id_salt("toc_level").selected_text(r.level.to_string()).width(60.0).show_ui(ui, |ui| { for l in 1..=9u8 { if ui.selectable_label(r.level == l, l.to_string()).clicked() { r.level = l; } } - }); + }) + .response + .labelled_by(level); ui.end_row(); }); } From 559f8f590bea8bf959be6baea03195ce507c1b82 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 15:56:16 +0100 Subject: [PATCH 462/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/package_dialog.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/package_dialog.rs b/crates/ui-egui/src/package_dialog.rs index 536c5e4ac..a2eac5824 100644 --- a/crates/ui-egui/src/package_dialog.rs +++ b/crates/ui-egui/src/package_dialog.rs @@ -280,18 +280,18 @@ pub fn show(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.add_space(24.0); let name = crate::rtl::label(ui, tr("PDF Preset:")); let cur = d.s("pdfPreset"); - craft_ui::ComboBox::from_id_salt("package_pdf_preset").selected_text(crate::rtl::widget(ui, tr(&cur))).width(180.0).show_ui( - ui, - |ui| { + craft_ui::ComboBox::from_id_salt("package_pdf_preset") + .selected_text(crate::rtl::widget(ui, tr(&cur))) + .width(180.0) + .show_ui(ui, |ui| { for p in crate::dialogs::pdf_presets() { if ui.selectable_label(cur == p, crate::rtl::widget(ui, tr(p))).clicked() { d.fields.insert("pdfPreset".into(), json!(p)); } } - }, - ) - .response - .labelled_by(name.id); + }) + .response + .labelled_by(name.id); }); }); } From 7cb620608a2959687497796298348f8537a6e06a Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 16:23:18 +0100 Subject: [PATCH 463/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6c3690625..d7ded3ac6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -179,18 +179,18 @@ fn insert_pages_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { // The page, by name: At Start and At End need none. ui.add_enabled_ui(matches!(d.s("insert").as_str(), "after" | "before"), |ui| { let cur = (d.n("page").unwrap_or(0.0).max(0.0) as usize).min(pages.len().saturating_sub(1)); - craft_ui::ComboBox::from_id_salt("insert_page").selected_text(pages.get(cur).cloned().unwrap_or_default()).width(64.0).show_ui( - ui, - |ui| { + craft_ui::ComboBox::from_id_salt("insert_page") + .selected_text(pages.get(cur).cloned().unwrap_or_default()) + .width(64.0) + .show_ui(ui, |ui| { for (i, name) in pages.iter().enumerate() { if ui.selectable_label(i == cur, name).clicked() { d.fields.insert("page".into(), json!(i)); } } - }, - ) - .response - .accessible_name(crate::i18n::tr(&lang, "Page").to_owned()); + }) + .response + .accessible_name(crate::i18n::tr(&lang, "Page").to_owned()); }); }); ui.end_row(); From 7e3bca3d916ccd5af8cb0a513c575813b6393123 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 16:32:41 +0100 Subject: [PATCH 464/628] egui 0.37: the renamed APIs in this branch's code 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 d7ded3ac6..f6fc489aa 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -276,7 +276,7 @@ mod color_theme_tests { assert_eq!(app.ui.dialog.as_ref().unwrap().id, "insertPages"); let mut h = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state( |ui, app: &mut DesignApp| { - let key = egui::Id::new("insert_pages_test_fonts"); + let key = egui::Id::unique("insert_pages_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 cdb2057aab9afb9ec787373fcf6cb327de6a7fce Mon Sep 17 00:00:00 2001 From: Zinadin Zidan Date: Fri, 30 Oct 2026 21:36:32 +0600 Subject: [PATCH 465/628] Rectangle dialog: width and height in the ruler units, not always picas (#1964) A click with a frame tool opened the Rectangle dialog in picas whatever Preferences > Units & Increments or the rulers' unit menus said. It now shows the width in the horizontal ruler unit and the height in the vertical one, and reads bare numbers typed in each field the same way. The properties panel and the control channel open it the same way. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/app/commands.rs | 1 + crates/ui-egui/src/control.rs | 3 +- crates/ui-egui/src/dialogs.rs | 34 ++++++++++++++----- crates/ui-egui/src/dialogs/tests.rs | 34 +++++++++++++++++++ .../ui-egui/src/panels/properties/object.rs | 2 +- 5 files changed, 63 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 44e35ef15..bb75c102e 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -83,6 +83,7 @@ impl DesignApp { let was_find = self.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange"); match r { UiRequest::Dialog { id, params } if id == "colorTheme" => dialogs::open_color_theme(self, params), + UiRequest::Dialog { id, params } if id == "frameSize" => self.ui.dialog = Some(dialogs::frame_size_dialog(self, params)), UiRequest::Dialog { id, params } => self.ui.dialog = Some(dialogs::Dialog::new(&id, params)), UiRequest::View { params } => canvas::apply_view_request(self, ¶ms), UiRequest::Pick { purpose, params } => { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index e4c6fe723..97a99f925 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -389,7 +389,8 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest if id == "findChange" || app.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange") { app.session.reset_find_scope(); } - let d = crate::dialogs::Dialog::new(id, p.get("fields").cloned().unwrap_or(Value::Null)); + let fields = p.get("fields").cloned().unwrap_or(Value::Null); + let d = if id == "frameSize" { crate::dialogs::frame_size_dialog(app, fields) } else { crate::dialogs::Dialog::new(id, fields) }; let v = serde_json::to_value(&d).unwrap_or_default(); app.ui.dialog = Some(d); ok(v) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index bb3077103..63619da5e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -79,13 +79,6 @@ impl Dialog { if id == "ruby" { fields.entry("section".to_string()).or_insert(json!("cjk.rubyPlacement")); } - if id == "frameSize" { - for k in ["width", "height"] { - if let Some(v) = fields.get(k).and_then(Value::as_f64) { - fields.insert(k.into(), json!(format_measure(v, Unit::Picas))); - } - } - } Dialog { id: id.into(), fields, unit: Unit::Picas } } /// Set a field and report a genuine Find domain/mode transition to the caller. @@ -106,9 +99,13 @@ impl Dialog { } } fn m(&self, k: &str) -> Option { + self.m_in(k, self.unit) + } + /// A measure where a bare number is in `unit`. + fn m_in(&self, k: &str, unit: Unit) -> Option { match self.fields.get(k) { Some(Value::Number(n)) => n.as_f64(), - Some(Value::String(s)) => parse_measure(s, self.unit).ok(), + Some(Value::String(s)) => parse_measure(s, unit).ok(), _ => None, } } @@ -934,6 +931,25 @@ fn doc_unit(app: &DesignApp) -> Unit { app.session.active().map(|s| s.doc.settings.horizontal_units).unwrap_or(Unit::Picas) } +/// The document's vertical ruler unit: what a bare number means in a height field. +fn doc_vertical_unit(app: &DesignApp) -> Unit { + app.session.active().map(|s| s.doc.settings.vertical_units).unwrap_or(Unit::Picas) +} + +/// The Rectangle dialog a click with a frame tool opens: the width (points in `params`) shown in +/// the horizontal ruler unit, the height in the vertical one, as Preferences › Units & +/// Increments and the rulers' menus set them (#1964). +pub fn frame_size_dialog(app: &DesignApp, params: Value) -> Dialog { + let mut d = Dialog::new("frameSize", params); + for (k, unit) in [("width", doc_unit(app)), ("height", doc_vertical_unit(app))] { + if let Some(v) = d.fields.get(k).and_then(Value::as_f64) { + d.fields.insert(k.into(), json!(format_measure(v, unit))); + } + } + d.unit = doc_unit(app); + d +} + /// Layout › Create Guides…: guides go on the page in view (or the parent page being edited), not /// on the first page, and the gutters are typed in the document's ruler unit. pub fn open_create_guides(app: &mut DesignApp) { @@ -3277,7 +3293,7 @@ fn confirm_part1(app: &mut DesignApp, d: Dialog) -> Result { let x = d.fields.get("x").and_then(Value::as_f64).unwrap_or(0.0); let y = d.fields.get("y").and_then(Value::as_f64).unwrap_or(0.0); let w = d.m("width").unwrap_or(72.0); - let h = d.m("height").unwrap_or(72.0); + let h = d.m_in("height", doc_vertical_unit(app)).unwrap_or(72.0); app.run( "frame.create", json!({"spread": d.fields.get("spread").cloned().unwrap_or(json!(0)), "shape": d.s("shape"), "content": d.s("content"), "rect": [x, y, x + w, y + h]}), diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 84cbf3bea..0bd29e02d 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3521,3 +3521,37 @@ 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)); } + +/// The Rectangle dialog a frame-tool click opens shows the width in the horizontal ruler unit and +/// the height in the vertical one, not always picas, and reads bare numbers the same way (#1964). +#[test] +fn frame_size_dialog_follows_the_ruler_units() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("document.preferences", json!({"horizontalUnits": "inches", "verticalUnits": "millimeters"})).unwrap(); + // As the frame tool asks: a click at (36, 36), 72 pt by 72 pt. + app.session.ui_requests.push(designcraft_engine::UiRequest::Dialog { + id: "frameSize".into(), + params: json!({"spread": 0, "shape": "rectangle", "content": "graphic", "x": 36.0, "y": 36.0, "width": 72.0, "height": 72.0}), + }); + app.after_engine(); + let d = app.ui.dialog.as_ref().expect("the Rectangle dialog"); + assert_eq!((d.id.as_str(), d.unit), ("frameSize", Unit::Inches)); + assert_eq!(d.s("width"), format_measure(72.0, Unit::Inches)); + assert_eq!(d.s("height"), format_measure(72.0, Unit::Millimeters)); + assert!(!d.s("width").contains('p') && !d.s("height").contains('p'), "no picas: {} x {}", d.s("width"), d.s("height")); + // Bare numbers typed in each field are in that field's unit: 2 in by 50 mm. + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("width".into(), json!("2")); + d.fields.insert("height".into(), json!("50")); + let before = app.session.doc().unwrap().doc.clone(); + confirm(&mut app).unwrap(); + let after = &app.session.doc().unwrap().doc; + let added: Vec<_> = after.spreads[0].items.iter().filter(|i| !before.spreads[0].items.iter().any(|b| b.id == i.id)).collect(); + assert_eq!(added.len(), 1, "one frame"); + let b = added[0].bounds(); + assert!((b.width() - 144.0).abs() < 0.01 && (b.height() - 50.0 / 25.4 * 72.0).abs() < 0.01, "{b:?}"); + // The properties panel's link opens it in the same units. + let d = frame_size_dialog(&app, json!({"width": 36.0, "height": 36.0})); + assert_eq!((d.s("width"), d.s("height")), (format_measure(36.0, Unit::Inches), format_measure(36.0, Unit::Millimeters))); +} diff --git a/crates/ui-egui/src/panels/properties/object.rs b/crates/ui-egui/src/panels/properties/object.rs index 3819894fe..6b142e0d4 100644 --- a/crates/ui-egui/src/panels/properties/object.rs +++ b/crates/ui-egui/src/panels/properties/object.rs @@ -327,7 +327,7 @@ pub(super) fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) let b = block(ui, FIELD_H); icons::paint(ui.painter(), sub(b, 3.0, 1.0, 19.0, 19.0), "corner", t.accent); if place(ui, sub(b, 34.0, 0.0, 56.0, FIELD_H), |ui| link_label(ui, crate::i18n::tr(&app.ui.language, "Corner"), 56.0)).clicked() { - app.ui.dialog = Some(crate::dialogs::Dialog::new("frameSize", json!({}))); + app.ui.dialog = Some(crate::dialogs::frame_size_dialog(app, json!({}))); } let (shape, size) = i.corner; if let Some(v) = place(ui, sub(b, 96.0, 0.0, 69.0, FIELD_H), |ui| { From 93d21bc0899522bcb1f39ec9a0bcecdef51555a2 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:37:44 +0100 Subject: [PATCH 466/628] Add File > Quit to the in-window menu on Windows and Linux (#2044) --- crates/ui-egui/src/menus/tables.rs | 2 ++ crates/ui-egui/src/menus/tests.rs | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/crates/ui-egui/src/menus/tables.rs b/crates/ui-egui/src/menus/tables.rs index 4d63d485f..1fee25f3f 100644 --- a/crates/ui-egui/src/menus/tables.rs +++ b/crates/ui-egui/src/menus/tables.rs @@ -36,6 +36,8 @@ pub const MENUS: &[(&str, &[&str])] = &[ "-", "cmd:layout.documentSetup", "cmd:file.info", + "-", + "ui:app.quit", ], ), ( diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 5e82f6d3f..58251a5fe 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -2976,3 +2976,17 @@ fn forwarded_keys_run_shortcuts() { forwarded_shortcuts(&mut app, &ctx, press(Key::Delete), Modifiers::NONE); assert!(app.session.active().unwrap().doc.item(designcraft_doc::ItemId(frame["id"].as_u64().unwrap())).is_none(), "Delete clears the selection"); } + +#[test] +fn file_menu_ends_with_quit_but_the_macos_menu_bar_leaves_it_to_the_app_menu() { + let file = |tree: Vec<(&'static str, Vec)>| { + let mut out = Vec::new(); + walk(&tree.into_iter().find(|(t, _)| *t == "File").map(|(_, e)| e).unwrap_or_default(), &mut out); + out.into_iter().map(|(_, id, _)| id).collect::>() + }; + assert_eq!(file(menu_tree()).last().map(String::as_str), Some("app.quit"), "File ends with the quit item"); + assert!(!file(native_menu_tree()).iter().any(|id| id == "app.quit"), "macOS has Quit only in the app menu"); + let native = native_menu_tree(); + let file_items = native.iter().find(|(t, _)| *t == "File").map(|(_, e)| e); + assert!(!matches!(file_items.and_then(|e| e.last()), Some(Item::Sep)), "no separator left at the end of File"); +} From 69cadd9aa925e4c1ca74b64361b64432070c33e3 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:36:53 +0100 Subject: [PATCH 467/628] Spellcheck squiggles follow the text frame rotation (#2042) --- crates/ui-egui/src/canvas/text_overlay.rs | 59 +++++++++++++++++++++-- 1 file changed, 54 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/canvas/text_overlay.rs b/crates/ui-egui/src/canvas/text_overlay.rs index dc48ae720..89d0101f7 100644 --- a/crates/ui-egui/src/canvas/text_overlay.rs +++ b/crates/ui-egui/src/canvas/text_overlay.rs @@ -346,17 +346,31 @@ pub(super) fn draw_dynamic_spelling(app: &DesignApp, ctx: &egui::Context, painte }; let y = l.baseline + l.descent * 0.5; let (p0, p1) = (xf.to_screen(m * Point::new(x0, y)), xf.to_screen(m * Point::new(x1, y))); - // A zigzag 2 px high, 4 px per wave. - let n = ((p1.x - p0.x) / 2.0).max(1.0) as usize; - let pts: Vec = - (0..=n).map(|k| pos2(p0.x + (p1.x - p0.x) * k as f32 / n as f32, p0.y + if k % 2 == 0 { 0.0 } else { 2.0 })).collect(); - painter.add(egui::Shape::line(pts, red)); + // Screen direction of the text's own "down", so the wave hangs below the baseline in rotated, flipped and vertical frames. + let down = xf.to_screen(m * Point::new(x0, y + 1.0)) - p0; + painter.add(egui::Shape::line(squiggle_points(p0, p1, down), red)); } } } } } +/// Zigzag points for a spelling squiggle from `p0` to `p1` (screen space): 2 px high, 4 px per wave, +/// offset along `down` (the screen direction of the text's own "down") so it follows the text frame's +/// rotation and flips. +pub(super) fn squiggle_points(p0: Pos2, p1: Pos2, down: egui::Vec2) -> Vec { + let d = p1 - p0; + let n = (d.length() / 2.0).max(1.0) as usize; + let len = down.length(); + let off = if len > 1e-6 { down / len } else { egui::vec2(0.0, 1.0) }; + (0..=n) + .map(|k| { + let wave = if k % 2 == 0 { 0.0 } else { 2.0 }; + p0 + d * (k as f32 / n as f32) + off * wave + }) + .collect() +} + /// Plain text of the current text selection (story markers resolved to readable characters). pub fn selected_text(app: &DesignApp) -> Option { let st = app.session.active()?; @@ -368,3 +382,38 @@ pub fn selected_text(app: &DesignApp) -> Option { } Some(s.replace(designcraft_doc::story::FORCED_LINE_BREAK, "\n")) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn squiggle_horizontal_offsets_down() { + let pts = squiggle_points(pos2(0.0, 10.0), pos2(20.0, 10.0), egui::vec2(0.0, 1.0)); + assert_eq!(pts.len(), 11); + assert!((pts[0].y - 10.0).abs() < 1e-4); + assert!((pts[1].y - 12.0).abs() < 1e-4); + assert!((pts[1].x - 2.0).abs() < 1e-4); + } + + #[test] + fn squiggle_follows_rotation() { + // Frame rotated 90 degrees: the line runs down the screen and the text's "down" points left, + // so the wave must move sideways, not along y. + let pts = squiggle_points(pos2(10.0, 0.0), pos2(10.0, 20.0), egui::vec2(-3.0, 0.0)); + assert_eq!(pts.len(), 11); + assert!((pts[1].y - 2.0).abs() < 1e-4); + assert!((pts[1].x - 8.0).abs() < 1e-4); + assert!((pts[0].x - 10.0).abs() < 1e-4); + } + + #[test] + fn squiggle_follows_horizontal_flip() { + // A horizontally flipped frame runs the word right to left; the wave still hangs below it. + let pts = squiggle_points(pos2(20.0, 10.0), pos2(0.0, 10.0), egui::vec2(0.0, 1.0)); + assert_eq!(pts.len(), 11); + assert!((pts[1].x - 18.0).abs() < 1e-4); + assert!((pts[1].y - 12.0).abs() < 1e-4); + assert!(pts[10].x.abs() < 1e-4); + } +} From 759ab3ba7cce46e2b6eed40f2aa453807231a6de Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:37:32 +0100 Subject: [PATCH 468/628] Separate the CJK commands from Insert Math Expression in the Type menu (#2043) --- apps/designcraft/src/native_menu.rs | 6 ++++-- crates/ui-egui/src/menus.rs | 3 ++- crates/ui-egui/src/menus/tables.rs | 1 + crates/ui-egui/src/menus/tests.rs | 16 ++++++++++++++++ crates/ui-egui/src/menus/tree.rs | 16 ++++++++++++++-- 5 files changed, 37 insertions(+), 5 deletions(-) diff --git a/apps/designcraft/src/native_menu.rs b/apps/designcraft/src/native_menu.rs index e127776ba..ba92e0031 100644 --- a/apps/designcraft/src/native_menu.rs +++ b/apps/designcraft/src/native_menu.rs @@ -230,10 +230,12 @@ fn build( path: &str, recent: &mut Option, ) { - for e in entries { + for (i, e) in entries.iter().enumerate() { match e { Item::Sep => { - let _ = parent.append(&PredefinedMenuItem::separator()); + if menus::has_shown_item(app, &entries[i + 1..]) { + let _ = parent.append(&PredefinedMenuItem::separator()); + } } Item::Sub(label, children) => { let sub = Submenu::new(menu_title(app, label), true); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index ffcfa0988..d1e41d549 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -39,7 +39,8 @@ pub use tables::{CLASSIC_GROUPS, MENUS, TYPOGRAPHY_GROUPS, WORKSPACE_OPEN_PANELS #[cfg(test)] use tree::menu_items; pub use tree::{ - AppMenuItem, MAC_APP_MENU, OPEN_RECENT, all_menu_items, menu_key, menu_tree, native_label, native_menu_tree, recent_items, recent_label, + AppMenuItem, MAC_APP_MENU, OPEN_RECENT, all_menu_items, has_shown_item, menu_key, menu_tree, native_label, native_menu_tree, recent_items, + recent_label, }; pub use ui_commands::{CJK_ONLY, UI_COMMANDS, shown, ui_label}; diff --git a/crates/ui-egui/src/menus/tables.rs b/crates/ui-egui/src/menus/tables.rs index 4d63d485f..f3c725585 100644 --- a/crates/ui-egui/src/menus/tables.rs +++ b/crates/ui-egui/src/menus/tables.rs @@ -274,6 +274,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:type.storyDirection|Horizontal|{\"vertical\": false}", "cmd:type.storyDirection|Vertical|{\"vertical\": true}", "<", + "-", "cmd:type.tateChuYoko", "ui:app.rubyDialog", "cmd:type.kenten", diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 5e82f6d3f..8b86cf81e 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -2437,6 +2437,22 @@ fn cjk_menu_items_follow_show_cjk_features() { assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("ruby"), "hidden, not removed"); } +/// The CJK commands sit in a group of their own, after a separator, and hiding them leaves no +/// separator at the end of the menu. +#[test] +fn cjk_menu_items_have_their_own_group() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let ty = menu_tree().into_iter().find(|(m, _)| *m == "Type").unwrap().1; + let first = ty.iter().position(|i| matches!(i, Item::Cmd { id, .. } if CJK_ONLY.contains(&id.as_str()))).unwrap(); + assert!(matches!(ty[first - 1], Item::Sep), "separator before the CJK group"); + assert!(matches!(ty[first - 2], Item::Sub(..)), "the group comes after Story Direction"); + app.ui.language = String::new(); + app.run("prefs.set", json!({"cjkFeatures": false})).unwrap(); + assert!(!has_shown_item(&app, &ty[first..]), "nothing left after the separator"); + app.run("prefs.set", json!({"cjkFeatures": true})).unwrap(); + assert!(has_shown_item(&app, &ty[first..])); +} + /// Preferences › Type › Show CJK Features: left as it is, it keeps following the interface /// language; changed, it is kept in prefs.json. #[test] diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index b711b8376..6ab2e1765 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -175,13 +175,25 @@ pub fn menu_key(menu: &str, label: &str) -> String { format!("{menu}/{label}") } +/// Whether any shown item is in `rest`, the entries after a separator: the separator is drawn only then, so hidden +/// CJK commands leave no separator at the end of a menu (in the window and in the native macOS menu bar). +pub fn has_shown_item(app: &DesignApp, rest: &[Item]) -> bool { + rest.iter().any(|e| match e { + Item::Cmd { id, .. } => shown(app, id), + Item::Sep => false, + Item::Sub(..) => true, + }) +} + /// Draws a menu's items; returns how many were hidden. pub(super) fn menu_items(app: &mut DesignApp, ui: &mut egui::Ui, items: &[Item], path: &str) -> usize { let mut hidden = 0; - for it in items { + for (i, it) in items.iter().enumerate() { match it { Item::Sep => { - ui.separator(); + if has_shown_item(app, &items[i + 1..]) { + ui.separator(); + } } Item::Sub(name, children) => { let sub = format!("{path}/{name}"); From 133536f0e7d614cd576806b12fbe94ff5765462f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 16:48:55 +0100 Subject: [PATCH 469/628] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 13d7e54b3..8e52ac46c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4404,21 +4404,27 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo }; let same = crate::i18n::tr(&app.ui.language, "[Same style]").to_string(); let shown = next.as_deref().map_or(same.clone(), |n| crate::i18n::style_name(&app.ui.language, n).to_string()); - craft_ui::ComboBox::from_id_salt("psnext").selected_text(crate::rtl::widget(ui, shown)).width(220.0).show_ui(ui, |ui| { - if ui.selectable_label(next.is_none(), crate::rtl::widget(ui, &same)).clicked() { - d.fields.insert("nextStyle".into(), Value::Null); - } - for n in names.iter().filter(|n| *n != designcraft_doc::NO_PARA_STYLE) { - if ui - .selectable_label(next.as_deref() == Some(n), crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n))) - .clicked() - { - d.fields.insert("nextStyle".into(), json!(n)); + craft_ui::ComboBox::from_id_salt("psnext") + .selected_text(crate::rtl::widget(ui, shown)) + .width(220.0) + .show_ui(ui, |ui| { + if ui.selectable_label(next.is_none(), crate::rtl::widget(ui, &same)).clicked() { + d.fields.insert("nextStyle".into(), Value::Null); } - } - }) - .response - .labelled_by(next_name.id); + for n in names.iter().filter(|n| *n != designcraft_doc::NO_PARA_STYLE) { + if ui + .selectable_label( + next.as_deref() == Some(n), + crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n)), + ) + .clicked() + { + d.fields.insert("nextStyle".into(), json!(n)); + } + } + }) + .response + .labelled_by(next_name.id); ui.end_row(); }); // Reset To Base: the style keeps nothing of its own (the edits made here stay). From 4461ebd8866dd1463a8e3313172fb461f9c02853 Mon Sep 17 00:00:00 2001 From: texjer Date: Fri, 30 Oct 2026 11:50:41 -0400 Subject: [PATCH 470/628] Import warnings: Show selects the frame a story warning is about --- ROADMAP.md | 1 + crates/engine/src/cmd/file/registry.rs | 2 +- crates/engine/src/cmd/interchange.rs | 44 ++++++++++++++++++++++---- crates/idml/src/import.rs | 6 +++- crates/ui-egui/src/app/files.rs | 19 +++++++---- crates/ui-egui/src/dialogs.rs | 41 +++++++++++++++++++++++- crates/ui-egui/src/dialogs/tests.rs | 34 ++++++++++++++++++++ 7 files changed, 132 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ad3f52a14..06668b9de 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -384,6 +384,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-30** · Import warnings about a story have a Show button: it closes the warnings (the status line keeps them) and selects the story's first frame, fitted in the window. `file.open` returns `warningItems`, the frame each IDML or InDesign warning is about (or null), matching the converter's INDD story numbers (`story 30892`, written to IDML as `u78ac`) and IDML `Self` names to the imported stories. No readiness percentage change. - **2026-10-29** · Inserting text with many returns no longer takes time that grows with their square (part of #1588): each return copied its paragraph's format with its own insert, moving every format after it. Splitting 20,000 returns into paragraphs took 15.8 s; placing a text file of them now takes 0.2 s, and one of a million returns 5.5 s. No readiness percentage change. - **2026-10-28** · Discretionary hyphens (U+00AD) break in the single-line composer with automatic hyphenation on or off, matching the paragraph composer. Unbroken soft hyphens remain invisible and zero-width, and words containing them keep only their discretionary break points (#1955). No readiness percentage change. - **2026-10-28** · Autoflow no longer sizes the rest of a story by a frame that a Start Paragraph option (next column, frame, page, or odd or even page) ends early (#1209). With a title before a chapter that starts on the next odd page, it estimated a page per few characters, added up to 2000 pages (the autoflow limit) and removed them again: placing 2000 words took 36 s in the app, now 0.4 s, with the same pages. It looks past tracked-deleted paragraphs for the option, as composing does, and asks for every frame that start options and breaks force in one round: 200 one-line chapters on their own pages take 9 ms (58 ms before). A break and the start option it meets count once, and breaks that tracked changes delete or a hidden condition hides don't count. No readiness percentage change. diff --git a/crates/engine/src/cmd/file/registry.rs b/crates/engine/src/cmd/file/registry.rs index 216f12bdb..c1f522956 100644 --- a/crates/engine/src/cmd/file/registry.rs +++ b/crates/engine/src/cmd/file/registry.rs @@ -37,7 +37,7 @@ pub fn specs() -> Vec { ok() }), cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), - "{path} — a DesignCraft or IDML file (recognised by its content, so the extension may be missing), or an InDesign .indd/.indt (recognised by its signature or extension, converted to IDML; its conversion warnings join `warnings`); the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped, GREP styles whose pattern can't compile, Based on Parent links cleared and, for IDML and InDesign files, package parts missing}", + "{path} — a DesignCraft or IDML file (recognised by its content, so the extension may be missing), or an InDesign .indd/.indt (recognised by its signature or extension, converted to IDML; its conversion warnings join `warnings`); the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped, GREP styles whose pattern can't compile, Based on Parent links cleared and, for IDML and InDesign files, package parts missing; warningItems (IDML and InDesign files): per warning, the id of the frame it is about, or null}", always, file_open), cmd!(noundo "file.openBytes", "Open Bytes", [], None, "{name, base64} — DesignCraft JSON, an IDML package or an InDesign .indd/.indt → {index, warnings}", always, file_open_bytes), cmd!(noundo "file.openRecent", "Open Recent File", [], None, diff --git a/crates/engine/src/cmd/interchange.rs b/crates/engine/src/cmd/interchange.rs index 0ddb6a1aa..4ca22309c 100644 --- a/crates/engine/src/cmd/interchange.rs +++ b/crates/engine/src/cmd/interchange.rs @@ -160,7 +160,7 @@ pub fn import(bytes: &[u8], package: Option<&std::path::Path>) -> Result) -> Result<(Document, Vec)> { - import_report_with_previews(bytes, package, &std::collections::HashMap::new()) + import_report_with_previews(bytes, package, &std::collections::HashMap::new()).map(|(document, warnings, _)| (document, warnings)) } /// [`import_report`], showing `previews` (graphic `Self` → image) for linked files that can't be read. @@ -168,7 +168,7 @@ fn import_report_with_previews( bytes: &[u8], package: Option<&std::path::Path>, previews: &std::collections::HashMap>>, -) -> Result<(Document, Vec)> { +) -> Result<(Document, Vec, std::collections::HashMap)> { #[cfg(not(target_arch = "wasm32"))] let resolved = std::cell::RefCell::new(std::collections::HashMap::new()); let read = |link: &str| -> Option> { @@ -184,7 +184,7 @@ fn import_report_with_previews( None } }; - let designcraft_idml::Imported { mut document, warnings } = + let designcraft_idml::Imported { mut document, warnings, story_ids } = designcraft_idml::import_idml_report_with_previews(bytes, &read, previews).map_err(|e| EngineError::Other(e.to_string()))?; resolve_pdf_crops(&mut document); #[cfg(not(target_arch = "wasm32"))] @@ -199,7 +199,7 @@ fn import_report_with_previews( resolve_packaged_links(&mut document, package); document }; - Ok((document, warnings)) + Ok((document, warnings, story_ids)) } /// Placed PDFs read from IDML name their crop (`PDFCrop`): find where each box sits on its page, @@ -422,7 +422,7 @@ pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result { } else { bytes }; - let (mut d, import_warnings) = import_report_with_previews(&bytes, package.as_deref(), &previews)?; + let (mut d, import_warnings, story_ids) = import_report_with_previews(&bytes, package.as_deref(), &previews)?; warnings.extend(import_warnings); if !title.is_empty() { d.title = title; @@ -433,11 +433,25 @@ pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result { }; warnings.extend(font_warnings); warnings.extend(super::style::grep_style_warnings(&d)); + let warning_items: Vec = warnings.iter().map(|w| warning_item(&d, &story_ids, w).map_or(Value::Null, |i| json!(i.0))).collect(); // Never save over the .idml or .indd with the native format: the document starts unsaved. let mut st = DocState::new(d, None); st.fonts = fonts; let i = s.add_document(st); - Ok(json!({"index": i, "documentFonts": faces, "warnings": warnings})) + Ok(json!({"index": i, "documentFonts": faces, "warnings": warnings, "warningItems": warning_items})) +} + +/// The frame an import warning is about, so it can be shown: the first frame of the story a +/// "story …: …" warning names, by the converter's INDD id (`story 30892`, which it writes to the +/// IDML as `u78ac`) or by its IDML `Self` (`story u78ac`). +fn warning_item( + d: &Document, + story_ids: &std::collections::HashMap, + warning: &str, +) -> Option { + let name = warning.strip_prefix("story ")?.split([':', ' ']).next()?; + let self_id = name.parse::().map_or_else(|_| name.to_string(), |uid| format!("u{uid:x}")); + d.stories.get(story_ids.get(&self_id)?)?.frames.first().copied() } /// An InDesign document or template (.indd, .indt): known by its signature, or by its name when @@ -479,6 +493,24 @@ mod tests { Ok((bytes, png)) } + #[test] + fn story_warnings_point_at_the_story_frame() { + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + session.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap(); + let idml = designcraft_idml::export_idml_with(&session.doc().unwrap().doc, &designcraft_idml::ExportOptions { embed_images: true }); + let (d, _, story_ids) = import_report_with_previews(&idml, None, &std::collections::HashMap::new()).unwrap(); + let (self_id, sid) = story_ids.iter().find(|(_, sid)| d.stories.get(*sid).is_some_and(|s| !s.frames.is_empty())).unwrap(); + let frame = d.stories[sid].frames[0]; + assert_eq!(warning_item(&d, &story_ids, &format!("story {self_id}: something")), Some(frame)); + // The INDD converter names stories by their number, which it writes as `u` + hex. + if let Some(uid) = self_id.strip_prefix('u').and_then(|h| u32::from_str_radix(h, 16).ok()) { + assert_eq!(warning_item(&d, &story_ids, &format!("story {uid}: text orientation of its frames is not known")), Some(frame)); + } + assert_eq!(warning_item(&d, &story_ids, "story 999999999: unknown"), None); + assert_eq!(warning_item(&d, &story_ids, "a table of 2 rows was read as 1"), None); + } + #[test] fn opens_nested_packaged_links_from_a_different_computer() { let dir = std::env::temp_dir().join(format!("dc-idml-nested-links-{}", std::process::id())); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 2c6185c5c..51cda9c5b 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -60,6 +60,9 @@ pub struct Imported { /// One line per problem that did not stop the import (a part listed in `designmap.xml` but /// absent from the package, …). pub warnings: Vec, + /// Each story's IDML `Self` → its id in `document`, so a warning that names a story can + /// point at it. + pub story_ids: HashMap, } /// Import with a custom reader for linked files, reporting the problems that did not stop it. @@ -119,6 +122,7 @@ pub fn import_idml_report_with_previews( )); } warnings.append(&mut im.warnings); + let story_ids = im.story_ids.clone(); let mut document = im.finish(&root).map_err(|e| match e { // Without its spread parts there is nothing to open: say which parts were missing. IdmlError::Invalid(msg) if !warnings.is_empty() => IdmlError::Invalid(format!("{msg} ({})", warnings.join("; "))), @@ -126,7 +130,7 @@ pub fn import_idml_report_with_previews( })?; warnings.extend(document.fix_parent_bases()); document.check().map_err(|e| IdmlError::Invalid(e.to_string()))?; - Ok(Imported { document, warnings }) + Ok(Imported { document, warnings, story_ids }) } struct ItemCtx { diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 49d2bd757..b5de53ab1 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -258,21 +258,27 @@ impl DesignApp { } self.ui.dialog = Some(dialogs::Dialog::new("alert", json!({"title": title, "file": file, "message": e}))); } else if let Ok(result) = &r { - let warnings: Vec<&str> = result + // Each warning with the frame it is about (`warningItems`), if any. + let items = result.get("warningItems").and_then(Value::as_array); + let (warnings, lines): (Vec<&str>, Vec) = result .get("warnings") .and_then(Value::as_array) .into_iter() .flatten() - .filter_map(Value::as_str) - .filter(|warning| !warning.is_empty()) - .collect(); + .enumerate() + .filter_map(|(i, w)| Some((i, w.as_str()?))) + .filter(|(_, warning)| !warning.is_empty()) + .map(|(i, w)| (w, json!({"text": w, "item": items.and_then(|a| a.get(i)).cloned().unwrap_or(Value::Null)}))) + .unzip(); if !warnings.is_empty() { self.status(format!("{file}: {}", warnings.join("; "))); // Find Font, offered for the document's missing fonts, stays up: the status line // keeps the warnings. if self.ui.dialog.as_ref().is_none_or(|d| d.id != "findFont") { - self.ui.dialog = - Some(dialogs::Dialog::new("alert", json!({"title": "Import Warnings", "file": file, "message": warnings.join("\n")}))); + self.ui.dialog = Some(dialogs::Dialog::new( + "alert", + json!({"title": "Import Warnings", "file": file, "message": warnings.join("\n"), "lines": lines}), + )); } } } @@ -326,6 +332,7 @@ mod import_warning_tests { assert_eq!(dialog.id, "alert"); assert_eq!(dialog.fields["title"], "Import Warnings"); assert_eq!(dialog.fields["file"], file); + assert!(dialog.fields["lines"].as_array().unwrap().iter().all(|l| l["item"].is_null()), "missing parts name no frame"); for part in ["Spreads/missing.xml", "Stories/missing.xml"] { assert!(dialog.fields["message"].as_str().unwrap().contains(part), "every missing part is named"); assert!(app.ui.status.contains(part), "the status line retains the warning"); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index bb3077103..0a60ef18f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2187,6 +2187,36 @@ fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { } /// Draw the active localized dialog; open popups own Enter/Escape, and result handling drives confirm or cancel. +/// An alert's message. Import warnings (`lines`: `{text, item}`) list one per line, scrolling +/// when there are many, with Show beside each that names a frame (sets `show`). +fn alert_message(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let lines: Vec<(String, Option)> = d + .fields + .get("lines") + .and_then(Value::as_array) + .into_iter() + .flatten() + .map(|l| (l.get("text").and_then(Value::as_str).unwrap_or_default().to_string(), l.get("item").and_then(Value::as_u64))) + .collect(); + if lines.iter().all(|(_, item)| item.is_none()) { + crate::rtl::label(ui, d.s("message")); + return; + } + let show = crate::i18n::tr(&app.ui.language, "Show"); + egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, true]).show(ui, |ui| { + for (text, item) in &lines { + ui.horizontal_wrapped(|ui| { + if let Some(item) = item + && ui.small_button(crate::rtl::widget(ui, show)).clicked() + { + d.fields.insert("show".into(), json!(item)); + } + crate::rtl::label(ui, text.as_str()); + }); + } + }); +} + pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let shortcuts = app.ui.dialog.as_ref().is_some_and(|d| d.id == crate::shortcuts_dialog::DIALOG_ID); if shortcuts { @@ -2999,7 +3029,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { crate::rtl::label(ui, egui::RichText::new(crate::rtl::isolate(&file)).font(semibold(13.0))); ui.add_space(4.0); } - crate::rtl::label(ui, d.s("message")); + alert_message(app, ui, &mut d); } _ => {} } @@ -3058,6 +3088,15 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }); }); }); + // Show on an import warning: close the warnings (the status line keeps them) and select the + // frame the warning is about, in view, so it can be fixed. + if let Some(item) = d.fields.remove("show").and_then(|v| v.as_u64()) { + app.ui.dialog = None; + if app.run("selection.set", json!({"ids": [item]})).is_ok() { + let _ = app.run("view.fitSelection", json!({})); + } + return; + } // Preferences › General › Interface Language takes effect at once, like any other app setting. if let Some(lang) = d.fields.remove("applyLanguage") && let Err(e) = app.run("app.language", json!({"lang": lang})) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 84cbf3bea..61aeea6a7 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3521,3 +3521,37 @@ 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)); } + +#[test] +fn import_warning_show_selects_the_frame_it_is_about() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap(); + app.run("selection.set", json!({"ids": []})).unwrap(); + let lines = json!([ + {"text": "a table of 2 rows was read as 1", "item": null}, + {"text": "story 30892: text orientation of its frames is not known or differs; written as horizontal", "item": f["id"]}, + ]); + app.ui.dialog = Some(Dialog::new("alert", json!({"title": "Import Warnings", "file": "book.indd", "message": "", "lines": lines}))); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 800.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::unique("import_warning_show_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.ctx()); + }, + app, + ); + h.run_steps(3); + // Only the warning that names a frame offers Show. + assert_eq!(h.query_all_by_label("Show").count(), 1); + h.get_by_label("Show").click(); + h.run_steps(3); + let app = h.state(); + assert!(app.ui.dialog.is_none(), "Show closes the warnings"); + assert_eq!(app.session.doc().unwrap().selection.items, vec![designcraft_doc::ItemId(f["id"].as_u64().unwrap())]); +} From fc46bf20b399696f69dc80f7e1a550d8134139b8 Mon Sep 17 00:00:00 2001 From: texjer Date: Fri, 30 Oct 2026 11:50:41 -0400 Subject: [PATCH 471/628] macOS: Finder opens .indd and .indt files with DesignCraft --- packaging/macos/Info.plist.in | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index cf80fbba8..03d7cb8e4 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -106,6 +106,18 @@ CFBundleTypeExtensions idml + + + CFBundleTypeName + InDesign Document + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + CFBundleTypeExtensions + inddindt +