From c20635aacfcbb9c628630a065e5b8e7917aede30 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 12:22:11 +0200 Subject: [PATCH 001/431] 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/431] 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/431] 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/431] 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/431] 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/431] 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/431] 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/431] 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/431] 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/431] 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/431] 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 d98b2cce01309536fcaed6ece2c6b2eedccb5818 Mon Sep 17 00:00:00 2001 From: Aykut Firat Date: Sat, 17 Oct 2026 15:32:00 -0400 Subject: [PATCH 012/431] IDML: TOC styles read InDesign's $ID/-prefixed separators and Properties-child entry styles InDesign writes a TOC style entry's separator with the built-in prefix (`Separator="$ID/^y"`, the right indent tab) and its FormatStyle / PageNumberStyle / SeparatorStyle as `` children, not attributes. The importer kept the prefix as text (entries read "Welcome$ID/3") and never found the entry styles, so a regenerated TOC lost its entry paragraph style and page-number character style. - names::toc_separator_in strips `$ID/` before decoding. - toc_of reads FormatStyle, PageNumberStyle and SeparatorStyle with `prop` (attribute or Properties child). - tests: a TOC style written as InDesign writes it imports its separator, entry style, page-number style and title style. Co-Authored-By: Claude Opus 5.5 --- crates/idml/src/import.rs | 7 ++++--- crates/idml/src/names.rs | 2 ++ crates/idml/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 5cd3746a9..cc438de6a 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1506,9 +1506,10 @@ impl<'r> Importer<'r> { _ => TocPageNumber::After, }; let separator = e.get("Separator").map(crate::names::toc_separator_in).filter(|s| s != "\t" && s.chars().count() <= 64); - let entry_style = known(e.get("FormatStyle")).filter(|n| *n != format!("TOC Level {level}")); - let page_number_style = known_chars(e.get("PageNumberStyle")); - let separator_style = known_chars(e.get("SeparatorStyle")); + // InDesign writes these as `Properties` children; attributes are read too. + let entry_style = known(e.prop("FormatStyle").as_deref()).filter(|n| *n != format!("TOC Level {level}")); + let page_number_style = known_chars(e.prop("PageNumberStyle").as_deref()); + let separator_style = known_chars(e.prop("SeparatorStyle").as_deref()); let sort = e.get("Sort").is_some_and(|v| v.trim() == "true"); Some(TocEntry { style, level, entry_style, page_number, separator, page_number_style, separator_style, sort }) }) diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs index 70d02e7c0..c242a1626 100644 --- a/crates/idml/src/names.rs +++ b/crates/idml/src/names.rs @@ -843,6 +843,8 @@ pub fn toc_separator_out(s: &str) -> String { } /// IDML TOC separator → text (`^t` and `^y`, the right indent tab, are tabs; other codes stay). pub fn toc_separator_in(s: &str) -> String { + // InDesign writes its built-in values with the `$ID/` prefix (`$ID/^y`, the right indent tab). + let s = s.strip_prefix("$ID/").unwrap_or(s); let mut out = String::new(); let mut it = s.chars(); while let Some(c) = it.next() { diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 3f5b7977d..785bf458f 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -4189,3 +4189,35 @@ mod liquid_page_rules { } } } + +/// A TOC style as InDesign writes it: `$ID/`-prefixed separator (`^y`, the right indent tab) and entry style. +const TOC_STYLES: &str = r#" + + + + + + + + + + + ParagraphStyle/$ID/TOC%3aTOC body text + CharacterStyle/TOC Page Number + $ID/[Same Style] + +"#; + +const TOC_STORY: &str = r#" +Welcome"#; + +#[test] +fn toc_style_entries_read_id_prefixed_separators_and_styles() { + let d = import_idml(&fixture_with_table_styles(TOC_STYLES, TOC_STORY)).unwrap(); + let toc = d.toc.as_ref().expect("the story's TOC style is imported"); + let entry = toc.entries.iter().find(|e| e.style == "Heading 1").unwrap(); + assert_eq!(entry.separator, None, "`$ID/^y` is the right indent tab, the default separator, not text: {:?}", entry.separator); + assert_eq!(entry.entry_style.as_deref(), Some("TOC/TOC body text")); + assert_eq!(entry.page_number_style.as_deref(), Some("TOC Page Number")); + assert_eq!(toc.title_style.as_deref(), Some("TOC/TOC Header")); +} From 71869ac3c7500dfcdaa0d1c064d2efb3f79dd099 Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:40:37 +0200 Subject: [PATCH 013/431] UI.NewDocument: delete saved presets from their cards Show the existing trash icon on selected or hovered custom Saved cards and route it through file.deletePreset. Keep the factory card protected, prevent the action from selecting or creating a document, and retain the configured preview. Cover both selected and hovered-card clicks in the whole-window regression. Co-Authored-By: GPT-6 Astra --- crates/ui-egui/src/new_document.rs | 64 ++++++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/new_document.rs b/crates/ui-egui/src/new_document.rs index ad415c83d..3830b1244 100644 --- a/crates/ui-egui/src/new_document.rs +++ b/crates/ui-egui/src/new_document.rs @@ -396,7 +396,7 @@ fn card(ui: &mut Ui, c: &Card, selected: bool) -> egui::Response { let (r, resp) = ui.allocate_exact_size(CARD, Sense::click()); if selected { ui.painter().rect(r.shrink(1.0), 3.0, t.hover, Stroke::new(2.0, t.accent), StrokeKind::Inside); - } else if resp.hovered() { + } else if ui.rect_contains_pointer(r) { ui.painter().rect_filled(r, 3.0, t.hover); } let page = Rect::from_center_size(pos2(r.center().x, r.top() + 56.0), vec2(72.0, 72.0)); @@ -501,13 +501,20 @@ fn presets(app: &mut DesignApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.spacing_mut().item_spacing = vec2(10.0, 10.0); for c in &list { let resp = card(ui, c, current == c.key); - if resp.clicked() || resp.double_clicked() { - select(d, c); - } - if resp.double_clicked() { - create = true; - } + let mut remove = false; if tab == "saved" && c.key != DEFAULT_CARD { + 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 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() { + ui.painter().rect_filled(rect, 2.0, t.hover); + } + crate::icons::paint(ui.painter(), rect.shrink(3.0), "trash", t.icon); + remove = button.on_hover_text(label).clicked(); + } resp.context_menu(|ui| { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&lang, "Delete Preset"))).clicked() { delete = Some(c.nd.title.clone()); @@ -515,6 +522,12 @@ fn presets(app: &mut DesignApp, ui: &mut Ui, d: &mut Dialog) -> bool { } }); } + if remove { + delete = Some(c.nd.title.clone()); + } else if resp.clicked() || resp.double_clicked() { + select(d, c); + create |= resp.double_clicked(); + } } }); if let Some(name) = delete @@ -1085,6 +1098,43 @@ mod tests { 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 escape_in_the_preset_popup_closes_only_the_popup() { let mut h = window_with_preset_popup(); From 924d9c1b73489a7ec135d3661ef18fec780387c0 Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:24:56 +0200 Subject: [PATCH 014/431] TOC-2: generate and refresh nested PDF bookmarks --- ROADMAP.md | 2 + crates/doc/src/lib.rs | 6 +++ crates/engine/src/cmd/interactive.rs | 2 +- crates/engine/src/cmd/style.rs | 1 + crates/engine/src/cmd/toc.rs | 65 +++++++++++++++++++++++++++- crates/engine/src/tests_idml.rs | 3 +- crates/idml/src/export.rs | 2 +- crates/idml/src/import.rs | 3 +- crates/ui-egui/src/dialogs.rs | 33 +++++++++++++- docs/agents.md | 8 ++++ 10 files changed, 118 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9bd0274ef..71aa1b62b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -346,6 +346,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **2026-10-18** · Table of Contents gains Create PDF Bookmarks: included headings become nested page destinations, regenerated on update without replacing manual bookmarks. The setting survives native and IDML files. No readiness percentage change. + Newest first. - **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change. diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 1bf834ae0..00c679ef9 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -793,6 +793,9 @@ pub struct Toc { /// Paragraph style of the title; `None` is the generated "TOC Title" style. #[serde(default, skip_serializing_if = "Option::is_none")] pub title_style: Option, + /// Generate a nested PDF outline from the included paragraphs when the TOC is updated. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub create_bookmarks: bool, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -803,6 +806,9 @@ pub struct Bookmark { pub page: usize, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub children: Vec, + /// Owned by the document's generated TOC, so updating it preserves manual bookmarks. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub generated_by_toc: bool, } impl Document { diff --git a/crates/engine/src/cmd/interactive.rs b/crates/engine/src/cmd/interactive.rs index 32562737c..2d918a01f 100644 --- a/crates/engine/src/cmd/interactive.rs +++ b/crates/engine/src/cmd/interactive.rs @@ -73,7 +73,7 @@ pub fn specs() -> Vec { let name = str_param(p, "name").map(str::to_string); s.edit(|d, _| { let name = name.clone().unwrap_or_else(|| format!("Bookmark {}", d.bookmarks.len() + 1)); - d.bookmarks.push(Bookmark { name, page, children: vec![] }); + d.bookmarks.push(Bookmark { name, page, children: vec![], generated_by_toc: false }); Ok(json!({"index": d.bookmarks.len() - 1})) }) }), diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index d1bd06ad0..642992e76 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -2987,6 +2987,7 @@ mod delete_tests { entries: vec![TocEntry { entry_style: Some("Table Body".into()), ..TocEntry::new("Table Body", 1) }], page_numbers: false, title_style: Some("Table Body".into()), + create_bookmarks: false, }); Ok(()) }) diff --git a/crates/engine/src/cmd/toc.rs b/crates/engine/src/cmd/toc.rs index 23f5847a4..0515df478 100644 --- a/crates/engine/src/cmd/toc.rs +++ b/crates/engine/src/cmd/toc.rs @@ -19,7 +19,7 @@ pub fn specs() -> Vec { "Table of Contents…", ["Layout"], None, - "{entries: [{style, level?: 1 (1-9), entryStyle?: paragraph style of its entries (default \"TOC Level n\"), pageNumber?: \"after\"|\"before\"|\"none\", separator?: text between entry and number (default a tab), pageNumberStyle?: character style of the page number, separatorStyle?: character style of the separator, sort?: bool (Sort Entries in Alphabetical Order: among their siblings, each with the entries under it)}] | styles: [names] (levels 1, 2, …), title?: \"Contents\", titleStyle?: paragraph style of the title (default \"TOC Title\"), pageNumbers?: true, replace?: true (regenerate the existing table of contents in place; false makes a new frame), page?: 1-based page for a new frame (its margins), rect?: [x0,y0,x1,y1] (spread coords)} → {story, entries}", + "{entries: [{style, level?: 1 (1-9), entryStyle?: paragraph style of its entries (default \"TOC Level n\"), pageNumber?: \"after\"|\"before\"|\"none\", separator?: text between entry and number (default a tab), pageNumberStyle?: character style of the page number, separatorStyle?: character style of the separator, sort?: bool (Sort Entries in Alphabetical Order: among their siblings, each with the entries under it)}] | styles: [names] (levels 1, 2, …), title?: \"Contents\", titleStyle?: paragraph style of the title (default \"TOC Title\"), pageNumbers?: true, createBookmarks?: bool (generate PDF bookmarks; keeps the existing setting when omitted), replace?: true (regenerate the existing table of contents in place; false makes a new frame), page?: 1-based page for a new frame (its margins), rect?: [x0,y0,x1,y1] (spread coords)} → {story, entries}", has_doc, generate ), @@ -302,9 +302,27 @@ fn fill(d: &mut Document, toc: &Toc, width: Option) -> Result { st.format_paras(r.start..r.start, |f| *f = ParaFormat { style: name.clone(), ..Default::default() }); } } + d.bookmarks.retain(|b| !b.generated_by_toc); + if toc.create_bookmarks { + d.bookmarks.extend(bookmarks(&found)); + } Ok(found.len()) } +/// Levels are validated to 1–9. Recursion follows strictly increasing levels, including when +/// a document skips a level; each destination is the physical page, not its section label. +fn bookmarks(found: &[Found]) -> Vec { + fn children(iter: &mut std::iter::Peekable>, parent: u8) -> Vec { + let mut out = Vec::new(); + while iter.peek().is_some_and(|f| f.level > parent) { + let Some(f) = iter.next() else { break }; + out.push(designcraft_doc::Bookmark { name: f.text.clone(), page: f.key.0, children: children(iter, f.level), generated_by_toc: true }); + } + out + } + children(&mut found.iter().peekable(), 0) +} + fn generate(s: &mut Session, p: &Value) -> Result { let entries = entries_param(p)?; if entries.is_empty() { @@ -320,6 +338,10 @@ fn generate(s: &mut Session, p: &Value) -> Result { return Err(bad("toc.generate", format!("style names are at most {MAX_NAME} characters"))); } let page_numbers = p.get("pageNumbers").and_then(Value::as_bool).unwrap_or(true); + let create_bookmarks = match p.get("createBookmarks") { + Some(v) => v.as_bool().ok_or_else(|| bad("toc.generate", "createBookmarks must be a boolean"))?, + None => s.doc()?.doc.toc.as_ref().is_some_and(|t| t.create_bookmarks), + }; let replace = p.get("replace").and_then(Value::as_bool).unwrap_or(true); let rect = super::rect_param(p, "rect"); if rect.is_some_and(|r| ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) || r.width() <= 0.0 || r.height() <= 0.0) { @@ -345,7 +367,7 @@ fn generate(s: &mut Session, p: &Value) -> Result { (sid, w) } }; - let toc = Toc { story, title: title.clone(), entries: entries.clone(), page_numbers, title_style: title_style.clone() }; + let toc = Toc { story, title: title.clone(), entries: entries.clone(), page_numbers, title_style: title_style.clone(), create_bookmarks }; let n = fill(d, &toc, width)?; d.toc = Some(toc); Ok(json!({"story": story.0, "entries": n})) @@ -623,6 +645,45 @@ mod tests { assert!(toc_runs(&s).iter().any(|(t, st)| t == "1" && st == "Folio"), "{:?}", toc_runs(&s)); } + #[test] + fn toc_bookmarks_refresh_nested_page_destinations_and_preserve_manual_bookmarks() { + let (mut s, page) = doc_with(&[("First", "Head"), ("Child", "Sub"), ("Last", "Head")]); + s.execute("layout.section", &json!({"page": 1, "style": "lowerRoman", "startNumber": 4})).unwrap(); + s.execute("bookmark.add", &json!({"name": "Manual", "page": page})).unwrap(); + let p = json!({"page": page, "createBookmarks": true, "entries": [ + {"style": "Head", "level": 1}, {"style": "Sub", "level": 3} + ]}); + s.execute("toc.generate", &p).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.bookmarks.iter().map(|b| b.name.as_str()).collect::>(), ["Manual", "First", "Last"]); + assert_eq!(d.bookmarks[1].page, 0, "physical page, although its label is iv"); + assert_eq!(d.bookmarks[1].children[0].name, "Child"); + assert!(d.bookmarks[1].children[0].generated_by_toc); + let sid = d.stories.iter().find(|(_, st)| st.text.starts_with("First")).map(|(sid, _)| *sid).unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 5})).unwrap(); + s.execute("text.insert", &json!({"text": "Changed"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.bookmarks[1].name, "First", "refresh is explicit"); + s.execute("toc.update", &json!({})).unwrap(); + let refreshed = s.doc().unwrap().doc.bookmarks.clone(); + assert_eq!(refreshed[1].name, "Changed"); + s.execute("toc.update", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.bookmarks, refreshed, "updates do not duplicate bookmarks"); + // Native storage retains both the option and ownership; old bookmarks default to manual. + let stored = serde_json::to_value(&s.doc().unwrap().doc).unwrap(); + let back: Document = serde_json::from_value(stored).unwrap(); + assert!(back.toc.as_ref().unwrap().create_bookmarks); + assert_eq!(back.bookmarks, refreshed); + let old: designcraft_doc::Bookmark = serde_json::from_value(json!({"name": "Old", "page": 0})).unwrap(); + assert!(!old.generated_by_toc); + let mut disabled = p; + disabled["createBookmarks"] = json!(false); + s.execute("toc.generate", &disabled).unwrap(); + assert_eq!(s.doc().unwrap().doc.bookmarks.len(), 1); + assert_eq!(s.doc().unwrap().doc.bookmarks[0].name, "Manual"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.bookmarks, refreshed); + } + #[test] fn toc_entry_options_round_trip_through_the_native_format() { let mut e = TocEntry::new("Head", 2); diff --git a/crates/engine/src/tests_idml.rs b/crates/engine/src/tests_idml.rs index 0701052d0..7afb2acd5 100644 --- a/crates/engine/src/tests_idml.rs +++ b/crates/engine/src/tests_idml.rs @@ -509,7 +509,7 @@ fn table_of_contents_definition_round_trips_through_idml_and_updates() { let basic = designcraft_doc::story::BASIC_PARAGRAPH; s.execute( "toc.generate", - &json!({"page": page, "title": "Inside", "titleStyle": basic, "entries": [ + &json!({"page": page, "title": "Inside", "titleStyle": basic, "createBookmarks": true, "entries": [ {"style": "Cover Title", "level": 1, "pageNumber": "before", "separator": " ^ "}, {"style": "Headline", "level": 2, "entryStyle": basic, "pageNumber": "none"}, ]}), @@ -524,6 +524,7 @@ fn table_of_contents_definition_round_trips_through_idml_and_updates() { let b64 = s.execute("file.exportIdml", &json!({})).unwrap()["base64"].as_str().unwrap().to_string(); s.execute("file.openIdml", &json!({"base64": b64, "name": "toc.idml"})).unwrap(); let after = s.doc().unwrap().doc.toc.clone().expect("the TOC definition is imported"); + assert!(after.create_bookmarks); assert_eq!((&after.title, &after.title_style, &after.entries), (&before.title, &before.title_style, &before.entries)); assert_eq!(toc_text(&s), text); s.execute("toc.update", &json!({})).unwrap(); diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index a773ec9e5..180f25305 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1111,7 +1111,7 @@ impl<'a> Ex<'a> { .attr("RunIn", "false") .attr("IncludeHidden", "false") .attr("IncludeBookDocuments", "false") - .attr("CreatePdfBookmarks", "false"); + .attr("CreateBookmarks", if toc.create_bookmarks { "true" } else { "false" }); for e in &toc.entries { let format = e.entry_style.clone().unwrap_or_else(|| format!("TOC Level {}", e.level)); let position = match e.page_number { diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 9a8985348..92e6b250b 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1519,7 +1519,8 @@ impl<'r> Importer<'r> { } let title: String = ts.get("Title").unwrap_or("").chars().filter(|c| !matches!(c, '\n' | '\r' | '\u{2029}')).take(1024).collect(); let title_style = known(ts.get("TitleStyle")).filter(|n| n != "TOC Title"); - Some(designcraft_doc::Toc { story, title, entries, page_numbers: true, title_style }) + let create_bookmarks = ts.get("CreateBookmarks").or_else(|| ts.get("CreatePdfBookmarks")) == Some("true"); + Some(designcraft_doc::Toc { story, title, entries, page_numbers: true, title_style, create_bookmarks }) } fn para_style_ref(&self, r: &str) -> String { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 358416a4b..2a5cd7b24 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -6261,6 +6261,7 @@ pub fn open_toc(app: &mut DesignApp) { "charStyles": char_styles, "hasToc": toc.is_some(), "replace": toc.is_some(), + "createBookmarks": toc.is_none_or(|t| t.create_bookmarks), }); app.ui.dialog = Some(Dialog::new(TOC, fields)); } @@ -6289,7 +6290,7 @@ fn toc_params(app: &DesignApp, d: &Dialog) -> Value { e }) .collect(); - let mut p = json!({"title": d.s("title"), "entries": entries, "replace": d.b("replace")}); + let mut p = json!({"title": d.s("title"), "entries": entries, "replace": d.b("replace"), "createBookmarks": d.b("createBookmarks")}); let title_style = d.s("titleStyle"); if !title_style.is_empty() { p["titleStyle"] = json!(title_style); @@ -6427,6 +6428,7 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } ui.add_space(8.0); crate::rtl::label(ui, egui::RichText::new(tr("Options")).font(semibold(12.0))); + check(ui, d, "createBookmarks", &tr("Create PDF Bookmarks")); let has_toc = d.b("hasToc"); ui.add_enabled_ui(has_toc, |ui| check(ui, d, "replace", &tr("Replace Existing Table of Contents"))); if let Some(st) = d.fields.get("status").and_then(Value::as_str) { @@ -7957,6 +7959,35 @@ mod tests { assert_eq!(opts(&app), before); } + #[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 toc_dialog_adds_a_style_and_ok_makes_the_table_of_contents() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/docs/agents.md b/docs/agents.md index ed0d3bbd8..2358235ec 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -54,6 +54,14 @@ same references, and MCP's `batch` tool takes `commands` or `script` text with t `.epub`, `.html`, and `.rtf` or `.txt` (the story being edited or of the selected frame). A name with no extension is a PNG of the page; another extension is an error, and nothing runs. +## Table of contents bookmarks + +`toc.generate` accepts `createBookmarks: true` to generate page-level PDF bookmarks from the +included paragraph styles and their levels. `toc.update` refreshes their titles, hierarchy and +destinations. Manual bookmarks stay intact; disabling the option removes only the generated +bookmarks. Omitting the parameter keeps the existing TOC setting (false for a new command-created +TOC). The Table of Contents dialog exposes Create PDF Bookmarks and defaults it on for a new TOC. + ## Color themes `color.extractTheme {id?}` previews up to five RGB colours from a placed raster image (or the selected image). From ec179d5e69667f283037a766377e7e308f7e47ef Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:59:55 +0200 Subject: [PATCH 015/431] TOC-2: preserve manual bookmark ordering on refresh --- ROADMAP.md | 2 +- crates/engine/src/cmd/interactive.rs | 1 + crates/engine/src/cmd/style.rs | 1 + crates/engine/src/cmd/toc.rs | 13 +++++++++---- crates/engine/src/tests_idml.rs | 1 + crates/ui-egui/src/dialogs.rs | 1 + docs/agents.md | 3 ++- 7 files changed, 16 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 71aa1b62b..255c941d7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -346,7 +346,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log -- **2026-10-18** · Table of Contents gains Create PDF Bookmarks: included headings become nested page destinations, regenerated on update without replacing manual bookmarks. The setting survives native and IDML files. No readiness percentage change. +- **2026-10-18** · Table of Contents gains Create PDF Bookmarks: included headings become nested page destinations, regenerated in their existing outline position on update without replacing or moving surrounding manual bookmarks. The setting survives native and IDML files. No readiness percentage change. Newest first. diff --git a/crates/engine/src/cmd/interactive.rs b/crates/engine/src/cmd/interactive.rs index 2d918a01f..c49425d7a 100644 --- a/crates/engine/src/cmd/interactive.rs +++ b/crates/engine/src/cmd/interactive.rs @@ -6,6 +6,7 @@ use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, str_param}; use crate::{Result, Session}; +/// Register commands for editing hyperlinks and manual bookmarks. pub fn specs() -> Vec { vec![ cmd!( diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 642992e76..5e22142cb 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -2967,6 +2967,7 @@ mod delete_tests { assert_eq!(s.doc().unwrap().history.undo.len(), undo); } + /// Deleting a paragraph style also redirects TOC style references to its replacement. #[test] fn deleting_a_paragraph_style_points_every_use_at_the_replacement() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/toc.rs b/crates/engine/src/cmd/toc.rs index 0515df478..d3697c677 100644 --- a/crates/engine/src/cmd/toc.rs +++ b/crates/engine/src/cmd/toc.rs @@ -12,6 +12,7 @@ use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, str_param}; use crate::{Result, Session}; +/// Register TOC generation and refresh commands. pub fn specs() -> Vec { vec![ cmd!( @@ -302,9 +303,10 @@ fn fill(d: &mut Document, toc: &Toc, width: Option) -> Result { st.format_paras(r.start..r.start, |f| *f = ParaFormat { style: name.clone(), ..Default::default() }); } } + let insertion = d.bookmarks.iter().position(|b| b.generated_by_toc).unwrap_or(d.bookmarks.len()); d.bookmarks.retain(|b| !b.generated_by_toc); if toc.create_bookmarks { - d.bookmarks.extend(bookmarks(&found)); + d.bookmarks.splice(insertion..insertion, bookmarks(&found)); } Ok(found.len()) } @@ -312,6 +314,7 @@ fn fill(d: &mut Document, toc: &Toc, width: Option) -> Result { /// Levels are validated to 1–9. Recursion follows strictly increasing levels, including when /// a document skips a level; each destination is the physical page, not its section label. fn bookmarks(found: &[Found]) -> Vec { + /// Collect descendants until the next sibling or ancestor entry. fn children(iter: &mut std::iter::Peekable>, parent: u8) -> Vec { let mut out = Vec::new(); while iter.peek().is_some_and(|f| f.level > parent) { @@ -323,6 +326,7 @@ fn bookmarks(found: &[Found]) -> Vec { children(&mut found.iter().peekable(), 0) } +/// Validate the TOC settings and generate its frame, text, and outline in one edit. fn generate(s: &mut Session, p: &Value) -> Result { let entries = entries_param(p)?; if entries.is_empty() { @@ -645,6 +649,7 @@ mod tests { assert!(toc_runs(&s).iter().any(|(t, st)| t == "1" && st == "Folio"), "{:?}", toc_runs(&s)); } + /// Refresh preserves manual outline positions, nested destinations, storage, and undo. #[test] fn toc_bookmarks_refresh_nested_page_destinations_and_preserve_manual_bookmarks() { let (mut s, page) = doc_with(&[("First", "Head"), ("Child", "Sub"), ("Last", "Head")]); @@ -660,12 +665,13 @@ mod tests { assert_eq!(d.bookmarks[1].children[0].name, "Child"); assert!(d.bookmarks[1].children[0].generated_by_toc); let sid = d.stories.iter().find(|(_, st)| st.text.starts_with("First")).map(|(sid, _)| *sid).unwrap(); + s.execute("bookmark.add", &json!({"name": "Manual after", "page": page})).unwrap(); s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 5})).unwrap(); s.execute("text.insert", &json!({"text": "Changed"})).unwrap(); assert_eq!(s.doc().unwrap().doc.bookmarks[1].name, "First", "refresh is explicit"); s.execute("toc.update", &json!({})).unwrap(); let refreshed = s.doc().unwrap().doc.bookmarks.clone(); - assert_eq!(refreshed[1].name, "Changed"); + assert_eq!(refreshed.iter().map(|b| b.name.as_str()).collect::>(), ["Manual", "Changed", "Last", "Manual after"]); s.execute("toc.update", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc.bookmarks, refreshed, "updates do not duplicate bookmarks"); // Native storage retains both the option and ownership; old bookmarks default to manual. @@ -678,8 +684,7 @@ mod tests { let mut disabled = p; disabled["createBookmarks"] = json!(false); s.execute("toc.generate", &disabled).unwrap(); - assert_eq!(s.doc().unwrap().doc.bookmarks.len(), 1); - assert_eq!(s.doc().unwrap().doc.bookmarks[0].name, "Manual"); + assert_eq!(s.doc().unwrap().doc.bookmarks.iter().map(|b| b.name.as_str()).collect::>(), ["Manual", "Manual after"]); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc.bookmarks, refreshed); } diff --git a/crates/engine/src/tests_idml.rs b/crates/engine/src/tests_idml.rs index 7afb2acd5..7c8fe8609 100644 --- a/crates/engine/src/tests_idml.rs +++ b/crates/engine/src/tests_idml.rs @@ -500,6 +500,7 @@ fn type_para_sets_a_drop_cap_that_survives_idml() { assert_eq!(p.nested_styles, Some(vec![designcraft_doc::NestedStyle::drop_cap("Initial")])); } +/// Imported TOC definitions retain their settings and remain refreshable. #[test] fn table_of_contents_definition_round_trips_through_idml_and_updates() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2a5cd7b24..c4f8823c7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -7959,6 +7959,7 @@ mod tests { assert_eq!(opts(&app), before); } + /// 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()); diff --git a/docs/agents.md b/docs/agents.md index 2358235ec..532811664 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -58,7 +58,8 @@ is a PNG of the page; another extension is an error, and nothing runs. `toc.generate` accepts `createBookmarks: true` to generate page-level PDF bookmarks from the included paragraph styles and their levels. `toc.update` refreshes their titles, hierarchy and -destinations. Manual bookmarks stay intact; disabling the option removes only the generated +destinations. Refresh keeps the generated outline at its previous position, preserving manual +bookmarks before and after it; disabling the option removes only the generated bookmarks. Omitting the parameter keeps the existing TOC setting (false for a new command-created TOC). The Table of Contents dialog exposes Create PDF Bookmarks and defaults it on for a new TOC. From e8ec23a88d71e4b004634e4967df0346ad91bd44 Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:51:54 +0200 Subject: [PATCH 016/431] TOC-1: preserve right-indent separators and expose their picker --- ROADMAP.md | 2 ++ crates/doc/src/story.rs | 29 ++++++++++++++++++++++ crates/engine/src/tests_idml.rs | 2 +- crates/idml/src/names.rs | 39 +++++++++++++----------------- crates/idml/src/tests.rs | 2 +- crates/ui-egui/src/dialogs.rs | 43 ++++++++++++++++++++++++++++++--- docs/ui-parity.md | 8 +++++- 7 files changed, 97 insertions(+), 28 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9bd0274ef..9a52fcd2b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -346,6 +346,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **2026-10-18** · TOC separators preserve right-indent tabs through the dialog and IDML, including localized `$ID/^y` values. A separator picker exposes Tab Character, Right Indent Tab, Em Space and En Space. Existing paragraph tab leaders now work with the tutorial's TOC workflow. No readiness percentage change. + Newest first. - **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change. diff --git a/crates/doc/src/story.rs b/crates/doc/src/story.rs index e3a0e39ee..2f9860d31 100644 --- a/crates/doc/src/story.rs +++ b/crates/doc/src/story.rs @@ -31,6 +31,35 @@ pub const FRAME_BREAK: char = '\u{E003}'; pub const PAGE_BREAK: char = '\u{E004}'; pub const INDENT_HERE: char = '\u{E005}'; pub const RIGHT_INDENT_TAB: char = '\u{E006}'; + +/// Encode a table-of-contents separator using InDesign's special-character notation. +pub fn toc_separator_notation(text: &str) -> String { + text.replace('^', "^^").replace('\t', "^t").replace(RIGHT_INDENT_TAB, "^y").replace('\u{2003}', "^m").replace('\u{2002}', "^>") +} + +/// Decode TOC notation, preserving unknown codes and an escaped literal caret. +pub fn toc_separator_text(notation: &str) -> String { + let mut text = String::new(); + let mut chars = notation.chars(); + while let Some(ch) = chars.next() { + if ch != '^' { + text.push(ch); + continue; + } + match chars.next() { + Some('t') => text.push('\t'), + Some('y') => text.push(RIGHT_INDENT_TAB), + Some('m') => text.push('\u{2003}'), + Some('>') => text.push('\u{2002}'), + Some('^') | None => text.push('^'), + Some(other) => { + text.push('^'); + text.push(other); + } + } + } + text +} pub const NEXT_PAGE_NUMBER: char = '\u{E007}'; pub const PREV_PAGE_NUMBER: char = '\u{E008}'; /// Anchor of a table: alone in its paragraph, whose [`ParaFormat::table`] names the table. diff --git a/crates/engine/src/tests_idml.rs b/crates/engine/src/tests_idml.rs index 0701052d0..686687781 100644 --- a/crates/engine/src/tests_idml.rs +++ b/crates/engine/src/tests_idml.rs @@ -510,7 +510,7 @@ fn table_of_contents_definition_round_trips_through_idml_and_updates() { s.execute( "toc.generate", &json!({"page": page, "title": "Inside", "titleStyle": basic, "entries": [ - {"style": "Cover Title", "level": 1, "pageNumber": "before", "separator": " ^ "}, + {"style": "Cover Title", "level": 1, "pageNumber": "before", "separator": " ^ \u{E006} "}, {"style": "Headline", "level": 2, "entryStyle": basic, "pageNumber": "none"}, ]}), ) diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs index c242a1626..69d17c50b 100644 --- a/crates/idml/src/names.rs +++ b/crates/idml/src/names.rs @@ -837,30 +837,25 @@ pub fn digits_in(s: &str) -> Option { [Digits::Default, Digits::Arabic, Digits::Hindi, Digits::Farsi, Digits::Native].into_iter().find(|d| digits_out(*d) == s) } -/// A TOC separator in IDML's special-character notation: `^t` is a tab, `^^` a caret. +/// A TOC separator in IDML's special-character notation. pub fn toc_separator_out(s: &str) -> String { - s.replace('^', "^^").replace('\t', "^t") + designcraft_doc::story::toc_separator_notation(s) } -/// IDML TOC separator → text (`^t` and `^y`, the right indent tab, are tabs; other codes stay). +/// IDML may prefix a separator with its localized-string marker. pub fn toc_separator_in(s: &str) -> String { - // InDesign writes its built-in values with the `$ID/` prefix (`$ID/^y`, the right indent tab). - let s = s.strip_prefix("$ID/").unwrap_or(s); - let mut out = String::new(); - let mut it = s.chars(); - while let Some(c) = it.next() { - if c != '^' { - out.push(c); - continue; - } - match it.next() { - Some('t' | 'y') => out.push('\t'), - Some('^') => out.push('^'), - Some(o) => { - out.push('^'); - out.push(o); - } - None => out.push('^'), - } + designcraft_doc::story::toc_separator_text(s.strip_prefix("$ID/").unwrap_or(s)) +} + +#[cfg(test)] +mod toc_separator_tests { + use super::*; + + #[test] + fn localized_right_indent_tab_is_not_an_ordinary_tab() { + assert_eq!(toc_separator_in("$ID/^y"), "\u{E006}"); + assert_eq!(toc_separator_in("$ID/ ^y "), " \u{E006} "); + assert_eq!(toc_separator_in("^t"), "\t"); + assert_eq!(toc_separator_out("\u{E006}"), "^y"); + assert_eq!(toc_separator_in("^^y ^q ^"), "^y ^q ^"); } - out } diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 785bf458f..a1551b3e6 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -4216,7 +4216,7 @@ fn toc_style_entries_read_id_prefixed_separators_and_styles() { let d = import_idml(&fixture_with_table_styles(TOC_STYLES, TOC_STORY)).unwrap(); let toc = d.toc.as_ref().expect("the story's TOC style is imported"); let entry = toc.entries.iter().find(|e| e.style == "Heading 1").unwrap(); - assert_eq!(entry.separator, None, "`$ID/^y` is the right indent tab, the default separator, not text: {:?}", entry.separator); + assert_eq!(entry.separator.as_deref(), Some("\u{E006}"), "right-indent tabs must not become ordinary tab stops"); assert_eq!(entry.entry_style.as_deref(), Some("TOC/TOC body text")); assert_eq!(entry.page_number_style.as_deref(), Some("TOC Page Number")); assert_eq!(toc.title_style.as_deref(), Some("TOC/TOC Header")); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 358416a4b..c1eff1078 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -6212,11 +6212,11 @@ struct TocRow { } fn toc_separator_shown(s: &str) -> String { - s.replace('\t', "^t").replace('\u{2003}', "^m").replace('\u{2002}', "^>") + designcraft_doc::story::toc_separator_notation(s) } fn toc_separator_typed(s: &str) -> String { - s.replace("^t", "\t").replace("^m", "\u{2003}").replace("^>", "\u{2002}") + designcraft_doc::story::toc_separator_text(s) } fn toc_rows(d: &Dialog) -> Vec { @@ -6407,7 +6407,19 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { toc_style_combo(ui, "page_number", &char_styles, none, &mut r.page_number_style); ui.end_row(); crate::rtl::label(ui, tr("Between Entry and Number:")); - ui.add(egui::TextEdit::singleline(&mut r.separator).desired_width(80.0).horizontal_align(egui::Align::Min)); + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut r.separator).desired_width(80.0).horizontal_align(egui::Align::Min)); + ui.menu_button("▸", |ui| { + for (label, code) in [("Tab Character", "^t"), ("Right Indent Tab", "^y"), ("Em Space", "^m"), ("En Space", "^>")] { + if ui.button(tr(label)).clicked() { + r.separator.push_str(code); + ui.close(); + } + } + }) + .response + .on_hover_text(tr("Insert Special Character")); + }); crate::rtl::label(ui, tr("Style:")); toc_style_combo(ui, "separator", &char_styles, none, &mut r.separator_style); ui.end_row(); @@ -7957,6 +7969,31 @@ mod tests { assert_eq!(opts(&app), before); } + #[test] + fn toc_right_indent_separator_survives_the_dialog_and_update() { + 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": 1, "separator": " ^y "} + ]), + ); + confirm(&mut app).unwrap(); + let separator = format!(" {} ", designcraft_doc::story::RIGHT_INDENT_TAB); + let d = &app.session.doc().unwrap().doc; + let toc = d.toc.as_ref().unwrap(); + assert_eq!(toc.entries[0].separator.as_deref(), Some(separator.as_str())); + assert!(d.story(toc.story).unwrap().text.contains(&separator)); + app.run("toc.update", json!({})).unwrap(); + open_toc(&mut app); + assert_eq!(toc_rows(app.ui.dialog.as_ref().unwrap())[0].separator, " ^y "); + for text in ["^y", "^t^y", "^m^>", "unknown ^q and trailing ^", "a\t\u{E006}\u{2003}\u{2002}z"] { + assert_eq!(toc_separator_typed(&toc_separator_shown(text)), text); + } + } + #[test] fn toc_dialog_adds_a_style_and_ok_makes_the_table_of_contents() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2dcf4687a..d7984e115 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS) Tools, handles, snapping, nudging, modifiers, shortcuts, panels, menus, context menus and feel, against InDesign 2026. The look was measured from the running app @@ -70,6 +70,12 @@ history (DC-1). ## Remaining effort +The Table of Contents separator field accepts `^y` (Right Indent Tab), with a special-character +picker alongside it. Literal carets use `^^`; ordinary tabs, em/en spaces and surrounding spaces +survive reopening. IDML preserves right-indent separators, including `$ID/^y`, so updating an +imported TOC keeps page numbers at the right edge and inherits paragraph tab leaders. This covers +10:54–16:08 of [Michael Bullo's TOC tutorial](https://www.youtube.com/watch?v=m4y2tm6LH_c). + Applying a paragraph or character style now removes local values equal to the applied style, so text used to capture a style follows later edits to that style. Different local formatting and named character styles stay intact. Paragraph-style application treats selection endpoints From 81913c2f482cecfcef98c6e8ce40368e58f8dee2 Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:07:02 +0200 Subject: [PATCH 017/431] TOC-1: localize separator picker and address review cleanup --- ROADMAP.md | 4 ++-- crates/doc/src/story.rs | 15 ++++++++------- crates/ui-egui/src/i18n.rs | 1 + crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/src/i18n/it.rs | 4 ++++ crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 4 ++++ crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 4 ++++ 13 files changed, 30 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9a52fcd2b..3b8d62fd4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -346,10 +346,10 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log -- **2026-10-18** · TOC separators preserve right-indent tabs through the dialog and IDML, including localized `$ID/^y` values. A separator picker exposes Tab Character, Right Indent Tab, Em Space and En Space. Existing paragraph tab leaders now work with the tutorial's TOC workflow. No readiness percentage change. - Newest first. +- **2026-10-18** · TOC separators preserve right-indent tabs through the dialog and IDML, including localized `$ID/^y` values. A separator picker exposes Tab Character, Right Indent Tab, Em Space and En Space. Existing paragraph tab leaders now work with the tutorial's TOC workflow. No readiness percentage change. + - **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change. - **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change. - **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change. diff --git a/crates/doc/src/story.rs b/crates/doc/src/story.rs index 2f9860d31..510c2d849 100644 --- a/crates/doc/src/story.rs +++ b/crates/doc/src/story.rs @@ -32,6 +32,14 @@ pub const PAGE_BREAK: char = '\u{E004}'; pub const INDENT_HERE: char = '\u{E005}'; pub const RIGHT_INDENT_TAB: char = '\u{E006}'; +pub const NEXT_PAGE_NUMBER: char = '\u{E007}'; +pub const PREV_PAGE_NUMBER: char = '\u{E008}'; +/// Anchor of a table: alone in its paragraph, whose [`ParaFormat::table`] names the table. +pub const TABLE_ANCHOR: char = '\u{E009}'; +pub const FORCED_LINE_BREAK: char = '\u{2028}'; +pub const ODD_PAGE_BREAK: char = '\u{E011}'; +pub const EVEN_PAGE_BREAK: char = '\u{E012}'; + /// Encode a table-of-contents separator using InDesign's special-character notation. pub fn toc_separator_notation(text: &str) -> String { text.replace('^', "^^").replace('\t', "^t").replace(RIGHT_INDENT_TAB, "^y").replace('\u{2003}', "^m").replace('\u{2002}', "^>") @@ -60,13 +68,6 @@ pub fn toc_separator_text(notation: &str) -> String { } text } -pub const NEXT_PAGE_NUMBER: char = '\u{E007}'; -pub const PREV_PAGE_NUMBER: char = '\u{E008}'; -/// Anchor of a table: alone in its paragraph, whose [`ParaFormat::table`] names the table. -pub const TABLE_ANCHOR: char = '\u{E009}'; -pub const FORCED_LINE_BREAK: char = '\u{2028}'; -pub const ODD_PAGE_BREAK: char = '\u{E011}'; -pub const EVEN_PAGE_BREAK: char = '\u{E012}'; /// A column, frame or page break character. It ends its paragraph: the story stores it as the /// paragraph's last character, followed by the `\n` separator (also at the end of the story), and diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index efe089ef9..a2f3f269e 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -4397,6 +4397,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("OpenType font", ["OpenType-Schrift", "Police OpenType", "Fuente OpenType", "OpenType フォント", "OpenType 字体"]), ("Variable font", ["Variable Schrift", "Police variable", "Fuente variable", "バリアブルフォント", "可变字体"]), ("Color font", ["Farbschrift", "Police couleur", "Fuente de color", "カラーフォント", "彩色字体"]), + ("Tab Character", ["Tabulatorzeichen", "Caractère de tabulation", "Carácter de tabulación", "タブ文字", "制表符"]), // Table of Contents. ( "Sort Entries in Alphabetical Order", diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..5b22f124b 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1,5 +1,6 @@ //! Original Arabic interface translations, stored in logical Unicode order. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Tab Character", "محرف جدولة"), ("File", "ملف"), ("Edit", "تحرير"), ("Layout", "تخطيط"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..f47746c4a 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -3,6 +3,7 @@ //! product's localization was used). Phrases shared with the MIT OR Apache-2.0 Czech catalogs of //! the ArtCraft siblings (VectorCraft, PhotoCraft) are kept identical; see ASSETS.md for sources. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Tab Character", "Znak tabulátoru"), ("File", "Soubor"), ("Edit", "Úpravy"), ("Layout", "Rozvržení"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index efc128858..80874fe91 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -3,6 +3,7 @@ //! Brazilian Portuguese, Italian and Ukrainian tables has a row here; the German column of the shared //! table is carried over and stays the fallback for keys added there after this table. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Tab Character", "Tabulatorzeichen"), ("File", "Datei"), ("Edit", "Bearbeiten"), ("Layout", "Layout"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..f8175c619 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1,6 +1,10 @@ //! Original Italian interface translations, written from the English source //! using ordinary Italian desktop-publishing vocabulary. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Tab Character", "Carattere di tabulazione"), + ("Right Indent Tab", "Tabulazione rientro destro"), + ("Em Space", "Spazio em"), + ("En Space", "Spazio en"), ("File", "File"), ("Edit", "Modifica"), ("Layout", "Layout"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 5647ca940..919b10ed4 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -3,6 +3,7 @@ //! Portuguese tables has a row here; the menu titles and panel names that were already Japanese in the //! shared table are carried over unchanged. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Tab Character", "タブ文字"), ("File", "ファイル"), ("Edit", "編集"), ("Layout", "レイアウト"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 975da9ca1..dcd92b4d7 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -4,6 +4,7 @@ //! Portuguese, Japanese, Italian and Ukrainian tables has a row here, plus a few labels that only //! the code or the command table use. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Tab Character", "ტაბულაციის სიმბოლო"), ("File", "ფაილი"), ("Edit", "რედაქტირება"), ("Layout", "განლაგება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..a36518fda 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -2,6 +2,7 @@ //! are welcome. Terminology follows common Polish DTP usage //! (rozkładówka, łam, strona wzorcowa, obszar informacyjny) and the sibling ArtCraft catalogs. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Tab Character", "Znak tabulacji"), ("File", "Plik"), ("Edit", "Edycja"), ("Layout", "Układ"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..888387797 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -1,6 +1,10 @@ //! Original Brazilian Portuguese interface translations, written from the English source //! using ordinary Brazilian Portuguese computing vocabulary. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Tab Character", "Caractere de tabulação"), + ("Right Indent Tab", "Tabulação de recuo à direita"), + ("Em Space", "Espaço eme"), + ("En Space", "Espaço ene"), ("File", "Arquivo"), ("Edit", "Editar"), ("Layout", "Layout"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..4069930c8 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -3,6 +3,7 @@ //! The translations are our own; counted nouns are genitive plurals because the caption puts the //! category before the number ("страниц: 5"), see `count_label`. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Tab Character", "Символ табуляции"), ("File", "Файл"), ("Edit", "Редактирование"), ("Can't Open the File", "Не удалось открыть файл"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..43030ff08 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -2,6 +2,10 @@ //! Common phrases are adapted from the MIT OR Apache-2.0 ArtCraft sibling catalogs //! (VectorCraft, PhotoCraft, WordCraft and DeckCraft); see ASSETS.md for pinned sources. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Tab Character", "Символ табуляції"), + ("Right Indent Tab", "Табуляція правого відступу"), + ("Em Space", "Кругла шпація"), + ("En Space", "Півкругла шпація"), ("File", "Файл"), ("Edit", "Редагування"), ("Layout", "Макет"), From 73088006237901ae990c50ec9ab063e82ecc4016 Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:29:40 +0200 Subject: [PATCH 018/431] TOC-3: keep the table of contents title readable --- ROADMAP.md | 2 ++ crates/ui-egui/src/dialogs.rs | 23 ++++++++++++++++++++++- docs/ui-parity.md | 5 ++++- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9bd0274ef..835c968ed 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-18** · Table of Contents reserves the title field's width so Contents is readable next to its style picker; the grid previously squeezed it to a few characters. No readiness percentage change. + - **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change. - **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change. - **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 358416a4b..6885dc6d0 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -6334,7 +6334,10 @@ 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); + let mut title = d.s("title"); + if ui.add_sized([200.0, ui.spacing().interact_size.y], egui::TextEdit::singleline(&mut title).horizontal_align(egui::Align::Min)).changed() { + d.fields.insert("title".into(), json!(title)); + } crate::rtl::label(ui, tr("Style:")); toc_style_combo(ui, "title", &styles, "TOC Title", &mut title_style); ui.end_row(); @@ -7957,6 +7960,24 @@ mod tests { assert_eq!(opts(&app), before); } + /// 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![]); + visible_label(&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(&labels, screen, "Table of Contents"); + } + #[test] fn toc_dialog_adds_a_style_and_ok_makes_the_table_of_contents() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2dcf4687a..00f2df462 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS) Tools, handles, snapping, nudging, modifiers, shortcuts, panels, menus, context menus and feel, against InDesign 2026. The look was measured from the running app @@ -70,6 +70,9 @@ history (DC-1). ## Remaining effort +The Table of Contents title field reserves its edit width inside the title/style row. The +default Contents title is fully visible instead of being clipped to its first few characters. + Applying a paragraph or character style now removes local values equal to the applied style, so text used to capture a style follows later edits to that style. Different local formatting and named character styles stay intact. Paragraph-style application treats selection endpoints From 8cc92a50b63f4339263474a3e87c5dd433f53434 Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:36:38 +0200 Subject: [PATCH 019/431] TOC-6: toggle advanced table of contents options --- ROADMAP.md | 2 ++ crates/ui-egui/src/dialogs.rs | 49 +++++++++++++++++++++++++++++++- crates/ui-egui/src/i18n.rs | 3 +- crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + docs/ui-parity.md | 3 ++ 14 files changed, 65 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9bd0274ef..6be95b6ad 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-18** · Table of Contents offers More Options / Fewer Options, preserving page-number, separator, sorting and level settings while their controls are hidden. No readiness percentage change. + - **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change. - **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change. - **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 358416a4b..f4be6cf2a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -6328,6 +6328,7 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); let styles: Vec = d.fields.get("styles").cloned().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); let char_styles: Vec = d.fields.get("charStyles").cloned().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); + let mut expanded = d.fields.get("moreOptions").and_then(Value::as_bool).unwrap_or(true); let mut rows = toc_rows(d); let mut sel = d.n("selected").map_or(0, |n| n.max(0.0) as usize); let mut other = d.s("other"); @@ -6340,7 +6341,12 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.end_row(); }); ui.add_space(8.0); - crate::rtl::label(ui, egui::RichText::new(tr("Styles in Table of Contents")).font(semibold(12.0))); + ui.horizontal(|ui| { + crate::rtl::label(ui, egui::RichText::new(tr("Styles in Table of Contents")).font(semibold(12.0))); + if ui.button(tr(if expanded { "Fewer Options" } else { "More Options" })).clicked() { + expanded = !expanded; + } + }); let others: Vec<&String> = styles.iter().filter(|n| !rows.iter().any(|r| &r.style == *n)).collect(); ui.horizontal_top(|ui| { ui.vertical(|ui| { @@ -6393,6 +6399,9 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let auto = format!("TOC Level {}", r.level.clamp(1, 9)); toc_style_combo(ui, "entry", &styles, &auto, &mut r.entry_style); ui.end_row(); + if !expanded { + return; + } crate::rtl::label(ui, tr("Page Number:")); 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); @@ -6433,6 +6442,7 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.add_space(6.0); crate::rtl::label(ui, egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); } + d.fields.insert("moreOptions".into(), json!(expanded)); d.fields.insert("included".into(), json!(rows)); d.fields.insert("selected".into(), json!(sel)); d.fields.insert("other".into(), json!(other)); @@ -7957,6 +7967,43 @@ mod tests { assert_eq!(opts(&app), before); } + /// 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); + } + #[test] fn toc_dialog_adds_a_style_and_ok_makes_the_table_of_contents() { 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 efe089ef9..514f68da2 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1351,7 +1351,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ "适合框架高度到文本", ], ), - ("More Options", ["More Options", "Plus d'options", "More Options", "More Options", "更多选项"]), + ("More Options", ["Weitere Optionen", "Plus d'options", "Más opciones", "その他のオプション", "更多选项"]), + ("Fewer Options", ["Weniger Optionen", "Moins d'options", "Menos opciones", "オプションを減らす", "较少选项"]), ("Flip Horizontal", ["Flip Horizontal", "Retourner horizontalement", "Flip Horizontal", "Flip Horizontal", "水平翻转"]), ( "Rotate 90° Clockwise", diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..eb98ea711 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1,5 +1,6 @@ //! Original Arabic interface translations, stored in logical Unicode order. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "خيارات أقل"), ("File", "ملف"), ("Edit", "تحرير"), ("Layout", "تخطيط"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..8781a7025 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -3,6 +3,7 @@ //! product's localization was used). Phrases shared with the MIT OR Apache-2.0 Czech catalogs of //! the ArtCraft siblings (VectorCraft, PhotoCraft) are kept identical; see ASSETS.md for sources. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "Méně možností"), ("File", "Soubor"), ("Edit", "Úpravy"), ("Layout", "Rozvržení"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index efc128858..aeed5daa9 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -3,6 +3,7 @@ //! Brazilian Portuguese, Italian and Ukrainian tables has a row here; the German column of the shared //! table is carried over and stays the fallback for keys added there after this table. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "Weniger Optionen"), ("File", "Datei"), ("Edit", "Bearbeiten"), ("Layout", "Layout"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..820ebe9cf 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1,6 +1,7 @@ //! Original Italian interface translations, written from the English source //! using ordinary Italian desktop-publishing vocabulary. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "Meno opzioni"), ("File", "File"), ("Edit", "Modifica"), ("Layout", "Layout"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 5647ca940..d4e65dab1 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -3,6 +3,7 @@ //! Portuguese tables has a row here; the menu titles and panel names that were already Japanese in the //! shared table are carried over unchanged. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "オプションを減らす"), ("File", "ファイル"), ("Edit", "編集"), ("Layout", "レイアウト"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 975da9ca1..157339a31 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -4,6 +4,7 @@ //! Portuguese, Japanese, Italian and Ukrainian tables has a row here, plus a few labels that only //! the code or the command table use. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "ნაკლები პარამეტრი"), ("File", "ფაილი"), ("Edit", "რედაქტირება"), ("Layout", "განლაგება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..6b0e60473 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -2,6 +2,7 @@ //! are welcome. Terminology follows common Polish DTP usage //! (rozkładówka, łam, strona wzorcowa, obszar informacyjny) and the sibling ArtCraft catalogs. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "Mniej opcji"), ("File", "Plik"), ("Edit", "Edycja"), ("Layout", "Układ"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..2bb8c8de3 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -1,6 +1,7 @@ //! Original Brazilian Portuguese interface translations, written from the English source //! using ordinary Brazilian Portuguese computing vocabulary. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "Menos opções"), ("File", "Arquivo"), ("Edit", "Editar"), ("Layout", "Layout"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..9ab1e23be 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -3,6 +3,7 @@ //! The translations are our own; counted nouns are genitive plurals because the caption puts the //! category before the number ("страниц: 5"), see `count_label`. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "Меньше параметров"), ("File", "Файл"), ("Edit", "Редактирование"), ("Can't Open the File", "Не удалось открыть файл"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..67c33cb8a 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -2,6 +2,7 @@ //! Common phrases are adapted from the MIT OR Apache-2.0 ArtCraft sibling catalogs //! (VectorCraft, PhotoCraft, WordCraft and DeckCraft); see ASSETS.md for pinned sources. pub(super) const TABLE: &[(&str, &str)] = &[ + ("Fewer Options", "Менше параметрів"), ("File", "Файл"), ("Edit", "Редагування"), ("Layout", "Макет"), diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2dcf4687a..32fe9c5ab 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -126,3 +126,6 @@ covers checking the sampled and redefined settings in the styles tutorial (2:45 | 2026-10-11 | minor | Added held Cmd+Space / Cmd+Alt+Space Zoom navigation and canvas input regression tests | | 2026-10-10 | minor | Threading row corrected to ✗ after checking the code (no out-port interaction, no thread command) | | 2026-10-10 | major | Created: measured menu coverage, 27-row interaction checklist from the code and issues | + +The Table of Contents dialog can collapse to its entry-style controls or expand to show page +number, separator, sorting and level controls. Toggling visibility preserves their settings. From 42903d7bd3e94aedc818db69dd11dc06acd1dbbd Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:43:17 +0200 Subject: [PATCH 020/431] TOC-5: open numbering options from section markers --- ROADMAP.md | 2 ++ crates/ui-egui/src/panels/pages.rs | 56 +++++++++++++++++++++++++++++- docs/ui-parity.md | 3 ++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4b47f97a0..3cb6d4d0b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-18** · Double-clicking a section triangle in the Pages panel opens that page’s numbering options, including from parent-page editing. No readiness percentage change. + - **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change. - **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change. - **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change. diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index ef9895373..88f04e362 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -472,7 +472,24 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { t.icon, Stroke::NONE, )); - + let marker = + ui.interact(egui::Rect::from_center_size(c, vec2(12.0, 8.0)), ui.id().with(("section", abs)), Sense::click()); + let label = format!( + "{}: {}", + crate::i18n::tr(&app.ui.language, "Numbering & Section Options…").trim_end_matches('…'), + app.session.page_label(abs) + ); + marker.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &label)); + if marker.double_clicked() { + if editing_parents { + let _ = app.run("layout.parents.edit", json!({"on": false})); + if let Some(view) = app.view_mut() { + view.fitted = false; + } + } + crate::canvas::go_to_page(app, abs); + crate::menus::activate(app, "layout.section", &serde_json::Value::Null); + } } // Parent prefix in the outer top corner (black on white). if let Some(pid) = p.parent @@ -1123,6 +1140,43 @@ mod spread_label_tests { use super::*; + /// Section-marker activation targets its page and leaves parent editing without editing the document. + #[test] + fn double_clicking_a_section_marker_opens_that_pages_numbering_options() { + let mut app = app(); + app.run("layout.section", json!({"page": 1, "startNumber": 1, "style": "lowerRoman"})).unwrap(); + app.run("layout.section", json!({"page": 3, "startNumber": 1, "style": "arabic"})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1000.0, 800.0))); + app.run("layout.parents.edit", json!({"on": true})).unwrap(); + let mut h = Harness::builder().with_size(vec2(280.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("section_marker_test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + let pos = h.get_by_label("Numbering & Section Options: 1").rect().center(); + let before = h.state().session.doc().unwrap().doc.clone(); + for _ in 0..2 { + for pressed in [true, false] { + h.event(egui::Event::PointerMoved(pos)); + h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + } + } + let dialog = h.state().ui.dialog.as_ref().unwrap(); + assert_eq!(dialog.id, "numberingSection"); + assert_eq!(dialog.fields.get("page"), Some(&json!(3))); + assert!(!h.state().session.doc().unwrap().editing_parents); + assert!(std::sync::Arc::ptr_eq(&h.state().session.doc().unwrap().doc, &before), "opening options does not edit the section"); + } + fn app() -> DesignApp { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"facingPages": true, "pages": 9})).unwrap(); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2dcf4687a..5c3f0b8b6 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -126,3 +126,6 @@ covers checking the sampled and redefined settings in the styles tutorial (2:45 | 2026-10-11 | minor | Added held Cmd+Space / Cmd+Alt+Space Zoom navigation and canvas input regression tests | | 2026-10-10 | minor | Threading row corrected to ✗ after checking the code (no out-port interaction, no thread command) | | 2026-10-10 | major | Created: measured menu coverage, 27-row interaction checklist from the code and issues | + +Double-clicking a section triangle in the Pages panel opens Numbering & Section Options for +that section’s first page. It leaves parent-page editing and does not edit section data until confirmed. From 7200e90615e9bfc36c5e16341ed66440be0786a3 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 15:55:23 +0200 Subject: [PATCH 021/431] PR-892: spread labels read on a light panel and are buttons for screen readers Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/pages.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 6221e940d..5f9a7aec6 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -523,15 +523,19 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { resp.context_menu(|ui| page_menu(app, ui, &doc, abs, si)); } let label = spread_label(app, &doc, first, sp.pages.len()); - let g = ui.painter().layout_no_wrap(label.clone(), crate::theme::semibold(11.0), Color32::WHITE); + // White on the badge; the theme's text colour elsewhere, so it reads on a light panel. + let badge = cur_spread == Some(si) && !editing_parents; + let ink = if badge { Color32::WHITE } else { t.text_strong }; + let g = ui.painter().layout_no_wrap(label.clone(), crate::theme::semibold(11.0), ink); let c = egui::pos2(row.min.x + center, top + row_h + 11.0); let br = egui::Rect::from_center_size(c, vec2(g.size().x + 7.0, 12.5)); - if cur_spread == Some(si) && !editing_parents { + if badge { ui.painter().rect_filled(br, 0.0, BADGE); } - ui.painter().galley(c - g.size() / 2.0, g, Color32::WHITE); + ui.painter().galley(c - g.size() / 2.0, g, ink); let lr = ui.interact(br, ui.id().with(("spread_label", si)), Sense::click()); - lr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, &label)); + // A click goes to the spread, so assistive technology hears a button. + lr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &label)); if lr.clicked() { if editing_parents { let _ = app.run("layout.parents.edit", json!({"on": false})); @@ -1211,7 +1215,8 @@ mod spread_label_tests { app, ); h.run_steps(3); - let _ = h.get_by_label("2, 1"); + // A spread label is a button: a click goes to the spread. + let _ = h.get_by(|n| n.role() == egui::accesskit::Role::Button && n.label().as_deref() == Some("2, 1")); assert!(h.query_by_label("2 1").is_none()); // Each page is named for screen readers: two pages are numbered 1. assert_eq!(h.query_all_by_label("Page 1").count(), 2); From 2a14be0dfa54ac96ce1bb242a72c45b0073eee7b Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 16:41:09 +0200 Subject: [PATCH 022/431] PR-1080: Add CMYK Swatch takes the C/M/Y/K the dialog shows Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 75 +++++++++-------------------------- 1 file changed, 19 insertions(+), 56 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6a546ade7..443df730e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -6781,10 +6781,14 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { 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] } + // The C/M/Y/K the fields show: the percentages typed or given, else the colour converted. + let cmyk = nums::<4>(d, "cmyk").map(|v| v.map(|x| x as f32)).unwrap_or_else(|| { + 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()); + .map(|v| v * 100.0) + }); + if pill(ui, 2, &tr("Add CMYK Swatch"), false) { + let [c, m, y, k] = cmyk.map(|v| v.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), @@ -6793,11 +6797,6 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { // The values: a radio button, a label, the field, a unit. let mut comp_now = comp; let mut edited: Option = None; - let cmyk = nums::<4>(d, "cmyk").map(|v| v.map(|x| x as f32)).unwrap_or_else(|| { - 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), "°"), @@ -10022,105 +10021,69 @@ mod tests { 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"); } From 13576c365d806c80461ef51f59aac7f1448197f4 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 16:57:48 +0200 Subject: [PATCH 023/431] PR-1125: Direct Selection selects pasted-in content too; test in a private temp dir Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests.rs | 23 +++++++++++++++++++---- crates/tools/src/select.rs | 9 ++------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ef08c2a6b..66b1889f1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1654,9 +1654,8 @@ fn a_line_fits_when_its_baseline_does() { #[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"); + let dir = tempfile::tempdir().unwrap(); + let png = dir.path().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(); @@ -1680,5 +1679,21 @@ fn direct_selection_on_a_selected_frame_deletes_only_its_image() { 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); + // A frame holding pasted-in items: the click selects them too, so arrow keys move them. + let inner = s.execute("frame.create", &json!({"rect": [320, 120, 360, 160]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [inner]})).unwrap(); + s.execute("edit.cut", &json!({})).unwrap(); + let holder = s.execute("frame.create", &json!({"rect": [300, 100, 400, 200]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [holder]})).unwrap(); + s.execute("edit.pasteInto", &json!({})).unwrap(); + s.set_tool("selection"); + s.pointer(&PointerEvent::new(PointerKind::Down, 340.0, 140.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 340.0, 140.0), v).unwrap(); + assert_eq!(s.doc().unwrap().selection.items, vec![designcraft_doc::ItemId(holder)]); + assert!(!s.doc().unwrap().selection.content); + s.set_tool("directSelection"); + s.pointer(&PointerEvent::new(PointerKind::Down, 340.0, 140.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 340.0, 140.0), v).unwrap(); + let st = s.doc().unwrap(); + assert!(st.selection.content && st.selection.items == [designcraft_doc::ItemId(holder)], "the pasted-in items are selected: {:?}", st.selection); } diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 8a4cb1bd4..32573b19e 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -369,11 +369,6 @@ 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(_))) -} - /// The selected placed graphic (content selection of one graphic frame) and its bounds on the canvas. fn selected_graphic(cx: &ToolContext) -> Option<(ItemId, Rect)> { let [id] = cx.selection.items.as_slice() else { return None }; @@ -791,8 +786,8 @@ impl Tool for SelectionTool { } else { out.push(Action::Exec("selection.toggle".into(), json!({"id": id.0}))); } - } 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. + } 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}))); } let content = self.direct || on_selected_content(cx, p); From 0c5dd0ede83256dee41f1f4a329c435de627e305 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 17:03:46 +0200 Subject: [PATCH 024/431] PR-1255: TOC style references in indented properties resolve; the picker keeps a trailing caret Co-Authored-By: Claude Opus 5.5 --- crates/idml/src/import.rs | 11 +++++++++-- crates/idml/src/tests.rs | 23 +++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 17 ++++++++++++++++- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 76e2104f1..260911582 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1498,10 +1498,12 @@ impl<'r> Importer<'r> { let story = top.iter().find(|e| e.local() == "Story" && e.get("AppliedTOCStyle") == Some(id))?; Some((ts, *self.story_ids.get(story.get("Self")?)?)) })?; - let known = |r: Option<&str>| r.map(|r| self.para_style_ref(r)).filter(|n| self.styles.para(n).is_some()); + // Property children are element text: formatting whitespace around a reference is dropped + // unless the reference as written names a style. + let known = |r: Option<&str>| r.map(|r| self.listed_para_style_ref(r)).filter(|n| self.styles.para(n).is_some()); // Character styles the document has; [None] is stored as no style. let known_chars = |r: Option<&str>| { - r.map(|r| self.char_style_ref(r)).filter(|n| n != designcraft_doc::NO_CHAR_STYLE && self.styles.char_style(n).is_some()) + r.map(|r| self.listed_char_style_ref(r)).filter(|n| n != designcraft_doc::NO_CHAR_STYLE && self.styles.char_style(n).is_some()) }; let entries: Vec = ts .elements() @@ -1538,6 +1540,11 @@ impl<'r> Importer<'r> { fn para_style_ref(&self, r: &str) -> String { self.para_names.get(r).cloned().unwrap_or_else(|| names::style_name_in(PARA_BUILTINS, &unescape_id(r.trim_start_matches("ParagraphStyle/")))) } + /// 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 { + 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). /// A style name may end in a space, so the reference is used as written when it names a /// style; only otherwise is surrounding whitespace dropped. diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 1c992192a..582242255 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -4266,3 +4266,26 @@ fn toc_style_entries_read_id_prefixed_separators_and_styles() { assert_eq!(entry.page_number_style.as_deref(), Some("TOC Page Number")); assert_eq!(toc.title_style.as_deref(), Some("TOC/TOC Header")); } + +#[test] +fn toc_style_references_in_indented_properties_resolve() { + // Property children laid out on their own lines: the whitespace around a reference isn't + // part of the style name. + let styles = TOC_STYLES + .replace( + ">ParagraphStyle/$ID/TOC%3aTOC body text<", + "> + ParagraphStyle/$ID/TOC%3aTOC body text + <", + ) + .replace( + ">CharacterStyle/TOC Page Number<", + "> + CharacterStyle/TOC Page Number + <", + ); + let d = import_idml(&fixture_with_table_styles(&styles, TOC_STORY)).unwrap(); + let entry = d.toc.as_ref().unwrap().entries.iter().find(|e| e.style == "Heading 1").unwrap(); + assert_eq!(entry.entry_style.as_deref(), Some("TOC/TOC body text")); + assert_eq!(entry.page_number_style.as_deref(), Some("TOC Page Number")); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index af1e75146..69cc222ea 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -6221,6 +6221,15 @@ fn toc_separator_typed(s: &str) -> String { designcraft_doc::story::toc_separator_text(s) } +/// Appends the special-character `code` (such as `^y`) to the separator field's notation. A +/// trailing unescaped caret is a literal caret: it's escaped first, so it doesn't swallow the code. +fn toc_separator_append(field: &mut String, code: &str) { + if field.chars().rev().take_while(|&c| c == '^').count() % 2 == 1 { + field.push('^'); + } + field.push_str(code); +} + fn toc_rows(d: &Dialog) -> Vec { d.fields.get("included").cloned().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default() } @@ -6414,7 +6423,7 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.menu_button("▸", |ui| { for (label, code) in [("Tab Character", "^t"), ("Right Indent Tab", "^y"), ("Em Space", "^m"), ("En Space", "^>")] { if ui.button(tr(label)).clicked() { - r.separator.push_str(code); + toc_separator_append(&mut r.separator, code); ui.close(); } } @@ -8008,6 +8017,12 @@ mod tests { for text in ["^y", "^t^y", "^m^>", "unknown ^q and trailing ^", "a\t\u{E006}\u{2003}\u{2002}z"] { assert_eq!(toc_separator_typed(&toc_separator_shown(text)), text); } + // The picker after a literal caret: the caret stays, and the code is the code. + for (field, typed) in [("^", "^\u{E006}"), ("^^", "^\u{E006}"), ("a", "a\u{E006}"), ("^^^", "^^\u{E006}")] { + let mut f = field.to_string(); + toc_separator_append(&mut f, "^y"); + assert_eq!(toc_separator_typed(&f), typed, "{field:?} → {f:?}"); + } } #[test] From 31e4c346f1969e586747bc4a6f5d10142fa9c44e Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 17:17:07 +0200 Subject: [PATCH 025/431] PR-1285: the TOC title field narrows in a small dialog so its style selector stays in view Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ab4070568..1abf914a3 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -6334,10 +6334,15 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let mut sel = d.n("selected").map_or(0, |n| n.max(0.0) as usize); let mut other = d.s("other"); let mut title_style = d.s("titleStyle"); + // The dialog body's width, less the two labels and the style selector (200 pt) beside the title. + let title_room = ui.available_width() - 300.0; egui::Grid::new("toc_title").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { crate::rtl::label(ui, tr("Title:")); let mut title = d.s("title"); - if ui.add_sized([200.0, ui.spacing().interact_size.y], egui::TextEdit::singleline(&mut title).horizontal_align(egui::Align::Min)).changed() { + // 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() { d.fields.insert("title".into(), json!(title)); } crate::rtl::label(ui, tr("Style:")); @@ -7993,6 +7998,18 @@ mod tests { 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] @@ -8931,7 +8948,7 @@ mod tests { #[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(450.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")] { @@ -9047,7 +9064,7 @@ mod tests { 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(450.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![]); } @@ -9107,7 +9124,7 @@ mod tests { #[test] fn arabic_new_document_fields_remain_reachable_and_editable() { - for size in [egui::vec2(392.0, 271.0), egui::vec2(450.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 fefeb2b5b619fe885369a02f2d963f6de39a2090 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 17:21:43 +0200 Subject: [PATCH 026/431] PR-1287: the keyboard and screen readers open a section marker's options too Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/pages.rs | 32 +++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 778331bff..473c70357 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -491,7 +491,10 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { app.session.page_label(abs) ); marker.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &label)); - if marker.double_clicked() { + // A double click, or the keyboard or a screen reader activating it (a + // single pointer click only selects, as on a page). + let activated = marker.double_clicked() || marker.clicked() && !marker.clicked_by(egui::PointerButton::Primary); + if activated { if editing_parents { let _ = app.run("layout.parents.edit", json!({"on": false})); if let Some(view) = app.view_mut() { @@ -1218,6 +1221,33 @@ mod spread_label_tests { assert!(std::sync::Arc::ptr_eq(&h.state().session.doc().unwrap().doc, &before), "opening options does not edit the section"); } + #[test] + fn a_section_marker_opens_its_options_from_the_keyboard_but_not_on_one_click() { + let mut app = app(); + app.run("layout.section", json!({"page": 3, "startNumber": 5})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1000.0, 800.0))); + let mut h = Harness::builder().with_size(vec2(280.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("section_marker_key_test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Numbering & Section Options: 5").click(); + h.run_steps(2); + assert!(h.state().ui.dialog.is_none(), "one pointer click doesn't open the options"); + h.run_steps(30); + h.get_by_label("Numbering & Section Options: 5").click_accesskit(); + h.run_steps(2); + assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.id.as_str()), Some("numberingSection")); + } + /// Create a facing-page document for section navigation tests. fn app() -> DesignApp { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From 54c369418a462029f8eba9339b64a9506353ef82 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 18:16:30 +0200 Subject: [PATCH 027/431] PR-1257: document TOC bookmark ownership across IDML and the createBookmarks default Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/lib.rs | 7 +++++++ crates/engine/src/cmd/toc.rs | 2 +- docs/agents.md | 6 ++++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 00c679ef9..4175776d4 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -807,6 +807,13 @@ pub struct Bookmark { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub children: Vec, /// Owned by the document's generated TOC, so updating it preserves manual bookmarks. + /// + /// Stored only in native files. Bookmarks (and so this flag) are not written to or read + /// from IDML yet, so after an IDML round trip the next TOC update builds the outline fresh. + /// If IDML bookmark import is added, imported bookmarks arrive with this flag false and the + /// next update would keep them as manual and add a second, duplicate outline. That import + /// must set this flag on the bookmarks the TOC generated (for example by matching them to + /// the TOC entries) when the TOC has `create_bookmarks` on. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub generated_by_toc: bool, } diff --git a/crates/engine/src/cmd/toc.rs b/crates/engine/src/cmd/toc.rs index d3697c677..b69d17e26 100644 --- a/crates/engine/src/cmd/toc.rs +++ b/crates/engine/src/cmd/toc.rs @@ -20,7 +20,7 @@ pub fn specs() -> Vec { "Table of Contents…", ["Layout"], None, - "{entries: [{style, level?: 1 (1-9), entryStyle?: paragraph style of its entries (default \"TOC Level n\"), pageNumber?: \"after\"|\"before\"|\"none\", separator?: text between entry and number (default a tab), pageNumberStyle?: character style of the page number, separatorStyle?: character style of the separator, sort?: bool (Sort Entries in Alphabetical Order: among their siblings, each with the entries under it)}] | styles: [names] (levels 1, 2, …), title?: \"Contents\", titleStyle?: paragraph style of the title (default \"TOC Title\"), pageNumbers?: true, createBookmarks?: bool (generate PDF bookmarks; keeps the existing setting when omitted), replace?: true (regenerate the existing table of contents in place; false makes a new frame), page?: 1-based page for a new frame (its margins), rect?: [x0,y0,x1,y1] (spread coords)} → {story, entries}", + "{entries: [{style, level?: 1 (1-9), entryStyle?: paragraph style of its entries (default \"TOC Level n\"), pageNumber?: \"after\"|\"before\"|\"none\", separator?: text between entry and number (default a tab), pageNumberStyle?: character style of the page number, separatorStyle?: character style of the separator, sort?: bool (Sort Entries in Alphabetical Order: among their siblings, each with the entries under it)}] | styles: [names] (levels 1, 2, …), title?: \"Contents\", titleStyle?: paragraph style of the title (default \"TOC Title\"), pageNumbers?: true, createBookmarks?: bool (generate PDF bookmarks; keeps the existing setting when omitted, which is off for a new table), replace?: true (regenerate the existing table of contents in place; false makes a new frame), page?: 1-based page for a new frame (its margins), rect?: [x0,y0,x1,y1] (spread coords)} → {story, entries}", has_doc, generate ), diff --git a/docs/agents.md b/docs/agents.md index 532811664..fcfffd933 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -60,8 +60,10 @@ is a PNG of the page; another extension is an error, and nothing runs. included paragraph styles and their levels. `toc.update` refreshes their titles, hierarchy and destinations. Refresh keeps the generated outline at its previous position, preserving manual bookmarks before and after it; disabling the option removes only the generated -bookmarks. Omitting the parameter keeps the existing TOC setting (false for a new command-created -TOC). The Table of Contents dialog exposes Create PDF Bookmarks and defaults it on for a new TOC. +bookmarks. Without `createBookmarks`, `toc.generate` keeps the document's existing TOC setting, +which is off when there is no TOC yet, so a script that makes a new TOC gets no bookmarks unless +it passes `createBookmarks: true`. The Table of Contents dialog differs: its Create PDF Bookmarks +option is on by default for a new TOC, and for an existing one it shows the saved setting. ## Color themes From 83e789340c8244c3ab0595ea62df109f9e244e0d Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 18:17:13 +0200 Subject: [PATCH 028/431] PR-1141: Swatches panel shows new-shape defaults; translate the spectrum label; test the fill Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n.rs | 9 ++++ crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + crates/ui-egui/src/panels/mod.rs | 13 ++++++ crates/ui-egui/src/panels/swatches.rs | 62 +++++++++++++++++++++++---- 13 files changed, 85 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index efe089ef9..1bd7074bd 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -826,6 +826,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Choose Image…", ["Choose Image…", "Choisir une image…", "Choose Image…", "Choose Image…", "选择图像…"]), ("Class:", ["Class:", "Classe :", "Class:", "Class:", "类别:"]), ("Color Picker", ["Color Picker", "Sélecteur de couleurs", "Color Picker", "Color Picker", "拾色器"]), + ("Color spectrum", ["Farbspektrum", "Spectre de couleurs", "Espectro de color", "カラースペクトル", "色谱"]), ("Color Settings", ["Color Settings", "Réglages des couleurs", "Color Settings", "Color Settings", "颜色设置"]), ("Color:", ["Color:", "Couleur :", "Color:", "Color:", "颜色:"]), ("Columns", ["Columns", "Colonnes", "Columns", "Columns", "栏"]), @@ -5132,4 +5133,12 @@ mod tests { } } } + + #[test] + fn color_panel_spectrum_label_is_translated_in_every_language() { + for (code, _) in LANGUAGES.iter().filter(|(c, _)| !c.is_empty()) { + assert_ne!(tr(code, "Color spectrum"), "Color spectrum", "{code}"); + } + assert_eq!(tr("", "Color spectrum"), "Color spectrum"); + } } diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..d3466915a 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -349,6 +349,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object Layer Options", "خيارات طبقات الكائن"), ("Colour Picker", "منتقي الألوان"), ("Color Picker", "أداة اختيار الألوان"), + ("Color spectrum", "طيف الألوان"), ("Color Settings", "إعدادات الألوان"), ("User Dictionary", "قاموس المستخدم"), ("Menu Customization", "تخصيص القوائم"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..8ee256913 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -154,6 +154,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Choose Image…", "Vybrat obrázek…"), ("Class:", "Třída:"), ("Color Picker", "Výběr barvy"), + ("Color spectrum", "Barevné spektrum"), ("Color Settings", "Nastavení barev"), ("Color:", "Barva:"), ("Columns", "Sloupce"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index efc128858..9747be28e 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -198,6 +198,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Choose Image…", "Bild auswählen …"), ("Class:", "Klasse:"), ("Color Picker", "Farbwähler"), + ("Color spectrum", "Farbspektrum"), ("Color Settings", "Farbeinstellungen"), ("Color:", "Farbe:"), ("Columns", "Spalten"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..9d4fcdd2f 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -265,6 +265,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Choose Image…", "Scegli immagine…"), ("Class:", "Classe:"), ("Color Picker", "Selettore colore"), + ("Color spectrum", "Spettro dei colori"), ("Color Settings", "Impostazioni colore"), ("Color:", "Colore:"), ("Columns", "Colonne"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 5647ca940..c8f33adf3 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -383,6 +383,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Choose Image…", "画像を選択…"), ("Class:", "クラス:"), ("Color Picker", "カラーピッカー"), + ("Color spectrum", "カラースペクトル"), ("Color Settings", "カラー設定"), ("Color:", "カラー:"), ("Columns", "列数"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 975da9ca1..7a80d8455 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -187,6 +187,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Choose Image…", "გამოსახულების არჩევა…"), ("Class:", "კლასი:"), ("Color Picker", "ფერის ამრჩევი"), + ("Color spectrum", "ფერთა სპექტრი"), ("Color Settings", "ფერის პარამეტრები"), ("Color:", "ფერი:"), ("Columns", "სვეტები"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..c70ba75ad 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -154,6 +154,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Choose Image…", "Wybierz obraz…"), ("Class:", "Klasa:"), ("Color Picker", "Próbnik kolorów"), + ("Color spectrum", "Spektrum kolorów"), ("Color Settings", "Ustawienia kolorów"), ("Color:", "Kolor:"), ("Columns", "Łamy"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..8b1a1af7e 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -357,6 +357,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object Layer Options", "Opções de camada do objeto"), ("Colour Picker", "Seletor de cores"), ("Color Picker", "Seletor de cores"), + ("Color spectrum", "Espectro de cores"), ("Color Settings", "Configurações de cores"), ("User Dictionary", "Dicionário do usuário"), ("Menu Customization", "Personalização de menus"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..a4bc3e7f5 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -154,6 +154,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Choose Image…", "Выбрать изображение…"), ("Class:", "Класс:"), ("Color Picker", "Палитра цветов"), + ("Color spectrum", "Цветовой спектр"), ("Color Settings", "Настройка цветов"), ("Color:", "Цвет:"), ("Columns", "Колонки"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..1fb033cce 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -268,6 +268,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Choose Image…", "Вибрати зображення…"), ("Class:", "Клас:"), ("Color Picker", "Палітра кольорів"), + ("Color spectrum", "Колірний спектр"), ("Color Settings", "Налаштування кольору"), ("Color:", "Колір:"), ("Columns", "Колонки"), diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 7c40f3b1b..bc3f71795 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -80,6 +80,19 @@ pub fn color_target(app: &mut DesignApp) -> Option<(String, f32, String, f32)> { Some((i.fill, sel_tint(app, false), i.stroke, sel_tint(app, true))) } +/// [`color_target`], or with nothing selected the colours new shapes get (the Color and Swatches +/// panels then edit those, as InDesign's do). +pub fn color_target_or_defaults(app: &mut DesignApp) -> Option<(String, f32, String, f32)> { + 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)) + }) +} + /// Apply a swatch (and tint) to the fill or stroke of what colour edits apply to. pub fn apply_swatch(app: &mut DesignApp, stroke: bool, swatch: &str, tint: Option) -> Result { remember_swatch(app, swatch); diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 9cd71ac82..87984b7a0 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -11,7 +11,8 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); - let target = super::color_target(app); + // With nothing selected, the proxy, Tint field and highlighted row are those of new shapes. + let target = super::color_target_or_defaults(app); header(app, ui, &doc, target.as_ref()); // The row of the colour in front of the proxy is highlighted. let fill_cur = target.map(|(fill, _, stroke, _)| if app.ui.stroke_front { stroke } else { fill }); @@ -340,14 +341,7 @@ 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 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 target = super::color_target_or_defaults(app); let Some((fill, fill_tint, stroke_sw, stroke_tint)) = target else { ui.label(crate::rtl::widget( ui, @@ -624,10 +618,60 @@ mod color_panel_defaults_tests { 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); + // Two undo steps: the new frame, then the spectrum click's default 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); + // With the Fill box in front, the ramp sets the default fill instead. + let original_fill = h.state().app.session.active().unwrap().doc.item(old).unwrap().fill.clone(); + h.state_mut().app.ui.stroke_front = false; + h.run_steps(2); + let ramp = h.get_by_label("Color spectrum").rect(); + crate::test_window::click_at(&mut h, egui::pos2(ramp.left() + ramp.width() * 2.0 / 3.0, ramp.center().y)); + let defaults = h.state().app.session.active().unwrap().doc.settings.new_object.clone(); + assert_ne!(defaults.fill, original_fill.swatch); + assert_eq!(defaults.stroke, original.swatch, "the stroke default is left alone"); + assert_eq!(h.state().app.session.active().unwrap().doc.item(old).unwrap().fill, original_fill); + } + + /// The Swatches panel shows the same defaults: its Tint field reads the default fill's tint, + /// and a click on a swatch row sets the default fill, not an existing frame's. + #[test] + fn swatches_panel_with_no_selection_shows_and_edits_future_shape_colors() { + use egui::accesskit::Role; + 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().fill.clone(); + app.run("selection.set", json!({"ids":[]})).unwrap(); + app.run("object.fill", json!({"swatch": "[Black]", "tint": 0.4})).unwrap(); + app.ui.stroke_front = false; + 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"); + 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); + let fields = h.query_all_by(|n| matches!(n.role(), Role::SpinButton | Role::TextInput)).filter_map(|n| n.value()).collect::>(); + assert!(fields.iter().any(|v| v.starts_with("40")), "the Tint field shows the default fill's 40 %: {fields:?}"); + let row = h.get_by_label("[Paper]").rect().center(); + h.hover_at(row); + h.drag_at(row); + h.drop_at(row); + h.run_steps(3); + let defaults = h.state().session.active().unwrap().doc.settings.new_object.clone(); + assert_eq!((defaults.fill.as_str(), defaults.fill_tint), ("[Paper]", 1.0)); + assert_eq!(h.state().session.active().unwrap().doc.item(old).unwrap().fill, original); } } From 44cbf0b214603e0ddabc45f62dd95d8e22177ae7 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 18:27:07 +0200 Subject: [PATCH 029/431] Properties: the arrow beside Opacity opens a slider, as in InDesign Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/panels/properties.rs | 84 ++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..a4a902e2f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-20** · Properties › Appearance: the arrow beside Opacity opens a slider under the field, as in InDesign, instead of the Effects panel; a drag shows the opacity live and is one undo step (the Opacity link still opens Effects). No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 44acf6e2c..fbdb417d8 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -856,9 +856,8 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { let mresp = ui.interact(more, ui.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); - if mresp.clicked() { - show_panel(app, "effects"); - } + mresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr(&app.ui.language, "Opacity"))); + opacity_slider(app, &mresp, i.opacity); ui.add_space(6.0); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 10.0; @@ -871,6 +870,36 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { }); } +/// The Opacity slider that the arrow beside the field opens, as in InDesign: a drag shows the +/// new opacity live and is one undo step when let go. +fn opacity_slider(app: &mut DesignApp, resp: &egui::Response, opacity: f64) { + let (mut slid, mut held) = (None, false); + egui::Popup::menu(resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + // A thin rail and a round knob, as InDesign draws it. + ui.spacing_mut().slider_width = 130.0; + ui.spacing_mut().slider_rail_height = 2.0; + let mut v = (opacity * 100.0).round(); + let slider = ui.add( + egui::Slider::new(&mut v, 0.0..=100.0).integer().show_value(false).handle_shape(egui::style::HandleShape::Circle).trailing_fill(false), + ); + let pressed = slider.is_pointer_button_down_on() || slider.dragged(); + if slider.changed() { + slid = Some((v, pressed)); + } + held = pressed; + }); + match slid { + Some((v, true)) => app.live_preview("Opacity", "object.opacity", json!({"opacity": v / 100.0})), + Some((v, false)) => { + // The keyboard (arrow keys on the focused slider): applied at once. + app.end_live_preview(); + let _ = app.run("object.opacity", json!({"opacity": v / 100.0})); + } + None if held => app.hold_live_preview(), + None => {} + } +} + /// The Fill (or Stroke) popup of Properties › Appearance, as InDesign's: Apply To (a text frame's /// frame or its text), the tint, then the swatches. `cur` is the frame's swatch. With `text_only` /// (text selected) it colours the selected text. @@ -3343,6 +3372,55 @@ mod tests { 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); + 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..=6 { + 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}"); + 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()) < 0.9); + 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"; #[test] From 9b48a538d5ad0745ffaaedfadc8ce88e95d2da55 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 18:35:46 +0200 Subject: [PATCH 030/431] Properties: Text Wrap shows the four wrap offsets, as in InDesign Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/panels/properties.rs | 99 +++++++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..35fbb08f8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-20** · Properties › Text Wrap shows the four wrap offsets under the wrap buttons, with the link that makes them the same, as InDesign's Properties panel does (greyed with no wrap). No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 44acf6e2c..3f8ed3353 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1015,11 +1015,64 @@ fn wrap_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { let _ = app.run("object.textWrap", json!({"mode": i.wrap, "invert": inv})); } }); + wrap_offsets(app, ui, i); if more_options(ui, &app.ui.language).clicked() { show_panel(app, "textWrap"); } } +/// The wrap offsets under the wrap buttons, as InDesign's Properties panel shows them: Top and +/// Bottom, the link that makes all four the same, then Left and Right. Greyed with no wrap. +fn wrap_offsets(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { + let t = Tokens::get(ui.ctx()); + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); + let u = units(app); + let enabled = i.wrap != "none"; + let offsets = i.wrap_offsets; + ui.add_space(4.0); + let b = block(ui, 2.0 * FIELD_H + 5.0); + let mut set = None; + ui.add_enabled_ui(enabled, |ui| { + // Linked while the four are the same, until the link is clicked. + let linked = temp_bool(ui, "prop_wrap_linked", offsets.iter().all(|v| (v - offsets[0]).abs() < 1e-9)); + // Offsets are [top, left, bottom, right]. + for (k, key, icon, tip, x, y) in [ + (0, "wrapTop", "margin-top", "Top", 0.0, 0.0), + (2, "wrapBottom", "margin-bottom", "Bottom", 0.0, FIELD_H + 5.0), + (1, "wrapLeft", "margin-inside", "Left", 140.0, 0.0), + (3, "wrapRight", "margin-outside", "Right", 140.0, FIELD_H + 5.0), + ] { + 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| { + crate::rtl::label(ui, tr(tip)); + }); + let nv = place(ui, sub(b, x + 20.0, y, 76.0, FIELD_H), |ui| { + NumField::measure(key, Some(offsets[k]), u).width(76.0).spinner().range(-8000.0, 8000.0).enabled(enabled).show(ui) + }); + if let Some(v) = nv { + let mut o = offsets; + if linked { + o = [v; 4]; + } else { + o[k] = v; + } + set = Some(o); + } + } + if place(ui, sub(b, 106.0, (FIELD_H + 5.0) / 2.0, 22.0, FIELD_H), |ui| { + widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, &tr("Make all settings the same"), vec2(22.0, 21.0)) + .clicked() + }) { + set_temp_bool(ui, "prop_wrap_linked", !linked); + } + }); + if let Some(o) = set { + let _ = app.run("object.textWrap", json!({"mode": i.wrap, "offset": o})); + } +} + fn arrange_menu(app: &mut DesignApp, resp: &egui::Response) { egui::Popup::menu(resp).show(|ui| { for (label, cmd) in [ @@ -3343,6 +3396,52 @@ mod tests { 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. + 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]"); + } + const CYAN: &str = "C=100 M=0 Y=0 K=0"; #[test] From e1812231a90290385ac8f55a3c8765010b37c836 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 18:52:13 +0200 Subject: [PATCH 031/431] Place cursor: a thumbnail of the loaded graphic; a drag draws a proportional frame with its scale Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/tests.rs | 27 ++++++++++++++++++ crates/engine/src/tooling.rs | 1 + crates/tools/src/lib.rs | 2 ++ crates/tools/src/nav.rs | 40 +++++++++++++++++++++----- crates/tools/src/snap.rs | 1 + crates/tools/src/tests.rs | 1 + crates/ui-egui/src/canvas.rs | 55 ++++++++++++++++++++++++++++++++++++ 8 files changed, 121 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..e8a0494ac 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-20** · The loaded place cursor shows a thumbnail of the graphic, as InDesign's does; dragging with it draws the frame at the graphic's proportions from where the drag started, with its scale beside the pointer, and places exactly that frame. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9aacf469d..ad8c5b107 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -476,6 +476,33 @@ fn place_gun_click_drag_and_into_frame() { assert_eq!(graphic_xf(&s), placed); } +/// A place-cursor drag draws the frame at the graphic's proportions from where it started, as +/// InDesign does, shows its scale, and places exactly that frame, in any direction. +#[test] +fn place_gun_drag_previews_and_places_a_proportional_frame() { + use designcraft_tools::{Cursor, Mods, Overlay, PointerEvent, PointerKind}; + let mut s = session(); + let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png(); + s.execute("place.load", &json!({"base64": cmd::base64_encode(&png), "name": "a.png"})).unwrap(); + let v = ViewInfo::at_zoom(1.0); + let p = designcraft_geom::Point::new; + assert_eq!(s.cursor(p(300.0, 300.0), Mods::default(), v), Cursor::LoadedGraphic); + // Up and to the left: 80 × 200 holds the 40 × 20 graphic at 200 %, 80 × 40, hanging from the press. + s.pointer(&PointerEvent::new(PointerKind::Down, 300.0, 300.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 220.0, 100.0), v).unwrap(); + assert_eq!(s.cursor(p(220.0, 100.0), Mods::default(), v), Cursor::Crosshair, "the crosshair while drawing"); + let o = s.overlays(v); + assert!(o.iter().any(|o| matches!(o, Overlay::Marquee(r) if *r == designcraft_geom::Rect::new(220.0, 260.0, 300.0, 300.0))), "{o:?}"); + assert!( + o.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == "X: 200.00% +Y: 200.00%")), + "{o:?}" + ); + s.pointer(&PointerEvent::new(PointerKind::Up, 220.0, 100.0), v).unwrap(); + let it = s.doc().unwrap().doc.spreads[0].items.last().unwrap().clone(); + assert_eq!(it.bounds(), designcraft_geom::Rect::new(220.0, 260.0, 300.0, 300.0), "the frame previewed"); +} + #[test] fn preflight_reports_overset_and_missing_fonts() { let mut s = session(); diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index c58236090..e388aa869 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -108,6 +108,7 @@ impl Session { snap: view.snap, unit: view.unit, stroke_in_front: view.stroke_in_front, + loaded: self.loaded.map(|l| l.size), }; Some(f(self, &cx)) } diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index a92f1c132..3491401b1 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -220,6 +220,8 @@ pub struct ToolContext<'a> { pub unit: Unit, /// The toolbox's Stroke box is in front: sampled colours go to strokes (the Eyedropper). pub stroke_in_front: bool, + /// Natural size, in points, of the graphic loaded in the place cursor. + pub loaded: Option<(f64, f64)>, } impl ToolContext<'_> { diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index f77c5d0bb..db15effad 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -142,7 +142,8 @@ impl Tool for PlaceGun { { return vec![Action::Exec("place.drop".into(), json!({"frame": id.0}))]; } - let r = designcraft_geom::Rect::from_points(sa, b); + // The frame the drag drew: at the graphic's proportions from where it started. + let r = cx.loaded.map_or_else(|| designcraft_geom::Rect::from_points(sa, b), |size| proportional(sa, b, size).0); vec![Action::Exec( "place.drop".into(), json!({"spread": crate::spread_json(sr), "x": sa.x, "y": sa.y, "rect": [r.x0, r.y0, r.x1, r.y1]}), @@ -151,11 +152,24 @@ impl Tool for PlaceGun { _ => vec![], } } - fn overlays(&self, _cx: &ToolContext) -> Vec { - match self.start { - Some(a) => vec![crate::Overlay::Marquee(designcraft_geom::Rect::from_points(a, self.cur))], - None => vec![], - } + fn overlays(&self, cx: &ToolContext) -> Vec { + let Some(a) = self.start else { return vec![] }; + let (Some(size), true) = (cx.loaded, (self.cur - a).hypot() >= cx.tol(3.0)) else { + return vec![crate::Overlay::Marquee(designcraft_geom::Rect::from_points(a, self.cur))]; + }; + // As InDesign: the frame the graphic will fill, and its scale beside the pointer. + let (r, k) = proportional(a, self.cur, size); + let pct = format!("{:.2}%", k * 100.0); + vec![ + crate::Overlay::Marquee(r), + crate::Overlay::Measure { + p: self.cur, + text: format!( + "X: {pct} +Y: {pct}" + ), + }, + ] } fn key(&mut self, _cx: &ToolContext, key: crate::ToolKey, _mods: Mods) -> Vec { match key { @@ -165,10 +179,22 @@ impl Tool for PlaceGun { } } fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { - Cursor::LoadedGraphic + // Drawing the frame: the crosshair (the loaded graphic's thumbnail would hide the frame). + if self.start.is_some() { Cursor::Crosshair } else { Cursor::LoadedGraphic } } } +/// The frame a place-cursor drag from `a` to `b` draws for a graphic of natural `size`: the +/// graphic's proportions, from `a` towards `b`, as large as fits the drag. With its scale. +fn proportional(a: Point, b: Point, size: (f64, f64)) -> (designcraft_geom::Rect, f64) { + let (w, h) = (size.0.max(1e-6), size.1.max(1e-6)); + let (dx, dy) = (b.x - a.x, b.y - a.y); + let k = (dx.abs() / w).min(dy.abs() / h); + let k = if k.is_finite() { k } else { 0.0 }; + let c = Point::new(a.x + (w * k).copysign(dx), a.y + (h * k).copysign(dy)); + (designcraft_geom::Rect::from_points(a, c), k) +} + /// Page tool (Shift+P): click a page to select it; drag one of its handles to preview its liquid /// page rule (the page springs back on release), Alt-drag to resize it, Shift to keep its /// proportions; double-click a page for exact sizes. diff --git a/crates/tools/src/snap.rs b/crates/tools/src/snap.rs index 0cbc9aadd..2bbb20a24 100644 --- a/crates/tools/src/snap.rs +++ b/crates/tools/src/snap.rs @@ -1416,6 +1416,7 @@ mod tests { snap: SnapView::FACTORY, unit: Unit::Points, stroke_in_front: false, + loaded: None, } } diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index ee3b362f1..86054431e 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -19,6 +19,7 @@ fn ctx<'a>(d: &'a Document, s: &'a Selection, c: &'a Cache, l: &'a CanvasLayout) snap: SnapView::OFF, unit: Unit::Points, stroke_in_front: false, + loaded: None, } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8d94f099f..66963c528 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -606,9 +606,63 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { 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); + if c == Cursor::LoadedGraphic { + draw_loaded_thumbnail(app, &painter, p, &t); + } } } +/// Longest side of the loaded place cursor's thumbnail, in points. +const LOADED_THUMB: f32 = 128.0; + +/// The loaded place cursor's thumbnail, as InDesign shows it: the graphic, at most 128 pt on its +/// longer side, hanging from the pointer, with an image badge in its corner. +fn draw_loaded_thumbnail(app: &DesignApp, painter: &egui::Painter, p: Pos2, t: &Tokens) { + let (Some(l), Ok(st)) = (app.session.loaded.as_ref(), app.session.doc()) else { return }; + let (w, h) = (l.size.0 as f32, l.size.1 as f32); + if !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) { + return; + } + let k = LOADED_THUMB / w.max(h); + let r = Rect::from_min_size(p + vec2(2.0, 2.0), vec2(w * k, h * k)); + if let Some(tex) = loaded_thumb_texture(painter.ctx(), &st.doc, l.asset, l.size) { + painter.image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::from_white_alpha(225)); + } else { + // Not drawable here (an unreadable file): its frame only. + painter.rect_stroke(r, 0.0, Stroke::new(1.0, t.measure_text), StrokeKind::Inside); + } + let badge = Rect::from_min_size(r.min + vec2(6.0, 6.0), vec2(16.0, 12.0)); + painter.rect_filled(badge, 2.0, t.measure_bg); + let ink = Stroke::new(1.2, t.measure_text); + let inner = badge.shrink2(vec2(3.0, 2.5)); + painter.rect_stroke(inner, 0.5, ink, StrokeKind::Middle); + painter.add(egui::Shape::line( + vec![pos2(inner.min.x, inner.max.y), pos2(inner.min.x + 3.5, inner.min.y + 2.5), pos2(inner.max.x, inner.max.y)], + ink, + )); +} + +/// 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)); + if let Some(t) = ctx.data(|d| d.get_temp::(key)) { + return Some(t); + } + // Twice the thumbnail's size in pixels, for high-density screens. + let scale = (2.0 * f64::from(LOADED_THUMB) / size.0.max(size.1)).min(1.0); + let pm = designcraft_render::images::mip(&a.data, a.page, size.0, scale)?; + let (pw, ph) = (pm.width() as usize, pm.height() as usize); + if pw == 0 || ph == 0 { + return None; + } + let ci = egui::ColorImage::from_rgba_premultiplied([pw, ph], pm.data_as_u8_slice()); + let max = ctx.input(|i| i.max_texture_side).min(1024); + 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) +} + /// The loaded text cursor's badge beside the system cursor: lines of text, a closed chain /// (a click threads) or a broken one (a click unthreads). Drawn in code. fn draw_cursor_badge(painter: &egui::Painter, p: Pos2, c: Cursor, t: &Tokens) { @@ -4481,6 +4535,7 @@ mod tests { 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 1d59b6e739ffaa227a3b383f35e05bf8f20bab9e Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 19:16:05 +0200 Subject: [PATCH 032/431] PR-1255: test the TOC importer's fallbacks for malformed and unsupported values Co-Authored-By: Claude Opus 5.5 --- crates/idml/src/tests.rs | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 582242255..98934c869 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -4267,6 +4267,43 @@ fn toc_style_entries_read_id_prefixed_separators_and_styles() { assert_eq!(toc.title_style.as_deref(), Some("TOC/TOC Header")); } +#[test] +fn toc_style_entries_fall_back_on_malformed_and_unsupported_values() { + let long = "x".repeat(65); + let entries = format!( + r#" + ParagraphStyle/Missing + CharacterStyle/$ID/[None] + CharacterStyle/Missing + + + + "# + ); + let start = TOC_STYLES.find("").unwrap(); + let styles = format!("{}{entries}\n{}", &TOC_STYLES[..start], &TOC_STYLES[end..]) + .replace(r#"TitleStyle="ParagraphStyle/TOC%3aTOC Header""#, r#"TitleStyle="ParagraphStyle/Missing""#); + let d = import_idml(&fixture_with_table_styles(&styles, TOC_STORY)).unwrap(); + d.check().unwrap(); + let toc = d.toc.as_ref().expect("the TOC style is still imported"); + assert_eq!(toc.title_style, None, "a title style the document lacks is dropped"); + assert_eq!(toc.entries.len(), 3, "an entry without a style name is skipped: {:?}", toc.entries); + let e1 = &toc.entries[0]; + assert_eq!(e1.level, 1, "an unreadable level falls back to 1"); + assert_eq!(e1.page_number, designcraft_doc::TocPageNumber::After, "an unknown page-number position falls back to After"); + assert_eq!(e1.separator.as_deref(), Some("^q"), "an unknown special-character code is kept as written"); + assert_eq!( + (e1.entry_style.as_deref(), e1.page_number_style.as_deref(), e1.separator_style.as_deref()), + (None, None, None), + "undefined and [None] styles are no style" + ); + let e2 = &toc.entries[1]; + assert_eq!(e2.level, 9, "a level past 9 is clamped"); + assert_eq!(e2.separator, None, "an over-long separator is dropped"); + assert_eq!((toc.entries[2].level, toc.entries[2].separator.as_deref()), (2, None), "a plain tab is the default separator"); +} + #[test] fn toc_style_references_in_indented_properties_resolve() { // Property children laid out on their own lines: the whitespace around a reference isn't From 6e2d3497990184bf22099913542c24f8e699458a Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 19:17:46 +0200 Subject: [PATCH 033/431] Properties: Frame Fitting has Auto-Fit and Options, as in InDesign; checkboxes are named for screen readers Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/panels/properties.rs | 40 +++++++++++++++++++++++++ crates/ui-egui/src/widgets.rs | 3 ++ 3 files changed, 44 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..3a334b9d3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-20** · Properties › Frame Fitting has InDesign's Auto-Fit checkbox and Options button (Frame Fitting Options); the app's checkboxes are now named and ticked-or-not for screen readers. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 44acf6e2c..b15e3471d 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -167,7 +167,21 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let _ = app.run("object.fit", json!({"mode": mode})); } } + // Frame Fitting Options…, as InDesign's Options button beside the fitting buttons. + if outline_button(ui, crate::i18n::tr(&app.ui.language, "Options"), 64.0).clicked() { + crate::menus::activate(app, "app.fittingOptions", &Value::Null); + } }); + // Auto-Fit: the graphic is refitted whenever its frame is resized. + let mut auto = app + .session + .active() + .and_then(|st| st.selection.items.first().and_then(|id| st.doc.item(*id))) + .and_then(|it| it.graphic()) + .is_some_and(|g| g.auto_fit != designcraft_doc::Fitting::None); + if widgets::checkbox(ui, &mut auto, crate::i18n::tr(&app.ui.language, "Auto-Fit")).changed() { + let _ = app.run("object.fittingOptions", json!({"autoFit": auto})); + } divider(ui); } section(ui, crate::i18n::tr(&app.ui.language, "Text Wrap"), true); @@ -3343,6 +3357,32 @@ mod tests { 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); + 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"; #[test] diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index fb9876aa2..645c83c1a 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -567,6 +567,9 @@ pub fn checkbox(ui: &mut Ui, on: &mut bool, label: &str) -> Response { *on = !*on; resp.mark_changed(); } + // Named and ticked or not for screen readers (and tests). + let checked = *on; + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, checked, label)); resp } From be9ee05a58a99dafcce72b8432c999019d92318f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 20:31:38 +0200 Subject: [PATCH 034/431] PR-1389: name the Opacity slider for assistive technology; keep the release frame in the drag Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index fbdb417d8..b781ed4b3 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -882,7 +882,13 @@ fn opacity_slider(app: &mut DesignApp, resp: &egui::Response, opacity: f64) { let slider = ui.add( egui::Slider::new(&mut v, 0.0..=100.0).integer().show_value(false).handle_shape(egui::style::HandleShape::Circle).trailing_fill(false), ); - let pressed = slider.is_pointer_button_down_on() || slider.dragged(); + // The slider has no visible caption (the field beside the arrow is the label), so it's + // named for assistive technology only. + let label = crate::i18n::tr(&app.ui.language, "Opacity"); + slider.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), v, label)); + // The release frame can move the value too; it stays part of the drag's preview, which + // the next frame commits as the one undo step. + let pressed = slider.is_pointer_button_down_on() || slider.dragged() || slider.drag_stopped(); if slider.changed() { slid = Some((v, pressed)); } @@ -3399,23 +3405,28 @@ mod tests { 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..=6 { + 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()) < 0.9); + 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); From 21c86ddcb7dc9c152d915ac2775de618bec83b1a Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 20:48:43 +0200 Subject: [PATCH 035/431] Layout: Create Guides puts its guides on both pages of the spread in view, as InDesign does Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/dialogs.rs | 29 ++++++++++++++++------------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..5f8ccdf11 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-20** · Layout › Create Guides puts its guides on both pages of the spread in view, as InDesign does; "This page" in Apply to narrows them to one page. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8b4043787..407e05775 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -869,19 +869,20 @@ fn doc_unit(app: &DesignApp) -> Unit { app.session.active().map(|s| s.doc.settings.horizontal_units).unwrap_or(Unit::Picas) } -/// 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. +/// Layout › Create Guides…: guides go on every page of the spread in view (or of the parent +/// spread being edited), as InDesign's do; "This page" narrows them to +/// the page in view. The gutters are typed in the document's ruler unit. pub fn open_create_guides(app: &mut DesignApp) { let unit = doc_unit(app); let Some((spread, page)) = crate::canvas::current_spread_page(app) else { return }; - let editing_parents = matches!(spread, SpreadRef::Parent(_)); let gutter = json!(format_measure(12.0, unit)); let mut d = Dialog::new( "createGuides", json!({"rows": 0, "columns": 0, "rowGutter": gutter, "columnGutter": gutter, "fitTo": "margins", "removeExisting": false, "spread": serde_json::to_value(spread).unwrap_or(json!(0)), "page": page, - // A parent spread is edited as a whole (both pages of a facing spread), so the guides show on every page that uses it. - "target": if editing_parents { "spread" } else { "page" }, + // InDesign puts the guides on both pages of a facing spread (and a parent spread is + // edited as a whole, so they show on every page that uses it). + "target": "spread", "where": target_description(app, spread, page)}), ); d.unit = unit; @@ -9875,18 +9876,20 @@ mod tests { } #[test] - fn create_guides_dialog_targets_the_page_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. + 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"); + 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"})); - assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); - assert_eq!(guide_counts(&app), vec![0, 0, 0, 0, 4, 0]); - // "Both pages of the spread" covers the facing pair. - crate::canvas::go_to_page(&mut app, 1); - crate::menus::activate(&mut app, "layout.createGuides", &Value::Null); - ctl(&mut app, "ui.dialog.set", json!({"field": "columns", "value": "2"})); - ctl(&mut app, "ui.dialog.set", json!({"field": "target", "value": "spread"})); + 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 39dfbdb36b006f66499c8d0030672089cb2c409a Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 20:55:08 +0200 Subject: [PATCH 036/431] Units: centimetres show four decimals, as InDesign's fields do Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/geom/src/units.rs | 14 +++++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..1692c590f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-20** · Measures in centimetres show four decimals, as InDesign's fields do (a 1p gutter reads 0.4233 cm, not 0.423 cm). No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/geom/src/units.rs b/crates/geom/src/units.rs index da1676d4b..f0457e75d 100644 --- a/crates/geom/src/units.rs +++ b/crates/geom/src/units.rs @@ -258,9 +258,9 @@ fn parse_single(tok: &str, default: Unit, percent_base: Option) -> Option String { - // As InDesign shows them: inches to four decimals (a 1p gutter is 0.1667 in, A4 is - // 8.2677 in wide), the other units to three. - let decimals = if matches!(unit, Unit::Inches | Unit::InchesDecimal) { 4 } else { 3 }; + // As InDesign shows them: inches and centimetres to four decimals (a 1p gutter is 0.1667 in + // or 0.4233 cm, A4 is 8.2677 in wide), the other units to three. + let decimals = if matches!(unit, Unit::Inches | Unit::InchesDecimal | Unit::Centimeters) { 4 } else { 3 }; format_measure_prec(pt, unit, decimals) } @@ -304,14 +304,18 @@ mod tests { (a - b).abs() < 1e-6 } - /// Inches show four decimals, as InDesign's fields do; other units three. + /// Inches and centimetres show four decimals, as InDesign's fields do; other units three. #[test] - fn inches_show_four_decimals() { + fn inches_and_centimeters_show_four_decimals() { assert_eq!(format_measure(12.0, Unit::Inches), "0.1667 in"); assert_eq!(format_measure(210.0 * PT_PER_MM, Unit::Inches), "8.2677 in"); assert_eq!(format_measure(612.0, Unit::Inches), "8.5 in"); assert_eq!(format_measure(9.0, Unit::Inches), "0.125 in"); assert_eq!(format_measure(10.0, Unit::Millimeters), "3.528 mm"); + // A 1p gutter and a frame's height, as InDesign's New Document and Transform fields show them. + assert_eq!(format_measure(12.0, Unit::Centimeters), "0.4233 cm"); + assert_eq!(format_measure(17.1125 / 2.54 * 72.0, Unit::Centimeters), "17.1125 cm"); + assert_eq!(format_measure(21.0 / 2.54 * 72.0, Unit::Centimeters), "21 cm"); assert_eq!(format_measure(10.0, Unit::Points), "10 pt"); } From 53b8b0414132a051a5e56397f838cc5bd607f3b9 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 21:17:37 +0200 Subject: [PATCH 037/431] PR-1392: the place-cursor preview follows place.drop's 2 pt drag threshold Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/file.rs | 2 +- crates/engine/src/tests.rs | 16 ++++++++++++++++ crates/tools/src/lib.rs | 2 +- crates/tools/src/nav.rs | 18 +++++++++++++++++- 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 973262937..3fb243216 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -882,7 +882,7 @@ fn place_drop(s: &mut Session, p: &Value) -> Result { } // Drag: fit proportionally; click: actual size at the point. let (r, k) = match rect { - Some(r) if r.width() > 2.0 && r.height() > 2.0 => { + Some(r) if r.width() > designcraft_tools::PLACE_DRAG_MIN && r.height() > designcraft_tools::PLACE_DRAG_MIN => { let k = (r.width() / nw).min(r.height() / nh); (Rect::new(r.x0, r.y0, r.x0 + nw * k, r.y0 + nh * k), k) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ad8c5b107..5d0795196 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -501,6 +501,22 @@ Y: 200.00%")), s.pointer(&PointerEvent::new(PointerKind::Up, 220.0, 100.0), v).unwrap(); let it = s.doc().unwrap().doc.spreads[0].items.last().unwrap().clone(); assert_eq!(it.bounds(), designcraft_geom::Rect::new(220.0, 260.0, 300.0, 300.0), "the frame previewed"); + // 80 × 1 is past the drag tolerance, but its 2 × 1 proportional frame is too small for + // `place.drop`, which places at natural size: the preview shows that frame and 100 %. + s.execute("place.load", &json!({"base64": cmd::base64_encode(&png), "name": "a.png"})).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 400.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 180.0, 401.0), v).unwrap(); + let natural = designcraft_geom::Rect::new(100.0, 400.0, 140.0, 420.0); + let o = s.overlays(v); + assert!(o.iter().any(|o| matches!(o, Overlay::Marquee(r) if *r == natural)), "{o:?}"); + assert!( + o.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == "X: 100.00% +Y: 100.00%")), + "{o:?}" + ); + s.pointer(&PointerEvent::new(PointerKind::Up, 180.0, 401.0), v).unwrap(); + let it = s.doc().unwrap().doc.spreads[0].items.last().unwrap().clone(); + assert_eq!(it.bounds(), natural, "the frame previewed"); } #[test] diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 3491401b1..5f05d01ba 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -16,7 +16,7 @@ mod frame; mod gradient; pub mod layout; mod nav; -pub use nav::{LOCK_OFFSET_PX, PIN_OFFSET_PX}; +pub use nav::{LOCK_OFFSET_PX, PIN_OFFSET_PX, PLACE_DRAG_MIN}; mod pen; mod pencil; pub mod select; diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index db15effad..4a582c385 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -158,7 +158,7 @@ impl Tool for PlaceGun { return vec![crate::Overlay::Marquee(designcraft_geom::Rect::from_points(a, self.cur))]; }; // As InDesign: the frame the graphic will fill, and its scale beside the pointer. - let (r, k) = proportional(a, self.cur, size); + let (r, k) = drawn_frame(a, self.cur, size); let pct = format!("{:.2}%", k * 100.0); vec![ crate::Overlay::Marquee(r), @@ -184,6 +184,22 @@ Y: {pct}" } } +/// A dragged frame `place.drop` takes only when it is wider and taller than this, in points; +/// a thinner one places the graphic at its natural size instead. +pub const PLACE_DRAG_MIN: f64 = 2.0; + +/// The frame `place.drop` makes for a place-cursor drag from `a` to `b`: the proportional frame +/// (see [`proportional`]), or the graphic at natural size from `a` when that frame is at most +/// [`PLACE_DRAG_MIN`] in either direction. With its scale. +fn drawn_frame(a: Point, b: Point, size: (f64, f64)) -> (designcraft_geom::Rect, f64) { + let (r, k) = proportional(a, b, size); + if r.width() > PLACE_DRAG_MIN && r.height() > PLACE_DRAG_MIN { + (r, k) + } else { + (designcraft_geom::Rect::new(a.x, a.y, a.x + size.0, a.y + size.1), 1.0) + } +} + /// The frame a place-cursor drag from `a` to `b` draws for a graphic of natural `size`: the /// graphic's proportions, from `a` towards `b`, as large as fits the drag. With its scale. fn proportional(a: Point, b: Point, size: (f64, f64)) -> (designcraft_geom::Rect, f64) { From 83f2f252c7330bc1cef68694600b54761611b63a Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 22:08:23 +0200 Subject: [PATCH 038/431] Object: Corner Options opens a dialog laid out as InDesign's, from the menu and the Properties Corner link Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/dialogs.rs | 255 ++++++++++++++++++++++++ crates/ui-egui/src/i18n.rs | 4 + crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + crates/ui-egui/src/panels/properties.rs | 8 +- 14 files changed, 277 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..8c841853d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-20** · Object › Corner Options opens a Corner Options dialog laid out as InDesign's: each corner's size and shape, the link button that makes all four the same, and Preview. The Corner link in Properties › Appearance opens it too (it opened a Rectangle size dialog). No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8b4043787..4c646e956 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2757,6 +2757,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }); } STEP_AND_REPEAT => step_and_repeat_dialog(app, ui, &mut d), + CORNER_OPTIONS => corner_options_dialog(app, ui, &mut d), TOC => toc_dialog(app, ui, &mut d), id if id.starts_with("cmd:") => command_form(app, ui, &mut d), "goToPage" => { @@ -3579,6 +3580,22 @@ pub fn confirm(app: &mut DesignApp) -> Result { app.session.reset_find_scope(); result } + CORNER_OPTIONS => { + // The preview goes; the corners change once, as one undo step. + if d.fields.contains_key(PREVIEWED) { + app.session.cancel_interaction(); + } + let mut d = d; + seed_corner_options(app, &mut d); + link_corners(&mut d); + let r = corner_options_params(&d).and_then(|p| app.run("object.cornerOptions", p)); + if let Err(e) = &r { + d.fields.remove(PREVIEWED); + d.fields.insert("status".into(), json!(e)); + app.ui.dialog = Some(d); + } + r + } STEP_AND_REPEAT => { // The preview goes; the copies are made once, as one undo step. if d.fields.contains_key(PREVIEWED) { @@ -6195,6 +6212,7 @@ fn command_form(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } const STEP_AND_REPEAT: &str = "cmd:edit.stepAndRepeat"; +pub(crate) const CORNER_OPTIONS: &str = "cmd:object.cornerOptions"; const TOC: &str = "cmd:toc.generate"; /// One included style of the Table of Contents dialog, as `toc.generate` takes it, with the @@ -6554,6 +6572,179 @@ fn step_and_repeat_preview(app: &mut DesignApp, d: &mut Dialog) { d.fields.insert(PREVIEWED.into(), params); } +/// The corners of Object › Corner Options, in `object.cornerOptions` path order (top-left, +/// top-right, bottom-right, bottom-left), with the tip InDesign gives each. +const CORNERS: [(&str, &str); 4] = [("0", "Top Left:"), ("1", "Top Right:"), ("2", "Bottom Right:"), ("3", "Bottom Left:")]; + +/// The Corner Options dialog's fields from the first selected object's corners, once: each +/// corner's size (in the ruler units) and shape, linked when all four are the same. +fn seed_corner_options(app: &DesignApp, d: &mut Dialog) { + use designcraft_geom::corners::Corner; + let Some(st) = app.session.active() else { return }; + d.unit = st.doc.settings.horizontal_units; + if d.fields.contains_key("_seeded") { + return; + } + let corners: [Corner; 4] = st.selection.items.iter().find_map(|id| st.doc.item(*id)).map(|it| it.corners.corners).unwrap_or_default(); + for ((k, _), c) in CORNERS.iter().zip(corners) { + d.fields.entry(format!("size{k}")).or_insert(json!(format_measure(c.size, d.unit))); + d.fields.entry(format!("shape{k}")).or_insert(serde_json::to_value(c.shape).unwrap_or(json!("none"))); + } + let first = corners[0]; + d.fields.entry("link").or_insert(json!(corners.iter().all(|c| *c == first))); + d.fields.insert("_seeded".into(), json!(true)); +} + +/// Linked corners: a corner changed since the dialog last looked (size or shape, typed or set +/// over the control channel) is all four. +fn link_corners(d: &mut Dialog) { + let now: Vec<(String, String)> = CORNERS.iter().map(|(k, _)| (d.s(&format!("size{k}")), d.s(&format!("shape{k}")))).collect(); + let seen: Vec<(String, String)> = d.fields.get("_corners").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_else(|| now.clone()); + let mut now = now; + if d.b("link") + && let Some(i) = now.iter().zip(&seen).position(|(a, b)| a != b) + && let Some(changed) = now.get(i).cloned() + { + for (k, _) in &CORNERS { + d.fields.insert(format!("size{k}"), json!(changed.0)); + d.fields.insert(format!("shape{k}"), json!(changed.1)); + } + now = vec![changed; CORNERS.len()]; + } + d.fields.insert("_corners".into(), json!(now)); +} + +/// `object.cornerOptions`' params from the dialog. A size that does not parse is an error to show. +fn corner_options_params(d: &Dialog) -> Result { + use designcraft_geom::corners::CornerShape; + let mut corners = Vec::with_capacity(CORNERS.len()); + for (k, _) in &CORNERS { + let key = format!("size{k}"); + let size = + d.m(&key).filter(|v| v.is_finite() && *v >= 0.0).ok_or_else(|| format!("Enter a valid corner size, e.g. 12 or 3 mm: {}", d.s(&key)))?; + let shape: CornerShape = d.fields.get(&format!("shape{k}")).and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + corners.push(json!({"shape": shape, "size": size})); + } + Ok(json!({"corners": corners})) +} + +/// A corner's icon: the corner of a small square, with a dot at the corner it sets. +fn paint_corner_icon(ui: &mut egui::Ui, corner: usize, tip: &str) { + let t = crate::theme::Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover()); + resp.on_hover_text(tip); + let b = r.shrink(3.0); + ui.painter().rect_stroke(b, 0.0, egui::Stroke::new(1.0, t.icon), egui::StrokeKind::Inside); + let at = match corner { + 0 => b.left_top(), + 1 => b.right_top(), + 2 => b.right_bottom(), + _ => b.left_bottom(), + }; + ui.painter().rect_filled(egui::Rect::from_center_size(at, egui::vec2(4.0, 4.0)), 0.0, t.accent); +} + +/// Object › Corner Options, as InDesign's: under "Corner Size and Shape:", each corner's size and +/// shape (top corners above, bottom ones below, as on the frame), the link button that makes all +/// four the same, and Preview. +fn corner_options_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + use designcraft_geom::corners::CornerShape; + seed_corner_options(app, d); + link_corners(d); + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); + crate::rtl::label(ui, tr("Corner Size and Shape:")); + ui.add_space(8.0); + let corner = |ui: &mut egui::Ui, d: &mut Dialog, i: usize| { + let Some((k, tip)) = CORNERS.get(i) else { return }; + ui.horizontal(|ui| { + paint_corner_icon(ui, i, &tr(tip)); + measure_field(ui, d, &format!("size{k}"), 70.0); + 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())))); + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(150.0); + for s in CornerShape::ALL { + if ui.selectable_label(s == shape, crate::rtl::widget(ui, tr(s.label()))).clicked() { + d.fields.insert(key.clone(), json!(s)); + ui.close(); + } + } + }); + }); + }; + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + corner(ui, d, 0); + ui.add_space(6.0); + corner(ui, d, 3); + }); + ui.add_space(8.0); + let linked = d.b("link"); + let tip = tr("Make all settings the same"); + let resp = ui + .vertical(|ui| { + ui.add_space(12.0); + 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)); + if resp.clicked() { + d.fields.insert("link".into(), json!(!linked)); + } + ui.add_space(8.0); + ui.vertical(|ui| { + corner(ui, d, 1); + ui.add_space(6.0); + corner(ui, d, 2); + }); + }); + link_corners(d); + ui.add_space(10.0); + check(ui, d, "preview", &tr("Preview")); + corner_options_preview(app, d); + if let Some(st) = d.fields.get("status").and_then(Value::as_str) { + ui.add_space(6.0); + crate::rtl::label(ui, egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); + } +} + +/// Preview on: the corners show on the frame (redone when a field changes); off: they go back. +fn corner_options_preview(app: &mut DesignApp, d: &mut Dialog) { + let shown = d.fields.get(PREVIEWED).cloned(); + if !d.b("preview") { + if shown.is_some() { + app.session.cancel_interaction(); + d.fields.remove(PREVIEWED); + d.fields.remove("status"); + } + return; + } + let params = match corner_options_params(d) { + Ok(p) => p, + Err(e) => { + d.fields.insert("status".into(), json!(e)); + return; + } + }; + if shown.as_ref() == Some(¶ms) { + return; + } + if shown.is_none() + && let Err(e) = app.session.begin_interaction("Corner Options") + { + d.fields.insert("status".into(), json!(e.to_string())); + return; + } + match app.session.preview("object.cornerOptions", params.clone()) { + Ok(()) => d.fields.remove("status"), + Err(e) => d.fields.insert("status".into(), json!(e.to_string())), + }; + d.fields.insert(PREVIEWED.into(), params); +} + /// New Cross-Reference: link to a paragraph (paragraph styles on the left, their paragraphs on /// the right) or a text anchor, with a cross-reference format. fn insert_xref(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { @@ -6965,6 +7156,70 @@ mod tests { assert_eq!(paragraph_style_summary("", &d, &style, &styles), "Parent"); } + #[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. + set(&mut app, "size0", json!("abc")); + assert!(confirm(&mut app).is_err()); + assert!(app.ui.dialog.is_some()); + cancel(&mut app); + assert_eq!(corners(&app), c); + } + #[test] fn step_and_repeat_dialog_previews_and_makes_the_copies_as_one_undo_step() { 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 efe089ef9..f4fbc2458 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -89,6 +89,10 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ( + "Corner Size and Shape:", + ["Corner Size and Shape:", "Taille et forme des angles:", "Corner Size and Shape:", "Corner Size and Shape:", "角大小和形状:"], + ), ("Hide Rulers", ["Lineale ausblenden", "Masquer les règles", "Ocultar reglas", "定規を隠す", "隐藏标尺"]), ("Inches Decimal", ["Zoll dezimal", "Pouces décimaux", "Pulgadas decimales", "インチ(10 進)", "十进制英寸"]), ( diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..ae9cc3259 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", "لصق"), + ("Corner Size and Shape:", "حجم الزوايا وشكلها:"), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), ("Fixed Number", "عدد ثابت"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..3f4db1024 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Corner Size and Shape:", "Velikost a tvar rohů:"), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index efc128858..1b3dd08cc 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Ausschneiden"), ("Copy", "Kopieren"), ("Paste", "Einfügen"), + ("Corner Size and Shape:", "Eckengröße und -form:"), ("Inches Decimal", "Zoll dezimal"), ("Back", "Zurück"), ("Warning", "Warnung"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..f403c1fea 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"), + ("Corner Size and Shape:", "Dimensione e forma angoli:"), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 5647ca940..bed2b966d 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", "ペースト"), + ("Corner Size and Shape:", "角のサイズとシェイプ:"), ("Paste Vector Art", "ベクトルアートをペースト"), ("Baseline Options", "ベースラインオプション"), ("Column Rules", "段落境界線"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index d938c2dc7..a02711406 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Corner Size and Shape:", "კუთხეების ზომა და ფორმა:"), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..3a19441d9 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Corner Size and Shape:", "Rozmiar i kształt narożników:"), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..5fea60b67 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"), + ("Corner Size and Shape:", "Tamanho e forma dos cantos:"), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), ("Fixed Number", "Número fixo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..2c3489c6f 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Corner Size and Shape:", "Размер и форма углов:"), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..b3884bd8e 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Corner Size and Shape:", "Розмір і форма кутів:"), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 44acf6e2c..50f60c6cd 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -761,6 +761,11 @@ fn paint_corner_shape(p: &egui::Painter, r: Rect, shape: CornerShape) { p.add(egui::Shape::line(pts, s)); } +/// A corner shape picker's face: the shape's glyph and the dropdown arrow (the menu is the caller's). +pub(crate) fn corner_shape_button(ui: &mut Ui, shape: CornerShape, width: f32) -> egui::Response { + preview_dropdown(ui, width, |p, r| paint_corner_shape(p, r, shape)) +} + pub(crate) fn corner_type_dropdown(app: &mut DesignApp, ui: &mut Ui, shape: CornerShape, size: f64, width: f32) { let resp = preview_dropdown(ui, width, |p, r| paint_corner_shape(p, r, shape)); egui::Popup::menu(&resp).show(|ui| { @@ -828,7 +833,8 @@ 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!({}))); + // Corner Options, as Object › Corner Options… opens it. + crate::menus::activate(app, "object.cornerOptions", &Value::Null); } let (shape, size) = i.corner; if let Some(v) = place(ui, sub(b, 96.0, 0.0, 69.0, FIELD_H), |ui| { From 4a65451d404eb424491a9ab2d4c5fbf633282bea Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 22:20:26 +0200 Subject: [PATCH 039/431] Type tool: a drag that starts in an empty frame draws a new text frame over it, as in InDesign Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/tools/src/tests.rs | 24 ++++++++++++++++++++++++ crates/tools/src/text.rs | 14 ++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..62dc3a8dc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-20** · Type tool: a drag that starts in an empty frame draws a new text frame over it, as in InDesign; a click still turns the frame into a text frame. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index ee3b362f1..5295708cb 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -1058,6 +1058,30 @@ fn double_click_on_a_port_breaks_its_link() { } } +/// With the Type tool, a drag that starts in an empty rectangle draws a new text frame over it +/// (the rectangle stays as it is), as InDesign's does; a click still turns the rectangle into a +/// text frame with the caret in it. +#[test] +fn type_tool_drag_over_an_empty_frame_draws_a_new_text_frame() { + let mut d = Document::new(&NewDocument::default()); + let id = add_rect(&mut d, Rect::new(100.0, 100.0, 300.0, 300.0)); + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let off = l.xf(SpreadRef::Doc(0)).translation(); + let at = |x: f64, y: f64| (x + off.x, y + off.y); + let mut t = create("type"); + let (x, y) = at(120.0, 120.0); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y)).is_empty()); + let (x1, y1) = at(280.0, 200.0); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x1, y1)); + assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(cmd, p)] if cmd == "frame.create" && p["content"] == "text"), "{a:?}"); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x1, y1)), vec![Action::Commit]); + // A click: the caret goes into the rectangle, which becomes a text frame. + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y)).is_empty()); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x, y)); + assert!(matches!(&a[..], [Action::Exec(cmd, p)] if cmd == "text.placeCaret" && p["frame"] == id.0), "{a:?}"); +} + #[test] fn clicks_in_a_row_edit_and_select_text() { let mut d = Document::new(&NewDocument::default()); diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 56658f41e..c8cb778a9 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -31,6 +31,9 @@ pub struct TypeTool { start: Option, selecting: Option, drawing: bool, + /// A press on an empty frame: (frame, press point in spread space). A click turns it into a + /// text frame with the caret in it; a drag draws a new text frame over it, as in InDesign. + convert: Option<(u64, Point)>, /// A press on a table whose whole rows or columns are selected: (frame, press point in /// spread space) — a drag moves them, a click places the caret. cell_drag: Option<(u64, Point)>, @@ -226,6 +229,7 @@ impl Tool for TypeTool { self.cell_drag = None; self.unit = None; self.border = None; + self.convert = None; if ev.kind == PointerKind::Down && let Some(b) = border_at(cx, ev.pos) { @@ -246,6 +250,11 @@ impl Tool for TypeTool { self.cell_drag = Some((id.0, sp)); return vec![]; } + // An empty frame: wait to see whether this is a click or a drag. + if clicks == 1 && !ev.mods.shift && !it.is_text_frame() { + self.convert = Some((id.0, sp)); + return vec![]; + } self.selecting = Some(id.0); // Two presses select a word (or a run of spaces), three a line, four a // paragraph, five the story. @@ -296,6 +305,7 @@ impl Tool for TypeTool { let mut out = vec![]; if !self.drawing { self.drawing = true; + self.convert = None; out.push(Action::Begin("Create Text Frame".into())); } out.push(Action::Preview( @@ -323,6 +333,10 @@ impl Tool for TypeTool { self.selecting = None; return vec![Action::Commit]; } + // A click in an empty frame: it becomes a text frame with the caret in it. + if let Some((fid, at)) = self.convert.take() { + return vec![Action::Exec("text.placeCaret".into(), json!({"frame": fid, "point": [at.x, at.y]}))]; + } // Ends a press in text: drops dragged text, or is ignored by the engine. if let Some(fid) = self.selecting.take() { let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos); From 061c4faaf0948213a0e895fb191ef68dd9e9be8c Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 22:32:46 +0200 Subject: [PATCH 040/431] Direct Selection: select anchor points with a marquee across paths and move them together Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/edit.rs | 32 +++++++- crates/engine/src/cmd/object.rs | 87 ++++++++++++++-------- crates/engine/src/tests.rs | 35 +++++++++ crates/tools/src/select.rs | 128 ++++++++++++++++++++++++++++++-- crates/tools/src/tests.rs | 57 +++++++++++++- crates/ui-egui/src/canvas.rs | 8 +- 7 files changed, 307 insertions(+), 41 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..7e485d1ad 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-20** · Direct Selection selects anchor points: a marquee selects the points inside it on every path it reaches, a click selects one point and Shift+click adds or removes one, selected points show solid, and dragging one (Shift: at 45° steps) or pressing the arrow keys moves them all in one step (`selection.set {anchors}`, `path.moveAnchors {points}`). No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 9ee963317..3add6af64 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -8,6 +8,21 @@ use serde_json::{Value, json}; use super::{CommandSpec, bool_or, cmd, has_doc, has_selection, ids_param, ok}; use crate::{HistoryEntry, Result, Session}; +/// `anchors` of `selection.set`: the points that exist, once each. +fn anchors_param(doc: &designcraft_doc::Document, p: &Value) -> Vec<(ItemId, usize, usize)> { + let mut out: Vec<(ItemId, usize, usize)> = Vec::new(); + for v in p.get("anchors").and_then(Value::as_array).into_iter().flatten() { + let n = |i: usize| v.get(i).and_then(Value::as_u64); + let (Some(id), Some(si), Some(ai)) = (n(0), n(1), n(2)) else { continue }; + let (Ok(si), Ok(ai)) = (usize::try_from(si), usize::try_from(ai)) else { continue }; + let a = (ItemId(id), si, ai); + if doc.item(a.0).is_some_and(|it| it.path.subpaths.get(si).is_some_and(|sp| ai < sp.anchors.len())) && !out.contains(&a) { + out.push(a); + } + } + out +} + fn can_undo(s: &Session) -> std::result::Result<(), String> { s.active().filter(|d| !d.history.undo.is_empty()).map(|_| ()).ok_or_else(|| "nothing to undo".into()) } @@ -193,11 +208,17 @@ pub fn specs() -> Vec { s.typing_format = None; Ok(json!({"redone": e.label})) }), - cmd!(noundo "selection.set", "Select", [], None, "{ids: [id], add?: bool, content?: bool}", has_doc, |s, p| { - let ids = ids_param(p, "ids").unwrap_or_default(); + cmd!(noundo "selection.set", "Select", [], None, "{ids: [id], add?: bool, content?: bool, anchors?: [[id, subpath, index]] (Direct Selection: the selected anchor points; their paths are selected too)}", has_doc, |s, p| { + let mut ids = ids_param(p, "ids").unwrap_or_default(); let add = bool_or(p, "add", false); let content = bool_or(p, "content", false); let st = s.doc_mut()?; + let anchors = anchors_param(&st.doc, p); + for (id, _, _) in &anchors { + if !ids.contains(id) { + ids.push(*id); + } + } let ids: Vec = ids.into_iter().filter(|i| st.doc.item(*i).is_some()).collect(); if add { for i in ids { @@ -205,8 +226,13 @@ pub fn specs() -> Vec { st.selection.items.push(i); } } + for a in anchors { + if !st.selection.anchors.contains(&a) { + st.selection.anchors.push(a); + } + } } else { - st.selection = Selection { items: ids, content, ..Default::default() }; + st.selection = Selection { items: ids, content, anchors, ..Default::default() }; } st.selection.text = None; st.selection.cells = None; diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 82f33a6d8..357e95272 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -680,7 +680,15 @@ pub fn specs() -> Vec { path_reverse ), cmd!("path.close", "Close Path", ["Object", "Paths"], None, "{id, subpath?: index (default every subpath)}", has_doc, path_close), - cmd!("path.moveAnchors", "Move Anchors", [], None, "{id, anchors: [[subpath, index]], dx, dy, handle?: in|out}", has_doc, path_move_anchors), + cmd!( + "path.moveAnchors", + "Move Anchors", + [], + None, + "{id, anchors: [[subpath, index]], dx, dy, handle?: in|out} | {points: [[id, subpath, index]], dx, dy} (anchors of several paths, as Direct Selection drags them: one step)", + has_doc, + path_move_anchors + ), cmd!( "object.align", "Align", @@ -3338,38 +3346,59 @@ fn path_close(s: &mut Session, p: &Value) -> Result { } fn path_move_anchors(s: &mut Session, p: &Value) -> Result { - let id = super::id_param(p, "id").ok_or_else(|| bad("path.moveAnchors", "missing id"))?; - let which: Vec<(usize, usize)> = p.get("anchors").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + // The anchors to move, by path: `points` (several paths) or `id` with `anchors`. + let mut targets: Vec<(ItemId, Vec<(usize, usize)>)> = Vec::new(); + if let Some(points) = p.get("points").and_then(Value::as_array) { + for v in points { + let n = |i: usize| v.get(i).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok()); + let (Some(id), Some(si), Some(ai)) = (v.get(0).and_then(Value::as_u64), n(1), n(2)) else { + return Err(bad("path.moveAnchors", "each point is [id, subpath, index]")); + }; + match targets.iter_mut().find(|(i, _)| i.0 == id) { + Some((_, which)) => which.push((si, ai)), + None => targets.push((ItemId(id), vec![(si, ai)])), + } + } + } else { + let id = super::id_param(p, "id").ok_or_else(|| bad("path.moveAnchors", "missing id"))?; + let which: Vec<(usize, usize)> = p.get("anchors").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + targets.push((id, which)); + } let d0 = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0)); let handle = str_param(p, "handle").map(str::to_string); s.edit(|d, _| { - path_editable(d, id, "path.moveAnchors")?; - let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; - let m = d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default(); - // Spread-space delta → inner-space delta (linear part only). - let inv = m.inverse(); - let dv = (inv * Point::new(d0.x, d0.y)) - (inv * Point::ZERO); - let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; - for (si, ai) in &which { - let Some(a) = it.path.anchor_mut(*si, *ai) else { continue }; - match handle.as_deref() { - Some("in") => { - a.h_in += dv; - if a.kind == designcraft_geom::AnchorKind::Smooth { - a.h_out = a.p - (a.h_in - a.p); - } - } - Some("out") => { - a.h_out += dv; - if a.kind == designcraft_geom::AnchorKind::Smooth { - a.h_in = a.p - (a.h_out - a.p); - } - } - _ => a.translate(dv), - } + for (id, _) in &targets { + path_editable(d, *id, "path.moveAnchors")?; } - if it.shape == Shape::Rectangle || it.shape == Shape::Oval || it.shape == Shape::Polygon { - it.shape = Shape::Path; + for (id, which) in &targets { + let id = *id; + let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + let m = d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default(); + // Spread-space delta → inner-space delta (linear part only). + let inv = m.inverse(); + let dv = (inv * Point::new(d0.x, d0.y)) - (inv * Point::ZERO); + let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + for (si, ai) in which { + let Some(a) = it.path.anchor_mut(*si, *ai) else { continue }; + match handle.as_deref() { + Some("in") => { + a.h_in += dv; + if a.kind == designcraft_geom::AnchorKind::Smooth { + a.h_out = a.p - (a.h_in - a.p); + } + } + Some("out") => { + a.h_out += dv; + if a.kind == designcraft_geom::AnchorKind::Smooth { + a.h_in = a.p - (a.h_out - a.p); + } + } + _ => a.translate(dv), + } + } + if it.shape == Shape::Rectangle || it.shape == Shape::Oval || it.shape == Shape::Polygon { + it.shape = Shape::Path; + } } ok() }) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9aacf469d..9aeae9989 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1650,3 +1650,38 @@ 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"); } + +/// Direct Selection: a marquee over the top edges of two frames selects their top points, and a +/// drag on one of them slants both frames in one undo step (InDesign's way to make a row of +/// parallelograms). +#[test] +fn direct_selection_moves_the_points_of_several_frames_at_once() { + use designcraft_tools::{Mods, PointerEvent, PointerKind}; + let mut s = session(); + let v = ViewInfo::at_zoom(1.0); + let a = s.execute("frame.create", &json!({"rect": [100, 300, 200, 400]})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [200, 300, 300, 400]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": []})).unwrap(); + s.set_tool("directSelection"); + s.pointer(&PointerEvent::new(PointerKind::Down, 80.0, 280.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 320.0, 320.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 320.0, 320.0), v).unwrap(); + let sel = s.doc().unwrap().selection.clone(); + assert_eq!(sel.anchors.len(), 4, "{:?}", sel.anchors); + assert!(sel.items.contains(&designcraft_doc::ItemId(a)) && sel.items.contains(&designcraft_doc::ItemId(b))); + let shift = Mods { shift: true, ..Default::default() }; + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 300.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 136.0, 302.0).with_mods(shift), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 136.0, 302.0).with_mods(shift), v).unwrap(); + let corners = |s: &Session, id: u64| -> Vec<(f64, f64)> { + let st = s.doc().unwrap(); + let it = st.doc.item(designcraft_doc::ItemId(id)).unwrap(); + it.path.subpaths[0].anchors.iter().map(|an| ((it.xf * an.p).x.round(), (it.xf * an.p).y.round())).collect() + }; + // Both frames: top points 36 pt to the right, bottom points where they were. + assert_eq!(corners(&s, a), vec![(136.0, 300.0), (236.0, 300.0), (200.0, 400.0), (100.0, 400.0)]); + assert_eq!(corners(&s, b), vec![(236.0, 300.0), (336.0, 300.0), (300.0, 400.0), (200.0, 400.0)]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(corners(&s, a), vec![(100.0, 300.0), (200.0, 300.0), (200.0, 400.0), (100.0, 400.0)]); + assert_eq!(corners(&s, b), vec![(200.0, 300.0), (300.0, 300.0), (300.0, 400.0), (200.0, 400.0)]); +} diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 891f1d4ab..b18126a97 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -90,6 +90,8 @@ enum Drag { /// None when that point could not be read. The live document during a drag is the /// previous preview, so the snap must not read the anchor from it again. at: Option<(SpreadRef, Point)>, + /// The other selected anchor points, which move with it: (item, subpath, anchor). + others: Vec<(u64, usize, usize)>, }, } @@ -179,6 +181,60 @@ fn spread_delta_to_canvas(xf: Affine, d: Vec2) -> Vec2 { (xf * Point::new(d.x, d.y)) - (xf * Point::ORIGIN) } +/// The anchor points inside `r` (canvas) of the unlocked, visible paths, groups' members included. +fn anchors_in(cx: &ToolContext, r: Rect) -> Vec<(ItemId, usize, usize)> { + fn walk(cx: &ToolContext, items: &[std::sync::Arc], r: Rect, out: &mut Vec<(ItemId, usize, usize)>) { + for it in items { + let locked = it.locked || cx.doc.layer(it.layer).is_some_and(|l| l.locked || !l.visible); + if locked || it.hidden { + continue; + } + if let Content::Group { items } = &it.content { + walk(cx, items, r, out); + continue; + } + let Some(xf) = cx.item_canvas_xf(it.id) else { continue }; + let m = xf * it.xf; + for (si, ai, a) in it.path.anchors() { + let q = m * a.p; + if q.x >= r.x0 && q.x <= r.x1 && q.y >= r.y0 && q.y <= r.y1 { + out.push((it.id, si, ai)); + } + } + } + } + let mut out = Vec::new(); + for slot in &cx.layout.slots { + if let Some(sp) = cx.doc.spread(slot.spread) { + walk(cx, &sp.items, r, &mut out); + } + } + out +} + +/// `selection.set` params selecting `points`: their paths, and those points. +fn anchor_selection(cx: &ToolContext, points: &[(ItemId, usize, usize)]) -> Value { + let mut ids: Vec = cx.selection.items.iter().map(|i| i.0).collect(); + for (i, _, _) in points { + if !ids.contains(&i.0) { + ids.push(i.0); + } + } + let anchors: Vec = points.iter().map(|(i, s, a)| json!([i.0, s, a])).collect(); + json!({"ids": ids, "anchors": anchors}) +} + +/// `d` held to the nearest multiple of 45°, as Shift does. +fn constrain_45(d: Vec2) -> Vec2 { + if d.hypot() < 1e-9 { + return d; + } + let step = std::f64::consts::FRAC_PI_4; + let a = (d.y.atan2(d.x) / step).round() * step; + let proj = d.x * a.cos() + d.y * a.sin(); + Vec2::new(proj * a.cos(), proj * a.sin()) +} + pub struct SelectionTool { direct: bool, drag: Drag, @@ -709,8 +765,40 @@ impl Tool for SelectionTool { && let Some((id, si, ai, handle)) = anchor_at(cx, p) { let at = anchor_spread_point(cx, id, si, ai); - 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() })]; + 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 { + points.retain(|a| *a != 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))); + } + if ev.mods.shift && was { + self.drag = Drag::None; + return out; + } + 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; } // A selected placed graphic's own handles resize it inside its frame (the // frame's anchors, under Direct Selection, come first). @@ -1035,8 +1123,12 @@ impl Tool for 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 } => { - let d = p - start; + Drag::Anchor { id, si, ai, handle, start, at, others } => { + let mut d = p - start; + // 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(); @@ -1075,6 +1167,9 @@ impl Tool for SelectionTool { 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}); } vec![Action::Preview("path.moveAnchors".into(), params)] } @@ -1100,6 +1195,21 @@ impl Tool for SelectionTool { }, Drag::Marquee { start, cur } => { let r = Rect::from_points(start, cur); + // Direct Selection: the anchor points inside the marquee, on every path it + // reaches (InDesign's way to select points of several frames at once). + if self.direct { + let mut points = anchors_in(cx, r); + if !points.is_empty() { + if ev.mods.shift { + for a in &cx.selection.anchors { + if !points.contains(a) { + points.push(*a); + } + } + } + return vec![Action::Exec("selection.set".into(), anchor_selection(cx, &points))]; + } + } // Items whose bounds intersect the marquee. let mut ids: Vec = Vec::new(); for slot in &cx.layout.slots { @@ -1131,7 +1241,15 @@ impl Tool for SelectionTool { if key == ToolKey::Escape && self.live.take().is_some_and(|id| cx.selection.items == [id]) { return vec![Action::Handled]; } - let mv = |dx: f64, dy: f64| vec![Action::Exec("transform.move".into(), json!({"dx": dx, "dy": dy, "copy": mods.alt}))]; + let points: Vec = cx.selection.anchors.iter().map(|(i, s, a)| json!([i.0, s, a])).collect(); + let direct_points = self.direct && !points.is_empty(); + let mv = |dx: f64, dy: f64| { + // Direct Selection with anchor points selected: the arrow keys move the points. + if direct_points { + return vec![Action::Exec("path.moveAnchors".into(), json!({"points": points.clone(), "dx": dx, "dy": dy}))]; + } + vec![Action::Exec("transform.move".into(), json!({"dx": dx, "dy": dy, "copy": mods.alt}))] + }; if cx.selection.items.is_empty() { return vec![]; } diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index ee3b362f1..a8f98b7e1 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -1792,7 +1792,13 @@ fn direct_selection_drags_the_end_anchors_of_type_on_a_path() { let p = Point::new(x + off.x, 300.0 + off.y); let mut direct = create("directSelection"); assert_ne!(direct.cursor(&cx, p, Mods::default()), Cursor::PathBracket, "anchor at {x}"); - assert_eq!(direct.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y)), vec![Action::Begin("Move Anchor".into())]); + // The press selects that anchor point and starts moving it. + let a = direct.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y)); + assert!( + matches!(a.first(), Some(Action::Exec(cmd, p)) if cmd == "selection.set" && p["anchors"].as_array().map(Vec::len) == Some(1)), + "{a:?}" + ); + assert_eq!(a.last(), Some(&Action::Begin("Move Anchor".into()))); } } @@ -1936,3 +1942,52 @@ fn loaded_text_cursor_threads_into_a_frame_inside_a_group() { assert_eq!(cx2.hit_text_frame(q).map(|(_, i)| i), None, "no text frame in that group"); assert_eq!(t2.cursor(&cx2, q, Mods::default()), Cursor::NotAllowed); } + +/// Direct Selection selects anchor points with a marquee across several frames, and dragging one of +/// them moves them all, as InDesign does when the top points of a row of frames are dragged to slant +/// them (Zero2Graphic's page layout tutorial, 1:15). +#[test] +fn direct_selection_marquee_selects_points_of_several_frames_and_drags_them_together() { + let mut d = Document::new(&NewDocument::default()); + let a = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0)); + let b = add_rect(&mut d, Rect::new(200.0, 100.0, 300.0, 200.0)); + let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); + let none = Selection::default(); + let mut t = create("directSelection"); + // A marquee around the top edges: the four top points, not the frames' bottom ones. + let (p0, p1) = (spread_pt(&l, 80.0, 90.0), spread_pt(&l, 320.0, 110.0)); + t.pointer(&ctx(&d, &none, &c, &l), &PointerEvent::new(PointerKind::Down, p0.x, p0.y)); + t.pointer(&ctx(&d, &none, &c, &l), &PointerEvent::new(PointerKind::Drag, p1.x, p1.y)); + let up = t.pointer(&ctx(&d, &none, &c, &l), &PointerEvent::new(PointerKind::Up, p1.x, p1.y)); + let Some(Action::Exec(cmd, p)) = up.last() else { panic!("{up:?}") }; + assert_eq!(cmd, "selection.set"); + assert_eq!(p["ids"], serde_json::json!([a.0, b.0])); + let mut got: Vec<(u64, u64, u64)> = + p["anchors"].as_array().unwrap().iter().map(|v| (v[0].as_u64().unwrap(), v[1].as_u64().unwrap(), v[2].as_u64().unwrap())).collect(); + got.sort(); + assert_eq!(got, vec![(a.0, 0, 0), (a.0, 0, 1), (b.0, 0, 0), (b.0, 0, 1)]); + // Those points selected: dragging one moves all four, in one preview. + let sel = Selection { items: vec![a, b], anchors: vec![(a, 0, 0), (a, 0, 1), (b, 0, 0), (b, 0, 1)], ..Default::default() }; + let cx = ctx(&d, &sel, &c, &l); + let at = spread_pt(&l, 200.0, 100.0); + let down = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y)); + assert_eq!(down, vec![Action::Begin("Move Anchors".into())]); + let shift = Mods { shift: true, ..Default::default() }; + let drag = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, at.x + 36.0, at.y + 3.0).with_mods(shift)); + let Some(Action::Preview(cmd, p)) = drag.last() else { panic!("{drag:?}") }; + assert_eq!(cmd, "path.moveAnchors"); + assert_eq!(p["points"].as_array().map(Vec::len), Some(4), "{p}"); + // Shift keeps the move horizontal. + assert_eq!((p["dx"].as_f64(), p["dy"].as_f64()), (Some(36.0), Some(0.0))); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x + 36.0, at.y)), vec![Action::Commit]); + // Shift+click on a selected point takes it out of the selection; the arrow keys move the points. + let out = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(shift)); + let Some(Action::Exec(cmd, p)) = out.first() else { panic!("{out:?}") }; + assert_eq!(cmd, "selection.set"); + assert_eq!(p["anchors"].as_array().map(Vec::len), Some(3), "{p}"); + let keys = t.key(&cx, ToolKey::Right, Mods::default()); + assert!( + matches!(keys.as_slice(), [Action::Exec(cmd, p)] if cmd == "path.moveAnchors" && p["points"].as_array().map(Vec::len) == Some(4)), + "{keys:?}" + ); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8d94f099f..f0c6d30a7 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1229,9 +1229,11 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum let (Some(it), Some((a, _))) = (doc.item(*id), item_canvas_xf(doc, layout, *id)) else { continue }; let m = a * it.xf; let col = layer_color(doc, it); - for sp in &it.path.subpaths { - for an in &sp.anchors { + for (si, sp) in it.path.subpaths.iter().enumerate() { + for (ai, an) in sp.anchors.iter().enumerate() { let p = xf.to_screen(m * an.p); + // A selected anchor point is solid, the others hollow (InDesign). + let chosen = sel.anchors.contains(&(*id, si, ai)); for (h, has) in [(an.h_in, an.has_in()), (an.h_out, an.has_out())] { if has { let hp = xf.to_screen(m * h); @@ -1240,7 +1242,7 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum } } let r = Rect::from_center_size(p, vec2(5.0, 5.0)); - painter.rect_filled(r, 0.0, Color32::WHITE); + painter.rect_filled(r, 0.0, if chosen { col } else { Color32::WHITE }); painter.rect_stroke(r, 0.0, Stroke::new(1.0, col), StrokeKind::Inside); } } From 076de16622e599e2b88e7559671d7e566ded546e Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 22:34:16 +0200 Subject: [PATCH 041/431] Properties: Apply To Frame or Text for several text frames, as InDesign offers Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/panels/properties.rs | 36 +++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..4289a88ce 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-20** · Properties › Appearance: with several text frames selected, the Fill and Stroke popups offer Apply To Frame or Text, as with one frame, so their text can be coloured at once, as in InDesign. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 44acf6e2c..f2ceeeaba 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -777,6 +777,10 @@ pub(crate) fn corner_type_dropdown(app: &mut DesignApp, ui: &mut Ui, shape: Corn fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { let t = Tokens::get(ui.ctx()); let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; + // Every selected object is a text frame: the popups offer Apply To Frame or Text, for one + // frame or several, as InDesign's do. + let text_frames = + i.is_text && app.session.active().is_some_and(|st| st.selection.items.iter().all(|id| doc.item(*id).is_some_and(|it| it.is_text_frame()))); // Fill. let b = block(ui, FIELD_H); let (fc, fg) = widgets::swatch_colors(&doc, &i.fill, 1.0); @@ -789,7 +793,7 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { i.fill )); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Fill swatch")); - swatch_menu(app, &resp, &doc, &i.fill, false, i.is_text && i.count == 1, false); + swatch_menu(app, &resp, &doc, &i.fill, false, text_frames, false); crate::rtl::paint( ui.painter(), b.min + vec2(34.0, FIELD_H / 2.0), @@ -811,7 +815,7 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { i.stroke )); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Stroke swatch")); - swatch_menu(app, &resp, &doc, &i.stroke, true, i.is_text && i.count == 1, false); + swatch_menu(app, &resp, &doc, &i.stroke, true, text_frames, false); if place(ui, sub(b, 34.0, 0.0, 56.0, FIELD_H), |ui| link_label(ui, crate::i18n::tr(&app.ui.language, "Stroke"), 56.0)).clicked() { show_panel(app, "stroke"); } @@ -3419,6 +3423,34 @@ mod tests { 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 transform_flips_and_rotates_from_properties() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From 31b95b83f2916c56b4dbcb1075a9fcd719d352f4 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 23:08:29 +0200 Subject: [PATCH 042/431] Swatches: a new document has InDesign's default swatches Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/color/src/swatch.rs | 39 +++++++++++++++++++++++---------- crates/render/tests/colorize.rs | 2 +- 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..8c565060c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-20** · A new document's Swatches panel lists InDesign's defaults: [None], [Registration], [Paper], [Black] and six process colours (C=100, M=100, Y=100, C=15 M=100 Y=100, C=75 M=5 Y=100, C=100 M=90 Y=10); the extra M=100 Y=100, C=100 Y=100 and C=100 M=100 swatches are gone. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/color/src/swatch.rs b/crates/color/src/swatch.rs index e47bf4eef..76ccddd9e 100644 --- a/crates/color/src/swatch.rs +++ b/crates/color/src/swatch.rs @@ -143,7 +143,9 @@ pub fn apply_tint(c: Color, tint: f32) -> Color { } } -/// A new document's swatches, in panel order. +/// A new document's swatches, in panel order: InDesign's four specials, then its six process +/// colours (cyan, magenta, yellow, red, green and blue as C=15 M=100 Y=100, C=75 M=5 Y=100 and +/// C=100 M=90 Y=10). pub fn default_swatches() -> Vec { let special = |name: &str, value: SwatchValue| Swatch { name: name.into(), value, locked: true, named: true, hidden: false }; let mut v = vec![ @@ -152,17 +154,9 @@ pub fn default_swatches() -> Vec { special(PAPER, SwatchValue::Paper { color: Color::WHITE }), special(BLACK, SwatchValue::Color { color: Color::cmyk(0.0, 0.0, 0.0, 1.0), color_type: ColorType::Process }), ]; - for (c, m, y, k) in [ - (1.0, 0.0, 0.0, 0.0), - (0.0, 1.0, 0.0, 0.0), - (0.0, 0.0, 1.0, 0.0), - (0.0, 1.0, 1.0, 0.0), - (1.0, 0.0, 1.0, 0.0), - (1.0, 1.0, 0.0, 0.0), - (0.15, 1.0, 1.0, 0.0), - (0.75, 0.05, 1.0, 0.0), - (1.0, 0.9, 0.1, 0.0), - ] { + for (c, m, y, k) in + [(1.0, 0.0, 0.0, 0.0), (0.0, 1.0, 0.0, 0.0), (0.0, 0.0, 1.0, 0.0), (0.15, 1.0, 1.0, 0.0), (0.75, 0.05, 1.0, 0.0), (1.0, 0.9, 0.1, 0.0)] + { v.push(Swatch::cmyk(c, m, y, k)); } v @@ -181,6 +175,27 @@ mod tests { assert!(s.iter().any(|s| s.name == "C=100 M=0 Y=0 K=0")); } + /// The Swatches panel of a new InDesign document (Zero2Graphic's page layout tutorial, 3:22). + #[test] + fn defaults_are_indesigns_ten() { + let names: Vec = default_swatches().into_iter().map(|s| s.name).collect(); + assert_eq!( + names, + [ + "[None]", + "[Registration]", + "[Paper]", + "[Black]", + "C=100 M=0 Y=0 K=0", + "C=0 M=100 Y=0 K=0", + "C=0 M=0 Y=100 K=0", + "C=15 M=100 Y=100 K=0", + "C=75 M=5 Y=100 K=0", + "C=100 M=90 Y=10 K=0", + ] + ); + } + #[test] fn resolves_tints_and_specials() { let mut s = default_swatches(); diff --git a/crates/render/tests/colorize.rs b/crates/render/tests/colorize.rs index 883b36b8e..c4dcaad1c 100644 --- a/crates/render/tests/colorize.rs +++ b/crates/render/tests/colorize.rs @@ -13,7 +13,7 @@ use designcraft_geom::{Affine, Rect, shapes}; use designcraft_render::{RenderOptions, Renderer, color_of}; use image::{ExtendedColorType, ImageEncoder}; -const RED: &str = "C=0 M=100 Y=100 K=0"; +const RED: &str = "C=15 M=100 Y=100 K=0"; const CYAN: &str = "C=100 M=0 Y=0 K=0"; fn encode(px: &[u8], w: u32, ct: ExtendedColorType, gif: bool) -> Vec { From 09970a24b0a92636654bd15a577989ed87dc161e Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 23:12:05 +0200 Subject: [PATCH 043/431] Frame tools: the size readout shows the document's units, as InDesign's Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/tools/src/frame.rs | 10 +++++++--- crates/tools/src/tests.rs | 24 ++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..56ff46903 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-20** · Drawing a frame or shape shows its width and height in the document's ruler units, to two decimals and on two lines ("W: 6.08 cm", "H: 6.08 cm"), as InDesign does; it was always whole points. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/tools/src/frame.rs b/crates/tools/src/frame.rs index 4b52e2ee5..aaebaefd7 100644 --- a/crates/tools/src/frame.rs +++ b/crates/tools/src/frame.rs @@ -457,10 +457,14 @@ impl Tool for FrameTool { fn overlays(&self, cx: &ToolContext) -> Vec { if let (true, Some(a)) = (self.active, self.start) { - let r = Rect::from_points(a, self.cur); - let _ = cx; + // The frame being drawn (Shift, Alt and snapping included), in the document's units + // to two decimals, on two lines: InDesign's readout ("W: 3.13 cm", "H: 3.13 cm"). + let r = self.last.as_ref().map(|(_, r)| *r).unwrap_or_else(|| Rect::from_points(a, self.cur)); + let units = &cx.doc.settings; + let w = designcraft_geom::units::format_measure_prec(r.width().abs(), units.horizontal_units, 2); + let h = designcraft_geom::units::format_measure_prec(r.height().abs(), units.vertical_units, 2); let mut v = self.guides.clone(); - v.push(Overlay::Measure { p: self.cur, text: format!("W: {:.0} pt H: {:.0} pt", r.width(), r.height()) }); + v.push(Overlay::Measure { p: self.cur, text: format!("W: {w}\nH: {h}") }); return v; } vec![] diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index ee3b362f1..d39fb6433 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -69,6 +69,30 @@ fn frame_tool_drag_emits_begin_preview_commit() { assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 110.0, 60.0)), vec![Action::Commit]); } +/// While a frame is drawn, its size shows in the document's units, as InDesign's readout does +/// ("W: 6.08 cm" over "H: 6.08 cm" in Zero2Graphic's page layout tutorial, 0:43). +#[test] +fn frame_tool_readout_shows_the_size_in_the_document_units() { + let mut d = Document::new(&NewDocument::default()); + d.settings.horizontal_units = Unit::Centimeters; + d.settings.vertical_units = Unit::Centimeters; + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let mut t = create("rectangleFrame"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + // 172.35 pt square: 6.08 cm. + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 182.35, 182.35)); + let texts: Vec = t + .overlays(&cx) + .into_iter() + .filter_map(|o| match o { + Overlay::Measure { text, .. } => Some(text), + _ => None, + }) + .collect(); + assert_eq!(texts, vec!["W: 6.08 cm\nH: 6.08 cm".to_string()]); +} + #[test] fn selection_click_and_marquee() { let mut d = Document::new(&NewDocument::default()); From d7d9c4230ac26fb0f2416a65bff068134e319def Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 23:38:48 +0200 Subject: [PATCH 044/431] Object menu: InDesign's order and groups, Join/Open/Close Path under Paths, Convert Point Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/i18n.rs | 2 + crates/ui-egui/src/i18n/ar.rs | 2 + crates/ui-egui/src/i18n/cs.rs | 4 + crates/ui-egui/src/i18n/de.rs | 4 + crates/ui-egui/src/i18n/it.rs | 2 + crates/ui-egui/src/i18n/ja.rs | 2 + crates/ui-egui/src/i18n/ka.rs | 4 + crates/ui-egui/src/i18n/pl.rs | 4 + crates/ui-egui/src/i18n/pt_br.rs | 2 + crates/ui-egui/src/i18n/ru.rs | 4 + crates/ui-egui/src/i18n/uk.rs | 2 + crates/ui-egui/src/menus.rs | 220 +++++++++++++++++++++---------- 13 files changed, 186 insertions(+), 67 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..57285e793 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-20** · The Object menu is in InDesign's order and groups; Paths has Join, Open Path and Close Path (Convert Shape lists only shapes, as InDesign's does) and a Convert Point submenu (Plain, Corner, Smooth, Symmetrical) is added. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index efe089ef9..22e8a0a87 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -89,6 +89,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ("Join", ["Join", "Joindre", "Join", "Join", "连接"]), + ("Convert Point", ["Convert Point", "Convertir le point", "Convert Point", "Convert Point", "转换点"]), ("Hide Rulers", ["Lineale ausblenden", "Masquer les règles", "Ocultar reglas", "定規を隠す", "隐藏标尺"]), ("Inches Decimal", ["Zoll dezimal", "Pouces décimaux", "Pulgadas decimales", "インチ(10 進)", "十进制英寸"]), ( diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..c9e6f463e 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Join", "وصل"), + ("Convert Point", "تحويل النقطة"), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), ("Fixed Number", "عدد ثابت"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..dcebc1180 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Join", "Spojit"), + ("Convert Point", "Převést bod"), + ("Plain", "Obyčejný"), + ("Symmetrical", "Symetrický"), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index efc128858..778f028ae 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -31,6 +31,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Ausschneiden"), ("Copy", "Kopieren"), ("Paste", "Einfügen"), + ("Join", "Verbinden"), + ("Convert Point", "Punkt konvertieren"), + ("Plain", "Einfach"), + ("Symmetrical", "Symmetrisch"), ("Inches Decimal", "Zoll dezimal"), ("Back", "Zurück"), ("Warning", "Warnung"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..818eafcf3 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Join", "Unisci"), + ("Convert Point", "Converti punto"), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 5647ca940..9f98222dd 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Join", "連結"), + ("Convert Point", "ポイントを切り換え"), ("Paste Vector Art", "ベクトルアートをペースト"), ("Baseline Options", "ベースラインオプション"), ("Column Rules", "段落境界線"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index d938c2dc7..d0c67ae3c 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", "ჩასმა"), + ("Join", "შეერთება"), + ("Convert Point", "წერტილის გარდაქმნა"), + ("Plain", "მარტივი"), + ("Symmetrical", "სიმეტრიული"), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..4b5763383 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Join", "Połącz"), + ("Convert Point", "Konwertuj punkt"), + ("Plain", "Zwykły"), + ("Symmetrical", "Symetryczny"), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..4042fe559 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Join", "Unir"), + ("Convert Point", "Converter ponto"), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), ("Fixed Number", "Número fixo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..0503aa350 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Join", "Соединить"), + ("Convert Point", "Преобразовать точку"), + ("Plain", "Простая"), + ("Symmetrical", "Симметричная"), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..e21385496 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Join", "Зʼєднати"), + ("Convert Point", "Перетворити точку"), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 79b8b2c4e..667593746 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -597,57 +597,12 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:transform.again|Transform Sequence Again|{\"sequence\": true}", "cmd:transform.again|Transform Sequence Again Individually|{\"sequence\": true, \"individually\": true}", "<", - ">Flex Layout", - "cmd:flex.add", - "cmd:flex.convert", - "cmd:flex.remove", - "-", - "ui:window.panel|Show Flex Controls|{\"panel\": \"flexLayout\"}", - "<", - ">Paths", - "cmd:object.makeCompoundPath", - "cmd:object.releaseCompoundPath", - "-", - "cmd:path.reverse", - "<", - ">Pathfinder", - "cmd:object.pathfinder|Add|{\"op\": \"add\"}", - "cmd:object.pathfinder|Subtract|{\"op\": \"subtract\"}", - "cmd:object.pathfinder|Intersect|{\"op\": \"intersect\"}", - "cmd:object.pathfinder|Exclude Overlap|{\"op\": \"exclude\"}", - "cmd:object.pathfinder|Minus Back|{\"op\": \"minusBack\"}", - "<", ">Arrange", "cmd:object.bringToFront", "cmd:object.bringForward", "cmd:object.sendBackward", "cmd:object.sendToBack", "<", - "-", - "cmd:object.group", - "cmd:object.ungroup", - "cmd:object.lock", - "cmd:object.unlockAll", - "cmd:object.hide", - "cmd:object.showAll", - "-", - "cmd:object.textFrameOptions", - "ui:app.layerOptions", - "cmd:object.exportOptions", - "cmd:object.primaryTextFrame", - "cmd:object.cornerOptions", - "-", - ">Effects", - "ui:effects.dialog|Transparency…|{\"page\": \"transparency\"}", - "cmd:object.dropShadow", - "cmd:object.innerShadow", - "cmd:object.outerGlow", - "cmd:object.innerGlow", - "cmd:object.bevel", - "cmd:object.satin", - "cmd:object.feather", - "cmd:object.directionalFeather", - "<", ">Select", "cmd:select.firstAbove", "cmd:select.nextAbove", @@ -660,34 +615,23 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:select.previousInGroup", "cmd:select.nextInGroup", "<", - ">Convert Shape", - "cmd:object.convertShape|Rectangle|{\"to\": \"rectangle\"}", - "cmd:object.convertShape|Rounded Rectangle|{\"to\": \"roundedRectangle\"}", - "cmd:object.convertShape|Beveled Rectangle|{\"to\": \"beveledRectangle\"}", - "cmd:object.convertShape|Inverse Rounded Rectangle|{\"to\": \"inverseRoundedRectangle\"}", - "cmd:object.convertShape|Ellipse|{\"to\": \"ellipse\"}", - "cmd:object.convertShape|Triangle|{\"to\": \"triangle\"}", - "cmd:object.convertShape|Polygon|{\"to\": \"polygon\"}", - "cmd:object.convertShape|Line|{\"to\": \"line\"}", - "cmd:object.convertShape|Orthogonal Line|{\"to\": \"orthogonalLine\"}", "-", - "cmd:object.convertShape|Open Path|{\"to\": \"openPath\"}", - "cmd:object.convertShape|Closed Path|{\"to\": \"closedPath\"}", - "<", + "cmd:object.group", + "cmd:object.ungroup", + "cmd:object.lock", + "cmd:object.unlockAll", + "cmd:object.hide", + "cmd:object.showAll", + "-", "ui:app.qrCode", - ">Clipping Path", - "cmd:object.clippingPathOptions|None|{\"type\": \"none\"}", - "cmd:object.clippingPath|Alpha Channel|{\"type\": \"alpha\"}", - "cmd:object.clippingPath|Detect Edges|{\"type\": \"edges\"}", - "<", - ">Captions", - "cmd:object.caption", - "cmd:object.caption|Generate Live Caption|{\"live\": true}", - "<", + "-", + "cmd:object.textFrameOptions", + "cmd:object.primaryTextFrame", ">Anchored Object", "cmd:anchored.options", "cmd:anchored.release", "<", + "-", ">Fitting", "ui:fit.fillFrameProportionally", "ui:fit.contentProportionally", @@ -702,6 +646,73 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:object.content|Text|{\"type\":\"text\"}", "cmd:object.content|Unassigned|{\"type\":\"unassigned\"}", "<", + "-", + ">Effects", + "ui:effects.dialog|Transparency…|{\"page\": \"transparency\"}", + "cmd:object.dropShadow", + "cmd:object.innerShadow", + "cmd:object.outerGlow", + "cmd:object.innerGlow", + "cmd:object.bevel", + "cmd:object.satin", + "cmd:object.feather", + "cmd:object.directionalFeather", + "<", + "cmd:object.cornerOptions", + "-", + "ui:app.layerOptions", + "cmd:object.exportOptions", + ">Captions", + "cmd:object.caption", + "cmd:object.caption|Generate Live Caption|{\"live\": true}", + "<", + ">Clipping Path", + "cmd:object.clippingPathOptions|None|{\"type\": \"none\"}", + "cmd:object.clippingPath|Alpha Channel|{\"type\": \"alpha\"}", + "cmd:object.clippingPath|Detect Edges|{\"type\": \"edges\"}", + "<", + "-", + ">Flex Layout", + "cmd:flex.add", + "cmd:flex.convert", + "cmd:flex.remove", + "-", + "ui:window.panel|Show Flex Controls|{\"panel\": \"flexLayout\"}", + "<", + "-", + ">Paths", + "cmd:path.join", + "cmd:object.convertShape|Open Path|{\"to\": \"openPath\"}", + "cmd:object.convertShape|Close Path|{\"to\": \"closedPath\"}", + "cmd:path.reverse", + "-", + "cmd:object.makeCompoundPath", + "cmd:object.releaseCompoundPath", + "<", + ">Pathfinder", + "cmd:object.pathfinder|Add|{\"op\": \"add\"}", + "cmd:object.pathfinder|Subtract|{\"op\": \"subtract\"}", + "cmd:object.pathfinder|Intersect|{\"op\": \"intersect\"}", + "cmd:object.pathfinder|Exclude Overlap|{\"op\": \"exclude\"}", + "cmd:object.pathfinder|Minus Back|{\"op\": \"minusBack\"}", + "<", + ">Convert Shape", + "cmd:object.convertShape|Rectangle|{\"to\": \"rectangle\"}", + "cmd:object.convertShape|Rounded Rectangle|{\"to\": \"roundedRectangle\"}", + "cmd:object.convertShape|Beveled Rectangle|{\"to\": \"beveledRectangle\"}", + "cmd:object.convertShape|Inverse Rounded Rectangle|{\"to\": \"inverseRoundedRectangle\"}", + "cmd:object.convertShape|Ellipse|{\"to\": \"ellipse\"}", + "cmd:object.convertShape|Triangle|{\"to\": \"triangle\"}", + "cmd:object.convertShape|Polygon|{\"to\": \"polygon\"}", + "cmd:object.convertShape|Line|{\"to\": \"line\"}", + "cmd:object.convertShape|Orthogonal Line|{\"to\": \"orthogonalLine\"}", + "<", + ">Convert Point", + "cmd:path.convertPoints|Plain|{\"kind\": \"plain\"}", + "cmd:path.convertPoints|Corner|{\"kind\": \"corner\"}", + "cmd:path.convertPoints|Smooth|{\"kind\": \"smooth\"}", + "cmd:path.convertPoints|Symmetrical|{\"kind\": \"symmetrical\"}", + "<", ], ), ( @@ -3254,6 +3265,81 @@ mod tests { 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; + } + 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); + } + #[test] fn view_items_are_in_the_view_menu_as_in_indesign() { let (view, typ) = (menu_ids("View"), menu_ids("Type")); From a91438db8d5feaa3880f8da6ec4830ae054093c8 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 23:55:05 +0200 Subject: [PATCH 045/431] Properties: corner shape and stroke type lists show their glyphs, as in InDesign Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/i18n.rs | 2 + crates/ui-egui/src/i18n/ar.rs | 2 + crates/ui-egui/src/i18n/cs.rs | 2 + crates/ui-egui/src/i18n/de.rs | 2 + crates/ui-egui/src/i18n/it.rs | 2 + crates/ui-egui/src/i18n/ja.rs | 2 + crates/ui-egui/src/i18n/ka.rs | 2 + crates/ui-egui/src/i18n/pl.rs | 2 + crates/ui-egui/src/i18n/pt_br.rs | 2 + crates/ui-egui/src/i18n/ru.rs | 2 + crates/ui-egui/src/i18n/uk.rs | 2 + crates/ui-egui/src/panels/properties.rs | 140 ++++++++++++++++++------ 13 files changed, 130 insertions(+), 33 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..e1e4b8510 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-20** · Properties › Appearance: the corner shape list shows each shape's glyph beside its name, drawn as InDesign draws them (Fancy has its own glyph now), and the stroke type list shows each type's line; both dropdowns are named for screen readers. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index efe089ef9..b5494731c 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -89,6 +89,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ("Stroke Type", ["Stroke Type", "Type de contour", "Stroke Type", "Stroke Type", "描边类型"]), + ("Corner Shape", ["Corner Shape", "Forme d'angle", "Corner Shape", "Corner Shape", "角形状"]), ("Hide Rulers", ["Lineale ausblenden", "Masquer les règles", "Ocultar reglas", "定規を隠す", "隐藏标尺"]), ("Inches Decimal", ["Zoll dezimal", "Pouces décimaux", "Pulgadas decimales", "インチ(10 進)", "十进制英寸"]), ( diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..7c7406c21 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Stroke Type", "نوع الحد"), + ("Corner Shape", "شكل الزاوية"), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), ("Fixed Number", "عدد ثابت"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..bc28598ae 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Stroke Type", "Typ tahu"), + ("Corner Shape", "Tvar rohu"), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index efc128858..05a4b5b25 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Ausschneiden"), ("Copy", "Kopieren"), ("Paste", "Einfügen"), + ("Stroke Type", "Konturtyp"), + ("Corner Shape", "Eckenform"), ("Inches Decimal", "Zoll dezimal"), ("Back", "Zurück"), ("Warning", "Warnung"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..ce38d066e 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Stroke Type", "Tipo traccia"), + ("Corner Shape", "Forma angolo"), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 5647ca940..bd69f6628 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Stroke Type", "線の種類"), + ("Corner Shape", "角のシェイプ"), ("Paste Vector Art", "ベクトルアートをペースト"), ("Baseline Options", "ベースラインオプション"), ("Column Rules", "段落境界線"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index d938c2dc7..5a0736c09 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Stroke Type", "კონტურის ტიპი"), + ("Corner Shape", "კუთხის ფორმა"), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..4b01ab6af 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Stroke Type", "Rodzaj obrysu"), + ("Corner Shape", "Kształt narożnika"), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..75406e9b0 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Stroke Type", "Tipo de traçado"), + ("Corner Shape", "Forma do canto"), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), ("Fixed Number", "Número fixo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..72df9edd4 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Stroke Type", "Тип обводки"), + ("Corner Shape", "Форма угла"), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..f50e567fc 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Stroke Type", "Тип обведення"), + ("Corner Shape", "Форма кута"), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 44acf6e2c..c28f02958 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -674,11 +674,11 @@ pub(crate) fn stroke_type_dropdown(app: &mut DesignApp, ui: &mut Ui, cur: &str, ("Thin - Thin", "thinThin"), ("Thick - Thick", "thickThick"), ]; - let resp = preview_dropdown(ui, w, |p, r| paint_stroke_type(p, r, cur)); + let resp = preview_dropdown(ui, w, crate::i18n::tr(&app.ui.language, "Stroke Type"), |p, r| paint_stroke_type(p, r, cur, egui::Color32::BLACK)); egui::Popup::menu(&resp).show(|ui| { - ui.set_min_width(140.0); + ui.set_min_width(170.0); for (label, kind) in TYPES { - if ui.selectable_label(*kind == cur, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { + if preview_row(ui, *kind == cur, crate::i18n::tr(&app.ui.language, label), 40.0, |p, r, c| paint_stroke_type(p, r, kind, c)).clicked() { let ty = if *kind == "dashed" { json!({"kind": "dashed", "pattern": [12.0, 4.0]}) } else { json!({"kind": kind}) }; let _ = app.run("object.stroke", json!({"type": ty})); ui.close(); @@ -687,8 +687,7 @@ pub(crate) fn stroke_type_dropdown(app: &mut DesignApp, ui: &mut Ui, cur: &str, }); } -fn paint_stroke_type(p: &egui::Painter, r: Rect, kind: &str) { - let c = egui::Color32::BLACK; +fn paint_stroke_type(p: &egui::Painter, r: Rect, kind: &str, c: egui::Color32) { let (x0, x1, y) = (r.min.x + 5.0, r.max.x - 5.0, r.center().y); match kind { "dashed" => { @@ -721,10 +720,35 @@ fn paint_stroke_type(p: &egui::Painter, r: Rect, kind: &str) { } } +/// A row of a preview dropdown's list, as InDesign's: a small preview of the choice (`preview_w` +/// wide), then its name. Selected rows are highlighted. +fn preview_row(ui: &mut Ui, selected: bool, label: &str, preview_w: f32, paint: impl FnOnce(&egui::Painter, Rect, egui::Color32)) -> egui::Response { + let t = Tokens::get(ui.ctx()); + // As wide as the preview and the longest name (the popup otherwise grows to all it's offered). + let w = preview_w + 130.0; + let (r, resp) = ui.allocate_exact_size(vec2(w, 22.0), Sense::click()); + if selected || resp.hovered() { + ui.painter().rect_filled(r, 2.0, if selected { t.row_selected } else { t.hover }); + } + let ink = if selected { t.text_strong } else { t.text }; + paint(ui.painter(), Rect::from_min_size(r.min + vec2(4.0, 0.0), vec2(preview_w, 22.0)), ink); + crate::rtl::paint( + ui.painter(), + pos2(r.min.x + preview_w + 12.0, r.center().y), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(12.0), + ink, + ); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, label)); + resp +} + /// A dropdown whose face is a light preview box (stroke type, corner shape). -fn preview_dropdown(ui: &mut Ui, w: f32, paint: impl FnOnce(&egui::Painter, Rect)) -> egui::Response { +fn preview_dropdown(ui: &mut Ui, w: f32, label: &str, paint: impl FnOnce(&egui::Painter, Rect)) -> egui::Response { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(w, FIELD_H), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, label)); let face = Rect::from_min_max(r.min, pos2(r.max.x - widgets::SEG_W, r.max.y)); ui.painter().rect_filled(face, 0.0, egui::Color32::from_gray(0xdf)); paint(ui.painter(), face); @@ -735,38 +759,56 @@ fn preview_dropdown(ui: &mut Ui, w: f32, paint: impl FnOnce(&egui::Painter, Rect resp } -fn paint_corner_shape(p: &egui::Painter, r: Rect, shape: CornerShape) { - let c = egui::Color32::BLACK; - let s = Stroke::new(1.5, c); - let (x0, y0, x1, y1) = (r.min.x + 4.0, r.min.y + 4.0, r.max.x - 9.0, r.max.y - 3.0); - let k = 8.0; - let pts: Vec = match shape { - CornerShape::None => vec![pos2(x0, y0), pos2(x1, y0), pos2(x1, y1)], - CornerShape::Bevel => vec![pos2(x0, y0), pos2(x1 - k, y0), pos2(x1, y0 + k), pos2(x1, y1)], - CornerShape::Inset => vec![pos2(x0, y0), pos2(x1 - k, y0), pos2(x1 - k, y0 + k), pos2(x1, y0 + k), pos2(x1, y1)], - CornerShape::Rounded | CornerShape::InverseRounded | CornerShape::Fancy => { - let mut v = vec![pos2(x0, y0)]; - for i in 0..=8 { - let a = i as f32 / 8.0 * std::f32::consts::FRAC_PI_2; - v.push(if shape == CornerShape::InverseRounded { - pos2(x1 - k * a.cos(), y0 + k * a.sin()) - } else { - pos2(x1 - k + k * a.sin(), y0 + k - k * a.cos()) - }); - } - v.push(pos2(x1, y1)); - v - } +/// A corner shape's glyph, as InDesign draws it in its corner lists: the top edge running into the +/// corner and the right edge leaving it, in a square centred in `r`. +fn paint_corner_shape(p: &egui::Painter, r: Rect, shape: CornerShape, c: egui::Color32) { + let side = (r.width() - 3.0).min(r.height() - 3.0).max(4.0); + let o = pos2(r.center().x - side / 2.0, r.center().y - side / 2.0); + let at = |x: f32, y: f32| pos2(o.x + x * side, o.y + y * side); + let quarter = |cx: f32, cy: f32, rad: f32, from: f32, to: f32| -> Vec { + (0..=10).map(|i| from + (to - from) * i as f32 / 10.0).map(|a| at(cx + rad * a.cos(), cy + rad * a.sin())).collect() }; - p.add(egui::Shape::line(pts, s)); + use std::f32::consts::{FRAC_PI_2, PI}; + let stroke = Stroke::new(1.6, c); + let line = |pts: Vec| { + p.add(egui::Shape::line(pts, stroke)); + }; + match shape { + CornerShape::None => line(vec![at(0.0, 0.0), at(1.0, 0.0), at(1.0, 1.0)]), + // The corner cut by a diagonal, half the side. + CornerShape::Bevel => line(vec![at(0.0, 0.0), at(0.5, 0.0), at(1.0, 0.5), at(1.0, 1.0)]), + // A square notch. + CornerShape::Inset => line(vec![at(0.0, 0.0), at(0.5, 0.0), at(0.5, 0.45), at(1.0, 0.45), at(1.0, 1.0)]), + // A concave quarter circle centred on the corner. + CornerShape::InverseRounded => { + let mut v = vec![at(0.0, 0.0)]; + v.extend(quarter(1.0, 0.0, 0.5, PI, FRAC_PI_2)); + v.push(at(1.0, 1.0)); + line(v) + } + // A large convex arc. + CornerShape::Rounded => { + let mut v = vec![at(0.0, 0.0)]; + v.extend(quarter(0.08, 0.92, 0.92, -FRAC_PI_2, 0.0)); + v.push(at(1.0, 1.0)); + line(v) + } + // Steps into the corner with a small square loop between them. + CornerShape::Fancy => { + line(vec![at(0.0, 0.0), at(0.38, 0.0), at(0.38, 0.2), at(0.52, 0.2)]); + p.rect_stroke(Rect::from_min_max(at(0.52, 0.06), at(0.76, 0.3)), 0.0, stroke, StrokeKind::Middle); + line(vec![at(0.76, 0.3), at(0.76, 0.48), at(1.0, 0.48), at(1.0, 1.0)]); + } + } } pub(crate) fn corner_type_dropdown(app: &mut DesignApp, ui: &mut Ui, shape: CornerShape, size: f64, width: f32) { - let resp = preview_dropdown(ui, width, |p, r| paint_corner_shape(p, r, shape)); + let resp = + preview_dropdown(ui, width, crate::i18n::tr(&app.ui.language, "Corner Shape"), |p, r| paint_corner_shape(p, r, shape, egui::Color32::BLACK)); egui::Popup::menu(&resp).show(|ui| { - ui.set_min_width(140.0); + ui.set_min_width(150.0); for s in CornerShape::ALL { - if ui.selectable_label(s == shape, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, s.label()))).clicked() { + if preview_row(ui, s == shape, crate::i18n::tr(&app.ui.language, s.label()), 22.0, |p, r, c| paint_corner_shape(p, r, s, c)).clicked() { let _ = app.run("object.cornerOptions", json!({"shape": s, "size": if size > 0.0 { size } else { 12.0 }})); ui.close(); } @@ -3343,6 +3385,38 @@ mod tests { assert!((r.x0 - 200.0).abs() < 0.5 && (r.y1 - 200.0).abs() < 0.5 && (r.x1 - 300.0).abs() < 0.5, "{r:?}"); } + /// 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"; #[test] @@ -3746,7 +3820,7 @@ fn flex_section(app: &mut DesignApp, ui: &mut Ui) { let cur = serde_json::to_value(cur).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); let icon = FLEX_JUSTIFY.iter().find(|j| j.0 == cur).map(|j| j.1).unwrap_or("flex-start"); let resp = place(ui, sub(b, 0.0, 0.0, 58.0, FIELD_H), |ui| { - preview_dropdown(ui, 58.0, |p, r| icons::paint(p, r.shrink2(vec2(6.0, 2.0)), icon, egui::Color32::from_gray(60))) + preview_dropdown(ui, 58.0, &tr("Justification"), |p, r| icons::paint(p, r.shrink2(vec2(6.0, 2.0)), icon, egui::Color32::from_gray(60))) }); egui::Popup::menu(&resp).show(|ui| { ui.set_min_width(150.0); From c0bf260b5731f14044d5f871ad873ce9bb22381d Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 21 Oct 2026 00:13:51 +0200 Subject: [PATCH 046/431] PR-1394: checkboxes report whether they're enabled Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties.rs | 4 ++++ crates/ui-egui/src/widgets.rs | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index b15e3471d..f562d4d05 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -3370,6 +3370,10 @@ mod tests { 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"); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 645c83c1a..7ebd175a6 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -569,7 +569,7 @@ pub fn checkbox(ui: &mut Ui, on: &mut bool, label: &str) -> Response { } // Named and ticked or not for screen readers (and tests). let checked = *on; - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, checked, label)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), checked, label)); resp } From aa66d3455790710109185fb2eb1875c6f40f758e Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 21 Oct 2026 00:29:58 +0200 Subject: [PATCH 047/431] i18n: Spanish for the new labels Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 22e8a0a87..1b04cd89c 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -89,8 +89,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), - ("Join", ["Join", "Joindre", "Join", "Join", "连接"]), - ("Convert Point", ["Convert Point", "Convertir le point", "Convert Point", "Convert Point", "转换点"]), + ("Join", ["Join", "Joindre", "Unir", "Join", "连接"]), + ("Convert Point", ["Convert Point", "Convertir le point", "Convertir punto", "Convert Point", "转换点"]), ("Hide Rulers", ["Lineale ausblenden", "Masquer les règles", "Ocultar reglas", "定規を隠す", "隐藏标尺"]), ("Inches Decimal", ["Zoll dezimal", "Pouces décimaux", "Pulgadas decimales", "インチ(10 進)", "十进制英寸"]), ( From f66621ff5f8703973677069bce040b002a0b0532 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 21 Oct 2026 00:30:03 +0200 Subject: [PATCH 048/431] i18n: Spanish for the new labels Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index b5494731c..d5208664d 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -89,8 +89,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), - ("Stroke Type", ["Stroke Type", "Type de contour", "Stroke Type", "Stroke Type", "描边类型"]), - ("Corner Shape", ["Corner Shape", "Forme d'angle", "Corner Shape", "Corner Shape", "角形状"]), + ("Stroke Type", ["Stroke Type", "Type de contour", "Tipo de trazo", "Stroke Type", "描边类型"]), + ("Corner Shape", ["Corner Shape", "Forme d'angle", "Forma de esquina", "Corner Shape", "角形状"]), ("Hide Rulers", ["Lineale ausblenden", "Masquer les règles", "Ocultar reglas", "定規を隠す", "隐藏标尺"]), ("Inches Decimal", ["Zoll dezimal", "Pouces décimaux", "Pulgadas decimales", "インチ(10 進)", "十进制英寸"]), ( From 7db54c52e3a3fcad575a615b09f6024e4147316f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 21 Oct 2026 01:40:20 +0200 Subject: [PATCH 049/431] Canvas: the cursor readout is InDesign's light grey box The readout beside the cursor while drawing or measuring uses a new readout_bg token, InDesign's #a8a8a8 with white text as measured from the tutorials' frames. High Contrast keeps it black. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/theme.rs | 24 ++++++++++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..40ed35432 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · The readout beside the cursor while drawing or measuring ("W: 3.13 cm") is InDesign's light grey box with white text, measured from the tutorials' frames; High Contrast keeps it black. It was a dark translucent box. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8d94f099f..5f37574bf 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1705,7 +1705,7 @@ fn draw_tool_overlays(app: &mut DesignApp, painter: &egui::Painter, xf: &Xf) { let s = xf.to_screen(p) + vec2(14.0, 14.0); let g = painter.layout_no_wrap(text, egui::FontId::proportional(11.0), tok.measure_text); let r = Rect::from_min_size(s, g.size() + vec2(10.0, 6.0)); - painter.rect_filled(r, 3.0, tok.measure_bg); + painter.rect_filled(r, 3.0, tok.readout_bg); painter.galley(r.min + vec2(5.0, 3.0), g, tok.measure_text); } Overlay::Line { a, b, color, dashed: d } => { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 7b2364ffe..cce524de3 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -83,6 +83,9 @@ pub struct Tokens { /// Measurement labels on the canvas (smart dimensions, gaps). pub measure_bg: Color32, pub measure_text: Color32, + /// The readout beside the cursor while drawing, moving or measuring ("W: 3.13 cm"): InDesign's + /// light grey box with white text, in every brightness but High Contrast. + pub readout_bg: Color32, /// Live-corner widget and corner diamonds. pub live_corner: Color32, /// The Windows caption Close button when hovered, and its glyph. @@ -130,6 +133,7 @@ impl Tokens { smart_guide: hex(0x00c853), measure_bg: Color32::from_rgba_unmultiplied(70, 70, 70, 230), measure_text: Color32::WHITE, + readout_bg: hex(0xa8a8a8), live_corner: hex(0xffe500), caption_close: hex(0xc42b1c), caption_close_text: Color32::WHITE, @@ -255,6 +259,8 @@ impl Tokens { ruler_text: hex(0xffffff), section_divider: hex(0xffffff), tab_strip: hex(0x000000), + // White on InDesign's grey is too faint here: black, as the rest of the chrome. + readout_bg: hex(0x000000), ..dark }, } @@ -797,3 +803,21 @@ mod russian_font_tests { } } } + +#[cfg(test)] +mod readout_tests { + use super::{Brightness, Tokens}; + + /// The readout beside the cursor ("W: 3.13 cm" while drawing a frame) is InDesign's light + /// grey box with white text in every brightness, as measured from the tutorials' frames; + /// High Contrast keeps it black. + #[test] + fn the_cursor_readout_is_indesigns_light_grey() { + for b in [Brightness::Dark, Brightness::MediumDark, Brightness::MediumLight, Brightness::Light] { + let t = Tokens::for_brightness(b); + assert_eq!(t.readout_bg, egui::Color32::from_rgb(0xa8, 0xa8, 0xa8), "{b:?}"); + assert_eq!(t.measure_text, egui::Color32::WHITE, "{b:?}"); + } + assert_eq!(Tokens::for_brightness(Brightness::HighContrast).readout_bg, egui::Color32::BLACK); + } +} From 5e1fd65dfc50d64e848ac8ac8fa7b98f29068e12 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 21 Oct 2026 01:57:23 +0200 Subject: [PATCH 050/431] Properties: Anchor Point(s) with Convert Points and Delete Point(s) Points selected with Direct Selection title Properties "Anchor Point(s)" and show Transform, Appearance, Convert Points and the Delete Point(s) quick action, as InDesign does. path.deletePoints removes the selected (or given) points of several paths in one undo step. The Convert Point glyphs are redrawn after InDesign's: the anchor on a corner or a curve with its handles. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/anchors.rs | 98 +++++++++++++++++++++++++ crates/ui-egui/src/i18n.rs | 3 + crates/ui-egui/src/i18n/ar.rs | 3 + crates/ui-egui/src/i18n/cs.rs | 3 + crates/ui-egui/src/i18n/de.rs | 3 + crates/ui-egui/src/i18n/it.rs | 3 + crates/ui-egui/src/i18n/ja.rs | 3 + crates/ui-egui/src/i18n/ka.rs | 3 + crates/ui-egui/src/i18n/pl.rs | 3 + crates/ui-egui/src/i18n/pt_br.rs | 3 + crates/ui-egui/src/i18n/ru.rs | 3 + crates/ui-egui/src/i18n/uk.rs | 3 + crates/ui-egui/src/icons.rs | 45 ++++++++---- crates/ui-egui/src/panels/properties.rs | 62 ++++++++++++++++ docs/target-app-parity.md | 2 +- 16 files changed, 226 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7e485d1ad..21a438ed5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · Properties for points selected with Direct Selection, as InDesign's: the title is "Anchor Point(s)", and the panel shows Transform, Appearance, Convert Points (Plain, Corner, Smooth, Symmetrical) and the Delete Point(s) quick action (`path.deletePoints`, one undo step). Found in Zero2Graphic's *How to Page Layout design in Adobe InDesign 2022 CC*. No readiness percentage change. - **2026-10-20** · Direct Selection selects anchor points: a marquee selects the points inside it on every path it reaches, a click selects one point and Shift+click adds or removes one, selected points show solid, and dragging one (Shift: at 45° steps) or pressing the arrow keys moves them all in one step (`selection.set {anchors}`, `path.moveAnchors {points}`). No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. diff --git a/crates/engine/src/cmd/anchors.rs b/crates/engine/src/cmd/anchors.rs index 72b2f085b..fcbb1fa4a 100644 --- a/crates/engine/src/cmd/anchors.rs +++ b/crates/engine/src/cmd/anchors.rs @@ -66,6 +66,15 @@ pub fn specs() -> Vec { has_selection, join_paths ), + cmd!( + "path.deletePoints", + "Delete Points", + [], + None, + "{points?: [[id, subpath, index]]} — the Direct Selection tool's selected points by default, in one undo step (Properties › Quick Actions › Delete Point(s)); a subpath left with one point goes, and an object with none is removed → {deleted, removed: [id]}", + has_selection, + delete_points + ), cmd!( "path.convertPoints", "Convert Point", @@ -152,6 +161,66 @@ fn join_paths(s: &mut Session, p: &Value) -> Result { }) } +/// Delete Point(s): removes the given points, or the ones selected with Direct Selection. +fn delete_points(s: &mut Session, p: &Value) -> Result { + const ID: &str = "path.deletePoints"; + let points: Vec<(ItemId, usize, usize)> = match p.get("points") { + Some(v) => serde_json::from_value::>(v.clone()) + .map_err(|e| bad(ID, format!("points: [[id, subpath, index], …] ({e})")))? + .into_iter() + .map(|(id, si, ai)| (ItemId(id), si, ai)) + .collect(), + None => s.doc()?.selection.anchors.clone(), + }; + if points.is_empty() { + return Err(bad(ID, "no points selected")); + } + // Each subpath's points, worked from the last subpath and the last point back, so the indices + // still to delete keep their place. + let mut groups: Vec<((ItemId, usize), Vec)> = Vec::new(); + for (id, si, ai) in points { + match groups.iter_mut().find(|(k, _)| *k == (id, si)) { + Some((_, v)) => v.push(ai), + None => groups.push(((id, si), vec![ai])), + } + } + groups.sort_by_key(|((id, si), _)| std::cmp::Reverse((id.0, *si))); + s.edit(|d, sel| { + let (mut deleted, mut removed) = (0usize, Vec::new()); + for ((id, si), mut ais) in groups { + if d.item(id).is_none() { + continue; + } + path_editable(d, id, ID)?; + let Some(it) = d.item_mut(id) else { continue }; + let Some(sp) = it.path.subpaths.get_mut(si) else { continue }; + ais.sort_unstable(); + ais.dedup(); + for ai in ais.into_iter().rev() { + if ai < sp.anchors.len() { + sp.anchors.remove(ai); + deleted += 1; + } + } + // A subpath needs two points (three to stay closed). + let (left, closed) = (sp.anchors.len(), sp.closed); + if left < 2 { + it.path.subpaths.remove(si); + } else if closed && left < 3 { + sp.closed = false; + } + as_path(&mut it.shape); + if it.path.subpaths.is_empty() { + d.remove_item(id)?; + sel.items.retain(|i| *i != id); + removed.push(id.0); + } + } + sel.anchors.clear(); + Ok(json!({"deleted": deleted, "removed": removed})) + }) +} + /// Convert Point: the selected points (or every point of the selected paths) to a point type. fn convert_points(s: &mut Session, p: &Value) -> Result { let kind = super::str_param(p, "kind").unwrap_or("plain").to_string(); @@ -444,6 +513,35 @@ mod tests { } /// Convert Point turns the selected points (or a whole path's) plain, corner, smooth or symmetrical. + /// Delete Point(s) removes the selected points of several paths in one step; a triangle left + /// with two points opens, and Undo brings every point back. + #[test] + fn delete_points_removes_the_selected_points_of_several_paths() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [72, 72, 172, 172]})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [200, 72, 300, 172], "shape": "polygon", "sides": 3})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"anchors": [[a, 0, 0], [a, 0, 2], [b, 0, 1]]})).unwrap(); + let r = s.execute("path.deletePoints", &json!({})).unwrap(); + assert_eq!(r["deleted"], 3); + let d = &s.doc().unwrap().doc; + let pa = &d.item(ItemId(a)).unwrap().path.subpaths[0]; + assert_eq!(pa.anchors.len(), 2, "two of the square's four points are left"); + assert!(!pa.closed, "two points can't stay closed"); + assert_eq!(d.item(ItemId(b)).unwrap().path.subpaths[0].anchors.len(), 2); + assert!(s.doc().unwrap().selection.anchors.is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.item(ItemId(a)).unwrap().path.subpaths[0].anchors.len(), 4, "one undo step"); + assert_eq!(d.item(ItemId(b)).unwrap().path.subpaths[0].anchors.len(), 3); + // A line's two points: the line goes. + let l = s.execute("path.create", &json!({"anchors": [{"p": [72, 300]}, {"p": [172, 300]}]})).unwrap()["id"].as_u64().unwrap(); + let r = s.execute("path.deletePoints", &json!({"points": [[l, 0, 0]]})).unwrap(); + assert_eq!(r["removed"], json!([l])); + assert!(s.doc().unwrap().doc.item(ItemId(l)).is_none()); + assert!(s.execute("path.deletePoints", &json!({"points": []})).is_err()); + } + #[test] fn convert_point_kinds() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index efe089ef9..db5f193e8 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -251,6 +251,9 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Pathfinder:", ["Pathfinder:", "Pathfinder :", "Buscatrazos:", "パスファインダー:", "路径查找器:"]), ("Convert Shape:", ["Form konvertieren:", "Convertir la forme :", "Convertir forma:", "シェイプを変換:", "转换形状:"]), ("Convert Point:", ["Punkt konvertieren:", "Convertir le point :", "Convertir punto:", "ポイントを変換:", "转换点:"]), + ("Anchor Point(s)", ["Ankerpunkt(e)", "Point(s) d’ancrage", "Punto(s) de ancla", "アンカーポイント", "锚点"]), + ("Convert Points", ["Punkte konvertieren", "Convertir les points", "Convertir puntos", "ポイントを変換", "转换点"]), + ("Delete Point(s)", ["Punkt(e) löschen", "Supprimer le(s) point(s)", "Eliminar punto(s)", "ポイントを削除", "删除点"]), ("Join Path", ["Pfad zusammenfügen", "Joindre le tracé", "Unir trazado", "パスを連結", "连接路径"]), ("Open Path", ["Pfad öffnen", "Ouvrir le tracé", "Abrir trazado", "パスをオープン", "开放路径"]), ("Plain", ["Einfach", "Simple", "Normal", "プレーン", "普通"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..191ed31a7 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Anchor Point(s)", "نقطة (نقاط) الارتكاز"), + ("Convert Points", "تحويل النقاط"), + ("Delete Point(s)", "حذف النقطة (النقاط)"), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), ("Fixed Number", "عدد ثابت"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..86a07fdc5 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Anchor Point(s)", "Kotevní body"), + ("Convert Points", "Převést body"), + ("Delete Point(s)", "Odstranit body"), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index efc128858..1cee56de0 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -31,6 +31,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Ausschneiden"), ("Copy", "Kopieren"), ("Paste", "Einfügen"), + ("Anchor Point(s)", "Ankerpunkt(e)"), + ("Convert Points", "Punkte konvertieren"), + ("Delete Point(s)", "Punkt(e) löschen"), ("Inches Decimal", "Zoll dezimal"), ("Back", "Zurück"), ("Warning", "Warnung"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..3afbbc332 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Anchor Point(s)", "Punti di ancoraggio"), + ("Convert Points", "Converti punti"), + ("Delete Point(s)", "Elimina punti"), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 5647ca940..f0b1f3258 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Anchor Point(s)", "アンカーポイント"), + ("Convert Points", "ポイントを変換"), + ("Delete Point(s)", "ポイントを削除"), ("Paste Vector Art", "ベクトルアートをペースト"), ("Baseline Options", "ベースラインオプション"), ("Column Rules", "段落境界線"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index d938c2dc7..c1f32e54c 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Anchor Point(s)", "საყრდენი წერტილ(ებ)ი"), + ("Convert Points", "წერტილების გარდაქმნა"), + ("Delete Point(s)", "წერტილ(ებ)ის წაშლა"), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..19ab09e74 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Anchor Point(s)", "Punkty kontrolne"), + ("Convert Points", "Konwertuj punkty"), + ("Delete Point(s)", "Usuń punkty"), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..b37e2b7b1 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Anchor Point(s)", "Ponto(s) de ancoragem"), + ("Convert Points", "Converter pontos"), + ("Delete Point(s)", "Excluir ponto(s)"), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), ("Fixed Number", "Número fixo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..cd03d87bc 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Anchor Point(s)", "Опорные точки"), + ("Convert Points", "Преобразовать точки"), + ("Delete Point(s)", "Удалить точки"), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..ce34db353 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Anchor Point(s)", "Опорні точки"), + ("Convert Points", "Перетворити точки"), + ("Delete Point(s)", "Видалити точки"), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index a90fe989e..78cdf81ea 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -720,23 +720,40 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { pen.line(&[(10.0, 3.0), (10.0, 17.0)]); pen.line(&[(3.0, 10.0), (17.0, 10.0)]); } - // Pathfinder panel › Convert Point: a point on a path, with its direction handles. - "point-plain" | "point-corner" | "point-smooth" | "point-symmetrical" => { - match name { - "point-plain" | "point-corner" => pen.line(&[(3.0, 16.0), (10.0, 8.0), (17.0, 16.0)]), - _ => pen.line(&[(3.0, 16.0), (5.5, 11.5), (10.0, 8.0), (14.5, 11.5), (17.0, 16.0)]), + // Convert Point (Pathfinder panel, Properties › Convert Points): the anchor square on its + // path. Plain and Corner sit on a corner, Corner with a knob on each arm; Smooth and + // Symmetrical sit on a curve with a straight handle through them, its arms equal for + // Symmetrical. + "point-plain" | "point-corner" => { + pen.line(&[(17.0, 6.0), (6.0, 6.0), (6.0, 17.0)]); + if name == "point-corner" { + pen.fcircle(13.0, 6.0, 1.8); + pen.fcircle(6.0, 13.0, 1.8); } - let handles: &[(f32, f32)] = match name { - "point-corner" => &[(4.5, 4.0), (16.0, 5.0)], - "point-smooth" => &[(5.0, 8.0), (17.5, 8.0)], - "point-symmetrical" => &[(4.0, 8.0), (16.0, 8.0)], - _ => &[], + pen.frect(4.0, 4.0, 8.0, 8.0); + } + "point-smooth" | "point-symmetrical" => { + // A curve through the anchor (a quadratic sampled as a polyline), and the handle. + let ((p0, p2), (h0, h1)) = if name == "point-smooth" { + (((3.0, 6.5), (12.5, 18.0)), ((5.0, 4.0), (15.5, 15.5))) + } else { + (((7.0, 18.0), (17.5, 6.0)), ((5.0, 15.0), (15.0, 5.0))) }; - for &(hx, hy) in handles { - pen.line(&[(10.0, 8.0), (hx, hy)]); - pen.fcircle(hx, hy, 1.4); + let m = (10.0f32, 10.0f32); + let c = (2.0 * m.0 - (p0.0 + p2.0) / 2.0, 2.0 * m.1 - (p0.1 + p2.1) / 2.0); + let curve: Vec<(f32, f32)> = (0..=12) + .map(|k| { + let t = k as f32 / 12.0; + let u = 1.0 - t; + (u * u * p0.0 + 2.0 * u * t * c.0 + t * t * p2.0, u * u * p0.1 + 2.0 * u * t * c.1 + t * t * p2.1) + }) + .collect(); + pen.line(&curve); + pen.line(&[h0, h1]); + for (x, y) in [h0, h1] { + pen.frect(x - 1.4, y - 1.4, x + 1.4, y + 1.4); } - pen.frect(8.5, 6.5, 11.5, 9.5); + pen.frect(8.2, 8.2, 11.8, 11.8); } // ---- Properties panel glyphs (original drawings) "clock" => { diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 44acf6e2c..c3374c5dc 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -59,6 +59,9 @@ fn selection_title(app: &DesignApp, info: &Option) -> String { if st.selection.text.is_some() { return crate::i18n::tr(&app.ui.language, "Characters").into(); } + if !st.selection.anchors.is_empty() { + return crate::i18n::tr(&app.ui.language, "Anchor Point(s)").into(); + } match info { None => crate::i18n::tr(&app.ui.language, "No Selection").into(), Some(i) if i.count > 1 => crate::i18n::tr(&app.ui.language, "Multiple Objects").into(), @@ -118,6 +121,10 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { document_sections(app, ui); return; }; + if app.session.active().is_some_and(|st| !st.selection.anchors.is_empty()) { + anchor_point_sections(app, ui, &i); + return; + } section(ui, crate::i18n::tr(&app.ui.language, "Transform"), true); transform_section(app, ui, &i); divider(ui); @@ -2214,6 +2221,37 @@ const CONVERT_SHAPE: [(&str, &str, &str); 9] = [ ("shape-orthogonal", "orthogonalLine", "Orthogonal Line"), ]; +/// 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) { + section(ui, crate::i18n::tr(&app.ui.language, "Transform"), true); + transform_section(app, ui, i); + divider(ui); + section(ui, crate::i18n::tr(&app.ui.language, "Appearance"), true); + appearance_section(app, ui, i); + divider(ui); + let mut kind = None; + let lang = app.ui.language.clone(); + section_row(ui, crate::i18n::tr(&lang, "Convert Points"), |ui| { + ui.spacing_mut().item_spacing.x = 14.0; + // Laid out from the right: last first, so the row reads Plain … Symmetrical. + for (icon, k, tip) in CONVERT_POINT.iter().rev() { + if icon_toggle(ui, icon, false, crate::i18n::tr(&lang, tip)).clicked() { + kind = Some(k); + } + } + }); + if let Some(k) = kind { + let _ = app.run("path.convertPoints", json!({"kind": k})); + } + rule(ui); + section(ui, crate::i18n::tr(&app.ui.language, "Quick Actions"), true); + let fw = full_width(ui); + if outline_button(ui, crate::i18n::tr(&app.ui.language, "Delete Point(s)"), fw).clicked() { + let _ = app.run("path.deletePoints", json!({})); + } +} + /// Pathfinder panel › Convert Point: (icon, kind, tooltip). const CONVERT_POINT: [(&str, &str, &str); 4] = [ ("point-plain", "plain", "Plain"), @@ -3419,6 +3457,30 @@ mod tests { 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"); + } + } + #[test] fn transform_flips_and_rotates_from_properties() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cdd6da689..336c6967f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -435,7 +435,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Place SVG (vector in PDF export via krilla-svg, resvg on screen, text in the bundled fonts), Photoshop (.psd/.psb composite in its colour mode — Bitmap, Grayscale, Duotone, Indexed, RGB, CMYK, Lab, 8/16-bit — with merged transparency; extra alpha and spot channels don't change the image), BMP, and Illustrator files saved with PDF compatibility (.ai). A swatch on a placed greyscale or bitmap image (IDML ``) colours it on screen and in PDF. New `designcraft-images` crate (L2) for sniffing, sizes and decoding. EPS is still missing. - View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough). - OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes. -- Add Anchor Point (=), Delete Anchor Point (-) and Convert Direction Point (⇧C) tools (click toggles smooth/corner, drag pulls out handles), with `path.addAnchor`, `path.deleteAnchor`, `path.convertAnchor`. +- Add Anchor Point (=), Delete Anchor Point (-) and Convert Direction Point (⇧C) tools (click toggles smooth/corner, drag pulls out handles), with `path.addAnchor`, `path.deleteAnchor`, `path.convertAnchor`. Points selected with Direct Selection title Properties "Anchor Point(s)", with Convert Points and the Delete Point(s) quick action (`path.deletePoints`). - Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel (InDesign's layout: thumbnail, Type, Location, Angle, Reverse) sets type, angle, reverse and edits stops and midpoints on a ramp (drag, add, drag off to remove, location; drop a swatch from the Swatches panel on a stop or the ramp; stops keep their CMYK), and follows the Fill/Stroke proxy. Edited gradients are unnamed swatches until the Swatches panel's New Swatch names one "New Gradient Swatch" (`swatch.new`, which also duplicates a named swatch); `swatch.create {gradient}` builds one from colour swatches; `object.gradient` (`target: stroke` for a stroke's gradient, drawn across the path's box on screen and in PDF); the vector round-trips through IDML (`GradientFill…`/`GradientStroke…`). A radial gradient stretches with the object's box as InDesign's does (an ellipse reaching the sides of a non-square frame, on screen and in PDF). Text takes a gradient swatch as its fill or stroke, laid across its frame's box as in InDesign (screen and PDF). - New Document (⌘N) as in InDesign: Recent, Saved, Print, Web and Mobile tabs of page cards (double-click creates) beside the preset's details: document name, size with arrows, units, orientation, pages, start page #, facing pages, primary text frame, columns, margins, bleed and slug per edge (linked or not); Save Document Preset (`file.savePreset`, `file.deletePreset`; saved presets are kept with the preferences, listed by `file.presets` and made by `file.new {preset: name}`); `file.new` takes `intent`, `units`, `startPage` and per-edge `bleed`/`slug`. Preview shows a document with the window's settings in the workspace while it is open; Create keeps it, Close removes it. - Document Setup (⌥⌘P), laid out like InDesign's: intent, number of pages, start page # (an even start begins with a left page), facing pages, binding, Page Size (presets keeping the orientation, width and height with arrows, orientation), margins for every page and parent page, bleed and slug per edge (each group linked or not), and Adjust Layout (objects follow the new page size and margins), all in one undo step; measures are read in the document's units, and a value that can't be read keeps the dialog open; `layout.documentSetup {}` reports the setup, margins included. From d24ce2cfd37e7b5024c0e1ff2da8b76c25570fd3 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 21 Oct 2026 02:08:17 +0200 Subject: [PATCH 051/431] Swatches: InDesign's type columns on each row Rows show a crossed pencil on the swatches that can't be edited ([None], [Registration], [Black]), the colour type (process halftone or spot disc) and the colour mode (CMYK, RGB, Gray, or the [None] and [Registration] glyphs), drawn in code, in place of a lock and "CMYK" text. The Tint field reads 100 with nothing selected, as InDesign's. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/panels/swatches.rs | 183 +++++++++++++++++++++++--- 2 files changed, 169 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..7ec6db9cf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · Swatches panel rows show InDesign's columns, drawn in code: a crossed pencil on the swatches that can't be edited ([None], [Registration], [Black]), the colour type (process or spot) and the colour mode (CMYK, RGB, Gray, or [None]'s and [Registration]'s own glyphs), in place of a lock and "CMYK" text. Tint reads 100 with nothing selected. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 3e7030e87..32bc9e128 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -196,7 +196,8 @@ fn header(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Documen } ui.add_space(6.0); crate::widgets::caption(ui, crate::i18n::tr(&app.ui.language, "Tint:")); - let tint = target.map(|(_, ft, _, st)| f64::from(if stroke_front { *st } else { *ft }) * 100.0); + // With nothing selected InDesign's field reads 100. + let tint = target.map_or(Some(100.0), |(_, ft, _, st)| Some(f64::from(if stroke_front { *st } else { *ft }) * 100.0)); if let Some(v) = crate::widgets::number(ui, "swatches_tint", tint, "", 44.0, 0) { let _ = set_tint(app, v); } @@ -232,6 +233,127 @@ fn folder_button(ui: &mut egui::Ui, tip: &str) -> egui::Response { }) } +/// What a Swatches panel row marks on its right, as InDesign's columns. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct RowMarks { + /// [None], [Registration] and [Black] can't be edited ([Paper] can). + not_editable: bool, + kind: Option, + mode: Option, +} + +/// The colour-mode column: the ink model, or the special swatches' own glyphs. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RowMode { + Cmyk, + Rgb, + Gray, + None, + Registration, +} + +fn row_marks(sw: &designcraft_color::swatch::Swatch) -> RowMarks { + use designcraft_color::Color; + let not_editable = sw.locked && sw.name != designcraft_color::swatch::PAPER; + match &sw.value { + SwatchValue::Color { color, color_type } => { + let mode = match color { + Color::Cmyk { .. } => RowMode::Cmyk, + Color::Rgb { .. } => RowMode::Rgb, + Color::Gray { .. } => RowMode::Gray, + }; + RowMarks { not_editable, kind: Some(*color_type), mode: Some(mode) } + } + SwatchValue::None => RowMarks { not_editable, kind: None, mode: Some(RowMode::None) }, + SwatchValue::Registration => RowMarks { not_editable, kind: None, mode: Some(RowMode::Registration) }, + _ => RowMarks { not_editable, kind: None, mode: None }, + } +} + +/// A pencil with a stroke through it: the swatch can't be edited. Drawn in code. +fn paint_not_editable(p: &egui::Painter, r: egui::Rect, ink: egui::Color32) { + // The pencil: its tip at the lower left, its body up to the right. + let tip = r.left_bottom() + vec2(1.5, -1.5); + let d = vec2(1.0, -1.0).normalized(); + let n = vec2(d.y, -d.x) * 1.9; + let (b0, b1) = (tip + d * 3.4, tip + d * 12.5); + p.add(egui::Shape::convex_polygon(vec![tip, b0 + n, b0 - n], ink, egui::Stroke::NONE)); + p.add(egui::Shape::convex_polygon(vec![b0 + n, b1 + n, b1 - n, b0 - n], ink, egui::Stroke::NONE)); + // The stroke through it, with a gap of the panel's colour around it so it reads on the body. + let (s0, s1) = (r.left_top() + vec2(1.5, 1.5), r.right_bottom() - vec2(1.5, 1.5)); + p.line_segment([s0, s1], egui::Stroke::new(3.2, egui::Color32::from_black_alpha(140))); + p.line_segment([s0, s1], egui::Stroke::new(1.2, ink)); +} + +/// The colour type: a process colour is a halftone square, a spot colour a disc in a square. +fn paint_color_type(p: &egui::Painter, r: egui::Rect, kind: ColorType, ink: egui::Color32) { + let sq = egui::Rect::from_center_size(r.center(), vec2(10.0, 10.0)); + p.rect_filled(sq, 0.0, ink); + if kind == ColorType::Spot { + p.circle_filled(sq.center(), 3.2, egui::Color32::from_black_alpha(110)); + } else { + // A fine halftone: a grey square with a light dot screen. + let dot = egui::Color32::from_black_alpha(70); + for i in 0..4u8 { + for j in 0..4u8 { + let o = if j % 2 == 0 { 1.5 } else { 2.6 }; + p.circle_filled(sq.min + vec2(o + 2.2 * f32::from(i), 1.6 + 2.3 * f32::from(j)), 0.6, dot); + } + } + } +} + +/// The colour mode: CMYK's four inks meeting in the middle, RGB's three primaries, a grey ramp, +/// [None]'s red slash and [Registration]'s target. +fn paint_mode(p: &egui::Painter, r: egui::Rect, mode: RowMode, ink: egui::Color32) { + let sq = egui::Rect::from_center_size(r.center(), vec2(11.0, 11.0)); + let c = sq.center(); + let rgb = egui::Color32::from_rgb; + match mode { + RowMode::Cmyk => { + let corners = [sq.left_top(), sq.right_top(), sq.right_bottom(), sq.left_bottom()]; + // Top, right, bottom, left: magenta, yellow, black, cyan. + let inks = [rgb(236, 0, 140), rgb(255, 230, 0), rgb(30, 30, 30), rgb(0, 174, 239)]; + for (k, colour) in inks.into_iter().enumerate() { + let (a, b) = (corners[k], corners[(k + 1) % 4]); + p.add(egui::Shape::convex_polygon(vec![a, b, c], colour, egui::Stroke::NONE)); + } + p.rect_stroke(sq, 0.0, egui::Stroke::new(1.0, ink), egui::StrokeKind::Outside); + } + RowMode::Rgb => { + let w = sq.width() / 3.0; + for (k, colour) in [rgb(230, 40, 40), rgb(40, 190, 60), rgb(40, 90, 230)].into_iter().enumerate() { + let x = sq.min.x + w * k as f32; + p.rect_filled(egui::Rect::from_min_max(egui::pos2(x, sq.min.y), egui::pos2(x + w, sq.max.y)), 0.0, colour); + } + p.rect_stroke(sq, 0.0, egui::Stroke::new(1.0, ink), egui::StrokeKind::Outside); + } + RowMode::Gray => { + let w = sq.width() / 4.0; + for k in 0..4u8 { + let x = sq.min.x + w * f32::from(k); + p.rect_filled( + egui::Rect::from_min_max(egui::pos2(x, sq.min.y), egui::pos2(x + w, sq.max.y)), + 0.0, + egui::Color32::from_gray(255 - 70 * k), + ); + } + p.rect_stroke(sq, 0.0, egui::Stroke::new(1.0, ink), egui::StrokeKind::Outside); + } + RowMode::None => { + p.rect_filled(sq, 0.0, egui::Color32::WHITE); + p.line_segment([sq.left_bottom(), sq.right_top()], egui::Stroke::new(1.5, rgb(220, 30, 30))); + p.rect_stroke(sq, 0.0, egui::Stroke::new(1.0, ink), egui::StrokeKind::Outside); + } + RowMode::Registration => { + p.rect_stroke(sq, 0.0, egui::Stroke::new(1.0, ink), egui::StrokeKind::Inside); + p.circle_stroke(c, 3.0, egui::Stroke::new(1.0, ink)); + p.line_segment([c - vec2(4.5, 0.0), c + vec2(4.5, 0.0)], egui::Stroke::new(1.0, ink)); + p.line_segment([c - vec2(0.0, 4.5), c + vec2(0.0, 4.5)], egui::Stroke::new(1.0, ink)); + } + } +} + /// One Swatches panel row: chip, name, type; click applies it to the fill or stroke in front of /// the proxy (of the frames or their text, see [`super::colors_affect_text`]), the context menu /// to the stroke, colour groups and delete. @@ -267,24 +389,26 @@ fn swatch_row( let chip = egui::Rect::from_min_size(row.min + vec2(4.0 + indent, 3.0), vec2(16.0, 16.0)); crate::widgets::paint_chip(ui.painter(), chip, c, g); ui.painter().text(row.min + vec2(28.0 + indent, 11.0), egui::Align2::LEFT_CENTER, &sw.name, egui::FontId::proportional(12.5), t.text); - // Type indicators: spot dot / process square, colour mode. - let kind = match &sw.value { - SwatchValue::Color { color, color_type } => { - let m = match color { - designcraft_color::Color::Cmyk { .. } => "CMYK", - designcraft_color::Color::Rgb { .. } => "RGB", - designcraft_color::Color::Gray { .. } => crate::i18n::tr(&app.ui.language, "Gray"), - }; - format!("{}{}", if *color_type == ColorType::Spot { "● " } else { "" }, m) - } - SwatchValue::Gradient { .. } => "Gradient".into(), + // InDesign's columns on the right: not editable, colour type, colour mode. + let marks = row_marks(sw); + let col = |k: f32| egui::Rect::from_min_size(egui::pos2(row.max.x - 19.0 - 18.0 * k, row.min.y + 4.5), vec2(13.0, 13.0)); + if marks.not_editable { + paint_not_editable(ui.painter(), col(2.0), t.text_dim); + } + if let Some(kind) = marks.kind { + paint_color_type(ui.painter(), col(1.0), kind, t.text_dim); + } + if let Some(mode) = marks.mode { + paint_mode(ui.painter(), col(0.0), mode, t.text_dim); + } + let label = match &sw.value { + SwatchValue::Gradient { .. } => crate::i18n::tr(&app.ui.language, "Gradient").to_string(), SwatchValue::Tint { tint, .. } => format!("{:.0}%", tint * 100.0), _ => String::new(), }; - if sw.locked { - icons::paint(ui.painter(), egui::Rect::from_min_size(egui::pos2(row.max.x - 64.0, row.min.y + 4.0), vec2(13.0, 13.0)), "lock", t.text_dim); + if !label.is_empty() { + ui.painter().text(row.right_center() - vec2(6.0, 0.0), egui::Align2::RIGHT_CENTER, label, egui::FontId::proportional(10.5), t.text_dim); } - ui.painter().text(row.right_center() - vec2(6.0, 0.0), egui::Align2::RIGHT_CENTER, kind, egui::FontId::proportional(10.5), t.text_dim); if resp.clicked() && !resp.double_clicked() { ui.data_mut(|d| d.insert_temp(egui::Id::new(PICKED), sw.name.clone())); let _ = super::apply_swatch(app, app.ui.stroke_front, &sw.name, None); @@ -1161,3 +1285,32 @@ mod swatches_header_tests { assert_eq!((it[1].fill.swatch.as_str(), it[1].fill.tint), ("C=0 M=100 Y=0 K=0", 0.4)); } } + +#[cfg(test)] +mod row_mark_tests { + use designcraft_color::swatch::{Swatch, default_swatches}; + use designcraft_color::{Color, ColorType, SwatchValue}; + + use super::{RowMarks, RowMode, row_marks}; + + /// The Swatches panel's right-hand columns, as InDesign's: [None], [Registration] and [Black] + /// can't be edited, [Paper] can; colours show their type and mode. + #[test] + fn swatch_rows_mark_type_mode_and_whether_they_can_be_edited() { + let all = default_swatches(); + let marks = |name: &str| row_marks(all.iter().find(|s| s.name == name).unwrap()); + assert_eq!(marks("[None]"), RowMarks { not_editable: true, kind: None, mode: Some(RowMode::None) }); + assert_eq!(marks("[Registration]"), RowMarks { not_editable: true, kind: None, mode: Some(RowMode::Registration) }); + assert_eq!(marks("[Paper]"), RowMarks { not_editable: false, kind: None, mode: None }); + assert_eq!(marks("[Black]"), RowMarks { not_editable: true, kind: Some(ColorType::Process), mode: Some(RowMode::Cmyk) }); + let cyan = all.iter().find(|s| matches!(&s.value, SwatchValue::Color { .. }) && !s.locked).unwrap(); + assert_eq!(row_marks(cyan), RowMarks { not_editable: false, kind: Some(ColorType::Process), mode: Some(RowMode::Cmyk) }); + let spot = Swatch { + name: "PANTONE".into(), + value: SwatchValue::Color { color: Color::Rgb { r: 1.0, g: 0.0, b: 0.0 }, color_type: ColorType::Spot }, + locked: false, + ..cyan.clone() + }; + assert_eq!(row_marks(&spot), RowMarks { not_editable: false, kind: Some(ColorType::Spot), mode: Some(RowMode::Rgb) }); + } +} From af36dc399de32adc3c60c9a24f8c900c6f58bf28 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 21 Oct 2026 02:38:12 +0200 Subject: [PATCH 052/431] Fitting: Clear Frame Fitting Options; the submenu grouped as InDesign's object.clearFittingOptions puts the frames' auto-fit, crop amounts and reference point back to their defaults and leaves the picture where it is. Object > Fitting (and the context menu) groups the proportional fits, the frame and content fits, Clear Frame Fitting Options and the dialog. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/object.rs | 50 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n.rs | 10 +++++++ crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + crates/ui-egui/src/menus.rs | 28 ++++++++++++++++++ 14 files changed, 99 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..7e0ff1b56 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · Object › Fitting › Clear Frame Fitting Options (`object.clearFittingOptions`): the frames' auto-fit, crop amounts and reference point go back to their defaults, and the picture stays where it is. The Fitting submenu is grouped as InDesign's. Found in the Learn Skills Daily course, chapter 11. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 82f33a6d8..f709b6dec 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -638,6 +638,15 @@ pub fn specs() -> Vec { has_selection, fitting_options ), + cmd!( + "object.clearFittingOptions", + "Clear Frame Fitting Options", + ["Object", "Fitting"], + None, + "{ids?} — the frames' fitting options back to the defaults: no auto-fit, no crop amounts, the centre reference point; the content stays where it is", + has_selection, + clear_fitting_options + ), cmd!("object.rename", "Rename", [], None, "{id | ids?, name}", has_doc, |s, p| { let name = str_param(p, "name").unwrap_or("").to_string(); set_flag(s, p, move |i| i.name = name.clone(), false) @@ -2884,6 +2893,24 @@ fn fitting_options(s: &mut Session, p: &Value) -> Result { }) } +/// Clear Frame Fitting Options: auto-fit off, no crop, the centre reference point. The content +/// stays where it is, as in InDesign. +fn clear_fitting_options(s: &mut Session, p: &Value) -> Result { + let ids = targets(s, p)?; + s.edit(|d, _| { + let mut n = 0; + for id in &ids { + let Some(it) = d.item_mut(*id) else { continue }; + let Content::Graphic(g) = &mut it.content else { continue }; + g.auto_fit = designcraft_doc::Fitting::None; + g.crop = [0.0; 4]; + g.fit_align = 4; + n += 1; + } + Ok(json!({"changed": n})) + }) +} + fn fit(s: &mut Session, p: &Value) -> Result { let doc = &s.doc()?.doc; let ids: Vec<_> = @@ -4264,6 +4291,29 @@ mod fitting_tests { let g = back.spreads.iter().flat_map(|sp| sp.items.iter()).find_map(|i| i.graphic().cloned()).unwrap(); assert_eq!(g.auto_fit, designcraft_doc::Fitting::FillProportionally); } + + /// Clear Frame Fitting Options puts auto-fit, crop and the reference point back to their + /// defaults and leaves the picture where it is. + #[test] + fn clear_fitting_options_resets_the_frames_options_and_keeps_the_picture() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let f = s.execute("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(); + s.execute("file.place", &json!({"base64": crate::cmd::base64_encode(&png), "name": "p.png", "frame": f})).unwrap(); + s.execute("object.fittingOptions", &json!({"ids": [f], "autoFit": true, "fitting": "fitProportionally", "crop": 10, "align": 0})).unwrap(); + let graphic = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().graphic().unwrap().clone(); + let before = graphic(&s); + assert_ne!(before.auto_fit, designcraft_doc::Fitting::None); + s.execute("object.clearFittingOptions", &json!({"ids": [f]})).unwrap(); + let g = graphic(&s); + assert_eq!(g.auto_fit, designcraft_doc::Fitting::None); + assert_eq!(g.crop, [0.0; 4]); + assert_eq!(g.fit_align, 4, "the centre reference point"); + assert_eq!(g.xf, before.xf, "the picture stays where it is"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(graphic(&s).crop, [10.0; 4], "one undo step"); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index efe089ef9..46ac7b73d 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -36,6 +36,16 @@ pub const LANGUAGES: &[(&str, &str)] = &[ /// English → [German, French, Spanish, Japanese, Simplified Chinese]. const TABLE: &[(&str, [&str; 5])] = &[ + ( + "Clear Frame Fitting Options", + [ + "Rahmeneinpassungsoptionen löschen", + "Effacer les options d’ajustement du bloc", + "Borrar opciones de encaje de marco", + "フレーム調整オプションを消去", + "清除框架适合选项", + ], + ), // Menus. ("File", ["Datei", "Fichier", "Archivo", "ファイル", "文件"]), ("Edit", ["Bearbeiten", "Édition", "Edición", "編集", "编辑"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..41295ee7e 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", "لصق"), + ("Clear Frame Fitting Options", "مسح خيارات ملاءمة الإطار"), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), ("Fixed Number", "عدد ثابت"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..bc39aea28 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Clear Frame Fitting Options", "Vymazat volby přizpůsobení rámečku"), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index efc128858..626918117 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Ausschneiden"), ("Copy", "Kopieren"), ("Paste", "Einfügen"), + ("Clear Frame Fitting Options", "Rahmeneinpassungsoptionen löschen"), ("Inches Decimal", "Zoll dezimal"), ("Back", "Zurück"), ("Warning", "Warnung"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..cf860b35c 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"), + ("Clear Frame Fitting Options", "Cancella opzioni di adattamento cornice"), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 5647ca940..b39c78ca0 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", "ペースト"), + ("Clear Frame Fitting Options", "フレーム調整オプションを消去"), ("Paste Vector Art", "ベクトルアートをペースト"), ("Baseline Options", "ベースラインオプション"), ("Column Rules", "段落境界線"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index d938c2dc7..6ba9ed276 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Clear Frame Fitting Options", "ჩარჩოს მორგების პარამეტრების გასუფთავება"), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..99b6de911 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Clear Frame Fitting Options", "Wyczyść opcje dopasowania ramki"), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..e9f7acbfa 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"), + ("Clear Frame Fitting Options", "Limpar opções de ajuste do quadro"), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), ("Fixed Number", "Número fixo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..0e43dc91f 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Clear Frame Fitting Options", "Очистить параметры подгонки фрейма"), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..bf5a25a93 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Clear Frame Fitting Options", "Очистити параметри припасування фрейму"), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 79b8b2c4e..4424f4f13 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -688,13 +688,17 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:anchored.options", "cmd:anchored.release", "<", + // InDesign's groups: proportional fits, frame and content fits, clearing, the dialog. ">Fitting", "ui:fit.fillFrameProportionally", "ui:fit.contentProportionally", + "-", "ui:fit.frameToContent", "ui:fit.contentToFrame", "ui:fit.centerContent", "-", + "cmd:object.clearFittingOptions", + "-", "ui:app.fittingOptions", "<", ">Content", @@ -3276,6 +3280,30 @@ mod tests { 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", + ] + ); + } + /// The submenu paths of a menu bar menu ("View/Screen Mode"), as Edit › Menus keys them. fn submenu_paths(title: &str) -> Vec { let items = MENUS.iter().find(|(t, _)| *t == title).map(|(_, items)| *items).unwrap_or_default(); From 72541208031cd1898d7614a8176a5da13c1e3132 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 21 Oct 2026 03:18:17 +0200 Subject: [PATCH 053/431] Properties: the Align To button shows its target, as InDesign's One glyph each for the selection, the key object, the margins, the page and the spread, drawn in code, so the button tells what the objects align to. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/icons.rs | 41 +++++++++++++++++++++++++ crates/ui-egui/src/panels/properties.rs | 23 +++++++++++++- 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..35f5b8d7c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · Properties › Align: the Align To button shows what the objects align to, one glyph each for the selection, the key object, the margins, the page and the spread, as InDesign's. Found in the Learn Skills Daily course, chapter 12. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index a90fe989e..2142b2b54 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -828,6 +828,33 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { _ => {} } } + // Properties › Align To, one glyph per target, with the drop-down mark: a page with its + // margins, a page, a spread of two pages, a key object drawn bold. + "align-to-margins" | "align-to-page" | "align-to-spread" | "align-to-key" => { + match name { + "align-to-margins" => { + pen.closed(&[(4.0, 2.5), (11.5, 2.5), (14.5, 5.5), (14.5, 16.0), (4.0, 16.0)]); + pen.w = 1.0; + pen.rect(6.5, 6.0, 12.0, 13.5); + } + "align-to-page" => { + pen.closed(&[(4.0, 2.5), (11.5, 2.5), (14.5, 5.5), (14.5, 16.0), (4.0, 16.0)]); + pen.line(&[(11.5, 2.5), (11.5, 5.5), (14.5, 5.5)]); + } + "align-to-spread" => { + pen.rect(1.5, 4.0, 8.5, 15.0); + pen.rect(8.5, 4.0, 15.5, 15.0); + } + _ => { + pen.rect(3.0, 4.0, 15.0, 14.0); + pen.frect(3.0, 4.0, 15.0, 6.0); + pen.frect(3.0, 12.0, 15.0, 14.0); + pen.frect(3.0, 4.0, 5.0, 14.0); + pen.frect(13.0, 4.0, 15.0, 14.0); + } + } + pen.fill(&[(16.0, 17.0), (19.5, 17.0), (17.75, 19.0)]); + } "align-to" | "frame-dashed" => { pen.w = 1.0; for (a, b) in [((3.0, 3.0), (15.0, 3.0)), ((15.0, 3.0), (15.0, 15.0)), ((15.0, 15.0), (3.0, 15.0)), ((3.0, 15.0), (3.0, 3.0))] { @@ -1152,6 +1179,20 @@ mod tests { assert!(!arrow_filled("tool-direct"), "Direct Selection draws a filled arrow"); } + /// Each Align To target has its own glyph, none the placeholder. + #[test] + fn align_to_glyphs_are_distinct() { + let names = ["align-to", "align-to-key", "align-to-margins", "align-to-page", "align-to-spread"]; + let unknown = drawing("no-such-icon"); + let glyphs: Vec = names.iter().map(|n| drawing(n)).collect(); + for (k, g) in glyphs.iter().enumerate() { + assert_ne!(*g, unknown, "{} falls back to the placeholder", names[k]); + for (j, other) in glyphs.iter().enumerate().skip(k + 1) { + assert_ne!(g, other, "{} and {} draw alike", names[k], names[j]); + } + } + } + #[test] fn distribute_icons_are_distinct_from_align_and_each_other() { // `objalign-*` ship upstream and back the Properties Align row; only the diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 44acf6e2c..81ba86643 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -947,6 +947,17 @@ fn swatch_menu( } } +/// The Align To button's glyph shows what the objects align to, as InDesign's. +fn align_to_icon(to: &str) -> &'static str { + match to { + "keyObject" => "align-to-key", + "margins" => "align-to-margins", + "page" => "align-to-page", + "spread" => "align-to-spread", + _ => "align-to", + } +} + fn align_row(app: &mut DesignApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); ui.horizontal(|ui| { @@ -961,7 +972,7 @@ fn align_row(app: &mut DesignApp, ui: &mut Ui) { other => other, }; let tip = format!("{}: {}", crate::i18n::tr(&app.ui.language, "Align To"), crate::i18n::tr(&app.ui.language, to_label)); - let resp = icon_toggle(ui, "align-to", false, &tip); + let resp = icon_toggle(ui, align_to_icon(&to), false, &tip); egui::Popup::menu(&resp).show(|ui| { for (label, id) in [ ("Align to Selection", "selection"), @@ -3419,6 +3430,16 @@ mod tests { 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"); + } + #[test] fn transform_flips_and_rotates_from_properties() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From 75d72c69b59a52acc14a1766a534a3f570bb4b87 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 21 Oct 2026 08:28:10 +0200 Subject: [PATCH 054/431] Effects: blend modes for Drop Shadow, Inner Shadow and Outer Glow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As InDesign's Effects dialog › Blending › Mode: the shadows start Multiply and the outer glow Screen in white; the renderer blends each with what's behind it, the commands take mode, IDML reads and writes the drop shadow's BlendMode. Effects saved before read Normal, as they were drawn. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/doc/src/item.rs | 37 ++++++++++- crates/engine/src/cmd/object.rs | 36 ++++++++++- crates/idml/src/export.rs | 1 + crates/idml/src/import.rs | 1 + crates/render/src/fx.rs | 17 +++-- crates/render/tests/effects.rs | 28 +++++++++ crates/render/tests/golden/inner-shadow.png | Bin 1879 -> 1859 bytes crates/ui-egui/src/effects_dialog.rs | 65 +++++++++++++++----- 9 files changed, 161 insertions(+), 25 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..2e579b927 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · Drop Shadow, Inner Shadow and Outer Glow have a blend mode, as InDesign's (Effects dialog › Blending › Mode; `mode` on `object.dropShadow`, `object.innerShadow`, `object.outerGlow`; IDML `BlendMode`). New shadows start Multiply and new outer glows Screen in white ([Paper]); effects saved before keep Normal, so documents look as they did. Found in the Learn Skills Daily course, chapter 14. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index cfb13dc53..6da69b9e2 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -973,12 +973,22 @@ pub struct DropShadow { pub distance: f64, pub size: f64, pub spread: f64, + /// How the shadow blends with what's behind it (InDesign: Multiply). A document saved before + /// effects had modes reads Normal, as they were drawn then. + #[serde(default = "normal_blend")] + pub mode: BlendMode, +} + +/// The blend of an effect saved before effects had one. +fn normal_blend() -> BlendMode { + BlendMode::Normal } impl Default for DropShadow { fn default() -> Self { DropShadow { on: false, + mode: BlendMode::Multiply, color: designcraft_color::swatch::BLACK.into(), opacity: 0.75, angle: 120.0, @@ -1005,12 +1015,16 @@ pub struct InnerShadow { pub size: f64, /// Percentage (0–100) of `size` that hardens the edge instead of blurring it. pub choke: f64, + /// How the shadow blends with the object (InDesign: Multiply; Normal when saved before). + #[serde(default = "normal_blend")] + pub mode: BlendMode, } impl Default for InnerShadow { fn default() -> Self { InnerShadow { on: false, + mode: BlendMode::Multiply, color: designcraft_color::swatch::BLACK.into(), opacity: 0.75, angle: 120.0, @@ -1032,11 +1046,15 @@ pub struct OuterGlow { pub size: f64, /// Percentage (0–100) of `size` that grows the silhouette instead of blurring it. pub spread: f64, + /// How the glow blends with what's behind it (InDesign: Screen; Normal when saved before). + #[serde(default = "normal_blend")] + pub mode: BlendMode, } impl Default for OuterGlow { fn default() -> Self { - OuterGlow { on: false, color: designcraft_color::swatch::BLACK.into(), opacity: 0.75, size: 7.0, spread: 0.0 } + // InDesign starts a glow screened in white. + OuterGlow { on: false, color: designcraft_color::swatch::PAPER.into(), opacity: 0.75, size: 7.0, spread: 0.0, mode: BlendMode::Screen } } } @@ -1639,6 +1657,23 @@ mod tests { use super::*; use designcraft_geom::shapes; + /// Shadows and glows saved before they had a blend mode read Normal, as they were drawn; + /// new ones start as InDesign's: Multiply for shadows, Screen for the outer glow. + #[test] + fn effect_blend_modes_default_as_indesign_and_keep_old_documents() { + let old: DropShadow = + serde_json::from_str(r#"{"on": true, "color": "[Black]", "opacity": 0.75, "angle": 120, "distance": 7, "size": 5, "spread": 0}"#) + .unwrap(); + assert_eq!(old.mode, BlendMode::Normal); + let old: OuterGlow = serde_json::from_str(r#"{"on": true, "color": "[Black]", "opacity": 0.75, "size": 7, "spread": 0}"#).unwrap(); + assert_eq!(old.mode, BlendMode::Normal); + assert_eq!(DropShadow::default().mode, BlendMode::Multiply); + assert_eq!(InnerShadow::default().mode, BlendMode::Multiply); + assert_eq!(OuterGlow::default().mode, BlendMode::Screen); + let saved = serde_json::to_value(DropShadow { mode: BlendMode::Screen, ..Default::default() }).unwrap(); + assert_eq!(serde_json::from_value::(saved).unwrap().mode, BlendMode::Screen, "round trip"); + } + #[test] fn graphics_without_a_clipping_path_load_and_save_as_before() { let g = Graphic { diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 82f33a6d8..29358fcac 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -382,7 +382,7 @@ pub fn specs() -> Vec { "Drop Shadow", ["Object", "Effects"], Some("Cmd+Alt+M"), - "{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, spread? (%), color? (swatch), ids?}", + "{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, spread? (%), color? (swatch), mode?: blend mode (multiply…), ids?}", has_selection, |s, p| { let p2 = p.clone(); @@ -398,6 +398,9 @@ pub fn specs() -> Vec { if let Some(c) = str_param(&p2, "color") { ds.color = c.into(); } + if let Some(m) = blend_param(&p2) { + ds.mode = m; + } }) } ), @@ -406,7 +409,7 @@ pub fn specs() -> Vec { "Inner Shadow", ["Object", "Effects"], None, - "{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, choke? (%), color?, ids?}", + "{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, choke? (%), color?, mode?: blend mode, ids?}", has_selection, |s, p| { let p2 = p.clone(); @@ -422,6 +425,9 @@ pub fn specs() -> Vec { if let Some(c) = str_param(&p2, "color") { e.color = c.into(); } + if let Some(m) = blend_param(&p2) { + e.mode = m; + } }) } ), @@ -430,7 +436,7 @@ pub fn specs() -> Vec { "Outer Glow", ["Object", "Effects"], None, - "{on?: bool, opacity?, size?, spread? (%), color?, ids?}", + "{on?: bool, opacity?, size?, spread? (%), color?, mode?: blend mode (screen…), ids?}", has_selection, |s, p| { let p2 = p.clone(); @@ -443,6 +449,9 @@ pub fn specs() -> Vec { if let Some(c) = str_param(&p2, "color") { e.color = c.into(); } + if let Some(m) = blend_param(&p2) { + e.mode = m; + } }) } ), @@ -3101,6 +3110,12 @@ fn align(s: &mut Session, p: &Value) -> Result { }) } +/// An effect's `mode` parameter: a blend mode by name ("multiply", "screen", or as saved). +fn blend_param(p: &Value) -> Option { + let v = p.get("mode")?; + v.as_str().and_then(BlendMode::parse).or_else(|| serde_json::from_value(v.clone()).ok()) +} + fn distribute(s: &mut Session, p: &Value) -> Result { let ids = targets(s, p)?; if ids.len() < 3 && p.get("spacing").is_none() { @@ -4055,6 +4070,21 @@ mod label_tests { mod light_tests { use super::*; + /// The shadow and glow commands take a blend mode by name. + #[test] + fn shadow_and_glow_commands_take_a_blend_mode() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [72, 72, 172, 172]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.dropShadow", &json!({"on": true, "mode": "screen", "ids": [id]})).unwrap(); + s.execute("object.innerShadow", &json!({"on": true, "mode": "overlay", "ids": [id]})).unwrap(); + s.execute("object.outerGlow", &json!({"on": true, "mode": "normal", "ids": [id]})).unwrap(); + let fx = s.doc().unwrap().doc.item(ItemId(id)).unwrap().effects.clone(); + assert_eq!(fx.drop_shadow.mode, BlendMode::Screen); + assert_eq!(fx.inner_shadow.mode, BlendMode::Overlay); + assert_eq!(fx.outer_glow.mode, BlendMode::Normal); + } + #[test] fn global_light_moves_shadows_that_use_it() { let mut s = Session::new(); diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 86fef8e46..1a845a2ac 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -2067,6 +2067,7 @@ impl<'a> Ex<'a> { .attr("Size", num(ds.size)) .attr("Spread", num(ds.spread)) .attr("Opacity", pct(ds.opacity as f64)) + .attr("BlendMode", names::blend_out(ds.mode)) .attr("EffectColor", self.sw(&ds.color)), ); } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index d770aef79..30a5caa34 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2699,6 +2699,7 @@ impl<'r> Importer<'r> { size: ds.num("Size").unwrap_or(def.size), spread: ds.num("Spread").unwrap_or(def.spread), global_light: false, + mode: ds.get("BlendMode").map_or(def.mode, names::blend_in), }; } if let Some(f) = t.find("FeatherSetting") diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 6147c9cf5..e7f54a3ea 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -102,19 +102,25 @@ impl Renderer { let mut below = vec![]; if e.outer_glow.on { let g = &e.outer_glow; - below.push((Vec2::ZERO, g.size, g.spread, g.color.clone(), g.opacity)); + below.push((Vec2::ZERO, g.size, g.spread, g.color.clone(), g.opacity, g.mode)); } if e.drop_shadow.on { let d = &e.drop_shadow; - below.push((offset(doc.light_angle(d.angle, d.global_light), d.distance), d.size, d.spread, d.color.clone(), d.opacity)); + below.push((offset(doc.light_angle(d.angle, d.global_light), d.distance), d.size, d.spread, d.color.clone(), d.opacity, d.mode)); } - for (off, size, pct, color, opacity) in below { + for (off, size, pct, color, opacity, mode) in below { let Some(c) = doc.resolve_color(&color, 1.0) else { continue }; let (sigma, grow) = split(size, pct); let filter = shadow(sigma, color_of(&c, opacity.clamp(0.0, 1.0))); // The offset moves the geometry, not the filter: vello drops layer content that lies // entirely outside the viewport before filtering. let r = (reach + off).inflate(grow, grow); + // The effect's blend sees what's behind the object: a layer around the filtered one. + let mix = crate::blend_mode(mode).mix; + if mix != Mix::Normal { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(BlendMode::new(mix, Compose::SrcOver)), None, None, None); + } self.with_filters(ctx, f, r, sigma, None, |me, c, fr| { let moved = Frame { view: fr.view * Affine::translate(off), ..*fr }; c.set_transform(moved.view); @@ -128,6 +134,9 @@ impl Renderer { } c.pop_layer(); }); + if mix != Mix::Normal { + ctx.pop_layer(); + } } // Gradient feather: the object (feathered or not) drawn in its own layer, then kept as // much as the opacity gradient says. @@ -218,7 +227,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)), crate::blend_mode(s.mode).mix); } // Inner glow: from the edges inwards (or out from the centre), screened over the object. if e.inner_glow.on diff --git a/crates/render/tests/effects.rs b/crates/render/tests/effects.rs index a9420fbcb..a371a1470 100644 --- a/crates/render/tests/effects.rs +++ b/crates/render/tests/effects.rs @@ -141,6 +141,8 @@ fn outer_glow_surrounds_the_object() { g.size = 12.0; g.color = "C=0 M=100 Y=0 K=0".into(); g.opacity = 1.0; + // Normal, so the glow shows on the white page (screened, magenta on white is white). + g.mode = designcraft_color::BlendMode::Normal; let img = render(&d); golden("outer-glow", &img); for (x, y) in [(46, 80), (114, 80), (80, 46), (80, 114)] { @@ -150,6 +152,32 @@ fn outer_glow_surrounds_the_object() { assert_eq!(lum(img.pixel(10, 10)), WHITE); } +/// An effect's blend mode works on what's behind it: a glow screened onto a dark backdrop +/// lightens it in the glow's colour, where Normal paints the glow's colour over it. +#[test] +fn outer_glow_blends_with_its_mode() { + let glow = |mode| { + let mut d = doc(); + rect_item(&mut d, Rect::new(0.0, 0.0, 160.0, 160.0), "C=100 M=90 Y=10 K=0"); + let id = rect_item(&mut d, Rect::new(50.0, 50.0, 110.0, 110.0), "[Black]"); + let g = &mut d.item_mut(id).unwrap().effects.outer_glow; + g.on = true; + g.size = 12.0; + g.color = "C=0 M=0 Y=100 K=0".into(); + g.opacity = 1.0; + g.mode = mode; + render(&d) + }; + let screen = glow(designcraft_color::BlendMode::Screen); + let normal = glow(designcraft_color::BlendMode::Normal); + let backdrop = screen.pixel(10, 10); + let (s, n) = (screen.pixel(46, 80), normal.pixel(46, 80)); + // Screen only lightens: every channel at least the backdrop's, and the navy shows through + // (blue stays high), where Normal paints yellow (blue drops). + assert!(s.iter().zip(backdrop).take(3).all(|(a, b)| *a + 2 >= b), "screen lightens: {s:?} over {backdrop:?}"); + assert!(s[2] > n[2] + 25, "screen keeps the backdrop's blue: {s:?} vs normal {n:?}"); +} + #[test] fn inner_shadow_darkens_inside_the_lit_edge() { let mut d = doc(); diff --git a/crates/render/tests/golden/inner-shadow.png b/crates/render/tests/golden/inner-shadow.png index c267aa386ffd379a63dfe29d51894328dadf52b2..5cba476417afef661d24d3a06699a2d2cdec7133 100644 GIT binary patch delta 792 zcmcc4cbIR23M1D<)fe@D4F{pgo0as}qNkLlegY2d2)||Cnzke7@g$s$Q5=NrCGMQ!INe`&Osxj=BL{ zAoCg-fJ&NgBpi6QpL^h&YhM_nGwz&`nFhPFnZtx=}dL1~ngHsEeW&zFzP?(XPFh6==sf zvz*+6&)CkS8_ahG`WNU07NPLJ+F&1~95VR`3YN(n9FDpmPaHhP2G;WC%>kg>o*B${ zTpPd*(NYi9vm|jA)MZnc1Ov3c$eyW+IFnc`Y-XNNEo^26bm_Y0CC(GV15*^LRDGY$L@ zI)Ksn#kQng_KWSAsu|~j795rNC70C*joPU>8xFSlcsR@c1cg^8DAXN+sRZcmD_p=R z1V$n#u7N?@xZ_p)=>}f26el$gu;Cs|j6y97L^N2v8lyl_mIVyy*RW`VxFd4|+*DA4 z1Nn*xn1q;su`8&sc{TGarf`W3JI}C58!7V6oWPlRqGejb0X9yRb-%(l9b}WfbU|v% zv3hZ>|GrR1W*hi|p{a!W3gzjkPAo3KD1^qw%nMRIz!;p6TYNS2zwP7z*20MgjKn_l XJ7rg`%wRRCVE_V8S3j3^P69qfmhM6sD~XEu98JE)6U~9x-3#Pq5AYuNU%fwuc&%l8^3%(hKDQt6y5J zI65UsX@SUu9)`}<|C(&)fwE~Qa6M9&L!v9L& zh)HYa-RAN6ek;g{D@1?sr5Q(@Ih&NX&Ft(mw@nc;w(}61 zw9!$Mk5Fq^ck%tYcjkFoa^g0#$#dVPWM9t7oAL9^ndi&Ig1I~t8bMCezM0U>YnF1z zr1QTTFmzlTT2>zYGI!?rGsoI)-ZHViotAEW^NvCH&YAV+ebz1$1NjH+JfNl<6T~j7 z{F@!{Ghc{tDNx^4puVp_SIw4AD?Xf)uO=-GbZ=~WRwIWp!m61QG?%G?H7PZwcr~tT zjsk|&%`<1xfvz+&J9BN>%=13Lz*9T~bl;KC|6XP{5}egi4q1Gx2YE`EQ$>T-%VF(> z?-xQZe^0wM8|a&Kpp~`x=(e<3eS&J3(37I%19aV$q%70g^k3(`>;(pzwu=O(ia_|^ zY{?A=+k8&2e7xUws$Q9s#Y3PeXn}|ZYgS{_!mt&rS*`1u1t2a_hB#%(!Dsuqr~X$H z0*4>R1P7**9%_eNW^f2MaVT$|$vlfG@ e6A!S8edt#beP{7*^Ob81K;Y@>=d#Wzp$P!HCU6%3 diff --git a/crates/ui-egui/src/effects_dialog.rs b/crates/ui-egui/src/effects_dialog.rs index 456ca4a58..dfc345346 100644 --- a/crates/ui-egui/src/effects_dialog.rs +++ b/crates/ui-egui/src/effects_dialog.rs @@ -302,6 +302,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| { + mode_row(ui, d, key, &tr); row(ui, "Color:"); swatch(app, ui, d, &[key, "color"]); row(ui, "Opacity:"); @@ -343,23 +344,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()); - egui::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:"); @@ -550,6 +535,28 @@ fn summary(lang: &str, d: &Dialog) -> String { } /// The dialog's body. +/// The Mode row of a shadow's or glow's Blending: its blend mode, as InDesign's (shadows start +/// Multiply, glows Screen). +fn mode_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, tr: &dyn Fn(&str) -> String) { + crate::rtl::label(ui, tr("Mode:")); + let fallback = if key.ends_with("Glow") { "Screen" } else { "Multiply" }; + let cur = d.fields.get("fx").and_then(|f| f[key]["mode"].as_str()).unwrap_or(fallback).to_string(); + 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| { + for m in designcraft_color::BlendMode::ALL { + if ui.selectable_label(name(&m) == cur, tr(m.label())).clicked() { + picked = Some(name(&m)); + } + } + }); + if let Some(m) = picked { + set(d, &[key, "mode"], json!(m)); + } + ui.end_row(); +} + pub fn show(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(); @@ -725,6 +732,30 @@ mod tests { assert!(!fx(&app).inner_shadow.on); } + /// Drop Shadow and Outer Glow have InDesign's Mode under Blending (Multiply and Screen to + /// start); a mode picked there is what OK applies. + #[test] + fn shadows_and_glows_have_a_blend_mode() { + let (mut app, id) = app_with_frame(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, "en"); + open(&mut app, "dropShadow"); + let texts = frame(&mut app, &ctx); + assert!(texts.iter().any(|x| x.trim() == "Mode:") && texts.iter().any(|x| x.trim() == "Multiply"), "{texts:?}"); + crate::dialogs::cancel(&mut app); + open(&mut app, "outerGlow"); + let texts = frame(&mut app, &ctx); + assert!(texts.iter().any(|x| x.trim() == "Screen"), "{texts:?}"); + if let Some(d) = app.ui.dialog.as_mut() { + set(d, &["outerGlow", "mode"], json!("Normal")); + } + frame(&mut app, &ctx); + crate::dialogs::confirm(&mut app).unwrap(); + let fx = app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().effects.clone(); + assert!(fx.outer_glow.on); + assert_eq!(fx.outer_glow.mode, designcraft_color::BlendMode::Normal); + } + #[test] fn offsets_follow_distance_and_angle_as_in_indesign() { let (mut app, _) = app_with_frame(); From 161b359c37ce2545724c86fa7985df186c167567 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 21 Oct 2026 08:33:09 +0200 Subject: [PATCH 055/431] Effects dialog: InDesign's angle dial beside each Angle field A circle whose radius points where the light comes from; a click or drag on it sets the angle (0 degrees to the right, counter-clockwise). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/effects_dialog.rs | 50 ++++++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..8c7b008b0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · Effects dialog: the Angle field of the shadows, Bevel and Emboss, Satin and Gradient Feather has InDesign's dial beside it, its radius pointing where the light comes from; a click or drag on it sets the angle. Found in the Learn Skills Daily course, chapter 14. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/ui-egui/src/effects_dialog.rs b/crates/ui-egui/src/effects_dialog.rs index 456ca4a58..d0c2228b8 100644 --- a/crates/ui-egui/src/effects_dialog.rs +++ b/crates/ui-egui/src/effects_dialog.rs @@ -204,10 +204,41 @@ fn percent(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str]) { fn angle(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str], enabled: bool, global: f64) { let mut v = if enabled { num(d, path) } else { global }; - let r = ui.add_enabled(enabled, egui::DragValue::new(&mut v).range(-180.0..=180.0).suffix("°").max_decimals(1)); - if r.changed() { - set(d, path, json!(v)); + ui.horizontal(|ui| { + if let Some(a) = angle_dial(ui, v, enabled) { + v = a; + set(d, path, json!(v)); + } + let r = ui.add_enabled(enabled, egui::DragValue::new(&mut v).range(-180.0..=180.0).suffix("°").max_decimals(1)); + if r.changed() { + set(d, path, json!(v)); + } + }); +} + +/// InDesign's angle dial: a circle with a radius pointing where the light comes from (0° to the +/// right, counter-clockwise). A click or a drag on it points it at the pointer. +fn angle_dial(ui: &mut egui::Ui, deg: f64, enabled: bool) -> Option { + let sense = if enabled { egui::Sense::click_and_drag() } else { egui::Sense::hover() }; + let (r, resp) = ui.allocate_exact_size(egui::vec2(26.0, 26.0), sense); + resp.widget_info(|| egui::WidgetInfo::slider(enabled, deg, "Angle")); + let col = if enabled { ui.visuals().text_color() } else { ui.visuals().weak_text_color() }; + let (c, rad) = (r.center(), 11.0); + ui.painter().circle_stroke(c, rad, egui::Stroke::new(1.2, col)); + let a = deg.to_radians() as f32; + ui.painter().line_segment([c, c + egui::vec2(a.cos(), -a.sin()) * rad], egui::Stroke::new(1.4, col)); + if enabled && (resp.dragged() || resp.clicked()) { + return resp.interact_pointer_pos().and_then(|p| dial_angle(c, p)); } + None +} + +/// The angle a dial at `c` points at for the pointer at `p`, in whole degrees (−180 to 180, +/// counter-clockwise from the right); none too close to the centre to tell. +fn dial_angle(c: egui::Pos2, p: egui::Pos2) -> Option { + let d = p - c; + // Straight left is 180° (not −180°, which a negative zero would give). + (d.length() > 2.0).then(|| f64::from((-d.y).atan2(d.x)).to_degrees().round()).map(|a| if a <= -180.0 { 180.0 } else { a }) } fn flag(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str], label: &str) { @@ -725,6 +756,19 @@ mod tests { assert!(!fx(&app).inner_shadow.on); } + /// The angle dial points where the pointer is, as InDesign's: right is 0°, up 90°, left 180°, + /// down −90°, upper left 135°. + #[test] + fn the_angle_dial_points_at_the_pointer() { + let c = egui::pos2(100.0, 100.0); + assert_eq!(dial_angle(c, egui::pos2(120.0, 100.0)), Some(0.0)); + assert_eq!(dial_angle(c, egui::pos2(100.0, 80.0)), Some(90.0)); + assert_eq!(dial_angle(c, egui::pos2(80.0, 100.0)), Some(180.0)); + assert_eq!(dial_angle(c, egui::pos2(100.0, 120.0)), Some(-90.0)); + assert_eq!(dial_angle(c, egui::pos2(90.0, 90.0)), Some(135.0)); + assert_eq!(dial_angle(c, egui::pos2(101.0, 100.0)), None, "too close to the centre"); + } + #[test] fn offsets_follow_distance_and_angle_as_in_indesign() { let (mut app, _) = app_with_frame(); From bd340e664863d0f5981a971f139f891a888ae7de Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:54:06 +0200 Subject: [PATCH 056/431] Text Frame Options: InDesign's layout, Preview and the Paragraph Spacing Limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dialog follows InDesign's: General, Column Rules, Baseline Options, Auto-Size; on General, framed Columns (Number and Width, Gutter and Maximum, Balance Columns), Inset Spacing (Top and Bottom, the link, Left and Right) and Vertical Justification (Align, Paragraph Spacing Limit), with spinner fields. With Fixed Number, Width shows each column's width as the number, gutter and insets change. Preview shows the options on the page while the dialog is open (redone as fields change); Cancel puts the frames back, OK sets them once as one undo step. Vertical Justification › Justify honours the Paragraph Spacing Limit: the extra space goes between paragraphs, up to the limit each, and what is left spreads over every line (all of it, with no limit, as before). object.textFrameOptions takes it as paragraphSpacingLimit. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 19 +- crates/compose/src/tests.rs | 46 +++++ crates/engine/src/cmd/object.rs | 6 +- crates/ui-egui/src/dialogs.rs | 293 ++++++++++++++++++++++++------- crates/ui-egui/src/i18n.rs | 11 ++ crates/ui-egui/src/i18n/ar.rs | 2 + crates/ui-egui/src/i18n/cs.rs | 2 + crates/ui-egui/src/i18n/it.rs | 2 + crates/ui-egui/src/i18n/ka.rs | 2 + crates/ui-egui/src/i18n/pl.rs | 2 + crates/ui-egui/src/i18n/pt_br.rs | 2 + crates/ui-egui/src/i18n/ru.rs | 2 + crates/ui-egui/src/i18n/uk.rs | 2 + 14 files changed, 330 insertions(+), 62 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..66b4d8fa7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · Text Frame Options is laid out as InDesign's, with Preview: General, Column Rules, Baseline Options, Auto-Size; framed Columns (Number and Width, Gutter and Maximum, Balance Columns), Inset Spacing (Top/Bottom, the link, Left/Right) and Vertical Justification (Align, Paragraph Spacing Limit) with spinner fields; Fixed Number shows each column's width as the fields change. Preview shows the change on the page until OK (one undo step) or Cancel. Justify honours the Paragraph Spacing Limit (`paragraphSpacingLimit` on `object.textFrameOptions`): the space goes between paragraphs up to the limit, the rest between lines. Found in the Learn Skills Daily course, chapter 18. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 4a2c66397..f3680c8dc 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -4099,9 +4099,24 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool) { .collect(); let n = rows.last().copied().unwrap_or(0); if n > 0 { - let per = space / n as f64; + // Paragraph Spacing Limit: the space goes between paragraphs, up to the limit + // each; what is left spreads over every line (all of it with no limit). + let mut starts = vec![false; n + 1]; for (&i, &k) in idx.iter().zip(&rows) { - ft.lines[i].baseline += per * k as f64; + if k > 0 && ft.lines[i].first_in_para { + starts[k] = true; + } + } + let gaps = starts.iter().filter(|s| **s).count(); + let limit = f.opts.vj_paragraph_spacing_limit.max(0.0); + let para = if gaps > 0 && limit > 0.0 { (space / gaps as f64).min(limit) } else { 0.0 }; + let per = (space - para * gaps as f64) / n as f64; + let mut shift = vec![0.0; n + 1]; + for k in 1..=n { + shift[k] = shift[k - 1] + per + if starts[k] { para } else { 0.0 }; + } + for (&i, &k) in idx.iter().zip(&rows) { + ft.lines[i].baseline += shift[k]; } } } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index ed4c7c476..398d8f1d5 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -6847,6 +6847,52 @@ fn anchored_object_wraps_its_own_storys_later_lines() { assert!((o.origin.y - top).abs() < 1e-6); } +/// Vertical Justification › Justify with a Paragraph Spacing Limit: the space goes between the +/// paragraphs (up to the limit each), the lines of a paragraph keep their leading; past the limit +/// the rest spreads over every line. Without a limit it spreads over every line. +#[test] +fn justify_paragraph_spacing_limit() { + let text = "One line. +Two +Three"; + let baselines = |limit: f64| { + let (mut d, sid, fid) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 400.0), ParaAttrs::default()); + let o = &mut d.item_mut(fid).unwrap().text_frame_mut().unwrap().options; + o.vertical_justification = designcraft_doc::VerticalJustification::Justify; + o.vj_paragraph_spacing_limit = limit; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + all_lines(&cs).iter().map(|l| l.baseline).collect::>() + }; + let (none, big, small) = (baselines(0.0), baselines(1000.0), baselines(20.0)); + assert_eq!(none.len(), 3); + let gaps = |b: &[f64]| (b[1] - b[0], b[2] - b[1]); + // No limit: equal steps. A big limit: all of it between paragraphs (here every line is one). + assert!((gaps(&none).0 - gaps(&none).1).abs() < 1e-6, "{none:?}"); + assert!((gaps(&big).0 - gaps(&big).1).abs() < 1e-6, "{big:?}"); + // A small limit: each paragraph gap gets at most 20 pt more than the even share's base. + assert!((none[2] - big[2]).abs() < 1e-6 && (none[2] - small[2]).abs() < 1e-6, "the last line stays at the bottom"); + // A paragraph of two lines keeps its leading when the limit absorbs the space. + let (mut d, sid, fid) = doc_with( + &[LOREM, "End."].join( + " +", + ), + Rect::new(0.0, 0.0, 300.0, 600.0), + ParaAttrs::default(), + ); + let o = &mut d.item_mut(fid).unwrap().text_frame_mut().unwrap().options; + o.vertical_justification = designcraft_doc::VerticalJustification::Justify; + o.vj_paragraph_spacing_limit = 10_000.0; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + let first: Vec = lines.iter().filter(|l| l.para == 0).map(|l| l.baseline).collect(); + assert!(first.len() > 2, "LOREM runs over several lines"); + let lead = first[1] - first[0]; + assert!(first.windows(2).all(|w| (w[1] - w[0] - lead).abs() < 1e-6), "the paragraph's lines keep their leading: {first:?}"); + let last = lines.iter().find(|l| l.para == 1).unwrap().baseline; + assert!(last - first[first.len() - 1] > lead + 50.0, "the space went between the paragraphs"); +} + #[test] fn anchored_object_without_wrap_leaves_layout_unchanged() { let long = [LOREM; 3].join(" "); diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 82f33a6d8..479a11fa6 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -598,7 +598,7 @@ pub fn specs() -> Vec { "Text Frame Options…", ["Object"], Some("Cmd+B"), - "{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, columnsKind?: fixedNumber|fixedWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin? (pt), autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}", + "{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, paragraphSpacingLimit? (pt: Justify adds at most this between paragraphs, the rest between lines), columnsKind?: fixedNumber|fixedWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin? (pt), autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}", has_selection, text_frame_options ), @@ -2496,6 +2496,7 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { let min_width = ranged("minimumWidth", 0.0, crate::autosize::MAX_SIDE, "a number of points")?; let column_width = ranged("columnWidth", 0.0, 15552.0, "a number of points")?; let first_baseline_min = ranged("firstBaselineMin", 0.0, 15552.0, "a number of points")?; + let spacing_limit = ranged("paragraphSpacingLimit", 0.0, 15552.0, "a number of points")?; let reference = match p.get("autoSizeReference") { None | Some(Value::Null) => None, Some(v) => Some( @@ -2547,6 +2548,9 @@ 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; + } if let Some(v) = p.get("useMinimumHeight").and_then(Value::as_bool) { o.auto_size_use_min_height = v; } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8b4043787..02ce87c49 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -527,6 +527,7 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) { "firstBaseline": kind(serde_json::to_value(o.first_baseline).unwrap_or_default()), "firstBaselineMin": fm(o.first_baseline_min), "insetTop": fm(o.inset[0]), "insetLeft": fm(o.inset[1]), "insetBottom": fm(o.inset[2]), "insetRight": fm(o.inset[3]), "verticalJustification": serde_json::to_value(o.vertical_justification).unwrap_or(Value::Null), + "paragraphSpacingLimit": fm(o.vj_paragraph_spacing_limit), "columnRule": o.column_rule, "columnRuleWeight": format_measure(o.column_rule_weight, Unit::Points), "columnRuleColor": o.column_rule_color, "columnRuleTint": percent(o.column_rule_tint), "columnRuleTopInset": fm(o.column_rule_top_inset), "columnRuleBottomInset": fm(o.column_rule_bottom_inset), "columnRuleOffset": fm(o.column_rule_offset), @@ -542,6 +543,10 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) { } } d.fields.entry("insetLinked".to_string()).or_insert_with(|| json!(o.inset.iter().all(|v| (v - o.inset[0]).abs() < 1e-9))); + // The frame's width: Fixed Number shows each column's width from it as the fields change. + if let Some(w) = selected_frame_width(app) { + d.fields.insert("_frameWidth".into(), json!(w)); + } d.fields.insert("_shown".into(), shown); } @@ -567,7 +572,7 @@ fn text_frame_options_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) ui, d, false, - &[("general", "General"), ("baseline", "Baseline Options"), ("autosize", "Auto-Size"), ("rules", "Column Rules")], + &[("general", "General"), ("rules", "Column Rules"), ("baseline", "Baseline Options"), ("autosize", "Auto-Size")], ); ui.separator(); ui.vertical(|ui| match d.s("section").as_str() { @@ -592,49 +597,141 @@ fn text_frame_options_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) _ => general(&lang, ui, d), }); }); + ui.add_space(6.0); + check(ui, d, "preview", tr("Preview")); + if let Some(st) = d.fields.get("status").and_then(Value::as_str) { + crate::rtl::label(ui, egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); + } } -/// Text Frame Options › General: columns, inset spacing, vertical justification, text wrap. +/// A measure field of the dialog with InDesign's spinner (stored as text in the document's unit). +fn spin_measure(ui: &mut egui::Ui, d: &mut Dialog, key: &str, w: f32, enabled: bool) { + let id = format!("tfo_{key}"); + if let Some(v) = crate::widgets::NumField::measure(&id, d.m(key), d.unit).width(w).spinner().range(0.0, 15552.0).enabled(enabled).show(ui) { + d.fields.insert(key.into(), json!(format_measure(v, d.unit))); + } +} + +/// A titled group in a thin frame, as InDesign's Text Frame Options draws them. +fn framed(ui: &mut egui::Ui, title: Option<&str>, add: impl FnOnce(&mut egui::Ui)) { + let t = crate::theme::Tokens::get(ui.ctx()); + ui.add_space(4.0); + egui::Frame::NONE.stroke(egui::Stroke::new(1.0, t.divider)).inner_margin(egui::Margin::symmetric(10, 8)).corner_radius(2.0).show(ui, |ui| { + ui.set_width(ui.available_width()); + if let Some(title) = title { + crate::rtl::label(ui, egui::RichText::new(title).font(semibold(12.0))); + } + add(ui); + }); +} + +/// A caption right-aligned in `w` (the dialog's fields line up after their captions). +fn caption_at(ui: &mut egui::Ui, text: &str, w: f32) { + ui.allocate_ui_with_layout(egui::vec2(w, crate::widgets::FIELD_H), egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.set_min_width(w); + ui.add(egui::Label::new(crate::rtl::widget(ui, text)).wrap_mode(egui::TextWrapMode::Extend)); + }); +} + +/// Preview on: the options show on the page (redone when a field changes) without an undo step; +/// off, the page goes back. +fn text_frame_preview(app: &mut DesignApp, d: &mut Dialog) { + let shown = d.fields.get(PREVIEWED).cloned(); + if !d.b("preview") { + if shown.is_some() { + app.session.cancel_interaction(); + d.fields.remove(PREVIEWED); + d.fields.remove("status"); + } + return; + } + let params = match text_frame_params(app, d) { + Ok(p) => Value::Object(p), + Err(e) => { + d.fields.insert("status".into(), json!(e)); + return; + } + }; + if shown.as_ref() == Some(¶ms) { + return; + } + // Nothing edited (yet, or any more): the page as it was. + if params.as_object().is_some_and(|m| m.keys().all(|k| k == "ids")) { + if shown.is_some() { + app.session.cancel_interaction(); + d.fields.remove(PREVIEWED); + } + return; + } + if shown.is_none() + && let Err(e) = app.session.begin_interaction("Text Frame Options") + { + d.fields.insert("status".into(), json!(e.to_string())); + return; + } + match app.session.preview("object.textFrameOptions", params.clone()) { + Ok(()) => d.fields.remove("status"), + Err(e) => d.fields.insert("status".into(), json!(e.to_string())), + }; + d.fields.insert(PREVIEWED.into(), params); +} + +/// Text Frame Options › General, as InDesign's: Columns (kind, number and width, gutter and +/// maximum, Balance Columns), Inset Spacing (top and bottom, the link, left and right), Vertical +/// Justification (align, paragraph spacing limit) and Ignore Text Wrap. fn general(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) { let tr = |s: &'static str| crate::i18n::tr(lang, s); - crate::rtl::label(ui, egui::RichText::new(tr("Columns")).font(semibold(12.0))); - egui::Grid::new("tfo_cols").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, tr("Columns:")); - translated_combo(lang, ui, d, "columnsKind", &[("fixedNumber", "Fixed Number"), ("fixedWidth", "Fixed Width")]); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr_context(lang, "Number:", "count")); - text_field(ui, d, "columns", 70.0); - // Fixed Number: the width follows from the frame; Fixed Width: it's set here. - crate::rtl::label(ui, tr("Width:")); - ui.add_enabled_ui(d.s("columnsKind") == "fixedWidth", |ui| text_field(ui, d, "columnWidth", 70.0)); - ui.end_row(); - crate::rtl::label(ui, tr("Gutter:")); - text_field(ui, d, "gutter", 70.0); - ui.end_row(); - }); - check(ui, d, "balanceColumns", tr("Balance Columns")); - ui.add_space(6.0); - crate::rtl::label(ui, egui::RichText::new(tr("Inset Spacing")).font(semibold(12.0))); - // While linked, a side typed in sets all four ("Make all settings the same"). - let sides = ["insetTop", "insetLeft", "insetBottom", "insetRight"]; - let before: Vec = sides.iter().map(|k| d.s(k)).collect(); - // Two rows, Top | Left and Bottom | Right, the link between the columns (fixed-width - // captions; the second column starts where the first row's does). - let mut col2_x: Option = None; - for (r, row) in [[("Top:", "insetTop"), ("Left:", "insetLeft")], [("Bottom:", "insetBottom"), ("Right:", "insetRight")]].into_iter().enumerate() { + let (cap, field, cap2) = (64.0, 110.0, 70.0); + framed(ui, None, |ui| { ui.horizontal(|ui| { - for (k, (label, key)) in row.into_iter().enumerate() { - if k == 1 { - match col2_x { - Some(x) => ui.add_space((x - ui.cursor().min.x).max(0.0)), - None => col2_x = Some(ui.cursor().min.x), - } - } - let x0 = ui.cursor().min.x; - crate::rtl::label(ui, tr(label)); - ui.add_space(((if k == 0 { 56.0 } else { 44.0 }) - (ui.cursor().min.x - x0)).max(0.0)); - text_field(ui, d, key, 70.0); - if k == 0 && r == 0 { + caption_at(ui, tr("Columns:"), cap); + translated_combo(lang, ui, d, "columnsKind", &[("fixedNumber", "Fixed Number"), ("fixedWidth", "Fixed Width")]); + }); + ui.add_space(4.0); + ui.horizontal(|ui| { + caption_at(ui, crate::i18n::tr_context(lang, "Number:", "count"), cap); + let n = d.n("columns"); + if let Some(v) = crate::widgets::NumField::number("tfo_columns", n, "", 0).width(field).spinner().range(1.0, 40.0).show(ui) { + d.fields.insert("columns".into(), json!(v.round().to_string())); + } + // Fixed Number: the width follows from the frame; Fixed Width: it's set here. + caption_at(ui, tr("Width:"), cap2); + if d.s("columnsKind") == "fixedWidth" { + spin_measure(ui, d, "columnWidth", field, true); + } else { + // Fixed Number: each column's width, from the frame, the insets and the gutter. + let frame = d.fields.get("_frameWidth").and_then(Value::as_f64); + let n = d.n("columns").unwrap_or(1.0).max(1.0); + let width = frame.map(|w| { + let inner = w - d.m("insetLeft").unwrap_or(0.0) - d.m("insetRight").unwrap_or(0.0); + ((inner - d.m("gutter").unwrap_or(0.0) * (n - 1.0)) / n).max(0.0) + }); + let _ = crate::widgets::NumField::measure("tfo_auto_width", width, d.unit).width(field).spinner().enabled(false).show(ui); + } + }); + ui.horizontal(|ui| { + caption_at(ui, tr("Gutter:"), cap); + spin_measure(ui, d, "gutter", field, true); + // Maximum is for Flexible Width columns, which aren't in the document model yet. + caption_at(ui, tr("Maximum:"), cap2); + ui.add_enabled_ui(false, |ui| { + let _ = crate::widgets::NumField::number("tfo_max", None, "", 0).width(field).spinner().enabled(false).show(ui); + }); + }); + check(ui, d, "balanceColumns", tr("Balance Columns")); + }); + framed(ui, Some(tr("Inset Spacing")), |ui| { + // While linked, a side typed in sets all four ("Make all settings the same"). + let sides = ["insetTop", "insetLeft", "insetBottom", "insetRight"]; + let before: Vec = sides.iter().map(|k| d.s(k)).collect(); + for (r, [(l1, k1), (l2, k2)]) in + [[("Top:", "insetTop"), ("Left:", "insetLeft")], [("Bottom:", "insetBottom"), ("Right:", "insetRight")]].into_iter().enumerate() + { + ui.horizontal(|ui| { + caption_at(ui, tr(l1), cap); + spin_measure(ui, d, k1, field, true); + ui.add_space(8.0); + if r == 0 { let linked = d.b("insetLinked"); let tip = tr("Make all settings the same"); if crate::widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, tip, egui::vec2(22.0, 21.0)) @@ -642,26 +739,40 @@ fn general(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) { { d.fields.insert("insetLinked".into(), json!(!linked)); } + } else { + ui.add_space(22.0 + ui.spacing().item_spacing.x); } + caption_at(ui, tr(l2), cap2 - 30.0); + spin_measure(ui, d, k2, field, true); + }); + } + if d.b("insetLinked") + && let Some(k) = sides.iter().zip(&before).find(|(k, b)| d.s(k) != **b).map(|(k, _)| *k) + { + let v = d.fields.get(k).cloned().unwrap_or_default(); + for side in sides { + d.fields.insert(side.into(), v.clone()); } - }); - } - if d.b("insetLinked") - && let Some(k) = sides.iter().zip(&before).find(|(k, b)| d.s(k) != **b).map(|(k, _)| *k) - { - let v = d.fields.get(k).cloned().unwrap_or_default(); - for side in sides { - d.fields.insert(side.into(), v.clone()); } - } - ui.add_space(6.0); - crate::rtl::label(ui, egui::RichText::new(tr("Vertical Justification")).font(semibold(12.0))); - egui::Grid::new("tfo_vj").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, tr("Align:")); - translated_combo(lang, ui, d, "verticalJustification", &[("top", "Top"), ("center", "Center"), ("bottom", "Bottom"), ("justify", "Justify")]); - ui.end_row(); }); - ui.add_space(6.0); + framed(ui, Some(tr("Vertical Justification")), |ui| { + ui.horizontal(|ui| { + caption_at(ui, tr("Align:"), 170.0); + translated_combo( + lang, + ui, + d, + "verticalJustification", + &[("top", "Top"), ("center", "Center"), ("bottom", "Bottom"), ("justify", "Justify")], + ); + }); + ui.horizontal(|ui| { + caption_at(ui, tr("Paragraph Spacing Limit:"), 170.0); + let justify = d.s("verticalJustification") == "justify"; + spin_measure(ui, d, "paragraphSpacingLimit", field, justify); + }); + }); + ui.add_space(4.0); check(ui, d, "ignoreWrap", tr("Ignore Text Wrap")); } @@ -769,7 +880,19 @@ fn edited(d: &Dialog, key: &str) -> bool { fn confirm_text_frame_options(app: &mut DesignApp, d: &mut Dialog) -> Result { seed_text_frame_options(app, d); - let d = &*d; + // The preview goes; the options are set once, as one undo step. + if d.fields.remove(PREVIEWED).is_some() { + app.session.cancel_interaction(); + } + let p = text_frame_params(app, d)?; + if p.keys().all(|k| k == "ids") { + return Ok(Value::Null); + } + app.run("object.textFrameOptions", Value::Object(p)) +} + +/// The `object.textFrameOptions` params of what the dialog changed. +fn text_frame_params(app: &DesignApp, d: &Dialog) -> Result, String> { let mut p = Map::new(); let bad = |what: &str| format!("Text Frame Options: {what}"); if edited(d, "columns") { @@ -850,8 +973,11 @@ fn confirm_text_frame_options(app: &mut DesignApp, d: &mut Dialog) -> Result= 0.0).ok_or_else(|| bad(what))?)); } } - if p.is_empty() { - return Ok(Value::Null); + if edited(d, "paragraphSpacingLimit") { + p.insert( + "paragraphSpacingLimit".into(), + json!(d.m("paragraphSpacingLimit").filter(|v| *v >= 0.0).ok_or_else(|| bad("the paragraph spacing limit is not a measure"))?), + ); } // Typing in a frame: the options apply to that frame. if let Some(st) = app.session.active() @@ -860,7 +986,7 @@ fn confirm_text_frame_options(app: &mut DesignApp, d: &mut Dialog) -> Result { seed_text_frame_options(app, &mut d); text_frame_options_dialog(app, ui, &mut d); + text_frame_preview(app, &mut d); } "colorTheme" => color_theme(app, ui, &mut d), "documentSetup" => { @@ -7921,6 +8048,54 @@ mod tests { assert_eq!((opts(&app).columns_kind, opts(&app).column_width), (designcraft_doc::ColumnsKind::FixedNumber, 120.0)); } + /// The course's chapter 18: Text Frame Options with Preview on shows the columns and the + /// inset on the page while the dialog is open; Cancel puts the frame back, OK sets them as one + /// undo step. The Paragraph Spacing Limit is set from the dialog too. + #[test] + fn text_frame_options_preview_shows_the_change_until_ok_or_cancel() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let r = app.run("frame.create", json!({"rect": [36, 36, 336, 236], "content": "text", "text": "Hello"})).unwrap(); + let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); + app.run("selection.set", json!({"ids": [id.0]})).unwrap(); + let opts = |app: &DesignApp| app.session.doc().unwrap().doc.item(id).unwrap().text_frame().unwrap().options.clone(); + let open = |app: &mut DesignApp| { + let mut d = Dialog::new("textFrameOptions", json!({})); + seed_text_frame_options(app, &mut d); + d + }; + let mut d = open(&mut app); + d.fields.insert("columns".into(), json!("3")); + d.fields.insert("preview".into(), json!(true)); + text_frame_preview(&mut app, &mut d); + assert_eq!(opts(&app).columns, 3, "previewed on the page"); + d.fields.insert("insetTop".into(), json!("1p0")); + text_frame_preview(&mut app, &mut d); + assert_eq!((opts(&app).columns, opts(&app).inset[0]), (3, 12.0), "redone when a field changes"); + d.fields.insert("preview".into(), json!(false)); + text_frame_preview(&mut app, &mut d); + assert_eq!(opts(&app).columns, 1, "Preview off: the page as it was"); + // Cancel after a preview: back as it was. + d.fields.insert("preview".into(), json!(true)); + text_frame_preview(&mut app, &mut d); + app.ui.dialog = Some(d); + cancel(&mut app); + assert_eq!(opts(&app).columns, 1); + // OK after a preview: set once, one undo step. + let mut d = open(&mut app); + d.fields.insert("columns".into(), json!("2")); + d.fields.insert("verticalJustification".into(), json!("justify")); + d.fields.insert("paragraphSpacingLimit".into(), json!("2p0")); + d.fields.insert("preview".into(), json!(true)); + text_frame_preview(&mut app, &mut d); + app.ui.dialog = Some(d); + confirm(&mut app).unwrap(); + let o = opts(&app); + assert_eq!((o.columns, o.vj_paragraph_spacing_limit), (2, 24.0)); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(opts(&app).columns, 1, "one undo step"); + } + #[test] fn text_frame_options_change_only_what_was_edited() { let mut app = crate::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 efe089ef9..de3cb22ab 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -36,6 +36,17 @@ pub const LANGUAGES: &[(&str, &str)] = &[ /// English → [German, French, Spanish, Japanese, Simplified Chinese]. const TABLE: &[(&str, [&str; 5])] = &[ + ("Maximum:", ["Maximum:", "Maximum :", "Máximo:", "最大 :", "最大值:"]), + ( + "Paragraph Spacing Limit:", + [ + "Abstandsgrenze für Absätze:", + "Limite d'espacement des paragraphes :", + "Límite de espaciado de párrafo:", + "段落スペース最大値 :", + "段落间距限制:", + ], + ), // Menus. ("File", ["Datei", "Fichier", "Archivo", "ファイル", "文件"]), ("Edit", ["Bearbeiten", "Édition", "Edición", "編集", "编辑"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..4d56dfb82 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Maximum:", "الحد الأقصى:"), + ("Paragraph Spacing Limit:", "حد تباعد الفقرات:"), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), ("Fixed Number", "عدد ثابت"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..8194bed74 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Maximum:", "Maximum:"), + ("Paragraph Spacing Limit:", "Limit mezer mezi odstavci:"), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..f95fa3cfd 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Maximum:", "Massimo:"), + ("Paragraph Spacing Limit:", "Limite spaziatura paragrafo:"), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index d938c2dc7..b1aaaa303 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Maximum:", "მაქსიმუმი:"), + ("Paragraph Spacing Limit:", "აბზაცებს შორის ინტერვალის ზღვარი:"), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..aa7c887d9 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Maximum:", "Maksimum:"), + ("Paragraph Spacing Limit:", "Limit odstępu akapitów:"), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..74181eb54 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Maximum:", "Máximo:"), + ("Paragraph Spacing Limit:", "Limite de espaçamento de parágrafo:"), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), ("Fixed Number", "Número fixo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..17f41424d 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Maximum:", "Максимум:"), + ("Paragraph Spacing Limit:", "Предел интервала абзацев:"), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..b06b9090e 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Maximum:", "Максимум:"), + ("Paragraph Spacing Limit:", "Межа інтервалу абзаців:"), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), From 85932f18ab9d5ec903f7842559f15dba6d615147 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:29:19 +0200 Subject: [PATCH 057/431] Paragraph rules: Type, Gap Color and Tint, Keep In Frame A paragraph rule has InDesign's Type: solid, the thick/thin stripes, dashed, dotted, straight hash, wavy, or a stroke style of the document. Composition draws it as that shape in the rule's box (dashes and dots as boxes and circles, stripes, waves and hashes as their outlines), with Gap Color and Gap Tint underneath; a pattern that can't be laid out draws solid. Keep In Frame keeps a rule above the first line of a frame inside the frame. The Paragraph Rules page (paragraph style options and the Paragraph Rules dialog) is laid out as InDesign's: Weight and Type, Color and Tint, Gap Color and Gap Tint, Width and Offset, Left and Right Indent, Keep In Frame. IDML reads and writes RuleAboveType/RuleBelowType, the gap colour and tint, and KeepRuleAboveInFrame. A rule's new settings at their defaults stay out of the JSON. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 59 +++++++++++++++--- crates/compose/src/tests.rs | 36 +++++++++++ crates/doc/src/attrs.rs | 42 ++++++++++++- crates/idml/src/export.rs | 6 ++ crates/idml/src/import.rs | 13 ++++ crates/idml/src/tests.rs | 4 ++ crates/ui-egui/src/dialogs.rs | 100 +++++++++++++++++++++++++++++-- crates/ui-egui/src/i18n.rs | 1 + crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/cs.rs | 2 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 2 + crates/ui-egui/src/i18n/pl.rs | 2 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 2 + crates/ui-egui/src/i18n/uk.rs | 1 + 17 files changed, 259 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..33918dc8f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · Paragraph rules have InDesign's Type (solid, thick/thin stripes, dashed, dotted, straight hash, wavy, or a stroke style of the document), Gap Color and Gap Tint, and Keep In Frame (rule above); the Paragraph Rules page is laid out as InDesign's (Weight and Type, Color and Tint, Gap Color and Gap Tint, Width and Offset, Left and Right Indent). IDML reads and writes them (`RuleAboveType`, `RuleBelowGapColor`, `KeepRuleAboveInFrame`…). Found in the Learn Skills Daily course, chapter 20. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 4a2c66397..1920b3020 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1872,12 +1872,11 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C .map(|l| if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) }) .unwrap_or((0.0, 0.0)); let (color, tint) = rule_color(r, &above); - ft.decos.push(Deco { - rect: Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y), - color, - tint, - ..Deco::default() - }); + // Keep In Frame: not above the frame's text area. + let top = frames.get(fi).map_or(f64::NEG_INFINITY, |f| f.area.y0); + let y = if r.keep_in_frame && y - r.weight < top { top + r.weight } else { y }; + let rect = Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y); + ft.decos.extend(rule_decos(doc, r, rect, color, tint)); } } if pp.rule_below.on @@ -1888,7 +1887,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let y = l.baseline + r.offset; let (x0, x1) = if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) }; let (color, tint) = rule_color(r, &below); - ft.decos.push(Deco { rect: Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), color, tint, ..Deco::default() }); + ft.decos.extend(rule_decos(doc, r, Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), color, tint)); } // The end of a split block: text after it continues below its deepest sub-column, which // are balanced first. A block that ends the story fills its sub-columns in turn. @@ -4062,6 +4061,52 @@ fn hyphenation_points( out } +/// A paragraph rule drawn in `rect` (its length and weight) with its Type: the gap colour under +/// it, then the line, as dashes, dots, stripes, waves or hash marks. A pattern that can't be laid +/// out along the rule draws solid. +fn rule_decos(doc: &Document, r: &designcraft_doc::Rule, rect: Rect, color: String, tint: f32) -> Vec { + use designcraft_doc::StrokeType; + let kind = doc.stroke_kind(&r.kind); + let (w, len) = (rect.height(), rect.width()); + let mut out = Vec::new(); + if !matches!(kind, StrokeType::Solid) && r.gap_color != designcraft_color::swatch::NONE && w > 0.0 && len > 0.0 { + out.push(Deco { rect, color: r.gap_color.clone(), tint: r.gap_tint, ..Deco::default() }); + } + let y = rect.center().y; + let mut line = designcraft_geom::BezPath::new(); + line.move_to((rect.x0, y)); + line.line_to((rect.x1, y)); + let path = match &kind { + StrokeType::Dashed { pattern } if StrokeType::expandable_dashes(pattern, len) => { + let mut p = designcraft_geom::BezPath::new(); + let (mut x, mut k, mut on) = (rect.x0, 0, true); + while x < rect.x1 { + let step = pattern[k % pattern.len()]; + if on && step > 0.0 { + let r = Rect::new(x, rect.y0, (x + step).min(rect.x1), rect.y1); + p.extend(designcraft_geom::Shape::path_elements(&r, 0.1)); + } + x += step; + k += 1; + on = !on; + } + Some(p) + } + StrokeType::Dotted if StrokeType::expandable_dashes(&[w, w], len) => { + let mut p = designcraft_geom::BezPath::new(); + let mut x = rect.x0 + w / 2.0; + while x <= rect.x1 - w / 2.0 + 1e-9 { + p.extend(designcraft_geom::Shape::path_elements(&designcraft_geom::kurbo::Circle::new((x, y), w / 2.0), 0.1)); + x += 2.0 * w; + } + Some(p) + } + k => k.outline(&line, w, 0.05), + }; + out.push(Deco { rect, color, tint, path, ..Deco::default() }); + out +} + /// `spanned`: a paragraph spans the frame's columns, so the lines move together. fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool) { if ft.lines.is_empty() || f.opts.vertical_justification == VerticalJustification::Top { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index ed4c7c476..e394e4fe1 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -3694,6 +3694,42 @@ fn drop_cap_scales_with_its_point_size() { assert!(b2 - fitted * 1.5 < b0 - base[0].ascent, "a 15 pt cap rises above the first line"); } +/// Paragraph Rules › Type: a wavy, dashed or dotted rule is drawn as its shape (filled paths in +/// the rule's box), a striped one as its bands; Gap Color goes under it. Keep In Frame keeps a rule +/// above the first line inside the frame. +#[test] +fn paragraph_rules_have_a_type_a_gap_colour_and_keep_in_frame() { + use designcraft_doc::{Rule, StrokeType}; + let rule = |kind: StrokeType| Rule { on: true, weight: 4.0, kind, ..Default::default() }; + let decos = |r: Rule| { + let para = ParaAttrs { rule_below: Some(r), ..Default::default() }; + let (d, sid, fid) = doc_with("Recipe Categories", Rect::new(0.0, 0.0, 300.0, 200.0), para); + compose_story(&d, sid, &ComposeOptions::default()).frame(fid).unwrap().decos.clone() + }; + let solid = decos(rule(StrokeType::Solid)); + assert!(solid.len() == 1 && solid[0].path.is_none()); + let wavy = decos(rule(StrokeType::Wavy)); + let p = wavy[0].path.as_ref().expect("a wavy rule is a shape"); + assert!(designcraft_geom::Shape::area(p).abs() > 0.0 && (wavy[0].rect.height() - 4.0).abs() < 1e-9); + let subpaths = |p: &designcraft_geom::BezPath| p.elements().iter().filter(|e| matches!(e, designcraft_geom::PathEl::MoveTo(_))).count(); + let dashed = decos(rule(StrokeType::Dashed { pattern: vec![12.0, 4.0] })); + assert!(subpaths(dashed[0].path.as_ref().unwrap()) > 5, "one box per dash"); + let dotted = decos(rule(StrokeType::Dotted)); + assert!(subpaths(dotted[0].path.as_ref().unwrap()) > 10, "one dot per 2 weights"); + let gap = decos(Rule { gap_color: "[Black]".into(), gap_tint: 0.3, ..rule(StrokeType::Dotted) }); + assert_eq!((gap.len(), gap[0].color.as_str(), gap[0].tint, gap[0].path.is_none()), (2, "[Black]", 0.3, true), "the gap colour under the dots"); + assert_eq!(decos(Rule { gap_color: "[Black]".into(), ..rule(StrokeType::Solid) }).len(), 1, "a solid rule has no gaps"); + // Keep In Frame: a rule above raised out of the frame comes back to its top. + let above = |keep: bool| { + let r = Rule { on: true, weight: 2.0, offset: 30.0, keep_in_frame: keep, ..Default::default() }; + let para = ParaAttrs { rule_above: Some(r), ..Default::default() }; + let (d, sid, fid) = doc_with("Title", Rect::new(0.0, 50.0, 300.0, 200.0), para); + compose_story(&d, sid, &ComposeOptions::default()).frame(fid).unwrap().decos[0].rect + }; + assert!(above(false).y0 < 50.0, "{:?}", above(false)); + assert!((above(true).y0 - 50.0).abs() < 1e-6, "{:?}", above(true)); +} + /// A rule in Text Color takes the colour of the paragraph's text: the first character's for the /// rule above, the last character's for the rule below. A swatch colour is used as is. #[test] diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index 077637672..3fb2a2a00 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -776,11 +776,46 @@ pub struct Rule { pub offset: f64, pub left_indent: f64, pub right_indent: f64, + /// Type: the rule's stroke type (solid, dashed, wavy…, or a stroke style of the document). + #[serde(rename = "type", skip_serializing_if = "rule_solid")] + pub kind: crate::item::StrokeType, + /// Gap Color (a swatch; [None]: none) and Gap Tint: under the gaps of a type that has them. + #[serde(skip_serializing_if = "rule_no_gap")] + pub gap_color: String, + #[serde(skip_serializing_if = "rule_full_tint")] + pub gap_tint: f32, + /// Keep In Frame: a rule above the first line of a frame stays inside the frame. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub keep_in_frame: bool, +} + +// A rule's settings at their defaults are left out of the document (and of the attributes). +fn rule_solid(k: &crate::item::StrokeType) -> bool { + *k == crate::item::StrokeType::Solid +} +fn rule_no_gap(c: &str) -> bool { + c == designcraft_color::swatch::NONE +} +fn rule_full_tint(t: &f32) -> bool { + *t == 1.0 } impl Default for Rule { fn default() -> Self { - Rule { on: false, weight: 1.0, color: TEXT_COLOR.into(), tint: 1.0, column_width: true, offset: 0.0, left_indent: 0.0, right_indent: 0.0 } + Rule { + on: false, + weight: 1.0, + color: TEXT_COLOR.into(), + tint: 1.0, + column_width: true, + offset: 0.0, + left_indent: 0.0, + right_indent: 0.0, + kind: crate::item::StrokeType::Solid, + gap_color: designcraft_color::swatch::NONE.into(), + gap_tint: 1.0, + keep_in_frame: false, + } } } @@ -1385,8 +1420,11 @@ impl ParaAttrs { let serde_json::Value::Object(patch) = value else { return self.set_json(key, value) }; let mut merged = serde_json::to_value(rule).map_err(|e| format!("{key}: {e}"))?; let serde_json::Value::Object(fields) = &mut merged else { return Err(format!("{key}: not an object")) }; + // Every field, those at their defaults (left out of the JSON) included. + const RULE_FIELDS: [&str; 12] = + ["on", "weight", "color", "tint", "columnWidth", "offset", "leftIndent", "rightIndent", "type", "gapColor", "gapTint", "keepInFrame"]; for (k, v) in patch { - if !fields.contains_key(k) { + if !RULE_FIELDS.contains(&k.as_str()) { return Err(format!("{key}: unknown rule field `{k}`")); } fields.insert(k.clone(), v.clone()); diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 86fef8e46..24822cecf 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1746,6 +1746,12 @@ impl<'a> Ex<'a> { el.set(&format!("{k}Offset"), num(r.offset)); el.set(&format!("{k}LeftIndent"), num(r.left_indent)); el.set(&format!("{k}RightIndent"), num(r.right_indent)); + el.set(&format!("{k}Type"), names::stroke_type_out(&r.kind)); + el.set(&format!("{k}GapColor"), self.sw(&r.gap_color)); + el.set(&format!("{k}GapTint"), pct(r.gap_tint as f64)); + if k == "RuleAbove" { + el.set("KeepRuleAboveInFrame", bool_s(r.keep_in_frame)); + } props.push(if r.color == designcraft_doc::TEXT_COLOR { p(&format!("{k}Color"), "string", designcraft_doc::TEXT_COLOR) } else { diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index d770aef79..58cd70271 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1959,6 +1959,19 @@ impl<'r> Importer<'r> { r.offset = num(5).unwrap_or(0.0); r.left_indent = num(6).unwrap_or(0.0); r.right_indent = num(7).unwrap_or(0.0); + // Type, gap colour and tint, Keep In Frame (rule above). + if let Some(t) = self.para_setting(e, &format!("{k}Type")) { + r.kind = names::stroke_type_in(t.trim()); + } + if let Some(c) = self.para_setting(e, &format!("{k}GapColor")).filter(|c| c.contains('/')) { + r.gap_color = self.swatch_ref(c.trim()); + } + if let Some(t) = tint(self.para_setting(e, &format!("{k}GapTint")).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite())) { + r.gap_tint = t; + } + if k == "RuleAbove" { + r.keep_in_frame = self.para_setting(e, "KeepRuleAboveInFrame").is_some_and(|v| v.trim() == "true"); + } if let Some(c) = get(2) { let c = c.trim(); if c == designcraft_doc::TEXT_COLOR { diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 035333da4..e03eeffb7 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -327,6 +327,10 @@ fn indesign_text_defaults() -> Vec { "RuleBelow": "false" => false, "true" => true, |r| r.0.rule_below.on; "RuleBelowColor": "Text Color" => designcraft_doc::TEXT_COLOR, "Color/Black" => "[Black]", |r| r.0.rule_below.color; "RuleBelowTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.0.rule_below.tint; + "RuleBelowType": "StrokeStyle/$ID/Solid" => designcraft_doc::StrokeType::Solid, "StrokeStyle/$ID/Wavy" => designcraft_doc::StrokeType::Wavy, |r| r.0.rule_below.kind; + "RuleBelowGapColor": "Swatch/None" => "[None]", "Color/Black" => "[Black]", |r| r.0.rule_below.gap_color; + "RuleBelowGapTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.0.rule_below.gap_tint; + "KeepRuleAboveInFrame": "false" => false, "true" => true, |r| r.0.rule_above.keep_in_frame; "ParagraphShadingOn": "false" => false, "true" => true, |r| r.0.shading_on; "ParagraphShadingColor": "Color/Black" => "[Black]", "Swatch/None" => "[None]", |r| r.0.shading_color; "ParagraphShadingTint": "20" => 0.2f32, "50" => 0.5f32, |r| r.0.shading_tint; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8b4043787..de57d77e9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -5882,22 +5882,64 @@ fn paragraph_rules(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, prefix: & }); ui.add_space(6.0); let on = rule["on"].as_bool().unwrap_or(false); + let kind: designcraft_doc::StrokeType = serde_json::from_value(rule["type"].clone()).unwrap_or_default(); + let doc = app.session.active().map(|st| &st.doc); + // As InDesign's: Weight and Type, Color and Tint, Gap Color and Gap Tint, Width and Offset, + // Left and Right Indent, then Keep In Frame (rule above). ui.add_enabled_ui(on, |ui| { egui::Grid::new(("rules", which)).num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { crate::rtl::label(ui, crate::i18n::tr(lang, "Weight:")); - if let Some(v) = sized(ui, &mut |ui| crate::widgets::number(ui, &key("weight"), rule["weight"].as_f64(), " pt", 80.0, 2)) { + if let Some(v) = sized(ui, &mut |ui| { + crate::widgets::NumField::number(&key("weight"), rule["weight"].as_f64(), " pt", 2).width(80.0).spinner().range(0.0, 1000.0).show(ui) + }) { d.fields.insert(key("weight"), json!(v.clamp(0.0, 1000.0))); } + crate::rtl::label(ui, crate::i18n::tr(lang, "Type:")); + let custom: Vec = doc.map_or_else(Vec::new, |doc| doc.stroke_styles.iter().map(|s| s.name.clone()).collect()); + let shown = match &kind { + 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| { + 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(), + k => crate::i18n::tr(lang, k.label()).to_string(), + }; + if ui.selectable_label(k == kind, crate::rtl::widget(ui, label)).clicked() { + d.fields.insert(key("type"), serde_json::to_value(&k).unwrap_or_default()); + } + } + }); + ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Color:")); let color = rule["color"].as_str().unwrap_or("").to_string(); - if let Some(w) = app.session.active().and_then(|st| swatch_menu(ui, &st.doc, ("rule_color", which), &color, 130.0)) { + if let Some(w) = doc.and_then(|doc| swatch_menu(ui, doc, ("rule_color", which), &color, 130.0)) { d.fields.insert(key("color"), json!(w)); } - ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Tint:")); if let Some(v) = sized(ui, &mut |ui| crate::widgets::number(ui, &key("tint"), rule["tint"].as_f64().map(|t| t * 100.0), "%", 80.0, 0)) { d.fields.insert(key("tint"), json!((v / 100.0).clamp(0.0, 1.0))); } + ui.end_row(); + // A solid rule has no gaps. + let gaps = !matches!(kind, designcraft_doc::StrokeType::Solid); + crate::rtl::label(ui, crate::i18n::tr(lang, "Gap Color:")); + let gap = rule["gapColor"].as_str().unwrap_or(designcraft_color::swatch::NONE).to_string(); + ui.add_enabled_ui(gaps, |ui| { + if let Some(w) = doc.and_then(|doc| swatch_menu(ui, doc, ("rule_gap", which), &gap, 130.0)) { + d.fields.insert(key("gapColor"), json!(w)); + } + }); + crate::rtl::label(ui, crate::i18n::tr(lang, "Gap Tint:")); + ui.add_enabled_ui(gaps && gap != designcraft_color::swatch::NONE, |ui| { + if let Some(v) = + sized(ui, &mut |ui| crate::widgets::number(ui, &key("gapTint"), rule["gapTint"].as_f64().map(|t| t * 100.0), "%", 80.0, 0)) + { + d.fields.insert(key("gapTint"), json!((v / 100.0).clamp(0.0, 1.0))); + } + }); + ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Width:")); let column = rule["columnWidth"].as_bool().unwrap_or(true); egui::ComboBox::from_id_salt(("rule_width", which)) @@ -5910,23 +5952,50 @@ fn paragraph_rules(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, prefix: & } } }); - ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Offset:")); - if let Some(v) = sized(ui, &mut |ui| crate::widgets::measure(ui, &key("offset"), rule["offset"].as_f64(), v_units, 80.0)) { + if let Some(v) = sized(ui, &mut |ui| { + crate::widgets::NumField::measure(&key("offset"), rule["offset"].as_f64(), v_units).width(80.0).spinner().show(ui) + }) { d.fields.insert(key("offset"), json!(v)); } ui.end_row(); for (label, field) in [("Left Indent:", "leftIndent"), ("Right Indent:", "rightIndent")] { crate::rtl::label(ui, crate::i18n::tr(lang, label)); - if let Some(v) = sized(ui, &mut |ui| crate::widgets::measure(ui, &key(field), rule[field].as_f64(), h_units, 80.0)) { + if let Some(v) = + sized(ui, &mut |ui| crate::widgets::NumField::measure(&key(field), rule[field].as_f64(), h_units).width(80.0).spinner().show(ui)) + { d.fields.insert(key(field), json!(v)); } } ui.end_row(); }); + if which == "ruleAbove" { + let mut keep = rule["keepInFrame"].as_bool().unwrap_or(false); + if ui.checkbox(&mut keep, crate::rtl::widget(ui, crate::i18n::tr(lang, "Keep In Frame"))).changed() { + d.fields.insert(key("keepInFrame"), json!(keep)); + } + } }); } +/// The built-in stroke types a paragraph rule can have (Paragraph Rules › Type). +fn rule_types() -> Vec { + use designcraft_doc::StrokeType as T; + vec![ + T::Solid, + T::ThickThick, + T::ThickThin, + T::ThickThinThick, + T::ThinThick, + T::ThinThickThin, + T::ThinThin, + T::Dashed { pattern: vec![12.0, 4.0] }, + T::Dotted, + T::Hashed, + T::Wavy, + ] +} + /// Document Footnote Options: Numbering and Formatting / Layout tabs. fn footnote_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.horizontal(|ui| { @@ -7493,6 +7562,25 @@ mod tests { assert_eq!(app.run("type.selectionAttrs", json!({})).unwrap()["para"]["ruleBelow"], rule); } + /// 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]")); + } + #[test] fn paragraph_style_options_edit_one_rule_field() { let (mut app, rule) = ruled_paragraph(); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index efe089ef9..a574af928 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -36,6 +36,7 @@ pub const LANGUAGES: &[(&str, &str)] = &[ /// English → [German, French, Spanish, Japanese, Simplified Chinese]. const TABLE: &[(&str, [&str; 5])] = &[ + ("Keep In Frame", ["Im Rahmen behalten", "Conserver dans le bloc", "Mantener en el marco", "フレーム内に保持", "保持在框架内"]), // Menus. ("File", ["Datei", "Fichier", "Archivo", "ファイル", "文件"]), ("Edit", ["Bearbeiten", "Édition", "Edición", "編集", "编辑"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..9e7f9594d 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", "لصق"), + ("Keep In Frame", "الإبقاء داخل الإطار"), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), ("Fixed Number", "عدد ثابت"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..d1987e741 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Keep In Frame", "Zachovat v rámečku"), + ("Gap Tint:", "Odstín mezery:"), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..e1858b6d8 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"), + ("Keep In Frame", "Mantieni nella cornice"), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index d938c2dc7..5c0afb06c 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Keep In Frame", "ჩარჩოში შენარჩუნება"), + ("Gap Tint:", "ღრეჩოს ტონი:"), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..e563c58a8 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Keep In Frame", "Zachowaj w ramce"), + ("Gap Tint:", "Odcień odstępu:"), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..608656aed 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"), + ("Keep In Frame", "Manter no quadro"), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), ("Fixed Number", "Número fixo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..8876dd677 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Keep In Frame", "Сохранить во фрейме"), + ("Gap Tint:", "Оттенок пробелов:"), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..fc34659e9 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Keep In Frame", "Зберегти у фреймі"), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), From 151f54ee46648f48829230d3341571435b46738f Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:48:16 +0200 Subject: [PATCH 058/431] Paragraph styles: a new style from the selected text, Next Style, Reset To Base New Paragraph Style with text selected starts from it, as InDesign's: Based On [No Paragraph Style] and every setting of the text that differs from it, with Apply Style to Selection (on), so OK gives the paragraph the new style. General has Next Style ([Same style] or a style) and Reset To Base (the style keeps nothing of its own; edits made in the dialog still apply). Basic Character Formats is laid out in two columns as InDesign's: the font, then Size | Leading, Kerning | Tracking, Case | Position. style.paragraph.edit honours nextStyle (string, or null for [Same style]); it was described but ignored. It takes resetToBase. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/style.rs | 20 ++++- crates/ui-egui/src/dialogs.rs | 140 +++++++++++++++++++++++++++++-- crates/ui-egui/src/i18n.rs | 13 +++ crates/ui-egui/src/i18n/ar.rs | 4 + crates/ui-egui/src/i18n/cs.rs | 4 + crates/ui-egui/src/i18n/it.rs | 4 + crates/ui-egui/src/i18n/ka.rs | 4 + crates/ui-egui/src/i18n/pl.rs | 4 + crates/ui-egui/src/i18n/pt_br.rs | 4 + crates/ui-egui/src/i18n/ru.rs | 4 + crates/ui-egui/src/i18n/uk.rs | 4 + 12 files changed, 196 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..41b3ab40d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · New Paragraph Style with text selected starts from it, as InDesign's (Based On [No Paragraph Style], every setting of the text that differs) and Apply Style to Selection gives the paragraph the new style; General has Next Style and Reset To Base; Basic Character Formats is in two columns (Size | Leading, Kerning | Tracking, Case | Position). `style.paragraph.edit` honours `nextStyle` (it was ignored) and takes `resetToBase`. Found in the Learn Skills Daily course, chapter 20. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index d1bd06ad0..4526db5b0 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -311,7 +311,7 @@ pub fn specs() -> Vec { "Paragraph Style Options…", [], None, - "{name, rename?, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, spaceBefore, …; ruleAbove?/ruleBelow?: only the named rule fields change; startAt?: null = continue numbering; kinsoku?: set | null (default rules); a grepStyles pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}}", + "{name, rename?, basedOn?, nextStyle?: style | null ([Same style]), resetToBase?: bool (Reset To Base: the style sets nothing of its own; `para`/`chars` then apply over its Based On), para?: {paragraph attributes, as type.para names them: align, leftIndent, spaceBefore, …; ruleAbove?/ruleBelow?: only the named rule fields change; startAt?: null = continue numbering; kinsoku?: set | null (default rules); a grepStyles pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}}", has_doc, edit_para ), @@ -936,12 +936,25 @@ fn edit_para(s: &mut Session, p: &Value) -> Result { super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.edit")?; let rename = str_param(p, "rename").map(str::to_string); let based = p.get("basedOn").cloned(); + let next = p.get("nextStyle").cloned(); + let reset = p.get("resetToBase").and_then(Value::as_bool).unwrap_or(false); s.edit(|d, _| { if let Some(Value::String(b)) = &based && d.styles.para_based_on_cycles(&name, b) { return Err(bad("style.paragraph.edit", "based-on would create a cycle")); } + if let Some(Value::String(n)) = &next + && d.styles.para(n).is_none() + { + return Err(bad("style.paragraph.edit", format!("no style `{n}`"))); + } + // Reset To Base: what the style set of its own goes before the edits apply. + if reset { + let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?; + st.para = ParaAttrs::default(); + st.chars = CharAttrs::default(); + } // A rule object changes only the rule fields it names, over the style's resolved rule. let (current, _) = d.styles.resolve_para_style(&name); let mut para: ParaAttrs = attrs("style.paragraph.edit", "para", p, |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, ¤t))?; @@ -956,6 +969,11 @@ fn edit_para(s: &mut Session, p: &Value) -> Result { Some(Value::Null) => st.based_on = None, _ => {} } + match next { + Some(Value::String(n)) => st.next_style = Some(n), + Some(Value::Null) => st.next_style = None, + _ => {} + } if let Some(n) = rename.clone() { // Every use: other styles, stories, table cells, footnotes, object styles. rename_style(d, true, &name, &n); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8b4043787..dc6a5d7e7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3680,16 +3680,30 @@ fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Resul } let based = d.s("basedOn"); let rename = d.s("rename"); + let next = d.fields.get("nextStyle").cloned(); let (r, style) = if d.b("new") { let mut params = json!({"name": if rename.trim().is_empty() { "Paragraph Style 1" } else { rename.trim() }, "para": para, "chars": chars}); if !based.is_empty() && based != designcraft_doc::NO_PARA_STYLE { params["basedOn"] = json!(based); } + if let Some(Value::String(n)) = &next { + params["nextStyle"] = json!(n); + } let r = app.run("style.paragraph.create", params)?; let style = r["name"].as_str().unwrap_or_default().to_string(); + // Apply Style to Selection. + if d.b("apply") && app.session.active().is_some_and(|st| st.selection.text.is_some()) { + app.run("style.paragraph.apply", json!({"name": style}))?; + } (r, style) } else { let mut params = json!({"name": name, "para": para, "chars": chars}); + if let Some(n) = next { + params["nextStyle"] = n; + } + if d.b("resetToBase") { + params["resetToBase"] = json!(true); + } if !based.is_empty() { params["basedOn"] = if based == designcraft_doc::NO_PARA_STYLE { Value::Null } else { json!(based) }; } @@ -3804,9 +3818,16 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo if new && !d.fields.contains_key("rename") { d.fields.insert("rename".into(), json!(next_style_name(&doc.styles, true))); } + let mut style = style; + let reset = !new && d.b("resetToBase"); + if reset { + style.para = Default::default(); + style.chars = Default::default(); + } let names: Vec = doc.styles.paragraph.iter().map(|p| p.name.clone()).filter(|n| new || *n != name).collect(); - // A new style shows what it would inherit from its Based On. - let (pp, cp) = doc.styles.resolve_para_style(if new { d.fields.get("basedOn").and_then(Value::as_str).unwrap_or("") } else { &name }); + // A new style shows what it would inherit from its Based On (as does one reset to it). + let based_name = d.fields.get("basedOn").and_then(Value::as_str).map(str::to_string).or(style.based_on.clone()).unwrap_or_default(); + let (pp, cp) = doc.styles.resolve_para_style(if new || reset { &based_name } else { &name }); let units = doc.settings.horizontal_units; let pv = serde_json::to_value(&pp).unwrap_or_default(); let cv = serde_json::to_value(&cp).unwrap_or_default(); @@ -4239,13 +4260,51 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo } }); 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 = match d.fields.get("nextStyle") { + Some(Value::String(n)) => Some(n.clone()), + Some(_) => None, + None => style.next_style.clone().filter(|n| *n != name), + }; + 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| { + 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)); + } + } + }); + ui.end_row(); }); + // Reset To Base: the style keeps nothing of its own (the edits made here stay). + if !new + && ui + .add_enabled( + !based_name.is_empty() && based_name != designcraft_doc::NO_PARA_STYLE, + egui::Button::new(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Reset To Base"))), + ) + .clicked() + { + d.fields.insert("resetToBase".into(), json!(true)); + } ui.add_space(8.0); crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Style Settings:")).font(semibold(12.0))); let summary = paragraph_style_summary(&app.ui.language, d, &style, &doc.styles); - egui::ScrollArea::vertical().id_salt("paragraph_style_settings").max_height(220.0).show(ui, |ui| { + egui::ScrollArea::vertical().id_salt("paragraph_style_settings").max_height(180.0).show(ui, |ui| { ui.add(egui::Label::new(egui::RichText::new(summary).color(crate::theme::Tokens::get(ui.ctx()).text_dim)).wrap()); }); + if new { + ui.add_space(8.0); + check(ui, d, "apply", crate::i18n::tr(&app.ui.language, "Apply Style to Selection")); + } } }); }); @@ -5155,8 +5214,28 @@ pub fn open_style_options(app: &mut DesignApp, para: bool, name: &str) { pub fn open_new_style(app: &mut DesignApp, para: bool) { let Some(st) = app.session.active() else { return }; let name = next_style_name(&st.doc.styles, para); + let has_text = st.selection.text.is_some(); + let (bp, bc) = st.doc.styles.resolve_para_style(designcraft_doc::NO_PARA_STYLE); let id = if para { "paragraphStyleOptions" } else { "characterStyleOptions" }; - app.ui.dialog = Some(Dialog::new(id, json!({"new": true, "rename": name}))); + let mut fields = json!({"new": true, "rename": name}); + // New Paragraph Style with text selected starts from it, as InDesign's: Based On [No Paragraph + // Style] and every setting of the text that differs, applied to the selection on OK. + if para + && has_text + && let Some(sel) = app.session.execute("type.selectionAttrs", &json!({})).ok().filter(|v| !v.is_null()) + { + for (prefix, now, base) in + [("p.", &sel["para"], serde_json::to_value(bp).unwrap_or_default()), ("c.", &sel["chars"], serde_json::to_value(bc).unwrap_or_default())] + { + for (k, v) in now.as_object().into_iter().flatten() { + if !v.is_null() && base.get(k) != Some(v) && base.get(k).is_some() { + fields[format!("{prefix}{k}")] = v.clone(); + } + } + } + fields["apply"] = json!(true); + } + app.ui.dialog = Some(Dialog::new(id, fields)); } /// Character Style Options: General, then the character sections it shares with Paragraph Style @@ -5321,7 +5400,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(200.0).show_ui(ui, |ui| { + egui::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); } @@ -5364,6 +5443,7 @@ fn basic_character_formats(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo let lang = lang.as_str(); let fonts = crate::panels::fonts(app); let menu = crate::panels::font_menu(app); + // Two columns, as InDesign's: the font, then Size | Leading, Kerning | Tracking, Case | Position. egui::Grid::new("psc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { crate::rtl::label(ui, crate::i18n::tr(lang, "Font Family:")); let fam = cf.get(d, "fontFamily").as_str().unwrap_or("").to_string(); @@ -5406,9 +5486,10 @@ fn basic_character_formats(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo }); } ui.end_row(); + }); + egui::Grid::new("psc2").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { crate::rtl::label(ui, crate::i18n::tr(lang, "Size:")); char_number(ui, d, cf, "size", " pt", 1.0, (0.1, 1296.0)); - ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Leading:")); let lv = match cf.get(d, "leading") { v if v["kind"] == "points" => v["value"].as_f64(), @@ -5429,7 +5510,6 @@ fn basic_character_formats(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo "kerning", &[(json!({"kind": "metrics"}), "Metrics"), (json!({"kind": "optical"}), "Optical"), (json!({"kind": "none"}), "0")], ); - ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Tracking:")); char_number(ui, d, cf, "tracking", "", 1.0, (-1000.0, 10000.0)); ui.end_row(); @@ -5447,7 +5527,6 @@ fn basic_character_formats(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo (json!("openTypeAllSmallCaps"), "OpenType All Small Caps"), ], ); - ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Position:")); char_choice( lang, @@ -7493,6 +7572,48 @@ mod tests { assert_eq!(app.run("type.selectionAttrs", json!({})).unwrap()["para"]["ruleBelow"], rule); } + /// 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)); + } + #[test] fn paragraph_style_options_edit_one_rule_field() { let (mut app, rule) = ruled_paragraph(); @@ -9660,7 +9781,8 @@ mod tests { 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)); - assert_eq!(st.chars, CharAttrs { size: Some(20.0), ..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] diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index efe089ef9..caaede51c 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -36,6 +36,19 @@ pub const LANGUAGES: &[(&str, &str)] = &[ /// English → [German, French, Spanish, Japanese, Simplified Chinese]. const TABLE: &[(&str, [&str; 5])] = &[ + ("Next Style:", ["Nächstes Format:", "Style suivant :", "Estilo siguiente:", "次のスタイル :", "下一样式:"]), + ("[Same style]", ["[Gleiches Format]", "[Même style]", "[Mismo estilo]", "[同じスタイル]", "[相同样式]"]), + ("Reset To Base", ["Auf Basis zurücksetzen", "Rétablir la base", "Restablecer a base", "基準に戻す", "重置为基准"]), + ( + "Apply Style to Selection", + [ + "Format auf Auswahl anwenden", + "Appliquer le style à la sélection", + "Aplicar estilo a la selección", + "選択範囲にスタイルを適用", + "将样式应用于选区", + ], + ), // Menus. ("File", ["Datei", "Fichier", "Archivo", "ファイル", "文件"]), ("Edit", ["Bearbeiten", "Édition", "Edición", "編集", "编辑"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..bc300eac8 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", "لصق"), + ("Next Style:", "النمط التالي:"), + ("[Same style]", "[النمط نفسه]"), + ("Reset To Base", "إعادة التعيين إلى الأساس"), + ("Apply Style to Selection", "تطبيق النمط على التحديد"), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), ("Fixed Number", "عدد ثابت"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..aa4215745 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Next Style:", "Další styl:"), + ("[Same style]", "[Stejný styl]"), + ("Reset To Base", "Obnovit podle základu"), + ("Apply Style to Selection", "Použít styl na výběr"), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..ab153084e 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"), + ("Next Style:", "Stile successivo:"), + ("[Same style]", "[Stesso stile]"), + ("Reset To Base", "Ripristina base"), + ("Apply Style to Selection", "Applica stile alla selezione"), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index d938c2dc7..128a872f6 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", "ჩასმა"), + ("Next Style:", "შემდეგი სტილი:"), + ("[Same style]", "[იგივე სტილი]"), + ("Reset To Base", "საბაზოზე დაბრუნება"), + ("Apply Style to Selection", "სტილის გამოყენება მონიშნულზე"), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..100168f3a 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Next Style:", "Następny styl:"), + ("[Same style]", "[Ten sam styl]"), + ("Reset To Base", "Przywróć bazowy"), + ("Apply Style to Selection", "Zastosuj styl do zaznaczenia"), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..3f2b2afe5 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"), + ("Next Style:", "Próximo estilo:"), + ("[Same style]", "[Mesmo estilo]"), + ("Reset To Base", "Redefinir para base"), + ("Apply Style to Selection", "Aplicar estilo à seleção"), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), ("Fixed Number", "Número fixo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..e567ec5a3 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Next Style:", "Следующий стиль:"), + ("[Same style]", "[Тот же стиль]"), + ("Reset To Base", "Сбросить до основы"), + ("Apply Style to Selection", "Применить стиль к выделенному"), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..33ebb766d 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Next Style:", "Наступний стиль:"), + ("[Same style]", "[Той самий стиль]"), + ("Reset To Base", "Скинути до основи"), + ("Apply Style to Selection", "Застосувати стиль до виділеного"), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), From ee48326797fd3c99cdbe4a53a8a7622acb79efc8 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:58:36 +0200 Subject: [PATCH 059/431] i18n: Corner Size and Shape: in Spanish (and the German and Japanese fallbacks) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index f4fbc2458..5eeb4b357 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -91,7 +91,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), ( "Corner Size and Shape:", - ["Corner Size and Shape:", "Taille et forme des angles:", "Corner Size and Shape:", "Corner Size and Shape:", "角大小和形状:"], + ["Eckengröße und -form:", "Taille et forme des angles:", "Tamaño y forma de esquinas:", "角のサイズとシェイプ:", "角大小和形状:"], ), ("Hide Rulers", ["Lineale ausblenden", "Masquer les règles", "Ocultar reglas", "定規を隠す", "隐藏标尺"]), ("Inches Decimal", ["Zoll dezimal", "Pouces décimaux", "Pulgadas decimales", "インチ(10 進)", "十进制英寸"]), From 717fcd6decef246b84797e2e9086a001eae3df74 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:00:01 +0200 Subject: [PATCH 060/431] Type tool: a release away from the press doesn't convert the empty frame Co-Authored-By: Claude Opus 5.5 --- crates/tools/src/tests.rs | 3 +++ crates/tools/src/text.rs | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 5295708cb..2e5ded8a3 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -1080,6 +1080,9 @@ fn type_tool_drag_over_an_empty_frame_draws_a_new_text_frame() { assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y)).is_empty()); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x, y)); assert!(matches!(&a[..], [Action::Exec(cmd, p)] if cmd == "text.placeCaret" && p["frame"] == id.0), "{a:?}"); + // A release away from the press with no drag in between isn't a click: no caret, no conversion. + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y)).is_empty()); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x1, y1)).is_empty()); } #[test] diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index c8cb778a9..e7ac00355 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -333,8 +333,12 @@ impl Tool for TypeTool { self.selecting = None; return vec![Action::Commit]; } - // A click in an empty frame: it becomes a text frame with the caret in it. + // A click in an empty frame: it becomes a text frame with the caret in it. A release + // away from the press (with no drag in between) isn't a click: nothing changes. if let Some((fid, at)) = self.convert.take() { + if start.is_some_and(|a| (ev.pos - a).hypot() >= cx.tol(3.0)) { + return vec![]; + } return vec![Action::Exec("text.placeCaret".into(), json!({"frame": fid, "point": [at.x, at.y]}))]; } // Ends a press in text: drops dragged text, or is ignored by the engine. From 9892ae05604b3b2e645f856a0e8b9610020da535 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:00:18 +0200 Subject: [PATCH 061/431] Docs: format_measure's precision per unit Co-Authored-By: Claude Opus 5.5 --- crates/geom/src/units.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/geom/src/units.rs b/crates/geom/src/units.rs index f0457e75d..45b0ddaee 100644 --- a/crates/geom/src/units.rs +++ b/crates/geom/src/units.rs @@ -255,8 +255,9 @@ fn parse_single(tok: &str, default: Unit, percent_base: Option) -> Option String { // As InDesign shows them: inches and centimetres to four decimals (a 1p gutter is 0.1667 in // or 0.4233 cm, A4 is 8.2677 in wide), the other units to three. From 7c07c883b0838f1ea41763740308d3a88a5ddc19 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:02:29 +0200 Subject: [PATCH 062/431] =?UTF-8?q?i18n:=20Object/Convert=20Point=20for=20?= =?UTF-8?q?Edit=20=E2=80=BA=20Menus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/src/i18n/fr.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + 8 files changed, 8 insertions(+) diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index c9e6f463e..ff86a04a4 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1925,6 +1925,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object/Fitting", "كائن/ملاءمة"), ("Object/Pathfinder", "كائن/باحث المسارات"), ("Object/Paths", "كائن/مسارات"), + ("Object/Convert Point", "كائن/تحويل النقطة"), ("Object/Select", "كائن/تحديد"), ("Object/Transform", "كائن/تحويل"), ("Object/Transform Again", "كائن/تحويل مجدداً"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index dcebc1180..98cdfe317 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -1609,6 +1609,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object/Fitting", "Objekt/Přizpůsobení"), ("Object/Pathfinder", "Objekt/Operace s cestami"), ("Object/Paths", "Objekt/Cesty"), + ("Object/Convert Point", "Objekt/Převést bod"), ("Object/Select", "Objekt/Vybrat"), ("Object/Transform", "Objekt/Transformace"), ("Object/Transform Again", "Objekt/Transformovat znovu"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 778f028ae..a12a70295 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -1639,6 +1639,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object/Fitting", "Objekt/Anpassen"), ("Object/Pathfinder", "Objekt/Pathfinder"), ("Object/Paths", "Objekt/Pfade"), + ("Object/Convert Point", "Objekt/Punkt konvertieren"), ("Object/Select", "Objekt/Auswählen"), ("Object/Transform", "Objekt/Transformieren"), ("Object/Transform Again", "Objekt/Erneut transformieren"), diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 78c5d5948..d9985411a 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -128,6 +128,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object/Fitting", "Objet/Ajustement"), ("Object/Pathfinder", "Objet/Pathfinder"), ("Object/Paths", "Objet/Tracés"), + ("Object/Convert Point", "Objet/Convertir le point"), ("Object/Select", "Objet/Sélectionner"), ("Object/Transform", "Objet/Transformation"), ("Object/Transform Again", "Objet/Répéter la transformation"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 9f98222dd..0fd41bd4a 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1841,6 +1841,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object/Fitting", "オブジェクト/オブジェクトサイズの調整"), ("Object/Pathfinder", "オブジェクト/パスファインダー"), ("Object/Paths", "オブジェクト/パス"), + ("Object/Convert Point", "オブジェクト/ポイントを切り換え"), ("Object/Select", "オブジェクト/選択"), ("Object/Transform", "オブジェクト/変形"), ("Object/Transform Again", "オブジェクト/変形を再適用"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index d0c67ae3c..6d25d5d72 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -1656,6 +1656,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object/Fitting", "ობიექტი/ზომაზე მორგება"), ("Object/Pathfinder", "ობიექტი/ტრაექტორიების ოპერაციები"), ("Object/Paths", "ობიექტი/ტრაექტორიები"), + ("Object/Convert Point", "ობიექტი/წერტილის გარდაქმნა"), ("Object/Select", "ობიექტი/მონიშვნა"), ("Object/Transform", "ობიექტი/გარდაქმნა"), ("Object/Transform Again", "ობიექტი/გარდაქმნის გამეორება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 4b5763383..b3e0eb79f 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -1587,6 +1587,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object/Fitting", "Obiekt/Dopasowanie"), ("Object/Pathfinder", "Obiekt/Kształtowanie ścieżek"), ("Object/Paths", "Obiekt/Ścieżki"), + ("Object/Convert Point", "Obiekt/Konwertuj punkt"), ("Object/Select", "Obiekt/Zaznacz"), ("Object/Transform", "Obiekt/Przekształć"), ("Object/Transform Again", "Obiekt/Przekształć ponownie"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 0503aa350..4a1e3d41f 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -1591,6 +1591,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object/Fitting", "Объект/Подгонка"), ("Object/Pathfinder", "Объект/Обработка контуров"), ("Object/Paths", "Объект/Контуры"), + ("Object/Convert Point", "Объект/Преобразовать точку"), ("Object/Select", "Объект/Выбрать"), ("Object/Transform", "Объект/Трансформирование"), ("Object/Transform Again", "Объект/Повторить трансформирование"), From f3800df46ba5376babdfdf53cc31cf16efbdfb3f Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:06:21 +0200 Subject: [PATCH 063/431] Properties: corner and stroke type rows fit their translated names Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties.rs | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index c28f02958..e3e12257f 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -724,8 +724,11 @@ fn paint_stroke_type(p: &egui::Painter, r: Rect, kind: &str, c: egui::Color32) { /// wide), then its name. Selected rows are highlighted. fn preview_row(ui: &mut Ui, selected: bool, label: &str, preview_w: f32, paint: impl FnOnce(&egui::Painter, Rect, egui::Color32)) -> egui::Response { let t = Tokens::get(ui.ctx()); - // As wide as the preview and the longest name (the popup otherwise grows to all it's offered). - let w = preview_w + 130.0; + // As wide as the preview and its name, translated (at least 130 pt for the name), and as the + // popup once its widest row has set it: the rows' highlights then end together. + let font = egui::FontId::proportional(12.0); + let text_w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_string(), font, egui::Color32::WHITE).size().x); + let w = (preview_w + 12.0 + text_w + 12.0).max(preview_w + 130.0).max(ui.available_width().min(600.0)); let (r, resp) = ui.allocate_exact_size(vec2(w, 22.0), Sense::click()); if selected || resp.hovered() { ui.painter().rect_filled(r, 2.0, if selected { t.row_selected } else { t.hover }); @@ -3387,6 +3390,24 @@ mod tests { /// 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. + /// 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:?}"); + } + #[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()); From dcc8aed3788f16e555e800fb6cbc98e6ffac72b9 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:11:06 +0200 Subject: [PATCH 064/431] Direct Selection: selected points follow added, deleted and reversed points Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/anchors.rs | 47 ++++++++++++++++++++++++++++++-- crates/engine/src/cmd/object.rs | 7 ++++- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/anchors.rs b/crates/engine/src/cmd/anchors.rs index 72b2f085b..c55284c39 100644 --- a/crates/engine/src/cmd/anchors.rs +++ b/crates/engine/src/cmd/anchors.rs @@ -289,7 +289,7 @@ fn add_anchor(s: &mut Session, p: &Value) -> Result { let id = super::id_param(p, "id").ok_or_else(|| bad("path.addAnchor", "missing id"))?; let at = point_param(p, "at").ok_or_else(|| bad("path.addAnchor", "missing at"))?; let tol = p.get("tolerance").and_then(Value::as_f64).unwrap_or(4.0); - s.edit(|d, _| { + s.edit(|d, sel| { path_editable(d, id, "path.addAnchor")?; let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?; let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; @@ -303,6 +303,10 @@ fn add_anchor(s: &mut Session, p: &Value) -> Result { let t = t.clamp(1e-4, 1.0 - 1e-4); let ai = it.path.subpaths[si].insert_anchor(seg, t); as_path(&mut it.shape); + // Selected points after the new one move up an index, so they stay the same points. + for a in sel.anchors.iter_mut().filter(|a| a.0 == id && a.1 == si && a.2 >= ai) { + a.2 += 1; + } Ok(json!({"subpath": si, "anchor": ai})) }) } @@ -318,15 +322,27 @@ fn delete_anchor(s: &mut Session, p: &Value) -> Result { } sp.anchors.remove(ai); // A subpath needs two points (three to stay closed). - if sp.anchors.len() < 2 { + let removed_subpath = sp.anchors.len() < 2; + if removed_subpath { it.path.subpaths.remove(si); } else if sp.closed && sp.anchors.len() < 3 { sp.closed = false; } as_path(&mut it.shape); + // The deleted point leaves the selection; the selected points after it (or the subpaths + // after a removed one) move down an index. + sel.anchors.retain(|a| !(a.0 == id && a.1 == si && (a.2 == ai || removed_subpath))); + for a in sel.anchors.iter_mut().filter(|a| a.0 == id) { + if removed_subpath && a.1 > si { + a.1 -= 1; + } else if !removed_subpath && a.1 == si && a.2 > ai { + a.2 -= 1; + } + } if it.path.subpaths.is_empty() { d.remove_item(id)?; sel.items.retain(|i| *i != id); + sel.anchors.retain(|a| a.0 != id); return Ok(json!({"deleted": true})); } Ok(json!({"deleted": false})) @@ -486,6 +502,33 @@ mod tests { assert!(s.execute("path.erase", &json!({"id": id, "points": [[100, 600]]})).is_err()); } + /// The Direct Selection's selected points stay the same points when the path changes under + /// them: a point added before them, one of them deleted, the path reversed. + #[test] + fn selected_anchors_follow_added_deleted_and_reversed_points() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + let point = |s: &Session, a: (usize, usize)| s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[a.0].anchors[a.1].p; + s.execute("selection.set", &json!({"anchors": [[id, 0, 2], [id, 0, 3]]})).unwrap(); + let picked = |s: &Session| s.doc().unwrap().selection.anchors.iter().map(|a| (a.1, a.2)).collect::>(); + let before: Vec<_> = picked(&s).into_iter().map(|a| point(&s, a)).collect(); + // A point added on the first side, before both. + let first = point(&s, (0, 0)); + let second = point(&s, (0, 1)); + let mid = [(first.x + second.x) / 2.0, (first.y + second.y) / 2.0]; + s.execute("path.addAnchor", &json!({"id": id, "at": mid})).unwrap(); + assert_eq!(picked(&s).into_iter().map(|a| point(&s, a)).collect::>(), before, "after an added point"); + // One of them deleted: the other stays selected, the same point. + let (si, ai) = picked(&s)[0]; + s.execute("path.deleteAnchor", &json!({"id": id, "subpath": si, "anchor": ai})).unwrap(); + assert_eq!(picked(&s).len(), 1); + assert_eq!(point(&s, picked(&s)[0]), before[1], "after a deleted point"); + // Reversed: still the same point. + s.execute("path.reverse", &json!({"ids": [id]})).unwrap(); + assert_eq!(point(&s, picked(&s)[0]), before[1], "after reversing"); + } + #[test] fn add_convert_delete_anchor_points() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 357e95272..498dd5674 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -3302,7 +3302,7 @@ fn path_reverse(s: &mut Session, p: &Value) -> Result { { path_editable(doc, *id, "path.reverse")?; } - s.edit(|d, _| { + s.edit(|d, sel| { let mut reversed = 0; for id in &editable { let Some(it) = d.item_mut(*id) else { continue }; @@ -3313,6 +3313,11 @@ fn path_reverse(s: &mut Session, p: &Value) -> Result { if only.is_some_and(|o| o != i) { continue; } + // Selected points keep pointing at the same points, now counted from the other end. + let n = sp.anchors.len(); + for a in sel.anchors.iter_mut().filter(|a| a.0 == *id && a.1 == i && a.2 < n) { + a.2 = n - 1 - a.2; + } sp.anchors.reverse(); for a in &mut sp.anchors { std::mem::swap(&mut a.h_in, &mut a.h_out); From d9f0d2c0044231f38c4e1493c4dc4fa2bcd9a035 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:21:42 +0200 Subject: [PATCH 065/431] Direct Selection: a point selection without Shift selects only those points' paths Co-Authored-By: Claude Opus 5.5 --- crates/tools/src/select.rs | 10 ++++++---- crates/tools/src/tests.rs | 15 ++++++++++++++- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index b18126a97..5dca1a19b 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -213,8 +213,10 @@ fn anchors_in(cx: &ToolContext, r: Rect) -> Vec<(ItemId, usize, usize)> { } /// `selection.set` params selecting `points`: their paths, and those points. -fn anchor_selection(cx: &ToolContext, points: &[(ItemId, usize, usize)]) -> Value { - let mut ids: Vec = cx.selection.items.iter().map(|i| i.0).collect(); +/// `keep` (Shift): the objects already selected stay selected; otherwise only the paths of +/// `points` are. +fn anchor_selection(cx: &ToolContext, points: &[(ItemId, usize, usize)], keep: bool) -> Value { + let mut ids: Vec = if keep { cx.selection.items.iter().map(|i| i.0).collect() } else { Vec::new() }; for (i, _, _) in points { if !ids.contains(&i.0) { ids.push(i.0); @@ -781,7 +783,7 @@ impl Tool for SelectionTool { points = vec![me]; } if points != cx.selection.anchors { - out.push(Action::Exec("selection.set".into(), anchor_selection(cx, &points))); + out.push(Action::Exec("selection.set".into(), anchor_selection(cx, &points, ev.mods.shift))); } if ev.mods.shift && was { self.drag = Drag::None; @@ -1207,7 +1209,7 @@ impl Tool for SelectionTool { } } } - return vec![Action::Exec("selection.set".into(), anchor_selection(cx, &points))]; + return vec![Action::Exec("selection.set".into(), anchor_selection(cx, &points, ev.mods.shift))]; } } // Items whose bounds intersect the marquee. diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index a8f98b7e1..d26b13de1 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -1966,13 +1966,25 @@ fn direct_selection_marquee_selects_points_of_several_frames_and_drags_them_toge p["anchors"].as_array().unwrap().iter().map(|v| (v[0].as_u64().unwrap(), v[1].as_u64().unwrap(), v[2].as_u64().unwrap())).collect(); got.sort(); assert_eq!(got, vec![(a.0, 0, 0), (a.0, 0, 1), (b.0, 0, 0), (b.0, 0, 1)]); + // With another frame selected before, a marquee without Shift selects only the paths whose + // points it takes; with Shift the other frame stays selected. + let other = add_rect(&mut d, Rect::new(400.0, 400.0, 450.0, 450.0)); + let l = CanvasLayout::new(&d, false); + let was = Selection { items: vec![other], ..Default::default() }; + let shift = Mods { shift: true, ..Default::default() }; + for (mods, want) in [(Mods::default(), serde_json::json!([a.0, b.0])), (shift, serde_json::json!([other.0, a.0, b.0]))] { + t.pointer(&ctx(&d, &was, &c, &l), &PointerEvent::new(PointerKind::Down, p0.x, p0.y).with_mods(mods)); + t.pointer(&ctx(&d, &was, &c, &l), &PointerEvent::new(PointerKind::Drag, p1.x, p1.y).with_mods(mods)); + let up = t.pointer(&ctx(&d, &was, &c, &l), &PointerEvent::new(PointerKind::Up, p1.x, p1.y).with_mods(mods)); + let Some(Action::Exec(_, p)) = up.last() else { panic!("{up:?}") }; + assert_eq!(p["ids"], want, "{mods:?}"); + } // Those points selected: dragging one moves all four, in one preview. let sel = Selection { items: vec![a, b], anchors: vec![(a, 0, 0), (a, 0, 1), (b, 0, 0), (b, 0, 1)], ..Default::default() }; let cx = ctx(&d, &sel, &c, &l); let at = spread_pt(&l, 200.0, 100.0); let down = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y)); assert_eq!(down, vec![Action::Begin("Move Anchors".into())]); - let shift = Mods { shift: true, ..Default::default() }; let drag = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, at.x + 36.0, at.y + 3.0).with_mods(shift)); let Some(Action::Preview(cmd, p)) = drag.last() else { panic!("{drag:?}") }; assert_eq!(cmd, "path.moveAnchors"); @@ -1981,6 +1993,7 @@ fn direct_selection_marquee_selects_points_of_several_frames_and_drags_them_toge assert_eq!((p["dx"].as_f64(), p["dy"].as_f64()), (Some(36.0), Some(0.0))); assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x + 36.0, at.y)), vec![Action::Commit]); // Shift+click on a selected point takes it out of the selection; the arrow keys move the points. + let shift = Mods { shift: true, ..Default::default() }; let out = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(shift)); let Some(Action::Exec(cmd, p)) = out.first() else { panic!("{out:?}") }; assert_eq!(cmd, "selection.set"); From bd8abe3972cb7d94161ccb07ace4c36c00e274e4 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:22:04 +0200 Subject: [PATCH 066/431] Docs: Swatches rows' type columns in the parity overview Co-Authored-By: Claude Opus 5.5 --- docs/target-app-parity.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cdd6da689..20f30db6d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DesignCraft parity with Adobe InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Swatches rows show InDesign's type columns; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature @@ -268,7 +268,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Text engine: Knuth–Plass paragraph composer and single-line composer, dictionary hyphenation (public-domain Moby list + our own trained Liang patterns), justification with word/letter/glyph-scaling ranges, keep options (keep with next, keep lines together — at start/end of paragraph by default, as in InDesign — widows/orphans), balance ragged lines, optical margin alignment, hyphenation zone/limit, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing. - FID2: IDML underline/strikethrough Text Color resets survive style inheritance and roundtrip. Raster and PDF paint underlines behind glyphs, retain strikethrough above, and center rules on their offsets; synthetic overlap tests cover both backends. - Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text. -- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts and the Fill/Stroke proxy (D, X, Shift+X, / as `color.*` commands), document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), document grid (View ▸ Grids & Guides, ⌘', on the snap positions, behind or in front of items), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches (InDesign's header: Fill/Stroke proxy, Formatting Affects Container/Text, Tint; a swatch goes to the fill or stroke in front; New Color Group / New Swatch / Delete Swatch row), Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. +- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts and the Fill/Stroke proxy (D, X, Shift+X, / as `color.*` commands), document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), document grid (View ▸ Grids & Guides, ⌘', on the snap positions, behind or in front of items), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches (InDesign's header: Fill/Stroke proxy, Formatting Affects Container/Text, Tint, which reads 100 with nothing selected; a swatch goes to the fill or stroke in front; rows show InDesign's type columns: the crossed pencil on swatches that can't be edited, process or spot, and the colour mode; New Color Group / New Swatch / Delete Swatch row), Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. - Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows, expandable groups and drag-and-drop reorder of layers and objects (the selection square moves the selection to another layer). - Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type; Option/Alt+Left/Right kerns at the caret or tracks a selection by the Kerning/Tracking increment, ×5 with Cmd/Ctrl; words move with Cmd+Left/Right on macOS and Ctrl+Left/Right elsewhere), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. - PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions. From e811b65e82c2565682ed18042cb3b4e7239a9825 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:38:29 +0200 Subject: [PATCH 067/431] Clear Fitting Options skips locked frames Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/object.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index f709b6dec..2fa9f4117 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -2900,6 +2900,10 @@ fn clear_fitting_options(s: &mut Session, p: &Value) -> Result { s.edit(|d, _| { let mut n = 0; for id in &ids { + // Locked frames (or frames on a locked layer) keep their options, as with Fit. + if !d.item(*id).is_some_and(|it| unlocked(d, it)) { + continue; + } let Some(it) = d.item_mut(*id) else { continue }; let Content::Graphic(g) = &mut it.content else { continue }; g.auto_fit = designcraft_doc::Fitting::None; @@ -4313,6 +4317,10 @@ mod fitting_tests { assert_eq!(g.xf, before.xf, "the picture stays where it is"); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(graphic(&s).crop, [10.0; 4], "one undo step"); + s.execute("object.lock", &json!({"ids": [f]})).unwrap(); + let r = s.execute("object.clearFittingOptions", &json!({"ids": [f]})).unwrap(); + assert_eq!(r["changed"], 0); + assert_eq!(graphic(&s).crop, [10.0; 4], "a locked frame keeps its options"); } } From c9e831e7321e3312004760609454a54ee259dbe9 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:58:48 +0200 Subject: [PATCH 068/431] Effect blend modes: reject an unknown mode; IDML shadows without BlendMode stay Normal Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/object.rs | 33 +++++++++++++++++++++++++++------ crates/idml/src/import.rs | 4 +++- crates/idml/src/tests.rs | 1 + 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 29358fcac..72f2fa01f 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -385,6 +385,7 @@ pub fn specs() -> Vec { "{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, spread? (%), color? (swatch), mode?: blend mode (multiply…), ids?}", has_selection, |s, p| { + let mode = blend_param(p, "object.dropShadow")?; let p2 = p.clone(); set_effects(s, p, "object.dropShadow", move |fx| { let ds = &mut fx.drop_shadow; @@ -398,7 +399,7 @@ pub fn specs() -> Vec { if let Some(c) = str_param(&p2, "color") { ds.color = c.into(); } - if let Some(m) = blend_param(&p2) { + if let Some(m) = mode { ds.mode = m; } }) @@ -412,6 +413,7 @@ pub fn specs() -> Vec { "{on?: bool, distance?, angle?, globalLight?: bool, opacity?, size?, choke? (%), color?, mode?: blend mode, ids?}", has_selection, |s, p| { + let mode = blend_param(p, "object.innerShadow")?; let p2 = p.clone(); set_effects(s, p, "object.innerShadow", move |fx| { let e = &mut fx.inner_shadow; @@ -425,7 +427,7 @@ pub fn specs() -> Vec { if let Some(c) = str_param(&p2, "color") { e.color = c.into(); } - if let Some(m) = blend_param(&p2) { + if let Some(m) = mode { e.mode = m; } }) @@ -439,6 +441,7 @@ pub fn specs() -> Vec { "{on?: bool, opacity?, size?, spread? (%), color?, mode?: blend mode (screen…), ids?}", has_selection, |s, p| { + let mode = blend_param(p, "object.outerGlow")?; let p2 = p.clone(); set_effects(s, p, "object.outerGlow", move |fx| { let e = &mut fx.outer_glow; @@ -449,7 +452,7 @@ pub fn specs() -> Vec { if let Some(c) = str_param(&p2, "color") { e.color = c.into(); } - if let Some(m) = blend_param(&p2) { + if let Some(m) = mode { e.mode = m; } }) @@ -3111,9 +3114,17 @@ fn align(s: &mut Session, p: &Value) -> Result { } /// An effect's `mode` parameter: a blend mode by name ("multiply", "screen", or as saved). -fn blend_param(p: &Value) -> Option { - let v = p.get("mode")?; - v.as_str().and_then(BlendMode::parse).or_else(|| serde_json::from_value(v.clone()).ok()) +/// An effect's `mode`: `None` when omitted or null, an error when it names no blend mode. +fn blend_param(p: &Value, cmd: &str) -> Result> { + match p.get("mode") { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_str() + .and_then(BlendMode::parse) + .or_else(|| serde_json::from_value(v.clone()).ok()) + .map(Some) + .ok_or_else(|| bad(cmd, format!("unknown blend mode {v}"))), + } } fn distribute(s: &mut Session, p: &Value) -> Result { @@ -4083,6 +4094,16 @@ mod light_tests { assert_eq!(fx.drop_shadow.mode, BlendMode::Screen); assert_eq!(fx.inner_shadow.mode, BlendMode::Overlay); assert_eq!(fx.outer_glow.mode, BlendMode::Normal); + let before = s.doc().unwrap().doc.clone(); + for cmd in ["object.dropShadow", "object.innerShadow", "object.outerGlow"] { + for mode in [json!("multply"), json!(3)] { + let e = s.execute(cmd, &json!({"on": true, "mode": mode, "ids": [id]})).unwrap_err(); + assert!(e.to_string().contains("unknown blend mode"), "{cmd}: {e}"); + } + } + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "nothing changed"); + s.execute("object.dropShadow", &json!({"mode": null, "ids": [id]})).unwrap(); + assert_eq!(s.doc().unwrap().doc.item(ItemId(id)).unwrap().effects.drop_shadow.mode, BlendMode::Screen, "null keeps the mode"); } #[test] diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 30a5caa34..4de0e08cc 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2699,7 +2699,9 @@ impl<'r> Importer<'r> { size: ds.num("Size").unwrap_or(def.size), spread: ds.num("Spread").unwrap_or(def.spread), global_light: false, - mode: ds.get("BlendMode").map_or(def.mode, names::blend_in), + // Without the attribute (earlier DesignCraft exports) the shadow stays Normal, as it + // rendered then; a new shadow's default is Multiply. + mode: ds.get("BlendMode").map_or(designcraft_color::BlendMode::Normal, names::blend_in), }; } if let Some(f) = t.find("FeatherSetting") diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 035333da4..99ae66625 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -3616,6 +3616,7 @@ fn object_style_effects_import_by_category_and_based_on() { let base = fx("Base").unwrap(); assert!(base.drop_shadow.on && base.drop_shadow.size == 9.0 && (base.drop_shadow.distance - 5.0).abs() < 1e-9); assert_eq!(base.feather, 0.0, "EnableFeather=false"); + assert_eq!(base.drop_shadow.mode, designcraft_color::BlendMode::Normal, "no BlendMode: Normal, as earlier exports rendered"); let child = fx("Child").unwrap(); assert_eq!((child.drop_shadow.size, child.feather), (9.0, 6.0), "settings from the BasedOn style"); assert!(fx("Off").is_none()); From 5544efdbdcff10f41dd49892b133665c54b7c4a9 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:03:03 +0200 Subject: [PATCH 069/431] Effects angle dial: arrow keys and assistive-technology steps turn it Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/effects_dialog.rs | 98 ++++++++++++++++++++++++++-- 1 file changed, 94 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/effects_dialog.rs b/crates/ui-egui/src/effects_dialog.rs index d0c2228b8..5341c15a6 100644 --- a/crates/ui-egui/src/effects_dialog.rs +++ b/crates/ui-egui/src/effects_dialog.rs @@ -217,8 +217,11 @@ fn angle(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str], enabled: bool, global } /// InDesign's angle dial: a circle with a radius pointing where the light comes from (0° to the -/// right, counter-clockwise). A click or a drag on it points it at the pointer. +/// right, counter-clockwise). A click or a drag on it points it at the pointer. Focused, the +/// arrow keys turn it a degree (Shift: 10°) — Right and Up counter-clockwise — and assistive +/// technology can step it or set its value, as with a slider. fn angle_dial(ui: &mut egui::Ui, deg: f64, enabled: bool) -> Option { + use egui::accesskit::{Action, ActionData}; let sense = if enabled { egui::Sense::click_and_drag() } else { egui::Sense::hover() }; let (r, resp) = ui.allocate_exact_size(egui::vec2(26.0, 26.0), sense); resp.widget_info(|| egui::WidgetInfo::slider(enabled, deg, "Angle")); @@ -227,10 +230,64 @@ fn angle_dial(ui: &mut egui::Ui, deg: f64, enabled: bool) -> Option { ui.painter().circle_stroke(c, rad, egui::Stroke::new(1.2, col)); let a = deg.to_radians() as f32; ui.painter().line_segment([c, c + egui::vec2(a.cos(), -a.sin()) * rad], egui::Stroke::new(1.4, col)); - if enabled && (resp.dragged() || resp.clicked()) { - return resp.interact_pointer_pos().and_then(|p| dial_angle(c, p)); + if resp.has_focus() { + ui.painter().circle_stroke(c, rad + 2.0, ui.visuals().selection.stroke); } - None + if !enabled { + return None; + } + ui.ctx().accesskit_node_builder(resp.id, |node| { + node.set_min_numeric_value(-180.0); + node.set_max_numeric_value(180.0); + node.set_numeric_value_step(1.0); + node.add_action(Action::SetValue); + node.add_action(Action::Increment); + node.add_action(Action::Decrement); + }); + let mut next = None; + let mut steps = 0i64; + if resp.has_focus() { + // The arrows turn the dial instead of moving the focus. + ui.memory_mut(|m| { + m.set_focus_lock_filter(resp.id, egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() }) + }); + ui.input(|i| { + for e in &i.events { + if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { + let dir = match key { + egui::Key::ArrowRight | egui::Key::ArrowUp => 1, + egui::Key::ArrowLeft | egui::Key::ArrowDown => -1, + _ => 0, + }; + steps += dir * if modifiers.shift { 10 } else { 1 }; + } + } + }); + } + ui.input(|i| { + steps += i.num_accesskit_action_requests(resp.id, Action::Increment) as i64; + steps -= i.num_accesskit_action_requests(resp.id, Action::Decrement) as i64; + for request in i.accesskit_action_requests(resp.id, Action::SetValue) { + if let Some(ActionData::NumericValue(v)) = request.data { + next = Some(v); + } + } + }); + if steps != 0 { + next = Some(deg + steps as f64); + } + if (resp.dragged() || resp.clicked()) + && let Some(a) = resp.interact_pointer_pos().and_then(|p| dial_angle(c, p)) + { + next = Some(a); + } + next.filter(|v| v.is_finite()).map(wrap_angle) +} + +/// `deg` turned into the dial's range, −180 (exclusive) to 180°. +fn wrap_angle(deg: f64) -> f64 { + let a = (deg + 180.0).rem_euclid(360.0) - 180.0; + if a <= -180.0 { 180.0 } else { a } } /// The angle a dial at `c` points at for the pointer at `p`, in whole degrees (−180 to 180, @@ -769,6 +826,39 @@ mod tests { assert_eq!(dial_angle(c, egui::pos2(101.0, 100.0)), None, "too close to the centre"); } + /// Without a pointer: Tab focuses the dial, the arrows turn it (Shift: 10°), wrapping at 180°. + #[test] + fn the_angle_dial_turns_with_the_keyboard() { + let ctx = egui::Context::default(); + let key = |key, shift| egui::Event::Key { + key, + physical_key: None, + pressed: true, + repeat: false, + modifiers: if shift { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE }, + }; + let mut deg = 175.0; + let frame = |events: Vec, deg: &mut f64| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + if let Some(a) = angle_dial(ui, *deg, true) { + *deg = a; + } + }); + out.textures_delta.clear(); + }; + frame(vec![], &mut deg); + frame(vec![key(egui::Key::Tab, false)], &mut deg); + frame(vec![key(egui::Key::ArrowRight, false)], &mut deg); + assert_eq!(deg, 176.0); + frame(vec![key(egui::Key::ArrowUp, true)], &mut deg); + assert_eq!(deg, -174.0, "past 180° it comes round to −174°"); + frame(vec![key(egui::Key::ArrowLeft, true)], &mut deg); + frame(vec![key(egui::Key::ArrowDown, false)], &mut deg); + assert_eq!(deg, 175.0); + assert_eq!(wrap_angle(-180.0), 180.0); + assert_eq!(wrap_angle(540.0), 180.0); + } + #[test] fn offsets_follow_distance_and_angle_as_in_indesign() { let (mut app, _) = app_with_frame(); From 3d3bed65b80d15b0ff51e0358142a4497721f0f3 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:26:56 +0200 Subject: [PATCH 070/431] Text Frame Options: an invalid value takes the preview off the page; parity entry Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 16 ++++++++++++++++ docs/target-app-parity.md | 3 ++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 02ce87c49..ffd6499c3 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -648,6 +648,11 @@ fn text_frame_preview(app: &mut DesignApp, d: &mut Dialog) { let params = match text_frame_params(app, d) { Ok(p) => Value::Object(p), Err(e) => { + // Values OK would refuse don't stay on the page: it goes back to how it was. + if shown.is_some() { + app.session.cancel_interaction(); + d.fields.remove(PREVIEWED); + } d.fields.insert("status".into(), json!(e)); return; } @@ -8075,6 +8080,17 @@ mod tests { d.fields.insert("preview".into(), json!(false)); text_frame_preview(&mut app, &mut d); assert_eq!(opts(&app).columns, 1, "Preview off: the page as it was"); + // A value OK would refuse: the page goes back while the status says why. + d.fields.insert("preview".into(), json!(true)); + text_frame_preview(&mut app, &mut d); + assert_eq!(opts(&app).columns, 3); + let gutter = d.fields.get("gutter").cloned().unwrap_or_default(); + d.fields.insert("gutter".into(), json!("wide")); + text_frame_preview(&mut app, &mut d); + assert_eq!(opts(&app).columns, 1, "the page as it was"); + assert!(!d.fields.contains_key(PREVIEWED) && !d.s("status").is_empty()); + d.fields.insert("gutter".into(), gutter); + d.fields.remove("status"); // Cancel after a preview: back as it was. d.fields.insert("preview".into(), json!(true)); text_frame_preview(&mut app, &mut d); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cdd6da689..c96143ff7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DesignCraft parity with Adobe InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Text Frame Options layout, Preview and Paragraph Spacing Limit; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature @@ -343,6 +343,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Column rules (Text Frame Options › Column Rules, `object.textFrameOptions {columnRule, columnRuleWeight, columnRuleColor, columnRuleTint, columnRuleOffset, columnRuleTopInset, columnRuleBottomInset}`, reported by `document.inspect`): a bar centred in each gutter of a multi-column frame, inside the insets and between the columns of right-to-left and vertical frames too, broken around paragraphs that span columns, with weight, colour, tint, horizontal offset and top/bottom insets; on screen, in PDF (tagged as an artifact) and in IDML (`ColumnRule…` attributes of `TextFramePreference`, also in object styles). The dialog shows the frame's options and changes only the fields edited. Rule stroke types and overprint aren't there yet. - Auto-size (Text Frame Options › Auto-Size, `object.textFrameOptions {autoSize: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference, useMinimumHeight, minimumHeight, useMinimumWidth, minimumWidth, noLineBreaks}`): the frame fits its text exactly after every edit (typing, pasting, style changes, option changes) in the same undo step (opening a file keeps the frame sizes it gives); it grows and shrinks around the reference point, which stays put in spread space for rotated frames too, never below the minimum height/width and never past 10,000 pt. Width Only and No Line Breaks set each paragraph on one line; Height and Width without No Line Breaks keeps the width; Keep Proportions scales both sides until the text fits. IDML keeps all the options (`AutoSizing…`, `…ForAutoSizing` attributes). Not covered: frames of a thread other than the last, vertical frames, type on a path, and multi-column balancing in Keep Proportions. - Balance Columns (Text Frame Options › Balance Columns, `object.textFrameOptions {balanceColumns}`, IDML `VerticalBalanceColumns`): in the frame the story ends in, the columns after the last explicit column break fill to about the same depth (the last may be shorter by a line per column); keep options hold, spanning and split paragraphs keep their own balancing, vertical justification then applies to each column as usual, and vertical frames balance their bands. A frame the text runs on from, or that is overset, fills normally. +- Text Frame Options dialog, laid out as InDesign's (General, Column Rules, Baseline Options, Auto-Size; framed Columns, Inset Spacing and Vertical Justification groups with spinner fields; Fixed Number shows each column's width as the fields change). Preview shows the edits on the page until OK (one undo step) or Cancel, and values OK would refuse put the page back. Vertical Justification › Paragraph Spacing Limit (`object.textFrameOptions {paragraphSpacingLimit}`, IDML `VerticalThreshold`): with Justify, the extra space goes between paragraphs up to the limit, the rest between lines. - Place a page of an IDML or DesignCraft document (`file.place {layoutPage}`): its objects, stories, styles and swatches as one group (INDD files are not readable). - Articles panel (`article.new/add/remove/options/delete/list`): reading order and content of EPUB and HTML exports. - Object ▸ Clipping Path (`object.clippingPath {type: alpha|edges, threshold, tolerance}`): traces the placed image and makes the outline the frame. Clipping paths saved in IDML (Detect Edges, Alpha Channel, Photoshop Path, User-Modified Path; Invert) hide the image outside the path on screen and in PDF and survive IDML export; `object.clippingPathOptions` reports, inverts or removes one (Object ▸ Clipping Path ▸ None). From 46d830cb3b753ee0b52eb77a0956ababd040d059 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:33:24 +0200 Subject: [PATCH 071/431] Paragraph rules: gap in Text Color, IDML overrides from the new fields alone, Text Color in the Color menu Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/lib.rs | 11 ++++-- crates/compose/src/tests.rs | 3 ++ crates/idml/src/import.rs | 8 +++- crates/idml/src/tests.rs | 27 +++++++++++++ crates/ui-egui/src/dialogs.rs | 74 ++++++++++++++++++++++++++++++++++- 5 files changed, 115 insertions(+), 8 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 1920b3020..c97171208 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1876,7 +1876,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let top = frames.get(fi).map_or(f64::NEG_INFINITY, |f| f.area.y0); let y = if r.keep_in_frame && y - r.weight < top { top + r.weight } else { y }; let rect = Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y); - ft.decos.extend(rule_decos(doc, r, rect, color, tint)); + ft.decos.extend(rule_decos(doc, r, rect, (color, tint), &above)); } } if pp.rule_below.on @@ -1887,7 +1887,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let y = l.baseline + r.offset; let (x0, x1) = if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) }; let (color, tint) = rule_color(r, &below); - ft.decos.extend(rule_decos(doc, r, Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), color, tint)); + ft.decos.extend(rule_decos(doc, r, Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), (color, tint), &below)); } // The end of a split block: text after it continues below its deepest sub-column, which // are balanced first. A block that ends the story fills its sub-columns in turn. @@ -4064,13 +4064,16 @@ fn hyphenation_points( /// A paragraph rule drawn in `rect` (its length and weight) with its Type: the gap colour under /// it, then the line, as dashes, dots, stripes, waves or hash marks. A pattern that can't be laid /// out along the rule draws solid. -fn rule_decos(doc: &Document, r: &designcraft_doc::Rule, rect: Rect, color: String, tint: f32) -> Vec { +/// `(color, tint)` is the rule's (Text Color already resolved); `text`, the colour of the text +/// the rule goes with, for a gap in Text Color. +fn rule_decos(doc: &Document, r: &designcraft_doc::Rule, rect: Rect, (color, tint): (String, f32), text: &(String, f32)) -> Vec { use designcraft_doc::StrokeType; let kind = doc.stroke_kind(&r.kind); let (w, len) = (rect.height(), rect.width()); let mut out = Vec::new(); if !matches!(kind, StrokeType::Solid) && r.gap_color != designcraft_color::swatch::NONE && w > 0.0 && len > 0.0 { - out.push(Deco { rect, color: r.gap_color.clone(), tint: r.gap_tint, ..Deco::default() }); + let gap = if r.gap_color == designcraft_doc::TEXT_COLOR { text.0.clone() } else { r.gap_color.clone() }; + out.push(Deco { rect, color: gap, tint: r.gap_tint, ..Deco::default() }); } let y = rect.center().y; let mut line = designcraft_geom::BezPath::new(); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index e394e4fe1..bd7235a69 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -3719,6 +3719,9 @@ fn paragraph_rules_have_a_type_a_gap_colour_and_keep_in_frame() { let gap = decos(Rule { gap_color: "[Black]".into(), gap_tint: 0.3, ..rule(StrokeType::Dotted) }); assert_eq!((gap.len(), gap[0].color.as_str(), gap[0].tint, gap[0].path.is_none()), (2, "[Black]", 0.3, true), "the gap colour under the dots"); assert_eq!(decos(Rule { gap_color: "[Black]".into(), ..rule(StrokeType::Solid) }).len(), 1, "a solid rule has no gaps"); + // A gap in Text Color takes the text's colour, whatever swatch the rule itself has. + let gap = decos(Rule { color: "[Paper]".into(), gap_color: designcraft_doc::TEXT_COLOR.into(), ..rule(StrokeType::Dotted) }); + assert_eq!((gap[0].color.as_str(), gap[1].color.as_str()), ("[Black]", "[Paper]")); // Keep In Frame: a rule above raised out of the frame comes back to its top. let above = |keep: bool| { let r = Rule { on: true, weight: 2.0, offset: 30.0, keep_in_frame: keep, ..Default::default() }; diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 58cd70271..827b39ea8 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1939,7 +1939,12 @@ impl<'r> Importer<'r> { // A style or range can set some of a rule's settings (such as only its colour); the others // come from its paragraph style chain, so the rule is whole. let fields = ["", "LineWeight", "Color", "Tint", "Width", "Offset", "LeftIndent", "RightIndent"]; - if !fields.iter().any(|f| e.prop(&format!("{k}{f}")).is_some()) { + // Any of them, or of the type, gap and Keep In Frame settings, makes it an override (the + // rest then come from the style chain, or the defaults: off, 1 pt). + let more = ["Type", "GapColor", "GapTint"]; + let own = fields.iter().chain(&more).any(|f| e.prop(&format!("{k}{f}")).is_some()) + || (k == "RuleAbove" && e.prop("KeepRuleAboveInFrame").is_some()); + if !own { return None; } let v: Vec> = fields.iter().map(|f| self.para_setting(e, &format!("{k}{f}"))).collect(); @@ -1947,7 +1952,6 @@ impl<'r> Importer<'r> { let num = |i: usize| get(i).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()); let on = get(0).map(|v| v.trim() == "true"); let weight = num(1); - on.or(weight.map(|_| false))?; let mut r = Rule { on: on.unwrap_or(false), ..Rule::default() }; if let Some(w) = weight { r.weight = w; diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index e03eeffb7..51ce9e7ff 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -3705,6 +3705,33 @@ fn style_names_ending_in_a_space_and_partial_rule_overrides_import() { assert_ne!(black.color, white.color, "only the colour is overridden"); } +#[test] +fn a_style_setting_only_a_rules_type_gap_or_keep_in_frame_overrides_it() { + let styles = STYLES.replace( + r#" "#, + r#" + $ID/[No paragraph style] + + + ParagraphStyle/Base + + + $ID/[No paragraph style] + + + $ID/[No paragraph style] + + "#, + ); + let d = import_idml(&fixture_with_table_styles(&styles, STORY)).unwrap(); + let style = |n: &str| d.styles.paragraph.iter().find(|s| s.name == n).unwrap().para.clone(); + let wavy = style("Wavy").rule_below.expect("the type alone overrides the rule"); + assert_eq!((wavy.on, wavy.weight, wavy.kind), (true, 3.0, designcraft_doc::StrokeType::Wavy), "the rest from the based-on style"); + let gap = style("Gap").rule_below.expect("the gap colour alone, with no on or weight in the chain"); + assert!(!gap.on && gap.gap_color != designcraft_color::swatch::NONE, "{gap:?}"); + assert!(style("Kept").rule_above.expect("Keep In Frame alone").keep_in_frame); +} + #[test] fn missing_linked_graphics_show_their_preview_and_keep_their_link() { // InDesign's preview of the missing photo (a PNG header is all the importer reads: 4 × 3). diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index de57d77e9..3d984fc13 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4500,8 +4500,30 @@ fn para_sides(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &s /// A swatch menu with a colour chip per swatch (hidden swatches left out); returns the swatch /// picked. fn swatch_menu(ui: &mut egui::Ui, doc: &designcraft_doc::Document, id: impl egui::AsIdSalt, current: &str, width: f32) -> Option { + swatch_menu_with(ui, doc, id, current, width, None) +} + +/// [`swatch_menu`] with, given its translated label, Text Color first (a paragraph rule's +/// colour: the text's own), as InDesign's Paragraph Rules › Color. +fn swatch_menu_with( + ui: &mut egui::Ui, + doc: &designcraft_doc::Document, + id: impl egui::AsIdSalt, + current: &str, + width: f32, + text_color: Option<&str>, +) -> Option { let mut picked = None; - egui::ComboBox::from_id_salt(id).selected_text(current).width(width).show_ui(ui, |ui| { + let shown = match text_color { + 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| { + if let Some(label) = text_color + && ui.selectable_label(current == designcraft_doc::TEXT_COLOR, label).clicked() + { + picked = Some(designcraft_doc::TEXT_COLOR.to_string()); + } for w in doc.swatches.iter().filter(|w| !w.hidden) { let (c, g) = crate::widgets::swatch_colors(doc, &w.name, 1.0); ui.horizontal(|ui| { @@ -5914,7 +5936,8 @@ fn paragraph_rules(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, prefix: & ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Color:")); let color = rule["color"].as_str().unwrap_or("").to_string(); - if let Some(w) = doc.and_then(|doc| swatch_menu(ui, doc, ("rule_color", which), &color, 130.0)) { + let text_color = crate::i18n::tr(lang, "Text Color"); + if let Some(w) = doc.and_then(|doc| swatch_menu_with(ui, doc, ("rule_color", which), &color, 130.0, Some(text_color))) { d.fields.insert(key("color"), json!(w)); } crate::rtl::label(ui, crate::i18n::tr(lang, "Tint:")); @@ -7562,6 +7585,53 @@ mod tests { assert_eq!(app.run("type.selectionAttrs", json!({})).unwrap()["para"]["ruleBelow"], rule); } + /// 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] From f972cebb960351db4bb23fef77ae875e1caccf10 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:47:05 +0200 Subject: [PATCH 072/431] Paragraph style edits: Reset To Base with a new Based On resolves over it, is checked against it and previewed Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/engine/src/cmd/style.rs | 14 ++++++---- crates/engine/src/tests.rs | 15 ++++++++++ crates/ui-egui/src/dialogs.rs | 50 ++++++++++++++++++++++++++++++++-- 4 files changed, 72 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 41b3ab40d..9a8a08c3b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,7 +11,7 @@ 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. -## Status (2026-10-01) +## Status (2026-10-21) **Working today** - Document model: facing/non-facing spreads, parent pages with page-number markers, sections, layers, frames (text / graphic / unassigned), threaded stories, paragraph/character/object styles with based-on, swatches (process/spot/tints/gradients), corner options, text wrap, drop shadow, opacity/blend. diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 4526db5b0..903cd7d0f 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -955,6 +955,15 @@ fn edit_para(s: &mut Session, p: &Value) -> Result { st.para = ParaAttrs::default(); st.chars = CharAttrs::default(); } + // A new Based On first, so the fields below resolve over the new parent. + if based.is_some() { + let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?; + match &based { + Some(Value::String(b)) => st.based_on = Some(b.clone()), + Some(Value::Null) => st.based_on = None, + _ => {} + } + } // A rule object changes only the rule fields it names, over the style's resolved rule. let (current, _) = d.styles.resolve_para_style(&name); let mut para: ParaAttrs = attrs("style.paragraph.edit", "para", p, |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, ¤t))?; @@ -964,11 +973,6 @@ fn edit_para(s: &mut Session, p: &Value) -> Result { let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?; st.para.merge(¶); st.chars.merge(&chars); - match based { - Some(Value::String(b)) => st.based_on = Some(b), - Some(Value::Null) => st.based_on = None, - _ => {} - } match next { Some(Value::String(n)) => st.next_style = Some(n), Some(Value::Null) => st.next_style = None, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9aacf469d..4bc7e360c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1650,3 +1650,18 @@ 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"); } + +/// Reset To Base with a new Based On: a partial rule edit resolves over the new parent, not the +/// old one. +#[test] +fn reset_to_base_with_a_new_parent_resolves_rule_edits_over_it() { + let mut s = session(); + let rule = |weight: f64, offset: f64| json!({"on": true, "weight": weight, "color": "[Black]", "offset": offset}); + s.execute("style.paragraph.create", &json!({"name": "Old", "para": {"ruleBelow": rule(3.0, 1.0)}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "New", "para": {"ruleBelow": rule(1.0, 9.0)}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Child", "basedOn": "Old"})).unwrap(); + s.execute("style.paragraph.edit", &json!({"name": "Child", "resetToBase": true, "basedOn": "New", "para": {"ruleBelow": {"weight": 2}}})) + .unwrap(); + let r = s.doc().unwrap().doc.styles.resolve_para_style("Child").0.rule_below; + assert_eq!((r.weight, r.offset), (2.0, 9.0), "the offset is the new parent's"); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index dc6a5d7e7..d5341722e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3646,9 +3646,25 @@ fn grep_error(p: &str) -> Option { /// The Justification spacing check of Paragraph Style Options, against what the section showed: /// the style's own values, or a new style's Based On. fn check_paragraph_style_justification(app: &DesignApp, d: &Dialog) -> Result<(), String> { - let shown = if d.b("new") { d.s("basedOn") } else { d.s("name") }; - let base = - app.session.active().map(|st| serde_json::to_value(st.doc.styles.resolve_para_style(&shown).0).unwrap_or_default()).unwrap_or_default(); + // After Reset To Base the style starts from its Based On (the one chosen here, if any). + let base_of = |st: &designcraft_engine::DocState| { + let chosen = d.s("basedOn"); + if chosen.is_empty() { st.doc.styles.para(&d.s("name")).and_then(|s| s.based_on.clone()).unwrap_or_default() } else { chosen } + }; + let base = app + .session + .active() + .map(|st| { + let shown = if d.b("new") { + d.s("basedOn") + } else if d.b("resetToBase") { + base_of(st) + } else { + d.s("name") + }; + serde_json::to_value(st.doc.styles.resolve_para_style(&shown).0).unwrap_or_default() + }) + .unwrap_or_default(); check_justification(d, "p.", &base) } @@ -3777,6 +3793,10 @@ fn style_options_preview(app: &mut DesignApp, d: &mut Dialog) { _ => based.clone(), }; } + // Reset To Base shows on the page too. + if paragraph && d.b("resetToBase") { + params["resetToBase"] = json!(true); + } if shown.as_ref() == Some(¶ms) { return; } @@ -7614,6 +7634,30 @@ mod tests { assert_eq!(s.based_on.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); } + /// 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 paragraph_style_options_edit_one_rule_field() { let (mut app, rule) = ruled_paragraph(); From cd53ae04be3eff279bb15f537c277f201bac52a7 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:50:38 +0200 Subject: [PATCH 073/431] Parity: the Text Frame Options Preview with an invalid value, spelt out Co-Authored-By: Claude Opus 5.5 --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c96143ff7..c08ef6e89 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -343,7 +343,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Column rules (Text Frame Options › Column Rules, `object.textFrameOptions {columnRule, columnRuleWeight, columnRuleColor, columnRuleTint, columnRuleOffset, columnRuleTopInset, columnRuleBottomInset}`, reported by `document.inspect`): a bar centred in each gutter of a multi-column frame, inside the insets and between the columns of right-to-left and vertical frames too, broken around paragraphs that span columns, with weight, colour, tint, horizontal offset and top/bottom insets; on screen, in PDF (tagged as an artifact) and in IDML (`ColumnRule…` attributes of `TextFramePreference`, also in object styles). The dialog shows the frame's options and changes only the fields edited. Rule stroke types and overprint aren't there yet. - Auto-size (Text Frame Options › Auto-Size, `object.textFrameOptions {autoSize: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference, useMinimumHeight, minimumHeight, useMinimumWidth, minimumWidth, noLineBreaks}`): the frame fits its text exactly after every edit (typing, pasting, style changes, option changes) in the same undo step (opening a file keeps the frame sizes it gives); it grows and shrinks around the reference point, which stays put in spread space for rotated frames too, never below the minimum height/width and never past 10,000 pt. Width Only and No Line Breaks set each paragraph on one line; Height and Width without No Line Breaks keeps the width; Keep Proportions scales both sides until the text fits. IDML keeps all the options (`AutoSizing…`, `…ForAutoSizing` attributes). Not covered: frames of a thread other than the last, vertical frames, type on a path, and multi-column balancing in Keep Proportions. - Balance Columns (Text Frame Options › Balance Columns, `object.textFrameOptions {balanceColumns}`, IDML `VerticalBalanceColumns`): in the frame the story ends in, the columns after the last explicit column break fill to about the same depth (the last may be shorter by a line per column); keep options hold, spanning and split paragraphs keep their own balancing, vertical justification then applies to each column as usual, and vertical frames balance their bands. A frame the text runs on from, or that is overset, fills normally. -- Text Frame Options dialog, laid out as InDesign's (General, Column Rules, Baseline Options, Auto-Size; framed Columns, Inset Spacing and Vertical Justification groups with spinner fields; Fixed Number shows each column's width as the fields change). Preview shows the edits on the page until OK (one undo step) or Cancel, and values OK would refuse put the page back. Vertical Justification › Paragraph Spacing Limit (`object.textFrameOptions {paragraphSpacingLimit}`, IDML `VerticalThreshold`): with Justify, the extra space goes between paragraphs up to the limit, the rest between lines. +- Text Frame Options dialog, laid out as InDesign's (General, Column Rules, Baseline Options, Auto-Size; framed Columns, Inset Spacing and Vertical Justification groups with spinner fields; Fixed Number shows each column's width as the fields change). Preview shows the edits on the page until OK (one undo step) or Cancel. An invalid value (such as a gutter that isn't a measure) takes the preview off the page, so the frame shows as it was, and the dialog's status line gives the reason; OK refuses the value too. Preview stays checked and shows the edits again once the value is valid. Vertical Justification › Paragraph Spacing Limit (`object.textFrameOptions {paragraphSpacingLimit}`, IDML `VerticalThreshold`): with Justify, the extra space goes between paragraphs up to the limit, the rest between lines. - Place a page of an IDML or DesignCraft document (`file.place {layoutPage}`): its objects, stories, styles and swatches as one group (INDD files are not readable). - Articles panel (`article.new/add/remove/options/delete/list`): reading order and content of EPUB and HTML exports. - Object ▸ Clipping Path (`object.clippingPath {type: alpha|edges, threshold, tolerance}`): traces the placed image and makes the outline the frame. Clipping paths saved in IDML (Detect Edges, Alpha Channel, Photoshop Path, User-Modified Path; Invert) hide the image outside the path on screen and in PDF and survive IDML export; `object.clippingPathOptions` reports, inverts or removes one (Object ▸ Clipping Path ▸ None). From ff56cdde4ec14646a2fe81d850de2c6afde4080a Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:22:35 +0200 Subject: [PATCH 074/431] Clear Fitting Options: no edit, and no undo step, when nothing can change Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/object.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 2fa9f4117..09e7324b4 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -2896,14 +2896,19 @@ fn fitting_options(s: &mut Session, p: &Value) -> Result { /// Clear Frame Fitting Options: auto-fit off, no crop, the centre reference point. The content /// stays where it is, as in InDesign. fn clear_fitting_options(s: &mut Session, p: &Value) -> Result { - let ids = targets(s, p)?; + // Locked frames (or frames on a locked layer) keep their options, as with Fit; with nothing + // to change, no edit (and no empty undo step). + let doc = &s.doc()?.doc; + let ids: Vec = targets(s, p)? + .into_iter() + .filter(|id| doc.item(*id).is_some_and(|it| unlocked(doc, it) && matches!(it.content, Content::Graphic(_)))) + .collect(); + if ids.is_empty() { + return Ok(json!({"changed": 0})); + } s.edit(|d, _| { let mut n = 0; for id in &ids { - // Locked frames (or frames on a locked layer) keep their options, as with Fit. - if !d.item(*id).is_some_and(|it| unlocked(d, it)) { - continue; - } let Some(it) = d.item_mut(*id) else { continue }; let Content::Graphic(g) = &mut it.content else { continue }; g.auto_fit = designcraft_doc::Fitting::None; @@ -4318,9 +4323,11 @@ mod fitting_tests { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(graphic(&s).crop, [10.0; 4], "one undo step"); s.execute("object.lock", &json!({"ids": [f]})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); let r = s.execute("object.clearFittingOptions", &json!({"ids": [f]})).unwrap(); assert_eq!(r["changed"], 0); assert_eq!(graphic(&s).crop, [10.0; 4], "a locked frame keeps its options"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no empty undo step"); } } From 4c5c25c4e4147cd32e14971efa96a923514a1210 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:24:41 +0200 Subject: [PATCH 075/431] Swatches: a Paper swatch is editable whatever its name Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/swatches.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 32bc9e128..bbb4b21a9 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -254,7 +254,8 @@ enum RowMode { fn row_marks(sw: &designcraft_color::swatch::Swatch) -> RowMarks { use designcraft_color::Color; - let not_editable = sw.locked && sw.name != designcraft_color::swatch::PAPER; + // Paper's colour can be edited, whatever the swatch is called (as the options dialog allows). + let not_editable = sw.locked && !matches!(sw.value, SwatchValue::Paper { .. }); match &sw.value { SwatchValue::Color { color, color_type } => { let mode = match color { @@ -1312,5 +1313,9 @@ mod row_mark_tests { ..cyan.clone() }; assert_eq!(row_marks(&spot), RowMarks { not_editable: false, kind: Some(ColorType::Spot), mode: Some(RowMode::Rgb) }); + // A locked Paper swatch under another name (a document from elsewhere) is still editable. + let paper = all.iter().find(|s| s.name == designcraft_color::swatch::PAPER).unwrap(); + let renamed = designcraft_color::swatch::Swatch { name: "Papier".into(), ..paper.clone() }; + assert!(!row_marks(&renamed).not_editable); } } From 03754e381a074078cf713a59bce4c6815fe06c46 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:58:44 +0200 Subject: [PATCH 076/431] Paragraph styles: Based On and Next Style must name styles; the dialog resolves over the chosen Based On Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/style.rs | 23 ++++++++++++++--- crates/engine/src/tests.rs | 17 ++++++++++++ crates/ui-egui/src/dialogs.rs | 47 +++++++++++++++++++++++++++++----- 3 files changed, 77 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 903cd7d0f..699b01ea8 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -875,6 +875,13 @@ fn create_para(s: &mut Session, p: &Value) -> Result { { return Err(bad("style.paragraph.create", format!("no style `{b}`"))); } + // Next Style: a style of the document, or this one. + if let Some(n) = &next + && *n != name + && d.styles.para(n).is_none() + { + return Err(bad("style.paragraph.create", format!("no style `{n}`"))); + } d.styles_mut().paragraph.push(ParagraphStyle { name: name.clone(), based_on, next_style: next, para, chars, shortcut: String::new() }); Ok(json!({"name": name})) }) @@ -938,11 +945,19 @@ fn edit_para(s: &mut Session, p: &Value) -> Result { let based = p.get("basedOn").cloned(); let next = p.get("nextStyle").cloned(); let reset = p.get("resetToBase").and_then(Value::as_bool).unwrap_or(false); + for (key, v) in [("basedOn", &based), ("nextStyle", &next)] { + if v.as_ref().is_some_and(|v| !v.is_string() && !v.is_null()) { + return Err(bad("style.paragraph.edit", format!("`{key}` must be a style name or null"))); + } + } s.edit(|d, _| { - if let Some(Value::String(b)) = &based - && d.styles.para_based_on_cycles(&name, b) - { - return Err(bad("style.paragraph.edit", "based-on would create a cycle")); + if let Some(Value::String(b)) = &based { + if d.styles.para(b).is_none() { + return Err(bad("style.paragraph.edit", format!("no style `{b}`"))); + } + if d.styles.para_based_on_cycles(&name, b) { + return Err(bad("style.paragraph.edit", "based-on would create a cycle")); + } } if let Some(Value::String(n)) = &next && d.styles.para(n).is_none() diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4bc7e360c..33ae449e0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1665,3 +1665,20 @@ fn reset_to_base_with_a_new_parent_resolves_rule_edits_over_it() { let r = s.doc().unwrap().doc.styles.resolve_para_style("Child").0.rule_below; assert_eq!((r.weight, r.offset), (2.0, 9.0), "the offset is the new parent's"); } + +/// Paragraph style references are checked: a Based On or Next Style that names no style, or +/// that isn't a name or null, is refused and changes nothing. +#[test] +fn paragraph_style_references_must_name_styles() { + let mut s = session(); + s.execute("style.paragraph.create", &json!({"name": "Body"})).unwrap(); + assert!(s.execute("style.paragraph.create", &json!({"name": "Lost", "nextStyle": "Nowhere"})).is_err()); + assert!(s.doc().unwrap().doc.styles.para("Lost").is_none()); + s.execute("style.paragraph.create", &json!({"name": "Self", "nextStyle": "Self"})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + for p in [json!({"name": "Body", "basedOn": "Nowhere"}), json!({"name": "Body", "basedOn": 3}), json!({"name": "Body", "nextStyle": true})] { + assert!(s.execute("style.paragraph.edit", &p).is_err(), "{p}"); + } + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "nothing changed"); + s.execute("style.paragraph.edit", &json!({"name": "Body", "basedOn": null, "nextStyle": "Self"})).unwrap(); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d5341722e..491b82866 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3645,6 +3645,21 @@ fn grep_error(p: &str) -> Option { /// The Justification spacing check of Paragraph Style Options, against what the section showed: /// the style's own values, or a new style's Based On. +/// What paragraph style `style` resolves to when based on `based` (the Based On chosen in its +/// dialog, which may not be its own yet): `based`'s chain with the style's own settings over it. +fn style_over( + styles: &designcraft_doc::Styles, + based: &str, + style: &designcraft_doc::ParagraphStyle, +) -> (designcraft_doc::ParaProps, designcraft_doc::CharProps) { + styles.resolve_para(&designcraft_doc::ParaFormat { + style: based.into(), + para: style.para.clone(), + chars: style.chars.clone(), + ..Default::default() + }) +} + fn check_paragraph_style_justification(app: &DesignApp, d: &Dialog) -> Result<(), String> { // After Reset To Base the style starts from its Based On (the one chosen here, if any). let base_of = |st: &designcraft_engine::DocState| { @@ -3655,14 +3670,18 @@ fn check_paragraph_style_justification(app: &DesignApp, d: &Dialog) -> Result<() .session .active() .map(|st| { - let shown = if d.b("new") { - d.s("basedOn") + let pp = if d.b("new") { + st.doc.styles.resolve_para_style(&d.s("basedOn")).0 } else if d.b("resetToBase") { - base_of(st) + st.doc.styles.resolve_para_style(&base_of(st)).0 } else { - d.s("name") + // The style's own settings over the Based On chosen in the dialog. + match st.doc.styles.para(&d.s("name")) { + Some(style) => style_over(&st.doc.styles, &base_of(st), style).0, + None => st.doc.styles.resolve_para_style(&d.s("name")).0, + } }; - serde_json::to_value(st.doc.styles.resolve_para_style(&shown).0).unwrap_or_default() + serde_json::to_value(pp).unwrap_or_default() }) .unwrap_or_default(); check_justification(d, "p.", &base) @@ -3847,7 +3866,8 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo let names: Vec = doc.styles.paragraph.iter().map(|p| p.name.clone()).filter(|n| new || *n != name).collect(); // A new style shows what it would inherit from its Based On (as does one reset to it). let based_name = d.fields.get("basedOn").and_then(Value::as_str).map(str::to_string).or(style.based_on.clone()).unwrap_or_default(); - let (pp, cp) = doc.styles.resolve_para_style(if new || reset { &based_name } else { &name }); + // An existing style shows its own settings over the Based On chosen here. + let (pp, cp) = if new || reset { doc.styles.resolve_para_style(&based_name) } else { style_over(&doc.styles, &based_name, &style) }; let units = doc.settings.horizontal_units; let pv = serde_json::to_value(&pp).unwrap_or_default(); let cv = serde_json::to_value(&cp).unwrap_or_default(); @@ -7634,6 +7654,21 @@ mod tests { 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] From 098613b75d916157f1fc307cb4634d113bf18e80 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:05:00 +0200 Subject: [PATCH 077/431] Place cursor: a drag under the threshold is a click, with no frame drawn Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests.rs | 9 +++++++++ crates/tools/src/nav.rs | 14 ++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5d0795196..adaa0fe8e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -517,6 +517,15 @@ Y: 100.00%")), s.pointer(&PointerEvent::new(PointerKind::Up, 180.0, 401.0), v).unwrap(); let it = s.doc().unwrap().doc.spreads[0].items.last().unwrap().clone(); assert_eq!(it.bounds(), natural, "the frame previewed"); + // A drag under the threshold is a click: no frame drawn, the graphic at actual size where + // the press was. + s.execute("place.load", &json!({"base64": cmd::base64_encode(&png), "name": "a.png"})).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 500.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 102.5, 501.25), v).unwrap(); + assert!(!s.overlays(v).iter().any(|o| matches!(o, Overlay::Marquee(_))), "no frame yet"); + s.pointer(&PointerEvent::new(PointerKind::Up, 102.5, 501.25), v).unwrap(); + let it = s.doc().unwrap().doc.spreads[0].items.last().unwrap().clone(); + assert_eq!(it.bounds(), designcraft_geom::Rect::new(100.0, 500.0, 140.0, 520.0), "actual size at the press"); } #[test] diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index 4a582c385..cbefbd448 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -133,7 +133,8 @@ impl Tool for PlaceGun { let Some(a) = self.start.take() else { return vec![] }; let Some((sr, sa)) = cx.layout.spread_at(a) else { return vec![] }; let b = cx.layout.to_spread(sr, ev.pos); - if (ev.pos - a).hypot() < cx.tol(3.0) + let click = (ev.pos - a).hypot() < cx.tol(3.0); + if click && let Some((_, id)) = cx.hit(a) && cx .doc @@ -142,6 +143,10 @@ impl Tool for PlaceGun { { return vec![Action::Exec("place.drop".into(), json!({"frame": id.0}))]; } + // Under the drag threshold it's a click, as the overlay shows: actual size there. + if click { + return vec![Action::Exec("place.drop".into(), json!({"spread": crate::spread_json(sr), "x": sa.x, "y": sa.y}))]; + } // The frame the drag drew: at the graphic's proportions from where it started. let r = cx.loaded.map_or_else(|| designcraft_geom::Rect::from_points(sa, b), |size| proportional(sa, b, size).0); vec![Action::Exec( @@ -154,9 +159,14 @@ impl Tool for PlaceGun { } fn overlays(&self, cx: &ToolContext) -> Vec { let Some(a) = self.start else { return vec![] }; - let (Some(size), true) = (cx.loaded, (self.cur - a).hypot() >= cx.tol(3.0)) else { + let Some(size) = cx.loaded else { return vec![crate::Overlay::Marquee(designcraft_geom::Rect::from_points(a, self.cur))]; }; + // Under the drag threshold the release is a click (actual size where it was pressed): + // no frame is drawn yet, the loaded cursor shows what lands. + if (self.cur - a).hypot() < cx.tol(3.0) { + return vec![]; + } // As InDesign: the frame the graphic will fill, and its scale beside the pointer. let (r, k) = drawn_frame(a, self.cur, size); let pct = format!("{:.2}%", k * 100.0); From 1ffa37aaa53928ba825159943a28187e3151c878 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:11:47 +0200 Subject: [PATCH 078/431] Opacity slider: shown without a change, it commits the drag's preview at once Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index b781ed4b3..216f2ecc3 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -873,8 +873,9 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { /// The Opacity slider that the arrow beside the field opens, as in InDesign: a drag shows the /// new opacity live and is one undo step when let go. fn opacity_slider(app: &mut DesignApp, resp: &egui::Response, opacity: f64) { - let (mut slid, mut held) = (None, false); + let (mut slid, mut held, mut shown) = (None, false, false); egui::Popup::menu(resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + shown = true; // A thin rail and a round knob, as InDesign draws it. ui.spacing_mut().slider_width = 130.0; ui.spacing_mut().slider_rail_height = 2.0; @@ -902,6 +903,9 @@ fn opacity_slider(app: &mut DesignApp, resp: &egui::Response, opacity: f64) { let _ = app.run("object.opacity", json!({"opacity": v / 100.0})); } None if held => app.hold_live_preview(), + // Shown but let go without a change (a click outside, Escape): the drag's preview is + // committed now, before whatever that click does, so that keeps its own undo step. + None if shown => app.end_live_preview(), None => {} } } From a401aba8c46ce724e7cd29e14f28f2d4cc5011ea Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:28:11 +0200 Subject: [PATCH 079/431] Clear Fitting Options leaves frames whose options are already clear Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/object.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 09e7324b4..c59acc2ff 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -2896,12 +2896,14 @@ fn fitting_options(s: &mut Session, p: &Value) -> Result { /// Clear Frame Fitting Options: auto-fit off, no crop, the centre reference point. The content /// stays where it is, as in InDesign. fn clear_fitting_options(s: &mut Session, p: &Value) -> Result { - // Locked frames (or frames on a locked layer) keep their options, as with Fit; with nothing - // to change, no edit (and no empty undo step). + // Locked frames (or frames on a locked layer) keep their options, as with Fit; frames whose + // options are already clear are left out; with nothing to change, no edit (and no empty undo + // step). let doc = &s.doc()?.doc; + let set = |g: &designcraft_doc::Graphic| g.auto_fit != designcraft_doc::Fitting::None || g.crop != [0.0; 4] || g.fit_align != 4; let ids: Vec = targets(s, p)? .into_iter() - .filter(|id| doc.item(*id).is_some_and(|it| unlocked(doc, it) && matches!(it.content, Content::Graphic(_)))) + .filter(|id| doc.item(*id).is_some_and(|it| unlocked(doc, it) && matches!(&it.content, Content::Graphic(g) if set(g)))) .collect(); if ids.is_empty() { return Ok(json!({"changed": 0})); @@ -4320,6 +4322,9 @@ mod fitting_tests { assert_eq!(g.crop, [0.0; 4]); assert_eq!(g.fit_align, 4, "the centre reference point"); assert_eq!(g.xf, before.xf, "the picture stays where it is"); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("object.clearFittingOptions", &json!({"ids": [f]})).unwrap(); + assert_eq!((r["changed"].as_u64(), s.doc().unwrap().history.undo.len()), (Some(0), undo), "already clear: no change, no step"); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(graphic(&s).crop, [10.0; 4], "one undo step"); s.execute("object.lock", &json!({"ids": [f]})).unwrap(); From ff5b065fafdcb66dbe9eb85046be35a82bac0cb8 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:33:11 +0200 Subject: [PATCH 080/431] Place cursor: the thumbnail stays until a drag starts drawing the frame Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests.rs | 1 + crates/tools/src/nav.rs | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index adaa0fe8e..3eb129c83 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -523,6 +523,7 @@ Y: 100.00%")), s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 500.0), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Drag, 102.5, 501.25), v).unwrap(); assert!(!s.overlays(v).iter().any(|o| matches!(o, Overlay::Marquee(_))), "no frame yet"); + assert_eq!(s.cursor(p(102.5, 501.25), Mods::default(), v), Cursor::LoadedGraphic, "the thumbnail stays"); s.pointer(&PointerEvent::new(PointerKind::Up, 102.5, 501.25), v).unwrap(); let it = s.doc().unwrap().doc.spreads[0].items.last().unwrap().clone(); assert_eq!(it.bounds(), designcraft_geom::Rect::new(100.0, 500.0, 140.0, 520.0), "actual size at the press"); diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index cbefbd448..95b471e4e 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -188,9 +188,12 @@ Y: {pct}" _ => vec![], } } - fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { + fn cursor(&self, cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { // Drawing the frame: the crosshair (the loaded graphic's thumbnail would hide the frame). - if self.start.is_some() { Cursor::Crosshair } else { Cursor::LoadedGraphic } + // Under the drag threshold no frame is drawn yet (the release is a click), so the + // thumbnail stays, as the overlay does. + let drawing = self.start.is_some_and(|a| cx.loaded.is_none() || (self.cur - a).hypot() >= cx.tol(3.0)); + if drawing { Cursor::Crosshair } else { Cursor::LoadedGraphic } } } From d755ea2b09ca8c97a1c0721a31e2efecd0eba230 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:07:30 +0200 Subject: [PATCH 081/431] Properties: Bullets and Numbering's Options opens the Bullets and Numbering dialog Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/panels/properties.rs | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..1cecb8acc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · Properties › Bullets and Numbering › Options opens the Bullets and Numbering dialog for the selected paragraphs, as InDesign's, instead of the Paragraph panel. Found in the Learn Skills Daily course, chapter 21. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 44acf6e2c..a01aeebc2 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1374,8 +1374,9 @@ fn bullets_section(app: &mut DesignApp, ui: &mut Ui) { } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.add_space(8.0); + // As InDesign's: the Bullets and Numbering dialog for the selected paragraphs. if outline_button(ui, crate::i18n::tr(&app.ui.language, "Options"), 66.0).clicked() { - show_panel(app, "paragraph"); + let _ = app.run("app.bulletsNumberingDialog", json!({})); } }); }); @@ -3317,6 +3318,22 @@ mod tests { 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")); + } + #[test] fn several_shapes_show_pathfinder_and_intersect_combines_them() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From 9d46967f160a3556e72e9b7fc149059bf6299075 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:15:28 +0200 Subject: [PATCH 082/431] Character styles: a new style from the selected text, applied to it New Character Style with text selected starts from what the text has of its own over its paragraph style (its character style's attributes and its overrides), as InDesign's "[None] + Bold + color", and Apply Style to Selection (on) gives the text the new style with no override left. A new style's dialog is titled New Paragraph Style / New Character Style. type.selectionAttrs reports the text's own character attributes as ownChars. Found in the Learn Skills Daily course, chapter 22. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/text.rs | 7 ++- crates/ui-egui/src/dialogs.rs | 76 +++++++++++++++++++++++++++++++- crates/ui-egui/src/i18n.rs | 2 + crates/ui-egui/src/i18n/ar.rs | 2 + crates/ui-egui/src/i18n/cs.rs | 2 + crates/ui-egui/src/i18n/it.rs | 2 + crates/ui-egui/src/i18n/ka.rs | 2 + crates/ui-egui/src/i18n/pl.rs | 2 + crates/ui-egui/src/i18n/pt_br.rs | 2 + crates/ui-egui/src/i18n/ru.rs | 2 + crates/ui-egui/src/i18n/uk.rs | 2 + 12 files changed, 98 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9a8a08c3b..95c6f7054 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · New Character Style with text selected starts from what the text has of its own over its paragraph style, as InDesign's ("[None] + Bold + color"), and Apply Style to Selection gives the text the new style, with no override left. A new style's dialog is titled New Paragraph Style / New Character Style. `type.selectionAttrs` reports the text's own character attributes (`ownChars`). Found in the Learn Skills Daily course, chapter 22. No readiness percentage change. - **2026-10-21** · New Paragraph Style with text selected starts from it, as InDesign's (Based On [No Paragraph Style], every setting of the text that differs) and Apply Style to Selection gives the paragraph the new style; General has Next Style and Reset To Base; Basic Character Formats is in two columns (Size | Leading, Kerning | Tracking, Case | Position). `style.paragraph.edit` honours `nextStyle` (it was ignored) and takes `resetToBase`. Found in the Learn Skills Daily course, chapter 20. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 12bd540b7..c45a8b467 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -374,7 +374,7 @@ pub fn specs() -> Vec { "Selection Attributes", [], None, - "{mixed?: bool} → resolved character/paragraph attributes at the text selection; with mixed, paraMixed lists the paragraph attributes that differ across the selected paragraphs; the selection's start as `selectedText` (one line, up to 60 characters)", + "{mixed?: bool} → resolved character/paragraph attributes at the text selection; `ownChars`: what the text has of its own over its paragraph (its character style's attributes and its overrides, what a character style made from the selection sets); with mixed, paraMixed lists the paragraph attributes that differ across the selected paragraphs; the selection's start as `selectedText` (one line, up to 60 characters)", has_doc, selection_attrs ), @@ -1813,9 +1813,12 @@ fn selection_attrs(s: &mut Session, p: &Value) -> Result { let char_delta = character_base.delta(&cp); let style_keys = character_style.keys(); let character_style_overrides = char_delta.keys().iter().filter(|key| style_keys.contains(key)).count(); + // The text's own formatting over its paragraph, as Create New Style from Selection captures it. + let mut own = character_style.clone(); + own.merge(&cf.over); // The selected text (its start, on one line), for previews such as the font menu's. let selected = selected_text_preview(story.text.get(r.start..r.end).unwrap_or_default().chars()); - let mut out = json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp, + let mut out = json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp, "ownChars": own, "paraOverrides": style_para.delta(&pp).count() + style_chars.delta(&base).count(), "charOverrides": char_delta.count(), "characterStyleOverrides": character_style_overrides, "selectedText": selected.trim()}); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 491b82866..fbd24101c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2046,8 +2046,10 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "documentSetup" => crate::i18n::tr(&app.ui.language, "Document Setup"), "numberingSection" => crate::i18n::tr(&app.ui.language, "Numbering & Section Options…").trim_end_matches('…'), "findChange" => crate::i18n::tr(&app.ui.language, "Find/Change"), + "paragraphStyleOptions" if d.b("new") => crate::i18n::tr(&app.ui.language, "New Paragraph Style"), "paragraphStyleOptions" => crate::i18n::tr(&app.ui.language, "Paragraph Style Options"), "deleteParagraphStyle" => crate::i18n::tr(&app.ui.language, "Delete Paragraph Style"), + "characterStyleOptions" if d.b("new") => crate::i18n::tr(&app.ui.language, "New Character Style"), "characterStyleOptions" => crate::i18n::tr(&app.ui.language, "Character Style Options"), "deleteCharacterStyle" => crate::i18n::tr(&app.ui.language, "Delete Character Style"), "footnoteOptions" => crate::i18n::tr(&app.ui.language, "Footnote Options"), @@ -3202,7 +3204,13 @@ pub fn confirm(app: &mut DesignApp) -> Result { if let Some(b) = based.filter(|b| b != designcraft_doc::NO_CHAR_STYLE) { params["basedOn"] = json!(b); } - app.run("style.character.create", params) + // Apply Style to Selection. + app.run("style.character.create", params).and_then(|r| { + if d.b("apply") && app.session.active().is_some_and(|st| st.selection.text.is_some()) { + app.run("style.character.apply", json!({"name": r["name"]}))?; + } + Ok(r) + }) } else { let mut params = json!({"name": name, "chars": chars}); if let Some(b) = based { @@ -5275,6 +5283,17 @@ pub fn open_new_style(app: &mut DesignApp, para: bool) { } fields["apply"] = json!(true); } + // New Character Style with text selected starts from what the text has of its own over its + // paragraph ("[None] + Bold + color"), applied to the selection on OK, as InDesign's. + if !para + && has_text + && let Some(sel) = app.session.execute("type.selectionAttrs", &json!({})).ok().filter(|v| !v.is_null()) + { + for (k, v) in sel["ownChars"].as_object().into_iter().flatten().filter(|(_, v)| !v.is_null()) { + fields[format!("c.{k}")] = v.clone(); + } + fields["apply"] = json!(true); + } app.ui.dialog = Some(Dialog::new(id, fields)); } @@ -5379,6 +5398,10 @@ fn character_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo let mut parts = vec![crate::i18n::style_name(&lang, &based).to_string()]; parts.extend(own.as_object().into_iter().flatten().map(|(k, v)| format!("{}: {}", setting_label(&lang, k), setting_text(v)))); ui.add(egui::Label::new(egui::RichText::new(parts.join(" + ")).color(crate::theme::Tokens::get(ui.ctx()).text_dim)).wrap()); + if new { + ui.add_space(8.0); + check(ui, d, "apply", crate::i18n::tr(&lang, "Apply Style to Selection")); + } } }); }); @@ -7654,6 +7677,53 @@ mod tests { 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] @@ -9844,7 +9914,9 @@ mod tests { 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(); - assert_eq!((st.based_on, st.chars), (None, CharAttrs { size: Some(9.0), ..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"); } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index caaede51c..ff637d7fb 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -39,6 +39,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Next Style:", ["Nächstes Format:", "Style suivant :", "Estilo siguiente:", "次のスタイル :", "下一样式:"]), ("[Same style]", ["[Gleiches Format]", "[Même style]", "[Mismo estilo]", "[同じスタイル]", "[相同样式]"]), ("Reset To Base", ["Auf Basis zurücksetzen", "Rétablir la base", "Restablecer a base", "基準に戻す", "重置为基准"]), + ("New Paragraph Style", ["Neues Absatzformat", "Nouveau style de paragraphe", "Nuevo estilo de párrafo", "新規段落スタイル", "新建段落样式"]), + ("New Character Style", ["Neues Zeichenformat", "Nouveau style de caractère", "Nuevo estilo de carácter", "新規文字スタイル", "新建字符样式"]), ( "Apply Style to Selection", [ diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index bc300eac8..6f3d54e71 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("New Paragraph Style", "نمط فقرة جديد"), + ("New Character Style", "نمط حرف جديد"), ("Next Style:", "النمط التالي:"), ("[Same style]", "[النمط نفسه]"), ("Reset To Base", "إعادة التعيين إلى الأساس"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index aa4215745..412f72019 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("New Paragraph Style", "Nový styl odstavce"), + ("New Character Style", "Nový styl znaku"), ("Next Style:", "Další styl:"), ("[Same style]", "[Stejný styl]"), ("Reset To Base", "Obnovit podle základu"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index ab153084e..a872b7cf5 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("New Paragraph Style", "Nuovo stile di paragrafo"), + ("New Character Style", "Nuovo stile di carattere"), ("Next Style:", "Stile successivo:"), ("[Same style]", "[Stesso stile]"), ("Reset To Base", "Ripristina base"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 128a872f6..f96dad86b 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("New Paragraph Style", "აბზაცის ახალი სტილი"), + ("New Character Style", "სიმბოლოს ახალი სტილი"), ("Next Style:", "შემდეგი სტილი:"), ("[Same style]", "[იგივე სტილი]"), ("Reset To Base", "საბაზოზე დაბრუნება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 100168f3a..d116bde3b 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("New Paragraph Style", "Nowy styl akapitowy"), + ("New Character Style", "Nowy styl znakowy"), ("Next Style:", "Następny styl:"), ("[Same style]", "[Ten sam styl]"), ("Reset To Base", "Przywróć bazowy"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 3f2b2afe5..e9dd4d6de 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("New Paragraph Style", "Novo estilo de parágrafo"), + ("New Character Style", "Novo estilo de caractere"), ("Next Style:", "Próximo estilo:"), ("[Same style]", "[Mesmo estilo]"), ("Reset To Base", "Redefinir para base"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index e567ec5a3..9b844616e 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("New Paragraph Style", "Новый стиль абзаца"), + ("New Character Style", "Новый стиль символов"), ("Next Style:", "Следующий стиль:"), ("[Same style]", "[Тот же стиль]"), ("Reset To Base", "Сбросить до основы"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 33ebb766d..fb547efe3 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("New Paragraph Style", "Новий стиль абзацу"), + ("New Character Style", "Новий стиль символів"), ("Next Style:", "Наступний стиль:"), ("[Same style]", "[Той самий стиль]"), ("Reset To Base", "Скинути до основи"), From 95006e9e9e2fd18c0dff17d85a167cf7ad7c9d5b Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:44:12 +0200 Subject: [PATCH 083/431] Find/Change: Change Format, with a character or paragraph style find.change takes a Change Format: charStyle and paraStyle as well as the character attributes (attrs) it took. With a Change Format and an empty change, the found text stays and is only formatted, as InDesign does; without one an empty change still deletes. The style-apply code of style.paragraph.apply / style.character.apply moves into helpers that find.change shares. The Find/Change dialog has InDesign's Change Format box, with Specify Attributes (opening Change Format Settings over Find/Change: Style Options, then the character sections of Character Style Options) and Clear. An info icon beside Change to says an empty Change to keeps the text. Found in the Learn Skills Daily course, chapter 22. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/find.rs | 80 +++++++- crates/engine/src/cmd/style.rs | 99 +++++----- crates/ui-egui/src/dialogs.rs | 324 ++++++++++++++++++++++++++++++- crates/ui-egui/src/i18n.rs | 37 ++++ crates/ui-egui/src/i18n/ar.rs | 10 + crates/ui-egui/src/i18n/cs.rs | 10 + crates/ui-egui/src/i18n/it.rs | 10 + crates/ui-egui/src/i18n/ka.rs | 10 + crates/ui-egui/src/i18n/pl.rs | 10 + crates/ui-egui/src/i18n/pt_br.rs | 10 + crates/ui-egui/src/i18n/ru.rs | 10 + crates/ui-egui/src/i18n/uk.rs | 10 + crates/ui-egui/src/icons.rs | 11 ++ 14 files changed, 578 insertions(+), 54 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..fa44d189e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · Find/Change has InDesign's Change Format. Specify Attributes opens Change Format Settings: Style Options (the character and paragraph style to apply, [Any Style] for none), then Basic and Advanced Character Formats, Character Color, OpenType Features, Underline and Strikethrough Options. The box shows the format ("Character Style: eggs"), and Change All gives it to the changed text. With a Change Format and nothing in Change to, the found text stays and is only formatted, as InDesign's (an ⓘ beside Change to says so). `find.change` takes `charStyle` and `paraStyle`. Found in the Learn Skills Daily course, chapter 22. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index 3ba6970a1..d6a43855d 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -61,18 +61,18 @@ pub fn specs() -> Vec { } ), cmd!(query "find.find", "Find", ["Edit", "Find/Change"], None, - "{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, change?|first?|attrs?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text}]", + "{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, change?|first?|attrs?|charStyle?|paraStyle?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text}]", has_doc, find), cmd!( "find.change", "Change All", ["Edit", "Find/Change"], None, - "{find, change, grep?, caseSensitive?, wholeWord?, scope?, story?, first?: bool (change only the first match), attrs?: {character attributes applied to changed text}} → {count}", + "{find, change, grep?, caseSensitive?, wholeWord?, scope?, story?, first?: bool (change only the first match), attrs?: {character attributes applied to changed text}, charStyle?: a character style applied to it, paraStyle?: a paragraph style applied to its paragraphs} → {count} — attrs, charStyle and paraStyle are the Change Format: with one and an empty change, the found text stays and is only formatted", has_doc, change ), - cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, caseSensitive?, wholeWord?, scope?: document|story|selection, story?, change?|first?|attrs?: ignored (find.change takes them; one request serves all three Find/Change commands)} — Selection starts at its original beginning; later calls advance within the scope", has_doc, find_next), + cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, caseSensitive?, wholeWord?, scope?: document|story|selection, story?, change?|first?|attrs?|charStyle?|paraStyle?: ignored (find.change takes them; one request serves all three Find/Change commands)} — Selection starts at its original beginning; later calls advance within the scope", has_doc, find_next), ] } @@ -314,8 +314,20 @@ fn change(s: &mut Session, p: &Value) -> Result { } } super::text::validate_char_fill(&s.doc()?.doc, &over, "find.change")?; + let char_style = str_param(p, "charStyle").map(str::to_string); + let para_style = str_param(p, "paraStyle").map(str::to_string); + let styles = &s.doc()?.doc.styles; + if let Some(n) = char_style.as_ref().filter(|n| styles.char_style(n).is_none()) { + return Err(bad("find.change", format!("no character style `{n}`"))); + } + if let Some(n) = para_style.as_ref().filter(|n| styles.para(n).is_none()) { + return Err(bad("find.change", format!("no paragraph style `{n}`"))); + } + // InDesign: with a Change Format and nothing in Change to, the found text is only formatted. + let format_only = str_param(p, "change").unwrap_or("").is_empty() && (char_style.is_some() || para_style.is_some() || !over.is_empty()); let targets = scope(s, p)?.targets; s.edit(|d, sel| { + let styles = d.styles.clone(); let mut count = 0usize; for (sid, r) in targets { let Some(story) = d.story(sid) else { continue }; @@ -332,9 +344,22 @@ fn change(s: &mut Session, p: &Value) -> Result { .collect(); let Some(st) = d.story_mut(sid) else { continue }; for (range, new) in edits.iter().rev() { - st.replace(range.clone(), new); - if !over.is_empty() && !new.is_empty() { - st.format_chars(range.start..range.start + new.len(), |f| f.over.merge(&over)); + let changed = if format_only { + range.clone() + } else { + st.replace(range.clone(), new); + range.start..range.start + new.len() + }; + if !changed.is_empty() { + if let Some(n) = ¶_style { + super::style::apply_para_style(st, &styles, n, &changed, false); + } + if let Some(n) = &char_style { + super::style::apply_char_style(st, &styles, n, &changed); + } + if !over.is_empty() { + st.format_chars(changed, |f| f.over.merge(&over)); + } } count += 1; } @@ -433,6 +458,49 @@ mod tests { assert!(s.doc().unwrap().selection.text.is_some()); } + #[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 change_first_changes_one_match_in_the_whole_document() { for grep in [false, true] { diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index d1bd06ad0..9ae967613 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -763,45 +763,50 @@ fn apply_para(s: &mut Session, p: &Value) -> Result { let targets = super::text::format_targets_pub(s); s.edit(|d, _| { let styles = d.styles.clone(); - let (style_para, style_chars) = styles.resolve_para_style(&name); for t in &targets { - let r = &t.range; if let Some(st) = d.text_story_mut(t.story, t.cell) { - // Text selections are half-open: a selection ending at the next paragraph's - // start must not apply a style to that paragraph too. - let end = if r.is_empty() { r.start } else { r.end.saturating_sub(1) }; - let (first, last) = (st.para_at(r.start), st.para_at(end)); - st.format_paras(r.start..end, |f| { - f.style = name.clone(); - if clear { - f.para = ParaAttrs::default(); - f.chars = CharAttrs::default(); - } else { - f.para.retain_differences(&style_para); - f.chars.retain_differences(&style_chars); - } - }); - for (pi, range) in st.para_ranges().into_iter().enumerate().take(last.saturating_add(1)).skip(first) { - 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()); - let range = if clear && !r.is_empty() { range.start.max(r.start)..end.min(r.end) } else { range.start..end }; - st.format_chars(range, |f| { - if clear { - f.over = CharAttrs::default(); - } else { - let mut inherited = base.clone(); - inherited.apply(&styles.char_style_attrs(&f.style)); - f.over.retain_differences(&inherited); - } - }); - } + apply_para_style(st, &styles, &name, &t.range, clear); } } ok() }) } +/// Paragraph style `name` applied to the paragraphs of `r` in `st`: the paragraphs' and their +/// text's overrides that the style now gives go (all of them with `clear`). +pub(super) fn apply_para_style(st: &mut designcraft_doc::Story, styles: &Styles, name: &str, r: &std::ops::Range, clear: bool) { + let (style_para, style_chars) = styles.resolve_para_style(name); + // Text selections are half-open: a selection ending at the next paragraph's + // start must not apply a style to that paragraph too. + let end = if r.is_empty() { r.start } else { r.end.saturating_sub(1) }; + let (first, last) = (st.para_at(r.start), st.para_at(end)); + st.format_paras(r.start..end, |f| { + f.style = name.to_string(); + if clear { + f.para = ParaAttrs::default(); + f.chars = CharAttrs::default(); + } else { + f.para.retain_differences(&style_para); + f.chars.retain_differences(&style_chars); + } + }); + for (pi, range) in st.para_ranges().into_iter().enumerate().take(last.saturating_add(1)).skip(first) { + 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()); + let range = if clear && !r.is_empty() { range.start.max(r.start)..end.min(r.end) } else { range.start..end }; + st.format_chars(range, |f| { + if clear { + f.over = CharAttrs::default(); + } else { + let mut inherited = base.clone(); + inherited.apply(&styles.char_style_attrs(&f.style)); + f.over.retain_differences(&inherited); + } + }); + } +} + fn apply_char(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("style.character.apply", "missing name"))?.to_string(); if s.doc()?.doc.styles.char_style(&name).is_none() { @@ -812,25 +817,31 @@ fn apply_char(s: &mut Session, p: &Value) -> Result { let styles = d.styles.clone(); for t in &targets { if let Some(st) = d.text_story_mut(t.story, t.cell) { - for (pi, range) in st.para_ranges().into_iter().enumerate() { - let range = range.start.max(t.range.start)..range.end.saturating_add(1).min(st.len()).min(t.range.end); - if range.is_empty() { - continue; - } - let Some(pf) = st.paras.get(pi) else { continue }; - let (_, mut inherited) = styles.resolve_para(pf); - inherited.apply(&styles.char_style_attrs(&name)); - st.format_chars(range, |f| { - f.style = name.clone(); - f.over.retain_differences(&inherited); - }); - } + apply_char_style(st, &styles, &name, &t.range); } } ok() }) } +/// Character style `name` applied to `r` in `st`: the text's overrides that the style (over its +/// paragraph) now gives go. +pub(super) fn apply_char_style(st: &mut designcraft_doc::Story, styles: &Styles, name: &str, r: &std::ops::Range) { + for (pi, range) in st.para_ranges().into_iter().enumerate() { + let range = range.start.max(r.start)..range.end.saturating_add(1).min(st.len()).min(r.end); + if range.is_empty() { + continue; + } + let Some(pf) = st.paras.get(pi) else { continue }; + let (_, mut inherited) = styles.resolve_para(pf); + inherited.apply(&styles.char_style_attrs(name)); + st.format_chars(range, |f| { + f.style = name.to_string(); + f.over.retain_differences(&inherited); + }); + } +} + /// In a style, `startAt: null` is Continue from Previous Number: it is stored as an explicit /// "continue" so it also overrides a Start At the style would inherit. fn continue_numbering(p: Option<&Value>, para: &mut ParaAttrs) { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8b4043787..6359a6c84 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2026,9 +2026,10 @@ fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let Some(mut d) = app.ui.dialog.clone() else { return }; - // Enter and Escape belong to an open popup (a menu, a name field) rather than the dialog. - // Read before the dialog draws: a popup closed by this frame's Escape is still open here. - let popup_open = egui::Popup::is_any_open(ctx); + // Enter and Escape belong to an open popup (a menu, a name field) or to Find/Change's Change + // Format Settings rather than the dialog. Read before the dialog draws: a popup closed by this + // frame's Escape is still open here. + let popup_open = egui::Popup::is_any_open(ctx) || d.fields.get(FORMAT_EDIT).is_some_and(Value::is_object); let mut result: Option = None; let alert_title = d.s("title"); let title = match d.id.as_str() { @@ -2280,11 +2281,24 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }); ui.label(d.s("status")); } else { + 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); ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Change to:")); + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Change to:")); + // InDesign's ⓘ: with a Change Format, an empty Change to keeps the text. + if formatted { + info_icon( + ui, + crate::i18n::tr( + &app.ui.language, + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + ), + ); + } + }); text_field(ui, &mut d, "change", 260.0); ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Search:")); @@ -2306,6 +2320,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { check(ui, &mut d, "caseSensitive", crate::i18n::tr(&app.ui.language, "Case sensitive")); check(ui, &mut d, "wholeWord", crate::i18n::tr(&app.ui.language, "Whole word")); }); + change_format_box(&app.ui.language, ui, &mut d); ui.horizontal(|ui| { let params0 = find_change_params(&d); let params = || params0.clone(); @@ -2871,6 +2886,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { if d.id == "newDocument" && result.is_none() { crate::new_document::preview(app, &mut d, ctx); } + if d.id == "findChange" && result.is_none() && d.fields.get(FORMAT_EDIT).is_some_and(Value::is_object) { + change_format_settings(app, ctx, &mut d); + } app.ui.dialog = Some(d); match result { Some(true) => { @@ -3026,9 +3044,217 @@ fn find_change_params(d: &Dialog) -> Value { if let Some(story) = d.fields.get("story") { p["story"] = story.clone(); } + // Change Format: find.change applies it to the changed text (the other commands ignore it). + if let Some(f) = d.fields.get(CHANGE_FORMAT) { + for key in ["charStyle", "paraStyle"] { + if let Some(v) = f.get(key).filter(|v| v.is_string()) { + p[key] = v.clone(); + } + } + if let Some(chars) = f.get("chars").filter(|c| c.as_object().is_some_and(|o| !o.is_empty())) { + p["attrs"] = chars.clone(); + } + } p } +/// Find/Change's Change Format ({charStyle?, paraStyle?, chars?}), as Change Format Settings set +/// it; absent when there is none. +const CHANGE_FORMAT: &str = "changeFormat"; +/// The fields of Change Format Settings while it is open (its section, `charStyle`, `paraStyle` +/// and `c.`), until OK makes them the Change Format. +const FORMAT_EDIT: &str = "formatEdit"; + +/// The sections of Change Format Settings: Style Options, then those of Character Style Options. +const FORMAT_SECTIONS: &[(&str, &str)] = &[ + ("style", "Style Options"), + ("chars", "Basic Character Formats"), + ("advanced", "Advanced Character Formats"), + ("color", "Character Color"), + ("openType", "OpenType Features"), + ("underline", "Underline Options"), + ("strikethrough", "Strikethrough Options"), +]; + +/// A small ⓘ whose tooltip is `tip`. +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.on_hover_text(tip); +} + +/// What the Change Format box shows: "Character Style: eggs + …". +fn change_format_summary(lang: &str, format: Option<&Value>) -> String { + let Some(f) = format else { return String::new() }; + let mut parts = Vec::new(); + for (key, label) in [("charStyle", "Character Style:"), ("paraStyle", "Paragraph Style:")] { + if let Some(name) = f.get(key).and_then(Value::as_str) { + parts.push(format!("{} {}", crate::i18n::tr(lang, label), crate::i18n::style_name(lang, name))); + } + } + parts.extend( + f.get("chars").and_then(Value::as_object).into_iter().flatten().map(|(k, v)| format!("{}: {}", setting_label(lang, k), setting_text(v))), + ); + parts.join(" + ") +} + +/// Find/Change's Change Format, as InDesign's: a box with what Change All also gives the changed +/// text, a button that opens Change Format Settings and one that clears it. +fn change_format_box(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) { + ui.add_space(6.0); + crate::rtl::label(ui, crate::i18n::tr(lang, "Change Format:")); + ui.horizontal_top(|ui| { + let t = crate::theme::Tokens::get(ui.ctx()); + egui::Frame::new().fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(6.0).show(ui, |ui| { + ui.set_width(340.0); + ui.set_min_height(40.0); + let summary = change_format_summary(lang, d.fields.get(CHANGE_FORMAT)); + ui.add(egui::Label::new(egui::RichText::new(summary).color(t.text)).wrap()); + }); + ui.vertical(|ui| { + if crate::icons::button(ui, "find-format", 22.0, false, crate::i18n::tr(lang, "Specify attributes to change")).clicked() { + let f = d.fields.get(CHANGE_FORMAT).cloned().unwrap_or_default(); + let mut edit = Map::new(); + edit.insert("section".into(), json!("style")); + for key in ["charStyle", "paraStyle"] { + if let Some(v) = f.get(key) { + edit.insert(key.into(), v.clone()); + } + } + for (k, v) in f.get("chars").and_then(Value::as_object).into_iter().flatten() { + edit.insert(format!("c.{k}"), v.clone()); + } + d.fields.insert(FORMAT_EDIT.into(), Value::Object(edit)); + } + if crate::icons::button(ui, "trash", 22.0, false, crate::i18n::tr(lang, "Clear specified attributes")).clicked() { + d.fields.remove(CHANGE_FORMAT); + } + }); + }); +} + +/// Find/Change › Change Format Settings, over Find/Change as InDesign's: Style Options (the +/// character and paragraph style to apply, [Any Style] for none), then the character sections of +/// Character Style Options with every field blank (left as it is). OK makes it the Change Format. +fn change_format_settings(app: &mut DesignApp, ctx: &egui::Context, d: &mut Dialog) { + let lang = app.ui.language.clone(); + let Some(doc) = app.session.active().map(|st| st.doc.clone()) else { + d.fields.remove(FORMAT_EDIT); + return; + }; + let mut fd = + Dialog { id: "changeFormat".into(), fields: d.fields.get(FORMAT_EDIT).and_then(Value::as_object).cloned().unwrap_or_default(), unit: d.unit }; + let base = json!({}); + 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"); + 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))); + let margin = egui::Frame::popup(ui.style()).inner_margin; + let title_row = egui::Rect::from_min_max( + egui::pos2(ui.max_rect().left() - margin.leftf(), title_top - margin.topf()), + egui::pos2(ui.max_rect().right() + margin.rightf(), ui.min_rect().bottom()), + ); + crate::modal_drag::title_drag(ui, host, title_row); + ui.add_space(10.0); + ui.horizontal_top(|ui| { + // A fixed height: the separator would otherwise stretch the dialog to the window. + ui.set_min_height(380.0); + ui.set_max_height(380.0); + section_list(&lang, ui, &mut fd, false, FORMAT_SECTIONS); + ui.separator(); + ui.vertical(|ui| { + ui.set_min_width(440.0); + match fd.s("section").as_str() { + "chars" => basic_character_formats(app, ui, &mut fd, &cf), + "advanced" => advanced_character_formats(&lang, ui, &mut fd, &cf), + "color" => character_color(&lang, ui, &mut fd, &cf, &doc), + "openType" => open_type_features(&lang, ui, &mut fd, &cf), + "underline" => line_options(&lang, ui, &mut fd, &cf, &doc, "underline"), + "strikethrough" => line_options(&lang, ui, &mut fd, &cf, &doc, "strikethrough"), + _ => format_style_options(&lang, ui, &mut fd, &doc.styles), + } + }); + }); + ui.add_space(12.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add( + egui::Button::new(crate::rtl::widget(ui, egui::RichText::new(crate::i18n::tr(&lang, " OK ")).color(egui::Color32::WHITE))) + .fill(crate::theme::Tokens::get(ui.ctx()).accent_strong), + ) + .clicked() + || (!popup_open && ui.input(|i| i.key_pressed(egui::Key::Enter))) + { + result = Some(true); + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&lang, "Cancel"))).clicked() + || (!popup_open && ui.input(|i| i.key_pressed(egui::Key::Escape))) + { + result = Some(false); + } + }); + }); + match result { + Some(true) => { + d.fields.remove(FORMAT_EDIT); + let mut f = Map::new(); + for key in ["charStyle", "paraStyle"] { + if let Some(v) = fd.fields.get(key).filter(|v| v.is_string()) { + f.insert(key.into(), v.clone()); + } + } + let chars: Map = + fd.fields.iter().filter(|(_, v)| !v.is_null()).filter_map(|(k, v)| Some((k.strip_prefix("c.")?.to_string(), v.clone()))).collect(); + if !chars.is_empty() { + f.insert("chars".into(), Value::Object(chars)); + } + if f.is_empty() { + d.fields.remove(CHANGE_FORMAT); + } else { + d.fields.insert(CHANGE_FORMAT.into(), Value::Object(f)); + } + } + Some(false) => { + d.fields.remove(FORMAT_EDIT); + } + None => { + d.fields.insert(FORMAT_EDIT.into(), Value::Object(fd.fields)); + } + } +} + +/// Change Format Settings › Style Options: the character and paragraph style the changed text +/// gets ([Any Style]: as it is). +fn format_style_options(lang: &str, ui: &mut egui::Ui, fd: &mut Dialog, styles: &designcraft_doc::Styles) { + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "Style Options")).font(semibold(13.0))); + ui.add_space(6.0); + let any = crate::i18n::tr(lang, "[Any Style]"); + let chars: Vec<&str> = styles.character.iter().map(|s| s.name.as_str()).collect(); + let paras: Vec<&str> = styles.paragraph.iter().map(|s| s.name.as_str()).collect(); + egui::Grid::new("format_style_options").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + for (key, label, names) in [("charStyle", "Character Style:", &chars), ("paraStyle", "Paragraph Style:", ¶s)] { + 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| { + if ui.selectable_label(cur.is_none(), crate::rtl::widget(ui, any)).clicked() { + fd.fields.remove(key); + } + for n in names.iter() { + if ui.selectable_label(cur.as_deref() == Some(*n), crate::rtl::widget(ui, crate::i18n::style_name(lang, n))).clicked() { + fd.fields.insert(key.into(), json!(n)); + } + } + }); + ui.end_row(); + } + }); +} + /// Apply the open dialog. pub fn confirm(app: &mut DesignApp) -> Result { let Some(d) = app.ui.dialog.take() else { return Err("no dialog open".into()) }; @@ -8259,6 +8485,96 @@ mod tests { } } + /// 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 find_scope_dialog_control_effective_noops_retain_bounds() { for (field, value) in [("objectMode", json!(false)), ("story", Value::Null)] { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index efe089ef9..0e8b47103 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -820,6 +820,43 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Center Content", ["Center Content", "Centrer le contenu", "Center Content", "Center Content", "内容居中"]), ("Change All", ["Change All", "Tout remplacer", "Change All", "Change All", "全部更改"]), ("Change to:", ["Change to:", "Remplacer par :", "Change to:", "Change to:", "更改为:"]), + ("Change Format:", ["Format ersetzen:", "Remplacer le format :", "Cambiar formato:", "置換形式 :", "更改格式:"]), + ( + "Change Format Settings", + [ + "Einstellungen für Format ersetzen", + "Paramètres de remplacement du format", + "Ajustes de cambio de formato", + "置換形式の設定", + "更改格式设置", + ], + ), + ("Style Options", ["Formatoptionen", "Options de style", "Opciones de estilo", "スタイルオプション", "样式选项"]), + ("[Any Style]", ["[Beliebiges Format]", "[Tout style]", "[Cualquier estilo]", "[任意のスタイル]", "[任意样式]"]), + ( + "Specify attributes to change", + [ + "Zu ersetzende Attribute festlegen", + "Indiquer les attributs à remplacer", + "Especificar atributos que cambiar", + "置換する属性を指定", + "指定要更改的属性", + ], + ), + ( + "Clear specified attributes", + ["Festgelegte Attribute löschen", "Effacer les attributs indiqués", "Borrar atributos especificados", "指定した属性を消去", "清除指定的属性"], + ), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + [ + "Mit einem Ersetzungsformat und leerem Feld „Ersetzen durch“ bleibt der gefundene Text erhalten und wird nur formatiert", + "Avec un format de remplacement et « Remplacer par » vide, le texte trouvé reste et seule sa mise en forme change", + "Con un formato de cambio y «Cambiar a» vacío, el texto encontrado se conserva y solo cambia su formato", + "置換形式があり置換文字列が空のときは、検索したテキストはそのままで書式だけが変わります", + "设置了更改格式且“更改为”为空时,找到的文本保持不变,只更改其格式", + ], + ), ("Character Style:", ["Character Style:", "Style de caractère :", "Character Style:", "Character Style:", "字符样式:"]), ("Checked by default", ["Checked by default", "Cochée par défaut", "Checked by default", "Checked by default", "默认选中"]), ("Choices", ["Choices", "Choix", "Choices", "Choices", "选项"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..33eff157d 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,16 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Change Format:", "تغيير التنسيق:"), + ("Change Format Settings", "إعدادات تغيير التنسيق"), + ("Style Options", "خيارات النمط"), + ("[Any Style]", "[أي نمط]"), + ("Specify attributes to change", "تحديد السمات المراد تغييرها"), + ("Clear specified attributes", "مسح السمات المحددة"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "مع تنسيق للتغيير وحقل «تغيير إلى» فارغ، يبقى النص الذي تم العثور عليه ويتغير تنسيقه فقط", + ), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), ("Fixed Number", "عدد ثابت"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..633978380 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,16 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Change Format:", "Změnit formát:"), + ("Change Format Settings", "Nastavení změny formátu"), + ("Style Options", "Volby stylu"), + ("[Any Style]", "[Libovolný styl]"), + ("Specify attributes to change", "Určit atributy ke změně"), + ("Clear specified attributes", "Vymazat určené atributy"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "Se změnou formátu a prázdným polem „Změnit na“ nalezený text zůstane a změní se jen jeho formát", + ), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..101167ea2 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,16 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Change Format:", "Modifica formato:"), + ("Change Format Settings", "Impostazioni di modifica formato"), + ("Style Options", "Opzioni stile"), + ("[Any Style]", "[Qualsiasi stile]"), + ("Specify attributes to change", "Specifica gli attributi da modificare"), + ("Clear specified attributes", "Cancella gli attributi specificati"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "Con un formato di modifica e «Sostituisci con» vuoto, il testo trovato resta e cambia solo la formattazione", + ), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index d938c2dc7..357350579 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,16 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Change Format:", "ფორმატის შეცვლა:"), + ("Change Format Settings", "ფორმატის შეცვლის პარამეტრები"), + ("Style Options", "სტილის პარამეტრები"), + ("[Any Style]", "[ნებისმიერი სტილი]"), + ("Specify attributes to change", "შესაცვლელი ატრიბუტების მითითება"), + ("Clear specified attributes", "მითითებული ატრიბუტების გასუფთავება"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "ფორმატის შეცვლით და ცარიელი „ჩანაცვლება“ ველით ნაპოვნი ტექსტი რჩება და მხოლოდ ფორმატი იცვლება", + ), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..e13ee7de2 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,16 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Change Format:", "Zmień format:"), + ("Change Format Settings", "Ustawienia zmiany formatu"), + ("Style Options", "Opcje stylu"), + ("[Any Style]", "[Dowolny styl]"), + ("Specify attributes to change", "Określ atrybuty do zmiany"), + ("Clear specified attributes", "Wyczyść określone atrybuty"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "Przy zmianie formatu i pustym polu „Zmień na” znaleziony tekst zostaje, a zmienia się tylko jego format", + ), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..c96ced85e 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,16 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Change Format:", "Alterar formato:"), + ("Change Format Settings", "Configurações de alteração de formato"), + ("Style Options", "Opções de estilo"), + ("[Any Style]", "[Qualquer estilo]"), + ("Specify attributes to change", "Especificar atributos a alterar"), + ("Clear specified attributes", "Limpar atributos especificados"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "Com um formato de alteração e “Alterar para” vazio, o texto encontrado permanece e só a formatação muda", + ), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), ("Fixed Number", "Número fixo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..719a6d653 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,16 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Change Format:", "Заменить формат:"), + ("Change Format Settings", "Параметры замены формата"), + ("Style Options", "Параметры стиля"), + ("[Any Style]", "[Любой стиль]"), + ("Specify attributes to change", "Задать атрибуты для замены"), + ("Clear specified attributes", "Очистить заданные атрибуты"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "Если задан формат замены, а поле «Заменить на» пусто, найденный текст остаётся, меняется только его формат", + ), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..ed57d814c 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,16 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Change Format:", "Замінити формат:"), + ("Change Format Settings", "Параметри заміни формату"), + ("Style Options", "Параметри стилю"), + ("[Any Style]", "[Будь-який стиль]"), + ("Specify attributes to change", "Задати атрибути для заміни"), + ("Clear specified attributes", "Очистити задані атрибути"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "Якщо задано формат заміни, а поле «Замінити на» порожнє, знайдений текст лишається, змінюється лише його формат", + ), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index a90fe989e..29952ca1d 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -1040,6 +1040,17 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { pen.rect(3.0, 4.0, 17.0, 16.0); pen.line(&[(3.0, 7.0), (17.0, 7.0)]); } + // Find/Change's Specify Attributes: a letter under a magnifier. + "find-format" => { + pen.text(7.5, 8.5, "A", 12.0); + pen.circle(13.0, 13.0, 3.2); + pen.line(&[(15.3, 15.3), (18.0, 18.0)]); + } + "info" => { + pen.circle(10.0, 10.0, 7.0); + pen.fcircle(10.0, 6.6, 1.0); + pen.line(&[(10.0, 9.0), (10.0, 14.2)]); + } _ => { pen.rect(4.0, 4.0, 16.0, 16.0); } From 64242eb5bfbf2b787b8668839dfd600cbcd9de2d Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:57:02 +0200 Subject: [PATCH 084/431] Find/Change: Find Format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit find.find, find.next and find.change take findFormat {charStyle?, paraStyle?, chars?}: only matches whose text has the format all through are found, and with an empty find, each stretch of text with it (the longest runs, one per paragraph). Resolved attributes are compared to within rounding; unknown styles and attributes are refused. A changed Find Format restarts Find Next. The dialog has InDesign's Find Format box above Change Format, with its ⓘ beside Find what; Find Format Settings is the Change Format Settings dialog opened for finding. Found in the Learn Skills Daily course, chapter 22. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/find.rs | 258 ++++++++++++++++++++++++++++--- crates/ui-egui/src/dialogs.rs | 163 +++++++++++++++---- crates/ui-egui/src/i18n.rs | 25 +++ crates/ui-egui/src/i18n/ar.rs | 7 + crates/ui-egui/src/i18n/cs.rs | 7 + crates/ui-egui/src/i18n/it.rs | 7 + crates/ui-egui/src/i18n/ka.rs | 7 + crates/ui-egui/src/i18n/pl.rs | 7 + crates/ui-egui/src/i18n/pt_br.rs | 7 + crates/ui-egui/src/i18n/ru.rs | 7 + crates/ui-egui/src/i18n/uk.rs | 7 + 12 files changed, 450 insertions(+), 53 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index fa44d189e..466b7f1e9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · Find/Change has InDesign's Find Format: Specify Attributes opens Find Format Settings (the same Style Options and character sections as Change Format Settings), and only text with the format is found. With nothing in Find what, each stretch of text with the format is found, so Change All can restyle every word in a character style. `find.find`, `find.next` and `find.change` take `findFormat {charStyle?, paraStyle?, chars?}`. Found in the Learn Skills Daily course, chapter 22. No readiness percentage change. - **2026-10-21** · Find/Change has InDesign's Change Format. Specify Attributes opens Change Format Settings: Style Options (the character and paragraph style to apply, [Any Style] for none), then Basic and Advanced Character Formats, Character Color, OpenType Features, Underline and Strikethrough Options. The box shows the format ("Character Style: eggs"), and Change All gives it to the changed text. With a Change Format and nothing in Change to, the found text stays and is only formatted, as InDesign's (an ⓘ beside Change to says so). `find.change` takes `charStyle` and `paraStyle`. Found in the Learn Skills Daily course, chapter 22. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index d6a43855d..eb821b0ae 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -1,12 +1,12 @@ //! Edit → Find/Change: plain text and GREP (regular expressions) with InDesign's metacharacters, -//! optional formatting in the change, scoped to the document, a story or the selection. +//! a Find Format and a Change Format, scoped to the document, a story or the selection. use std::ops::Range; use std::sync::{Arc, Weak}; use designcraft_compose::grep::{Grep, GrepOptions, translate_replacement}; use designcraft_doc::story::floor_char_boundary; -use designcraft_doc::{Document, Selection, StoryId, TextSel}; +use designcraft_doc::{Document, Selection, Story, StoryId, Styles, TextSel}; use serde_json::{Value, json}; use super::{CommandSpec, bad, bool_or, cmd, has_doc, str_param}; @@ -61,18 +61,18 @@ pub fn specs() -> Vec { } ), cmd!(query "find.find", "Find", ["Edit", "Find/Change"], None, - "{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, change?|first?|attrs?|charStyle?|paraStyle?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text}]", + "{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), change?|first?|attrs?|charStyle?|paraStyle?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text}]", has_doc, find), cmd!( "find.change", "Change All", ["Edit", "Find/Change"], None, - "{find, change, grep?, caseSensitive?, wholeWord?, scope?, story?, first?: bool (change only the first match), attrs?: {character attributes applied to changed text}, charStyle?: a character style applied to it, paraStyle?: a paragraph style applied to its paragraphs} → {count} — attrs, charStyle and paraStyle are the Change Format: with one and an empty change, the found text stays and is only formatted", + "{find, change, grep?, caseSensitive?, wholeWord?, scope?, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), first?: bool (change only the first match), attrs?: {character attributes applied to changed text}, charStyle?: a character style applied to it, paraStyle?: a paragraph style applied to its paragraphs} → {count} — attrs, charStyle and paraStyle are the Change Format: with one and an empty change, the found text stays and is only formatted; with no find, change is literal text", has_doc, change ), - cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, caseSensitive?, wholeWord?, scope?: document|story|selection, story?, change?|first?|attrs?|charStyle?|paraStyle?: ignored (find.change takes them; one request serves all three Find/Change commands)} — Selection starts at its original beginning; later calls advance within the scope", has_doc, find_next), + cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, caseSensitive?, wholeWord?, scope?: document|story|selection, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), change?|first?|attrs?|charStyle?|paraStyle?: ignored (find.change takes them; one request serves all three Find/Change commands)} — Selection starts at its original beginning; later calls advance within the scope", has_doc, find_next), ] } @@ -108,6 +108,170 @@ fn build(p: &Value) -> Result { re.map_err(|e| bad("find", e)) } +/// Find/Change's Find Format: the text a search finds must have it all through. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct FindFormat { + char_style: Option, + para_style: Option, + /// Resolved character attributes, as `type.selectionAttrs` reports them. + chars: serde_json::Map, +} + +impl FindFormat { + fn from_params(p: &Value) -> Result> { + let Some(f) = p.get("findFormat").filter(|f| !f.is_null()) else { return Ok(None) }; + let o = f.as_object().ok_or_else(|| bad("find", "`findFormat` is an object"))?; + let name = |k: &str| o.get(k).and_then(Value::as_str).map(str::to_string); + let chars = o.get("chars").and_then(Value::as_object).cloned().unwrap_or_default(); + // Only attributes text has. + let mut known = designcraft_doc::CharAttrs::default(); + for (k, v) in &chars { + known.set_json(k, v).map_err(|e| bad("find", format!("findFormat: {e}")))?; + } + let f = Self { char_style: name("charStyle"), para_style: name("paraStyle"), chars }; + Ok((f != Self::default()).then_some(f)) + } + + /// Styles the document doesn't have are refused, as `find.change` refuses them. + fn check(&self, styles: &Styles) -> Result<()> { + if let Some(n) = self.char_style.as_ref().filter(|n| styles.char_style(n).is_none()) { + return Err(bad("find", format!("no character style `{n}`"))); + } + if let Some(n) = self.para_style.as_ref().filter(|n| styles.para(n).is_none()) { + return Err(bad("find", format!("no paragraph style `{n}`"))); + } + Ok(()) + } + + /// The text in `r` of `story` that has the format, relative to `r.start`: the longest + /// stretches, each paragraph's apart (a paragraph's `\n` isn't in one). + fn spans(&self, styles: &Styles, story: &Story, r: &Range) -> Vec> { + let mut out: Vec> = Vec::new(); + for (pi, pr) in story.para_ranges().into_iter().enumerate() { + let (a, b) = (pr.start.max(r.start), pr.end.min(r.end)); + let Some(pf) = story.paras.get(pi) else { continue }; + if a >= b || self.para_style.as_ref().is_some_and(|n| *n != pf.style) { + continue; + } + let base = (!self.chars.is_empty()).then(|| styles.resolve_para(pf).1); + for (run, cf) in story.runs() { + let (x, y) = (run.start.max(a), run.end.min(b)); + if x >= y || self.char_style.as_ref().is_some_and(|n| *n != cf.style) { + continue; + } + if let Some(base) = &base { + let cp = serde_json::to_value(styles.resolve_char(base, cf)).unwrap_or_default(); + if !self.chars.iter().all(|(k, v)| cp.get(k).is_some_and(|c| same_value(c, v))) { + continue; + } + } + match out.last_mut() { + Some(last) if last.end == x - r.start => last.end = y - r.start, + _ => out.push(x - r.start..y - r.start), + } + } + } + out + } +} + +/// Attribute values alike: numbers to within rounding, the rest exactly. +fn same_value(a: &Value, b: &Value) -> bool { + match (a.as_f64(), b.as_f64()) { + (Some(x), Some(y)) => (x - y).abs() < 1e-6, + _ => a == b, + } +} + +/// Whether match `m` lies in one of `spans`. +fn within(spans: &[Range], m: &Range) -> bool { + spans.iter().any(|s| s.start <= m.start && m.end <= s.end) +} + +/// What a search finds: the pattern's matches (with a Find Format, those whose text has it all +/// through), or, with no pattern, the stretches of text with the Find Format. +struct Search { + re: Option, + format: Option, +} + +/// The search of a Find/Change request: an empty `find` needs a Find Format. +fn search(p: &Value) -> Result { + let format = FindFormat::from_params(p)?; + if format.is_some() && str_param(p, "find") == Some("") { + return Ok(Search { re: None, format }); + } + Ok(Search { re: Some(build(p)?), format }) +} + +impl Search { + fn check(&self, styles: &Styles) -> Result<()> { + self.format.as_ref().map_or(Ok(()), |f| f.check(styles)) + } + + /// Where the Find Format holds in target `r` of story `sid` (`None`: no Find Format). + fn spans(&self, d: &Document, sid: StoryId, r: &Range) -> Option>> { + let f = self.format.as_ref()?; + Some(d.story(sid).map(|story| f.spans(&d.styles, story, r)).unwrap_or_default()) + } + + /// Up to `limit` matches in `text`, target `r` of story `sid` (offsets in `text`). + fn find_all(&self, d: &Document, sid: StoryId, r: &Range, text: &str, limit: usize) -> Result>> { + let spans = self.spans(d, sid, r); + let Some(re) = &self.re else { return Ok(spans.unwrap_or_default().into_iter().take(limit).collect()) }; + let Some(spans) = spans else { return re.find_all(text, limit).map_err(|e| bad("find", e)) }; + Ok(re.find_all(text, usize::MAX).map_err(|e| bad("find", e))?.into_iter().filter(|m| within(&spans, m)).take(limit).collect()) + } + + /// The first match in `text` (target `r` of story `sid`) at or after byte `from`. + fn find_at(&self, d: &Document, sid: StoryId, r: &Range, text: &str, from: usize) -> Result>> { + let spans = self.spans(d, sid, r); + let Some(re) = &self.re else { + // From inside a stretch, the rest of it. + return Ok(spans.unwrap_or_default().into_iter().find(|s| s.end > from).map(|s| s.start.max(from)..s.end)); + }; + let mut at = from; + loop { + let Some(m) = re.find_at(text, at).map_err(|e| bad("find.next", e))? else { return Ok(None) }; + if spans.as_ref().is_none_or(|s| within(s, &m)) { + return Ok(Some(m)); + } + // On past it: its end, or a character on from an empty match. + at = if m.is_empty() { text.get(m.end..).and_then(|t| t.chars().next()).map_or(text.len() + 1, |c| m.end + c.len_utf8()) } else { m.end }; + if at > text.len() { + return Ok(None); + } + } + } + + /// The edits Change All makes in `text` (target `r` of story `sid`): `rep` expanded for each + /// GREP match, else `rep` itself. + #[allow(clippy::too_many_arguments)] + fn replacements( + &self, + d: &Document, + sid: StoryId, + r: &Range, + text: &str, + rep: &str, + grep: bool, + first_only: bool, + ) -> Result, String)>> { + let limit = if first_only { 1 } else { usize::MAX }; + match (&self.re, self.spans(d, sid, r)) { + (Some(re), None) if grep => re.replacements(text, rep, first_only).map_err(|e| bad("find.change", e)), + (Some(re), Some(spans)) if grep => Ok(re + .replacements(text, rep, false) + .map_err(|e| bad("find.change", e))? + .into_iter() + .filter(|(m, _)| within(&spans, m)) + .take(limit) + .collect()), + _ => Ok(self.find_all(d, sid, r, text, limit)?.into_iter().map(|m| (m, rep.to_string())).collect()), + } + } +} + #[derive(Clone, Debug, PartialEq, Eq)] enum Domain { Document, @@ -140,6 +304,7 @@ struct Matcher { grep: bool, case_sensitive: bool, whole_word: bool, + format: Option, } impl Matcher { @@ -149,6 +314,7 @@ impl Matcher { grep: bool_or(p, "grep", false), case_sensitive: bool_or(p, "caseSensitive", false), whole_word: bool_or(p, "wholeWord", false), + format: FindFormat::from_params(p).ok().flatten(), } } } @@ -281,13 +447,14 @@ fn scope(s: &Session, p: &Value) -> Result { const MAX_MATCHES: usize = 10_000; fn find(s: &mut Session, p: &Value) -> Result { - let re = build(p)?; + let re = search(p)?; + re.check(&s.doc()?.doc.styles)?; let context = scope(s, p)?; let st = s.doc()?; let mut out = Vec::new(); for (sid, r) in &context.targets { let text = target_text(&st.doc, *sid, r)?; - for m in re.find_all(text, MAX_MATCHES.saturating_sub(out.len())).map_err(|e| bad("find", e))? { + for m in re.find_all(&st.doc, *sid, r, text, MAX_MATCHES.saturating_sub(out.len()))? { out.push(json!({"story": sid.0, "start": r.start + m.start, "end": r.start + m.end, "text": text.get(m)})); } } @@ -302,9 +469,11 @@ fn replacement(p: &Value) -> String { } fn change(s: &mut Session, p: &Value) -> Result { - let re = build(p)?; - let grep = bool_or(p, "grep", false); - let rep = replacement(p); + let re = search(p)?; + re.check(&s.doc()?.doc.styles)?; + // With no pattern (a Find Format alone), the change is literal text. + let grep = bool_or(p, "grep", false) && re.re.is_some(); + let rep = if re.re.is_some() { replacement(p) } else { metachars(str_param(p, "change").unwrap_or("")) }; let first_only = bool_or(p, "first", false); let attrs = p.get("attrs").cloned(); let mut over = designcraft_doc::CharAttrs::default(); @@ -333,15 +502,11 @@ fn change(s: &mut Session, p: &Value) -> Result { let Some(story) = d.story(sid) else { continue }; // Collect matches first (positions refer to the original text), apply back to front. let text = story.text.get(r.clone()).ok_or_else(|| bad("find", "invalid selection bounds or UTF-8 boundary"))?; - let edits: Vec<(std::ops::Range, String)> = if grep { - re.replacements(text, &rep, first_only) - } else { - re.find_all(text, if first_only { 1 } else { usize::MAX }).map(|ms| ms.into_iter().map(|m| (m, rep.clone())).collect()) - } - .map_err(|e| bad("find.change", e))? - .into_iter() - .map(|(m, out)| (r.start + m.start..r.start + m.end, out)) - .collect(); + let edits: Vec<(std::ops::Range, String)> = re + .replacements(d, sid, &r, text, &rep, grep, first_only)? + .into_iter() + .map(|(m, out)| (r.start + m.start..r.start + m.end, out)) + .collect(); let Some(st) = d.story_mut(sid) else { continue }; for (range, new) in edits.iter().rev() { let changed = if format_only { @@ -386,7 +551,8 @@ fn change(s: &mut Session, p: &Value) -> Result { } fn find_next(s: &mut Session, p: &Value) -> Result { - let re = build(p)?; + let re = search(p)?; + re.check(&s.doc()?.doc.styles)?; let mut context = scope(s, p)?; let st = s.doc()?; let mut hit = None; @@ -396,7 +562,7 @@ fn find_next(s: &mut Session, p: &Value) -> Result { if text.get(from..).is_none() { return Err(bad("find", "invalid UTF-8 search cursor")); } - if let Some(m) = re.find_at(text, from).map_err(|e| bad("find.next", e))? { + if let Some(m) = re.find_at(&st.doc, sid, &r, text, from)? { let (a, b) = (r.start + m.start, r.start + m.end); // Match find_iter/captures_iter: suppress an empty match adjacent to the // previous match's end, but keep adjacent nonempty matches. @@ -501,6 +667,56 @@ mod tests { 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); + // 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"}, "charStyle": "[None]", "attrs": {"underline": true}}), + ) + .unwrap(); + assert_eq!(r["count"], 2); + 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()); + assert!(s.execute("find.find", &json!({"find": ""})).is_err()); + } + #[test] fn change_first_changes_one_match_in_the_whole_document() { for grep in [false, true] { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6359a6c84..b66b6c168 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2281,9 +2281,21 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }); ui.label(d.s("status")); } else { - let formatted = d.fields.get(CHANGE_FORMAT).and_then(Value::as_object).is_some_and(|f| !f.is_empty()); + let (find_formatted, formatted) = (format_set(&d, FIND_FORMAT), format_set(&d, CHANGE_FORMAT)); 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:")); + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Find what:")); + // InDesign's ⓘ: a Find Format narrows what is found. + if find_formatted { + info_icon( + ui, + crate::i18n::tr( + &app.ui.language, + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + ), + ); + } + }); text_field(ui, &mut d, "find", 260.0); ui.end_row(); ui.horizontal(|ui| { @@ -2320,7 +2332,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { check(ui, &mut d, "caseSensitive", crate::i18n::tr(&app.ui.language, "Case sensitive")); check(ui, &mut d, "wholeWord", crate::i18n::tr(&app.ui.language, "Whole word")); }); - change_format_box(&app.ui.language, ui, &mut d); + format_box(&app.ui.language, ui, &mut d, true); + format_box(&app.ui.language, ui, &mut d, false); ui.horizontal(|ui| { let params0 = find_change_params(&d); let params = || params0.clone(); @@ -2887,7 +2900,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { crate::new_document::preview(app, &mut d, ctx); } if d.id == "findChange" && result.is_none() && d.fields.get(FORMAT_EDIT).is_some_and(Value::is_object) { - change_format_settings(app, ctx, &mut d); + format_settings(app, ctx, &mut d); } app.ui.dialog = Some(d); match result { @@ -3044,6 +3057,10 @@ fn find_change_params(d: &Dialog) -> Value { if let Some(story) = d.fields.get("story") { p["story"] = story.clone(); } + // Find Format: only text with it is found (with nothing in Find what, all text with it). + if let Some(f) = d.fields.get(FIND_FORMAT).and_then(Value::as_object).filter(|f| !f.is_empty()) { + p["findFormat"] = Value::Object(f.clone()); + } // Change Format: find.change applies it to the changed text (the other commands ignore it). if let Some(f) = d.fields.get(CHANGE_FORMAT) { for key in ["charStyle", "paraStyle"] { @@ -3058,14 +3075,21 @@ fn find_change_params(d: &Dialog) -> Value { p } -/// Find/Change's Change Format ({charStyle?, paraStyle?, chars?}), as Change Format Settings set -/// it; absent when there is none. +/// Find/Change's Find Format and Change Format ({charStyle?, paraStyle?, chars?}), as their +/// settings set them; absent when there is none. +const FIND_FORMAT: &str = "findFormat"; const CHANGE_FORMAT: &str = "changeFormat"; -/// The fields of Change Format Settings while it is open (its section, `charStyle`, `paraStyle` -/// and `c.`), until OK makes them the Change Format. +/// The fields of Find or Change Format Settings while it is open (`for`: find|change, its +/// section, `charStyle`, `paraStyle` and `c.`), until OK makes them the format. const FORMAT_EDIT: &str = "formatEdit"; -/// The sections of Change Format Settings: Style Options, then those of Character Style Options. +/// Whether the dialog has a Find (`FIND_FORMAT`) or Change Format. +fn format_set(d: &Dialog, key: &str) -> bool { + d.fields.get(key).and_then(Value::as_object).is_some_and(|f| !f.is_empty()) +} + +/// The sections of Find and Change Format Settings: Style Options, then those of Character Style +/// Options. const FORMAT_SECTIONS: &[(&str, &str)] = &[ ("style", "Style Options"), ("chars", "Basic Character Formats"), @@ -3084,8 +3108,8 @@ fn info_icon(ui: &mut egui::Ui, tip: &str) { resp.on_hover_text(tip); } -/// What the Change Format box shows: "Character Style: eggs + …". -fn change_format_summary(lang: &str, format: Option<&Value>) -> String { +/// What a Find or Change Format box shows: "Character Style: eggs + …". +fn format_summary(lang: &str, format: Option<&Value>) -> String { let Some(f) = format else { return String::new() }; let mut parts = Vec::new(); for (key, label) in [("charStyle", "Character Style:"), ("paraStyle", "Paragraph Style:")] { @@ -3099,23 +3123,30 @@ fn change_format_summary(lang: &str, format: Option<&Value>) -> String { parts.join(" + ") } -/// Find/Change's Change Format, as InDesign's: a box with what Change All also gives the changed -/// text, a button that opens Change Format Settings and one that clears it. -fn change_format_box(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) { +/// Find/Change's Find Format (`find`) or Change Format, as InDesign's: a box with what the found +/// text must have or what Change All also gives the changed text, a button that opens its +/// settings and one that clears it. +fn format_box(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, find: bool) { + let (key, label, specify) = if find { + (FIND_FORMAT, "Find Format:", "Specify attributes to find") + } else { + (CHANGE_FORMAT, "Change Format:", "Specify attributes to change") + }; ui.add_space(6.0); - crate::rtl::label(ui, crate::i18n::tr(lang, "Change Format:")); + crate::rtl::label(ui, crate::i18n::tr(lang, label)); ui.horizontal_top(|ui| { let t = crate::theme::Tokens::get(ui.ctx()); egui::Frame::new().fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(6.0).show(ui, |ui| { ui.set_width(340.0); ui.set_min_height(40.0); - let summary = change_format_summary(lang, d.fields.get(CHANGE_FORMAT)); + let summary = format_summary(lang, d.fields.get(key)); ui.add(egui::Label::new(egui::RichText::new(summary).color(t.text)).wrap()); }); ui.vertical(|ui| { - if crate::icons::button(ui, "find-format", 22.0, false, crate::i18n::tr(lang, "Specify attributes to change")).clicked() { - let f = d.fields.get(CHANGE_FORMAT).cloned().unwrap_or_default(); + if crate::icons::button(ui, "find-format", 22.0, false, crate::i18n::tr(lang, specify)).clicked() { + let f = d.fields.get(key).cloned().unwrap_or_default(); let mut edit = Map::new(); + edit.insert("for".into(), json!(if find { "find" } else { "change" })); edit.insert("section".into(), json!("style")); for key in ["charStyle", "paraStyle"] { if let Some(v) = f.get(key) { @@ -3128,16 +3159,17 @@ fn change_format_box(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) { d.fields.insert(FORMAT_EDIT.into(), Value::Object(edit)); } if crate::icons::button(ui, "trash", 22.0, false, crate::i18n::tr(lang, "Clear specified attributes")).clicked() { - d.fields.remove(CHANGE_FORMAT); + d.fields.remove(key); } }); }); } -/// Find/Change › Change Format Settings, over Find/Change as InDesign's: Style Options (the -/// character and paragraph style to apply, [Any Style] for none), then the character sections of -/// Character Style Options with every field blank (left as it is). OK makes it the Change Format. -fn change_format_settings(app: &mut DesignApp, ctx: &egui::Context, d: &mut Dialog) { +/// Find/Change › Find or Change Format Settings, over Find/Change as InDesign's: Style Options +/// (the character and paragraph style the found text has or the changed text gets, [Any Style] +/// for none), then the character sections of Character Style Options with every field blank (any +/// value, or left as it is). OK makes it the Find or Change Format. +fn format_settings(app: &mut DesignApp, ctx: &egui::Context, d: &mut Dialog) { let lang = app.ui.language.clone(); let Some(doc) = app.session.active().map(|st| st.doc.clone()) else { d.fields.remove(FORMAT_EDIT); @@ -3147,12 +3179,14 @@ fn change_format_settings(app: &mut DesignApp, ctx: &egui::Context, d: &mut Dial Dialog { id: "changeFormat".into(), fields: d.fields.get(FORMAT_EDIT).and_then(Value::as_object).cloned().unwrap_or_default(), unit: d.unit }; let base = json!({}); let cf = CharFields { base: &base, sparse: true }; + let find = fd.s("for") == "find"; 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::new("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))); + let title = if find { "Find Format Settings" } else { "Change Format Settings" }; + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, title)).font(semibold(16.0))); let margin = egui::Frame::popup(ui.style()).inner_margin; let title_row = egui::Rect::from_min_max( egui::pos2(ui.max_rect().left() - margin.leftf(), title_top - margin.topf()), @@ -3212,10 +3246,11 @@ fn change_format_settings(app: &mut DesignApp, ctx: &egui::Context, d: &mut Dial if !chars.is_empty() { f.insert("chars".into(), Value::Object(chars)); } + let key = if find { FIND_FORMAT } else { CHANGE_FORMAT }; if f.is_empty() { - d.fields.remove(CHANGE_FORMAT); + d.fields.remove(key); } else { - d.fields.insert(CHANGE_FORMAT.into(), Value::Object(f)); + d.fields.insert(key.into(), Value::Object(f)); } } Some(false) => { @@ -3227,8 +3262,8 @@ fn change_format_settings(app: &mut DesignApp, ctx: &egui::Context, d: &mut Dial } } -/// Change Format Settings › Style Options: the character and paragraph style the changed text -/// gets ([Any Style]: as it is). +/// Find or Change Format Settings › Style Options: the character and paragraph style the found +/// text has or the changed text gets ([Any Style]: any, or as it is). fn format_style_options(lang: &str, ui: &mut egui::Ui, fd: &mut Dialog, styles: &designcraft_doc::Styles) { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "Style Options")).font(semibold(13.0))); ui.add_space(6.0); @@ -8553,8 +8588,8 @@ mod tests { [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(); + // 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()); @@ -8571,8 +8606,72 @@ mod tests { (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_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] diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 0e8b47103..fc7e906be 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -821,6 +821,31 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Change All", ["Change All", "Tout remplacer", "Change All", "Change All", "全部更改"]), ("Change to:", ["Change to:", "Remplacer par :", "Change to:", "Change to:", "更改为:"]), ("Change Format:", ["Format ersetzen:", "Remplacer le format :", "Cambiar formato:", "置換形式 :", "更改格式:"]), + ("Find Format:", ["Format suchen:", "Rechercher le format :", "Buscar formato:", "検索形式 :", "查找格式:"]), + ( + "Find Format Settings", + ["Einstellungen für Format suchen", "Paramètres de recherche de format", "Ajustes de búsqueda de formato", "検索形式の設定", "查找格式设置"], + ), + ( + "Specify attributes to find", + [ + "Zu suchende Attribute festlegen", + "Indiquer les attributs à rechercher", + "Especificar atributos que buscar", + "検索する属性を指定", + "指定要查找的属性", + ], + ), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + [ + "Mit einem Suchformat wird nur Text gefunden, der es hat; bei leerem Feld „Suchen nach“ jeder Text, der es hat", + "Avec un format de recherche, seul le texte qui l’a est trouvé ; si « Rechercher » est vide, tout le texte qui l’a", + "Con un formato de búsqueda solo se encuentra el texto que lo tiene; con «Buscar» vacío, todo el texto que lo tiene", + "検索形式があるときは、その書式のテキストだけが見つかります。検索文字列が空のときは、その書式のテキストすべてが見つかります", + "设置了查找格式时,只查找具有该格式的文本;“查找内容”为空时,查找所有具有该格式的文本", + ], + ), ( "Change Format Settings", [ diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 33eff157d..9fa1ac6cb 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Find Format:", "البحث عن تنسيق:"), + ("Find Format Settings", "إعدادات البحث عن تنسيق"), + ("Specify attributes to find", "تحديد السمات المراد البحث عنها"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "مع تنسيق للبحث، يُعثر فقط على النص الذي يحمله؛ وإذا كان حقل «البحث عن» فارغًا، فعلى كل نص يحمله", + ), ("Change Format:", "تغيير التنسيق:"), ("Change Format Settings", "إعدادات تغيير التنسيق"), ("Style Options", "خيارات النمط"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 633978380..03f84dfde 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Find Format:", "Hledat formát:"), + ("Find Format Settings", "Nastavení hledání formátu"), + ("Specify attributes to find", "Určit atributy k hledání"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "S formátem hledání se najde jen text, který ho má; s prázdným polem „Najít“ veškerý text, který ho má", + ), ("Change Format:", "Změnit formát:"), ("Change Format Settings", "Nastavení změny formátu"), ("Style Options", "Volby stylu"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 101167ea2..7f4c96bdc 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Find Format:", "Trova formato:"), + ("Find Format Settings", "Impostazioni di ricerca formato"), + ("Specify attributes to find", "Specifica gli attributi da trovare"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "Con un formato di ricerca si trova solo il testo che lo ha; con «Trova» vuoto, tutto il testo che lo ha", + ), ("Change Format:", "Modifica formato:"), ("Change Format Settings", "Impostazioni di modifica formato"), ("Style Options", "Opzioni stile"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 357350579..bda369b7f 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Find Format:", "ფორმატის ძიება:"), + ("Find Format Settings", "ფორმატის ძიების პარამეტრები"), + ("Specify attributes to find", "საძიებო ატრიბუტების მითითება"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "ძიების ფორმატით მხოლოდ ის ტექსტი მოიძებნება, რომელსაც ის აქვს; ცარიელი „ძიება“ ველით — ყველა ასეთი ტექსტი", + ), ("Change Format:", "ფორმატის შეცვლა:"), ("Change Format Settings", "ფორმატის შეცვლის პარამეტრები"), ("Style Options", "სტილის პარამეტრები"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index e13ee7de2..fc6bf7712 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Find Format:", "Znajdź format:"), + ("Find Format Settings", "Ustawienia wyszukiwania formatu"), + ("Specify attributes to find", "Określ atrybuty do znalezienia"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "Przy formacie wyszukiwania znajdowany jest tylko tekst, który go ma; przy pustym polu „Znajdź” — cały taki tekst", + ), ("Change Format:", "Zmień format:"), ("Change Format Settings", "Ustawienia zmiany formatu"), ("Style Options", "Opcje stylu"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index c96ced85e..748926937 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Find Format:", "Localizar formato:"), + ("Find Format Settings", "Configurações de localização de formato"), + ("Specify attributes to find", "Especificar atributos a localizar"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "Com um formato de localização, só o texto que o tem é encontrado; com “Localizar” vazio, todo o texto que o tem", + ), ("Change Format:", "Alterar formato:"), ("Change Format Settings", "Configurações de alteração de formato"), ("Style Options", "Opções de estilo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 719a6d653..3ac6179c1 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Find Format:", "Найти формат:"), + ("Find Format Settings", "Параметры поиска формата"), + ("Specify attributes to find", "Задать атрибуты для поиска"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "С форматом поиска находится только текст с этим форматом; если поле «Найти» пусто — весь такой текст", + ), ("Change Format:", "Заменить формат:"), ("Change Format Settings", "Параметры замены формата"), ("Style Options", "Параметры стиля"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index ed57d814c..163f7ae67 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Find Format:", "Знайти формат:"), + ("Find Format Settings", "Параметри пошуку формату"), + ("Specify attributes to find", "Задати атрибути для пошуку"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "З форматом пошуку знаходиться лише текст із цим форматом; якщо поле «Знайти» порожнє — увесь такий текст", + ), ("Change Format:", "Замінити формат:"), ("Change Format Settings", "Параметри заміни формату"), ("Style Options", "Параметри стилю"), From 7f03606f6bd2626c2615447c7b757661663b0e46 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:07:03 +0200 Subject: [PATCH 085/431] Find/Change: Change and Change/Find find.change takes found: true for Change: only the text Find Next found changes (the selection, while it's still a match of the search in its domain), it stays selected, and the search keeps its domain so the next Find Next goes on after it. A selection that isn't a match changes nothing and makes no undo step. The dialog has InDesign's Change and Change/Find buttons, off until text is selected, and its Search list reads Document, Story and Selection. Found in the Learn Skills Daily course, chapter 22. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/find.rs | 117 ++++++++++++++++++++++++++++--- crates/ui-egui/src/dialogs.rs | 112 ++++++++++++++++++++++++++--- crates/ui-egui/src/i18n.rs | 1 + crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + 12 files changed, 220 insertions(+), 19 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 466b7f1e9..f3232afdb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · Find/Change has InDesign's Change and Change/Find: Change changes the text Find Next found and keeps it selected, Change/Find also finds the next match, and the search keeps its domain (a selection or story) across them. The Search list reads Document, Story and Selection. `find.change` takes `found`. Found in the Learn Skills Daily course, chapter 22. No readiness percentage change. - **2026-10-21** · Find/Change has InDesign's Find Format: Specify Attributes opens Find Format Settings (the same Style Options and character sections as Change Format Settings), and only text with the format is found. With nothing in Find what, each stretch of text with the format is found, so Change All can restyle every word in a character style. `find.find`, `find.next` and `find.change` take `findFormat {charStyle?, paraStyle?, chars?}`. Found in the Learn Skills Daily course, chapter 22. No readiness percentage change. - **2026-10-21** · Find/Change has InDesign's Change Format. Specify Attributes opens Change Format Settings: Style Options (the character and paragraph style to apply, [Any Style] for none), then Basic and Advanced Character Formats, Character Color, OpenType Features, Underline and Strikethrough Options. The box shows the format ("Character Style: eggs"), and Change All gives it to the changed text. With a Change Format and nothing in Change to, the found text stays and is only formatted, as InDesign's (an ⓘ beside Change to says so). `find.change` takes `charStyle` and `paraStyle`. Found in the Learn Skills Daily course, chapter 22. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index eb821b0ae..dd8bccddc 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -61,18 +61,18 @@ pub fn specs() -> Vec { } ), cmd!(query "find.find", "Find", ["Edit", "Find/Change"], None, - "{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), change?|first?|attrs?|charStyle?|paraStyle?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text}]", + "{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), change?|first?|found?|attrs?|charStyle?|paraStyle?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text}]", has_doc, find), cmd!( "find.change", "Change All", ["Edit", "Find/Change"], None, - "{find, change, grep?, caseSensitive?, wholeWord?, scope?, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), first?: bool (change only the first match), attrs?: {character attributes applied to changed text}, charStyle?: a character style applied to it, paraStyle?: a paragraph style applied to its paragraphs} → {count} — attrs, charStyle and paraStyle are the Change Format: with one and an empty change, the found text stays and is only formatted; with no find, change is literal text", + "{find, change, grep?, caseSensitive?, wholeWord?, scope?, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), first?: bool (change only the first match), found?: bool (Change: only the text Find Next found, while it is selected and still a match; it stays selected, and Find Next goes on after it in the same domain), attrs?: {character attributes applied to changed text}, charStyle?: a character style applied to it, paraStyle?: a paragraph style applied to its paragraphs} → {count} — attrs, charStyle and paraStyle are the Change Format: with one and an empty change, the found text stays and is only formatted; with no find, change is literal text", has_doc, change ), - cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, caseSensitive?, wholeWord?, scope?: document|story|selection, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), change?|first?|attrs?|charStyle?|paraStyle?: ignored (find.change takes them; one request serves all three Find/Change commands)} — Selection starts at its original beginning; later calls advance within the scope", has_doc, find_next), + cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, caseSensitive?, wholeWord?, scope?: document|story|selection, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), change?|first?|found?|attrs?|charStyle?|paraStyle?: ignored (find.change takes them; one request serves all three Find/Change commands)} — Selection starts at its original beginning; later calls advance within the scope", has_doc, find_next), ] } @@ -346,6 +346,25 @@ impl FindContext { self.selection = st.selection.clone(); } + /// After Change put `new_len` bytes in place of `old` in story `sid`: the domain keeps the + /// changed text and the search goes on after it. + fn after_change(&mut self, st: &DocState, sid: StoryId, old: &Range, new_len: usize) { + let moved = |x: usize| if x >= old.end { x - old.len() + new_len } else { x }; + for (tid, r) in &mut self.targets { + if *tid == sid { + *r = moved(r.start)..moved(r.end); + } + } + self.offset = old.start + new_len; + self.last_match_end = Some(self.offset); + let at = self.offset; + if let Some(i) = self.order.iter().position(|i| self.targets.get(*i).is_some_and(|(tid, r)| *tid == sid && r.start <= at && at <= r.end)) { + self.target = i; + } + self.document = Arc::downgrade(&st.doc); + self.acknowledge(st); + } + fn restart(&mut self, st: &DocState) -> Result<()> { self.target = 0; self.last_match_end = None; @@ -494,19 +513,48 @@ fn change(s: &mut Session, p: &Value) -> Result { } // InDesign: with a Change Format and nothing in Change to, the found text is only formatted. let format_only = str_param(p, "change").unwrap_or("").is_empty() && (char_style.is_some() || para_style.is_some() || !over.is_empty()); - let targets = scope(s, p)?.targets; - s.edit(|d, sel| { + let context = scope(s, p)?; + let targets = context.targets.clone(); + // Change: only the text Find Next found, the selection, while it is still a match. + let found = if bool_or(p, "found", false) { + let st = s.doc()?; + let selected = st.selection.text.filter(|t| t.cell.is_none() && !t.is_caret()).map(|t| (t.story, t.range())); + let hit = selected.filter(|(sid, range)| { + targets.iter().any(|(tid, r)| { + tid == sid + && r.start <= range.start + && range.end <= r.end + && target_text(&st.doc, *tid, r) + .and_then(|text| re.replacements(&st.doc, *tid, r, text, &rep, grep, false)) + .is_ok_and(|edits| edits.iter().any(|(m, _)| r.start + m.start == range.start && r.start + m.end == range.end)) + }) + }); + match hit { + Some(hit) => Some(hit), + None => return Ok(json!({"count": 0})), + } + } else { + None + }; + let (count, changed) = s.edit(|d, sel| { let styles = d.styles.clone(); let mut count = 0usize; + let mut changed = None; for (sid, r) in targets { + if found.as_ref().is_some_and(|(at, _)| *at != sid) { + continue; + } let Some(story) = d.story(sid) else { continue }; // Collect matches first (positions refer to the original text), apply back to front. let text = story.text.get(r.clone()).ok_or_else(|| bad("find", "invalid selection bounds or UTF-8 boundary"))?; - let edits: Vec<(std::ops::Range, String)> = re + let mut edits: Vec<(std::ops::Range, String)> = re .replacements(d, sid, &r, text, &rep, grep, first_only)? .into_iter() .map(|(m, out)| (r.start + m.start..r.start + m.end, out)) .collect(); + if let Some((_, range)) = &found { + edits.retain(|(m, _)| m == range); + } let Some(st) = d.story_mut(sid) else { continue }; for (range, new) in edits.iter().rev() { let changed = if format_only { @@ -541,13 +589,29 @@ fn change(s: &mut Session, p: &Value) -> Result { } (t.anchor, t.focus) = if t.anchor <= t.focus { (start, end) } else { (end, start) }; } + // Change leaves the changed text selected. + if let Some((_, range)) = &found + && let Some((_, new)) = edits.first() + && let Some(t) = sel.text.as_mut() + { + let len = if format_only { range.len() } else { new.len() }; + (t.anchor, t.focus) = (range.start, range.start + len); + changed = Some(len); + } // `first` is the first match of the whole search, not of each story. if first_only && count > 0 { break; } } - Ok(json!({"count": count})) - }) + Ok((count, changed)) + })?; + // Change: Find Next goes on after the changed text, in the search's domain. + if let (Some((sid, old)), Some(len)) = (&found, changed) { + let mut context = context; + context.after_change(s.doc()?, *sid, old, len); + s.find_context = Some(context); + } + Ok(json!({"count": count})) } fn find_next(s: &mut Session, p: &Value) -> Result { @@ -717,6 +781,43 @@ mod tests { 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); + } + #[test] fn change_first_changes_one_match_in_the_whole_document() { for grep in [false, true] { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b66b6c168..e445c3a78 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2315,11 +2315,19 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { ui.end_row(); 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 { + "story" => "Story", + "selection" => "Selection", + _ => "Document", + }; egui::ComboBox::from_id_salt("fcscope") - .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &cur))) + .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, scope_name(&cur)))) .show_ui(ui, |ui| { for v in ["document", "story", "selection"] { - if ui.selectable_label(cur == v, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, v))).clicked() + if ui + .selectable_label(cur == v, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, scope_name(v)))) + .clicked() && d.set_field("scope", json!(v)) { app.session.reset_find_scope(); @@ -2337,11 +2345,27 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { ui.horizontal(|ui| { let params0 = find_change_params(&d); let params = || params0.clone(); + // Change and Change/Find act on the text Find Next found. + let mut found = params0.clone(); + found["found"] = json!(true); + let selected = app.session.active().and_then(|st| st.selection.text).is_some_and(|t| !t.is_caret()); + let next_status = |app: &mut DesignApp, r: Result| match r { + Ok(Value::Null) => crate::i18n::tr(&app.ui.language, "No matches").to_string(), + Ok(_) => crate::i18n::tr(&app.ui.language, "Found").to_string(), + Err(e) => e, + }; if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Find Next"))).clicked() { let r = app.run("find.next", params()); - let msg = match r { - Ok(Value::Null) => crate::i18n::tr(&app.ui.language, "No matches").to_string(), - Ok(_) => crate::i18n::tr(&app.ui.language, "Found").to_string(), + let msg = next_status(app, r); + d.fields.insert("status".into(), json!(msg)); + } + if ui + .add_enabled(selected, egui::Button::new(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Change")))) + .clicked() + { + let msg = match app.run("find.change", found.clone()) { + Ok(v) => crate::i18n::tr(&app.ui.language, "{count} replacement(s) made") + .replace("{count}", &v["count"].to_string()), Err(e) => e, }; d.fields.insert("status".into(), json!(msg)); @@ -2354,6 +2378,17 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }; d.fields.insert("status".into(), json!(msg)); } + if ui + .add_enabled( + selected, + egui::Button::new(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Change/Find"))), + ) + .clicked() + { + let r = app.run("find.change", found.clone()).and_then(|_| app.run("find.next", params())); + let msg = next_status(app, r); + d.fields.insert("status".into(), json!(msg)); + } if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Count"))).clicked() { let msg = match app.run("find.find", params()) { Ok(v) => crate::i18n::tr(&app.ui.language, "{count} match(es)") @@ -8411,6 +8446,15 @@ mod tests { 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", + } + } + 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:?}"); @@ -8492,8 +8536,8 @@ mod tests { 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) } @@ -8722,8 +8766,8 @@ mod tests { let cur = app.ui.dialog.as_ref().unwrap().s("scope"); let next = if cur == "selection" { "document" } else { "selection" }; if physical { - scope_click(&mut app, &ctx, screen, &cur); - scope_click(&mut app, &ctx, screen, next); + scope_click(&mut app, &ctx, screen, scope_label(&cur)); + scope_click(&mut app, &ctx, screen, scope_label(next)); } else { scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "scope", "value": next})); } @@ -8740,8 +8784,8 @@ mod tests { let cur = app.ui.dialog.as_ref().unwrap().s("scope"); let next = if cur == "selection" { "document" } else { "selection" }; if physical { - scope_click(&mut app, &ctx, screen, &cur); - scope_click(&mut app, &ctx, screen, next); + scope_click(&mut app, &ctx, screen, scope_label(&cur)); + scope_click(&mut app, &ctx, screen, scope_label(next)); } else { scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "scope", "value": next})); } @@ -8999,6 +9043,52 @@ mod tests { assert_eq!(app.session.doc().unwrap().history.undo.len(), before.history.undo.len() + 1); } + /// 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 find_scope_dialog_exact_baseline_buttons() { let (mut app, ctx, screen, sid) = scope_dialog_with_selection("red red", 3); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index fc7e906be..0bfb01e7e 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -821,6 +821,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Change All", ["Change All", "Tout remplacer", "Change All", "Change All", "全部更改"]), ("Change to:", ["Change to:", "Remplacer par :", "Change to:", "Change to:", "更改为:"]), ("Change Format:", ["Format ersetzen:", "Remplacer le format :", "Cambiar formato:", "置換形式 :", "更改格式:"]), + ("Change/Find", ["Ersetzen/Suchen", "Remplacer/Rechercher", "Cambiar/Buscar", "置換して検索", "更改/查找"]), ("Find Format:", ["Format suchen:", "Rechercher le format :", "Buscar formato:", "検索形式 :", "查找格式:"]), ( "Find Format Settings", diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 9fa1ac6cb..2611904f0 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", "لصق"), + ("Change/Find", "تغيير/بحث"), ("Find Format:", "البحث عن تنسيق:"), ("Find Format Settings", "إعدادات البحث عن تنسيق"), ("Specify attributes to find", "تحديد السمات المراد البحث عنها"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 03f84dfde..8e93636cf 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Change/Find", "Změnit/Najít"), ("Find Format:", "Hledat formát:"), ("Find Format Settings", "Nastavení hledání formátu"), ("Specify attributes to find", "Určit atributy k hledání"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 7f4c96bdc..8c76489c2 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"), + ("Change/Find", "Sostituisci/Trova"), ("Find Format:", "Trova formato:"), ("Find Format Settings", "Impostazioni di ricerca formato"), ("Specify attributes to find", "Specifica gli attributi da trovare"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index bda369b7f..d92220ec6 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Change/Find", "ჩანაცვლება/ძიება"), ("Find Format:", "ფორმატის ძიება:"), ("Find Format Settings", "ფორმატის ძიების პარამეტრები"), ("Specify attributes to find", "საძიებო ატრიბუტების მითითება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index fc6bf7712..af7e71b01 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Change/Find", "Zmień/Znajdź"), ("Find Format:", "Znajdź format:"), ("Find Format Settings", "Ustawienia wyszukiwania formatu"), ("Specify attributes to find", "Określ atrybuty do znalezienia"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 748926937..b3af42dd8 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"), + ("Change/Find", "Alterar/Localizar"), ("Find Format:", "Localizar formato:"), ("Find Format Settings", "Configurações de localização de formato"), ("Specify attributes to find", "Especificar atributos a localizar"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 3ac6179c1..2aff2248d 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Change/Find", "Заменить/Найти"), ("Find Format:", "Найти формат:"), ("Find Format Settings", "Параметры поиска формата"), ("Specify attributes to find", "Задать атрибуты для поиска"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 163f7ae67..329dea16b 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Change/Find", "Замінити/Знайти"), ("Find Format:", "Знайти формат:"), ("Find Format Settings", "Параметри пошуку формату"), ("Specify attributes to find", "Задати атрибути для пошуку"), From 9792554e00c2fee5d74f2188b046a9515ea1dabd Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:19:32 +0200 Subject: [PATCH 086/431] Properties: a double-click on a Fill or Stroke chip opens the Color Picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As in InDesign, a double-click on Properties › Appearance's Fill or Stroke chip opens the Color Picker with that colour (a click still opens the swatches). For selected text, its own Fill and Stroke chips open it for the text. Found in the Learn Skills Daily course, chapter 23. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/panels/properties.rs | 79 +++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..3230114b1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · A double-click on a Fill or Stroke chip of Properties › Appearance opens the Color Picker with its colour, as in InDesign (a click still opens the swatches); for selected text, its own chips open it for the text. Found in the Learn Skills Daily course, chapter 23. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 44acf6e2c..ea103f57c 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -790,6 +790,9 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { )); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Fill swatch")); swatch_menu(app, &resp, &doc, &i.fill, false, i.is_text && i.count == 1, false); + if resp.double_clicked() { + open_color_picker(app, &doc, &i.fill, false, false); + } crate::rtl::paint( ui.painter(), b.min + vec2(34.0, FIELD_H / 2.0), @@ -812,6 +815,9 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { )); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Stroke swatch")); swatch_menu(app, &resp, &doc, &i.stroke, true, i.is_text && i.count == 1, false); + if resp.double_clicked() { + open_color_picker(app, &doc, &i.stroke, true, false); + } if place(ui, sub(b, 34.0, 0.0, 56.0, FIELD_H), |ui| link_label(ui, crate::i18n::tr(&app.ui.language, "Stroke"), 56.0)).clicked() { show_panel(app, "stroke"); } @@ -871,6 +877,14 @@ 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); + fields["text"] = json!(text); + app.ui.dialog = Some(crate::dialogs::Dialog::new("colorPicker", fields)); +} + /// The Fill (or Stroke) popup of Properties › Appearance, as InDesign's: Apply To (a text frame's /// frame or its text), the tint, then the swatches. `cur` is the frame's swatch. With `text_only` /// (text selected) it colours the selected text. @@ -1219,7 +1233,13 @@ 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.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" }) + }); swatch_menu(app, &resp, &doc, &sw, k != 0, false, true); + if resp.double_clicked() { + open_color_picker(app, &doc, &sw, k != 0, true); + } if k == 0 { crate::rtl::paint( ui.painter(), @@ -3300,6 +3320,65 @@ mod tests { 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); + 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"); + 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( |ui, app: &mut DesignApp| { From 861afad3385c7c81fae32d2f6c80ed915f204bc3 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:27:32 +0200 Subject: [PATCH 087/431] Swatches panel: rename a swatch in place As in InDesign, a click on the name of the swatch picked already (a slow double-click) turns the name into a field: Enter renames the swatch (swatch.options, so everything that uses it follows), Escape keeps the name. A double-click still opens Swatch Options, and the special swatches keep their names. Found in the Learn Skills Daily course, chapter 23. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/panels/swatches.rs | 148 +++++++++++++++++++++++++- 2 files changed, 148 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..44977f5c6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · Swatches panel: a swatch is renamed in place, as in InDesign: click it, then click its name again (a slow double-click), type, and Enter (Escape keeps the name). Everything that uses it follows; a double-click still opens Swatch Options, and [None], [Registration], [Paper] and [Black] keep their names. Found in the Learn Skills Daily course, chapter 23. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 3e7030e87..1abe1d6c9 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -144,6 +144,11 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { /// Temp-data key of the swatch last clicked (Delete Swatch removes it). const PICKED: &str = "swatches_picked"; +/// Temp-data key of the swatch renamed in place and its name being typed. +const RENAMING: &str = "swatches_renaming"; +/// Temp-data key of a click on the picked swatch's name: (swatch, time). Unless it turns into a +/// double-click (Swatch Options), it starts renaming. +const RENAME_CLICK: &str = "swatches_rename_click"; /// The Swatches panel's top row, as InDesign's: a Fill/Stroke proxy (click: the other in front), /// Formatting Affects Container / Text, and the tint of the colour in front. `target` is @@ -232,6 +237,43 @@ 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 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 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() { + // The field opens with the name selected. + resp.request_focus(); + if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), id) { + state + .cursor + .set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count())))); + state.store(ui.ctx(), id); + } + } + if resp.lost_focus() { + ui.data_mut(|d| d.remove_temp::<(String, String)>(key)); + let to = text.trim(); + if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && !to.is_empty() && to != name { + match app.run("swatch.options", json!({"name": name, "to": to})) { + Ok(_) => ui.data_mut(|d| { + d.insert_temp(egui::Id::new(PICKED), to.to_string()); + }), + Err(e) => { + app.status_error("Swatch Options", e); + } + } + } + } else { + ui.data_mut(|d| d.insert_temp(key, (name, text))); + } + true +} + /// One Swatches panel row: chip, name, type; click applies it to the fill or stroke in front of /// the proxy (of the frames or their text, see [`super::colors_affect_text`]), the context menu /// to the stroke, colour groups and delete. @@ -266,7 +308,10 @@ fn swatch_row( let (c, g) = crate::widgets::swatch_colors(doc, &sw.name, 1.0); let chip = egui::Rect::from_min_size(row.min + vec2(4.0 + indent, 3.0), vec2(16.0, 16.0)); crate::widgets::paint_chip(ui.painter(), chip, c, g); - ui.painter().text(row.min + vec2(28.0 + indent, 11.0), egui::Align2::LEFT_CENTER, &sw.name, egui::FontId::proportional(12.5), t.text); + let name_rect = egui::Rect::from_min_max(row.min + vec2(26.0 + indent, 1.0), egui::pos2(row.max.x - 72.0, row.max.y - 1.0)); + if !rename_field(app, ui, sw, name_rect) { + ui.painter().text(row.min + vec2(28.0 + indent, 11.0), egui::Align2::LEFT_CENTER, &sw.name, egui::FontId::proportional(12.5), t.text); + } // Type indicators: spot dot / process square, colour mode. let kind = match &sw.value { SwatchValue::Color { color, color_type } => { @@ -286,13 +331,32 @@ fn swatch_row( } ui.painter().text(row.right_center() - vec2(6.0, 0.0), egui::Align2::RIGHT_CENTER, kind, egui::FontId::proportional(10.5), t.text_dim); 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); + 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::new(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))); 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) { + let wait = ui.ctx().options(|o| o.input_options.max_double_click_delay); + 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)); + }); + } else { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(wait)); + } + } resp.context_menu(|ui| { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Swatch Options…"))).clicked() { let _ = app.run("app.swatchOptionsDialog", json!({"name": sw.name})); @@ -1135,6 +1199,88 @@ mod swatches_header_tests { assert!(h.state().session.doc().unwrap().doc.swatch("Brand").is_none()); } + /// The course's chapter 23: a swatch added as "C=14 M=8 Y=95 K=0" is renamed in the panel by + /// clicking it, then clicking its name again: the name becomes a field, and Enter renames the + /// swatch (and what uses it). A double-click still opens Swatch Options, Escape keeps the name, + /// and [Black] can't be renamed. + #[test] + fn a_second_click_on_the_picked_swatch_renames_it_in_place() { + use egui::accesskit::Role; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "unassigned"})).unwrap(); + let added = app.run("swatch.create", json!({"color": {"c": 14, "m": 8, "y": 95, "k": 0}})).unwrap()["name"].as_str().unwrap().to_string(); + assert_eq!(added, "C=14 M=8 Y=95 K=0"); + // 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"); + 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); + // The panel's own fields (Tint); renaming adds one. + let fields = h.query_all_by_role(Role::TextInput).count(); + let field = |h: &Harness<'_, DesignApp>| h.query_all_by_role(Role::TextInput).count() > fields; + let has = |h: &Harness<'_, DesignApp>, n: &str| h.state().session.doc().unwrap().doc.swatch(n).is_some(); + let name_at = |h: &Harness<'_, DesignApp>, label: &str| h.get_by_label(label).rect().left_center() + vec2(40.0, 0.0); + let click = |h: &mut Harness<'_, DesignApp>, at: egui::Pos2| { + h.hover_at(at); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.step(); + }; + // A click on the name: applied, and picked. + let at = name_at(&h, &added); + click(&mut h, at); + h.run_steps(10); + assert_eq!(items(h.state())[0].fill.swatch, added); + assert!(!field(&h)); + // Again, after the double-click time: the name is a field, with the name selected. + click(&mut h, at); + h.run_steps(10); + assert!(field(&h), "the name is a field"); + h.event(egui::Event::Text("Lemon Yellow Sun".into())); + h.step(); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert!(has(&h, "Lemon Yellow Sun") && !has(&h, &added)); + assert_eq!(items(h.state())[0].fill.swatch, "Lemon Yellow Sun", "what uses it follows"); + // Escape keeps the name. + let at = name_at(&h, "Lemon Yellow Sun"); + h.run_steps(10); + click(&mut h, at); + h.run_steps(10); + assert!(field(&h)); + h.event(egui::Event::Text("Sour".into())); + h.step(); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(has(&h, "Lemon Yellow Sun") && !field(&h)); + // A double-click on it opens Swatch Options, and doesn't rename. + h.run_steps(20); + click(&mut h, at); + click(&mut h, at); + h.run_steps(10); + assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.id.as_str()), Some("swatchOptions")); + assert!(!field(&h), "no rename"); + // [Black] keeps its name. + h.state_mut().ui.dialog = None; + h.run_steps(20); + let at = name_at(&h, "[Black]"); + click(&mut h, at); + h.run_steps(10); + click(&mut h, at); + h.run_steps(10); + assert!(!field(&h)); + } + #[test] fn the_tint_field_keeps_each_objects_own_swatch() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From 114e3682fd57901abcc36dcffb17d1dd8d0c6ab7 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:48:00 +0200 Subject: [PATCH 088/431] Properties: the test checks a double-click leaves no swatches popup open Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index ea103f57c..9989ec56a 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -3362,6 +3362,7 @@ mod tests { }; 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])); From 3430a88cbca1d269f82f28fa03e5896fc6327352 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:54:54 +0200 Subject: [PATCH 089/431] Color Picker: the eyedropper takes a colour off the page The eyedropper beside the hex code works as InDesign's: pressed and dragged onto the page, it takes the colour shown under the pointer when let go (one device pixel rendered as the canvas renders it, over the paper); off the pages it takes nothing. Found in the Learn Skills Daily course, chapter 23. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 29 +++++++++++ crates/ui-egui/src/dialogs.rs | 83 +++++++++++++++++++++++++++++--- crates/ui-egui/src/i18n.rs | 10 ++++ crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + 11 files changed, 123 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8d94f099f..74c5284d5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -639,6 +639,35 @@ fn draw_cursor_badge(painter: &egui::Painter, p: Pos2, c: Cursor, t: &Tokens) { } /// 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]> { + let rect = app.canvas_rect.filter(|r| r.contains(pos))?; + let v = *app.view()?; + let xf = Xf::new(rect, &v); + let at = xf.to_canvas(pos); + let st = app.session.active()?; + let doc = st.doc.clone(); + let layout = CanvasLayout::new(&doc, st.editing_parents); + let on_page = layout + .slots + .iter() + .any(|slot| doc.spread(slot.spread).is_some_and(|sp| sp.pages.iter().any(|page| slot.xf.transform_rect_bbox(page.bounds()).contains(at)))); + if !on_page { + return None; + } + let placed: Vec = layout.slots.iter().map(|s| designcraft_render::Placed { spread: s.spread, xf: s.xf }).collect(); + 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 img = app.canvas.patcher.render(&doc, &app.session.cache, &placed, 1, 1, view, &opts); + let [r, g, b, a] = <[u8; 4]>::try_from(img.pixels.get(..4)?).ok()?; + // Premultiplied over the paper. + let paper = doc.resolve_color(designcraft_color::swatch::PAPER, 1.0).unwrap_or(designcraft_color::Color::WHITE).to_rgba8(1.0); + let over = |c: u8, p: u8| (u32::from(c) + u32::from(p) * (255 - u32::from(a)) / 255).min(255) as u8; + Some([over(r, paper[0]), over(g, paper[1]), over(b, paper[2])]) +} + fn screen_options(app: &DesignApp, layout: &CanvasLayout, preview: bool, ppp: f64) -> designcraft_render::RenderOptions { designcraft_render::RenderOptions { printing_only: preview, diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 443df730e..5b3849243 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -6870,7 +6870,7 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { if comp_now != comp { d.fields.insert("component".into(), json!(comp_now.key())); } - // The hex code, and the eyedropper's mark beside it. + // The hex code, and the eyedropper 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)); @@ -6886,12 +6886,26 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { 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, - ); + // The eyedropper, as InDesign's: dragged onto the page, it takes the colour shown under the + // 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)); + if resp.hovered() || resp.dragged() { + ui.painter().rect_filled(er, 3.0, t.hover); + } + crate::icons::paint(ui.painter(), egui::Rect::from_center_size(er.center(), egui::vec2(15.0, 15.0)), "tool-eyedropper", t.icon); + if resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + if resp.drag_stopped() + && let Some(pos) = ui.input(|i| i.pointer.latest_pos()) + && let Some(c) = crate::canvas::color_at(app, ui.ctx(), pos) + { + p = Picked::from_rgb(c.map(|v| f32::from(v) / 255.0), p.hsb[0]); + } + resp.on_hover_text(tip); if p != before { store_picked(d, &p); if let Some(c) = exact_cmyk { @@ -10017,8 +10031,63 @@ mod tests { /// 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. #[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); + 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); + } + #[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()); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 6e9a0b40b..0443399cd 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -848,6 +848,16 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Choose Image…", ["Choose Image…", "Choisir une image…", "Choose Image…", "Choose Image…", "选择图像…"]), ("Class:", ["Class:", "Classe :", "Class:", "Class:", "类别:"]), ("Color Picker", ["Color Picker", "Sélecteur de couleurs", "Color Picker", "Color Picker", "拾色器"]), + ( + "Eyedropper: drag it onto the page", + [ + "Pipette: auf die Seite ziehen", + "Pipette : faites-la glisser sur la page", + "Cuentagotas: arrástrelo a la página", + "スポイト : ページにドラッグ", + "吸管:将其拖到页面上", + ], + ), ("Color Settings", ["Color Settings", "Réglages des couleurs", "Color Settings", "Color Settings", "颜色设置"]), ("Color:", ["Color:", "Couleur :", "Color:", "Color:", "颜色:"]), ("Columns", ["Columns", "Colonnes", "Columns", "Columns", "栏"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..b74df5167 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", "لصق"), + ("Eyedropper: drag it onto the page", "القطارة: اسحبها إلى الصفحة"), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), ("Fixed Number", "عدد ثابت"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..e16f71a0b 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Eyedropper: drag it onto the page", "Kapátko: přetáhněte ho na stránku"), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..59caf152a 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"), + ("Eyedropper: drag it onto the page", "Contagocce: trascinalo sulla pagina"), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index d938c2dc7..99c455519 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Eyedropper: drag it onto the page", "პიპეტი: გადაიტანეთ გვერდზე"), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..ec533dbb0 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Eyedropper: drag it onto the page", "Kroplomierz: przeciągnij go na stronę"), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..f8ed98d04 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"), + ("Eyedropper: drag it onto the page", "Conta-gotas: arraste-o para a página"), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), ("Fixed Number", "Número fixo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..13b74df76 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Eyedropper: drag it onto the page", "Пипетка: перетащите её на страницу"), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..a5652335c 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Eyedropper: drag it onto the page", "Піпетка: перетягніть її на сторінку"), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), From d0e90e71cabcca305c94bc5eb14fe3920b56208d Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:01:26 +0200 Subject: [PATCH 090/431] Place cursor: the crosshair throughout when no graphic is loaded With nothing in the place cursor there's no thumbnail to show, so the cursor is the crosshair before a press too, as it already was while dragging. (Review.) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests.rs | 4 ++++ crates/tools/src/nav.rs | 5 +++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3eb129c83..b43ef439e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -527,6 +527,10 @@ Y: 100.00%")), s.pointer(&PointerEvent::new(PointerKind::Up, 102.5, 501.25), v).unwrap(); let it = s.doc().unwrap().doc.spreads[0].items.last().unwrap().clone(); assert_eq!(it.bounds(), designcraft_geom::Rect::new(100.0, 500.0, 140.0, 520.0), "actual size at the press"); + // With no graphic loaded there's no thumbnail to show: the crosshair, before any press too. + s.loaded = None; + s.set_tool("placeGun"); + assert_eq!(s.cursor(p(300.0, 300.0), Mods::default(), v), Cursor::Crosshair); } #[test] diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index 95b471e4e..74a8c5074 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -191,8 +191,9 @@ Y: {pct}" fn cursor(&self, cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { // Drawing the frame: the crosshair (the loaded graphic's thumbnail would hide the frame). // Under the drag threshold no frame is drawn yet (the release is a click), so the - // thumbnail stays, as the overlay does. - let drawing = self.start.is_some_and(|a| cx.loaded.is_none() || (self.cur - a).hypot() >= cx.tol(3.0)); + // thumbnail stays, as the overlay does. With no graphic loaded there's no thumbnail to + // show: the crosshair throughout. + let drawing = cx.loaded.is_none() || self.start.is_some_and(|a| (self.cur - a).hypot() >= cx.tol(3.0)); if drawing { Cursor::Crosshair } else { Cursor::LoadedGraphic } } } From d26750d947227ea7933c1a12528d6ca9509a677a Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:22:55 +0200 Subject: [PATCH 091/431] Place cursor: a short drag places the frame it previews The place cursor draws a frame from 3 points, but the canvas sent Drag events only once egui started a drag (at max_click_dist, 6 points). A release in between placed a frame the preview never showed. With the place cursor, the canvas now follows the pointer from 3 points. (Review.) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 58 ++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 66963c528..f619f8f37 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2455,9 +2455,12 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r events.push(PointerEvent { kind: PointerKind::Down, pos: pos(o), mods: m, clicks: n }); down = true; } + // The place cursor draws a frame from 3 points (its own threshold), before egui starts a + // drag (at `max_click_dist`): follow the pointer from there, so a release between the two + // places the frame that was previewed. + let place_drag = app.session.tool_id() == "placeGun" && origin.zip(latest).is_some_and(|(o, p)| (o - p).length() >= 3.0); if down - && resp.dragged() - && resp.drag_delta() != egui::Vec2::ZERO + && ((resp.dragged() && resp.drag_delta() != egui::Vec2::ZERO) || place_drag) && let Some(p) = latest { events.push(PointerEvent { kind: PointerKind::Drag, pos: pos(p), mods: m, clicks: 1 }); @@ -3052,6 +3055,57 @@ mod tests { use super::*; + /// 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() { let (delay, dist) = (0.3, 6.0); From f0ae1c318add8067879d667495cec2b1d188dd9e Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:36:22 +0200 Subject: [PATCH 092/431] Color panel edits the gradient stop selected in the Gradient panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a gradient fill or stroke, the Color panel showed "[None] — choose a colour on the ramp", and a spectrum click replaced the whole gradient with a flat colour. As in InDesign, it now shows the stop selected in the Gradient panel (the stop before a selected midpoint) with its values, and its C/M/Y/K or R/G/B sliders and spectrum recolour that stop, keeping the others. Found in the Learn Skills Daily course, chapter 24. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + .../ui-egui/src/panels/color_panel_tests.rs | 39 ++++++++ crates/ui-egui/src/panels/swatches.rs | 96 +++++++++++++++++-- 3 files changed, 126 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..e59952263 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · With a gradient fill or stroke, the Color panel shows the stop selected in the Gradient panel (the stop before a selected midpoint) with its values, and its C/M/Y/K or R/G/B sliders and spectrum colour that stop, keeping the others, as in InDesign. It showed "[None] — choose a colour on the ramp", and a spectrum click replaced the whole gradient with a flat colour. Found in the Learn Skills Daily course, chapter 24. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/ui-egui/src/panels/color_panel_tests.rs b/crates/ui-egui/src/panels/color_panel_tests.rs index cd102b2e3..05d5899b6 100644 --- a/crates/ui-egui/src/panels/color_panel_tests.rs +++ b/crates/ui-egui/src/panels/color_panel_tests.rs @@ -81,3 +81,42 @@ fn a_typed_tint_is_kept_while_typing_and_applied_on_enter_or_leaving_the_field() h.run_steps(3); assert_eq!(fill_tint(h.state(), id), 0.0, "dragging the slider to the left end applies 0 %"); } + +/// The course's chapter 24: with a gradient fill, the Color panel shows the stop selected in the +/// Gradient panel (black, then) and its C/M/Y/K fields colour that stop, keeping the others. +#[test] +fn the_color_panel_edits_the_gradient_stop_selected_in_the_gradient_panel() { + use super::swatches::{GRAD_SEL, RampSel}; + 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, 200, 200], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + // InDesign's default White → Black. + 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.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. + let at = h + .query_all_by(|n| matches!(n.role(), Role::SpinButton | Role::TextInput)) + .map(|n| n.rect()) + .min_by(|a, b| a.min.y.total_cmp(&b.min.y)) + .unwrap() + .center(); + h.hover_at(at); + h.drag_at(at); + h.drop_at(at); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("100".into())); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(3); + let doc = h.state().session.doc().unwrap().doc.clone(); + let fill = &doc.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch; + let g = designcraft_color::swatch::resolve_gradient(&doc.swatches, fill).cloned().expect("still a gradient"); + assert_eq!(g.stops[1].color, designcraft_color::Color::cmyk(1.0, 0.0, 0.0, 1.0), "C 100 on the black stop"); + assert_eq!(g.stops[0].color, designcraft_color::Gradient::default().stops[0].color, "the white stop stays"); +} diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 3e7030e87..8ae422e06 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -397,6 +397,11 @@ pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) { }); app.ui.stroke_front = stroke; ui.data_mut(|d| d.insert_temp(mode_id, mode)); + // A gradient: the stop selected in the Gradient panel, as InDesign's Color panel shows it. + if !text && let Some(g) = designcraft_color::swatch::resolve_gradient(&doc.swatches, &name).cloned() { + gradient_stop(app, ui, &g, target, mode); + return; + } let sw = doc.swatch(&name).cloned(); let Some(c) = doc.resolve_color(&name, 1.0) else { ui.label(crate::rtl::widget( @@ -544,7 +549,79 @@ fn cmyk_ramp(k: usize, x: f32) -> egui::Color32 { } /// The spectrum ramp: hue across, light to dark down; click to apply. +/// The Color panel on a gradient fill (or stroke): the stop selected in the Gradient panel (the +/// stop before a selected midpoint), in the panel's colour mode, as InDesign's. Its fields and the +/// spectrum colour that stop. +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 i = match sel { + RampSel::Stop(i) | RampSel::Mid(i) => i, + } + .min(g.stops.len().saturating_sub(1)); + let Some(stop) = g.stops.get(i) else { return }; + let c = stop.color; + let pct = |v: f32| (v * 100.0).round(); + let values = match c { + Color::Cmyk { c, m, y, k } => format!("C={} M={} Y={} K={}", pct(c), pct(m), pct(y), pct(k)), + Color::Gray { k } => format!("K={}", pct(k)), + Color::Rgb { .. } => { + let [r, g, b, _] = c.to_rgba8(1.0); + format!("R={r} G={g} B={b}") + } + }; + ui.label(egui::RichText::new(values).size(11.0).color(t.text_dim)); + let mut new = None; + match mode { + ColorMode::Cmyk => { + let mut v = c.to_cmyk(); + let mut done = false; + for (k, label) in ["C", "M", "Y", "K"].iter().enumerate() { + done |= channel(ui, label, &mut v[k], 100.0, "%", |x| cmyk_ramp(k, x)); + } + if done { + new = Some(Color::cmyk(v[0], v[1], v[2], v[3])); + } + } + ColorMode::Rgb => { + let mut v = c.to_rgb(); + let mut done = false; + for (k, label) in ["R", "G", "B"].iter().enumerate() { + done |= channel(ui, label, &mut v[k], 255.0, "", |x| { + let mut p = [0.0; 3]; + p[k] = x; + egui::Color32::from_rgb((p[0] * 255.0) as u8, (p[1] * 255.0) as u8, (p[2] * 255.0) as u8) + }); + } + if done { + new = Some(Color::rgb(v[0], v[1], v[2])); + } + } + ColorMode::Lab => { + let lab = c.to_lab(); + ui.label(egui::RichText::new(format!("L {:.0} a {:.0} b {:.0}", lab.l, lab.a, lab.b)).color(t.text_dim)); + } + } + if let Some([r, gr, b]) = spectrum_pick(ui) { + new = Some(Color::rgb(r, gr, b)); + } + if let Some(c) = new { + let mut stops = stops_json(g); + set_stop(&mut stops, i, "color", color_json(&c)); + let _ = app.run("object.gradient", json!({"stops": stops, "target": target})); + } +} + fn spectrum(app: &mut DesignApp, ui: &mut egui::Ui, target: &str) { + if let Some([r, g, b]) = spectrum_pick(ui) { + let text = super::colors_affect_text(app); + let _ = app.run("object.color", json!({"color": {"model": "rgb", "r": r, "g": g, "b": b}, "target": target, "text": text})); + } +} + +/// The spectrum ramp; a click on it gives that colour (RGB, 0–1, in 1/255 steps). +fn spectrum_pick(ui: &mut egui::Ui) -> Option<[f32; 3]> { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 36.0), Sense::click()); 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(); @@ -561,14 +638,9 @@ fn spectrum(app: &mut DesignApp, ui: &mut egui::Ui, target: &str) { ui.painter().rect_filled(cell, 0.0, egui::Color32::from_rgb((c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8)); } } - if resp.clicked() - && let Some(p) = resp.interact_pointer_pos() - { - let c = at(((p.x - r.min.x) / r.width()).clamp(0.0, 1.0), ((p.y - r.min.y) / r.height()).clamp(0.0, 1.0)); - let text = super::colors_affect_text(app); - let q = |v: f32| (v * 255.0).round() / 255.0; - let _ = app.run("object.color", json!({"color": {"model": "rgb", "r": q(c[0]), "g": q(c[1]), "b": q(c[2])}, "target": target, "text": text})); - } + let p = resp.interact_pointer_pos().filter(|_| resp.clicked())?; + let c = at(((p.x - r.min.x) / r.width()).clamp(0.0, 1.0), ((p.y - r.min.y) / r.height()).clamp(0.0, 1.0)); + Some(c.map(|v| (v * 255.0).round() / 255.0)) } /// A swatch dragged out of the Swatches panel (the Gradient panel's ramp takes it as a stop). @@ -621,9 +693,13 @@ fn thumbnail(p: &egui::Painter, r: egui::Rect, g: &designcraft_color::Gradient, p.rect_stroke(r, 0.0, egui::Stroke::new(1.0, border), egui::StrokeKind::Outside); } +/// Temp-data key of what's selected on the Gradient panel's ramp ([`RampSel`]): the Color panel +/// edits that stop. +pub(super) const GRAD_SEL: &str = "grad_sel"; + /// What's selected on the ramp: a stop, or the midpoint after a stop. #[derive(Clone, Copy, PartialEq, Debug)] -enum RampSel { +pub(super) enum RampSel { Stop(usize), Mid(usize), } @@ -666,7 +742,7 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let is_gradient = cur.is_some(); let g = cur.unwrap_or_default(); let radial = g.kind == designcraft_color::GradientKind::Radial; - let sel_id = egui::Id::new("grad_sel"); + let sel_id = egui::Id::new(GRAD_SEL); let drag_id = egui::Id::new("grad_drag"); let mut sel: RampSel = ui.data(|d| d.get_temp(sel_id)).unwrap_or(RampSel::Stop(0)); let last = g.stops.len().saturating_sub(1); From 87675f5b0bfe8c127f1c4920939cf3382b294433 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:49:49 +0200 Subject: [PATCH 093/431] Status bar page box names the parent in view and lists the parents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While a parent was shown, the status bar's page box and Properties › Page still read page 1. As in InDesign, they now name the parent ("A-Parent"), and their lists have the parents after the pages: choosing a parent shows it, choosing a page leaves the parents for that page. Found in the Learn Skills Daily course, chapter 25. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 46 +++++++++++++ crates/ui-egui/src/chrome.rs | 86 +++++++++++++++++++++---- crates/ui-egui/src/panels/properties.rs | 13 ++-- 4 files changed, 128 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..1cead9f02 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · While a parent is shown, the status bar's page box and Properties › Page name it ("A-Parent") instead of page 1, as InDesign's; their lists have the parents after the pages, and choosing one shows that parent (a page leaves the parents). Found in the Learn Skills Daily course, chapter 25. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8d94f099f..505fd9bce 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -238,6 +238,52 @@ pub fn current_parent_page(app: &DesignApp) -> Option<(usize, usize)> { Some((pi, qi)) } +/// The page box's entries (the status bar's, and Properties › Page), as InDesign's: the +/// document's pages by name, then the parents by label ("A-Parent"). +pub struct PageBox { + pub names: Vec, + /// The entry in view: the parent shown while parents are edited, else the page. + pub current: usize, + /// How many of `names` are pages; the parents follow. + pub pages: usize, + parents: Vec, +} + +impl PageBox { + /// The name of the entry in view ("3", "A-Parent"). + pub fn current_name(&self) -> &str { + self.names.get(self.current).map_or("", String::as_str) + } +} + +pub fn page_box(app: &DesignApp) -> Option { + let st = app.session.active()?; + let pages = st.doc.page_count(); + let mut names: Vec = (0..pages).map(|i| app.session.page_label(i)).collect(); + names.extend(st.doc.parents.iter().map(|p| p.parent.as_ref().map(|i| i.label()).unwrap_or_default())); + let current = match current_parent_page(app) { + Some((pi, _)) => pages + pi, + None => current_page(app).unwrap_or(0).min(pages.saturating_sub(1)), + }; + Some(PageBox { names, current, pages, parents: st.doc.parents.iter().map(|p| p.id).collect() }) +} + +/// Shows entry `i` of `b`: a page (leaving the parents, if they are shown) or a parent. +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})); + // The canvas refits to the page's spread once it is centred. + if let Some(v) = app.view_mut() { + v.fitted = false; + } + } + 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})); + } +} + /// Scroll so absolute page `abs` is centred (keeps zoom). pub fn go_to_page(app: &mut DesignApp, abs: usize) { let Some(st) = app.session.active() else { return }; diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 0df5361f9..d4814c49c 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -776,22 +776,45 @@ pub fn status_bar(app: &mut DesignApp, ui: &mut egui::Ui) { if icons::button(ui, "prev", 14.0, false, crate::i18n::tr(&app.ui.language, "Previous Spread")).clicked() { crate::canvas::go_to_page(app, cur.saturating_sub(1)); } - let name = app.session.page_label(cur); + // The page box, as InDesign's: the page in view, or the parent while one is shown + // ("A-Parent"); its menu lists the pages, then the parents. + let pb = crate::canvas::page_box(app); + let name = pb.as_ref().map_or_else(String::new, |b| b.current_name().to_string()); let (fr, fresp) = ui.allocate_exact_size(vec2(80.0, 14.0), Sense::click()); ui.painter().rect(fr, 0.0, t.input, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); - ui.painter().text(fr.left_center() + vec2(5.0, 0.0), egui::Align2::LEFT_CENTER, &name, small.clone(), t.text); + // A long parent name is cut at the arrow. + ui.painter().with_clip_rect(egui::Rect::from_min_max(fr.min, egui::pos2(fr.max.x - 14.0, fr.max.y))).text( + fr.left_center() + vec2(5.0, 0.0), + egui::Align2::LEFT_CENTER, + &name, + small.clone(), + t.text, + ); ui.painter().text(fr.right_center() - vec2(5.0, 0.0), egui::Align2::RIGHT_CENTER, "▾", small.clone(), t.text); - egui::Popup::menu(&fresp).show(|ui| { - let names: Vec = (0..n).map(|i| app.session.page_label(i)).collect(); - egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { - for (i, nm) in names.iter().enumerate() { - if ui.button(nm).clicked() { - crate::canvas::go_to_page(app, i); - ui.close(); - } - } - }); + fresp.widget_info(|| { + let mut info = egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, "Page"); + info.current_text_value = Some(name.clone()); + info }); + let mut picked = None; + if let Some(b) = &pb { + egui::Popup::menu(&fresp).show(|ui| { + egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { + for (i, nm) in b.names.iter().enumerate() { + if i == b.pages && i > 0 { + ui.separator(); + } + if ui.selectable_label(i == b.current, nm).clicked() { + picked = Some(i); + ui.close(); + } + } + }); + }); + } + if let (Some(b), Some(i)) = (&pb, picked) { + crate::canvas::page_box_go(app, b, i); + } if icons::button(ui, "next", 14.0, false, crate::i18n::tr(&app.ui.language, "Next Spread")).clicked() { crate::canvas::go_to_page(app, (cur + 1).min(n - 1)); } @@ -1261,6 +1284,45 @@ mod tests { assert_eq!(h.state().app.ui.open_panel.as_deref(), Some("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"); + } + /// As in InDesign, double-clicking the preflight count (or its light) shows the panel. #[test] fn double_clicking_the_status_bar_error_count_opens_the_preflight_panel() { diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 44acf6e2c..b700fcddc 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -444,12 +444,13 @@ 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); - let names: Vec = (0..d.page_count()).map(|i| app.session.page_label(i)).collect(); - let picked = place(ui, sub(b, 0.0, 0.0, 93.0, FIELD_H), |ui| { - widgets::dropdown_list(ui, &names[cur.min(names.len().saturating_sub(1))], 93.0, &names, Some(cur)) - }); - if let Some(pg) = picked { - crate::canvas::go_to_page(app, pg); + // 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); + } } let (add, del) = place(ui, sub(b, 172.0, 0.0, 50.0, FIELD_H), |ui| { ui.spacing_mut().item_spacing.x = 4.0; From e91f74835062f8ac8ca4d75affb0e93e01d054a0 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:17:21 +0200 Subject: [PATCH 094/431] Insert Pages dialog as InDesign's; pages can go before a page or first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Insert Pages… opened the generic command form (Count, a 0-based After index, a parent id). It now opens InDesign's dialog: Pages; Insert After Page, Before Page, At Start of Document or At End of Document, with the page by name; and Parent ([None] or a parent). It is set to the page in view (or the page right-clicked) and that page's parent. `layout.pages.insert` takes `before` (0: at the start of the document); `after` and `before` together are refused. Found in the Learn Skills Daily course, chapter 25. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/layout.rs | 44 +++++++++- crates/ui-egui/src/dialogs.rs | 133 +++++++++++++++++++++++++++++ crates/ui-egui/src/i18n.rs | 6 ++ crates/ui-egui/src/i18n/ar.rs | 6 ++ crates/ui-egui/src/i18n/cs.rs | 6 ++ crates/ui-egui/src/i18n/it.rs | 7 ++ crates/ui-egui/src/i18n/ka.rs | 6 ++ crates/ui-egui/src/i18n/pl.rs | 6 ++ crates/ui-egui/src/i18n/pt_br.rs | 7 ++ crates/ui-egui/src/i18n/ru.rs | 6 ++ crates/ui-egui/src/i18n/uk.rs | 7 ++ crates/ui-egui/src/menus.rs | 5 ++ crates/ui-egui/src/panels/pages.rs | 6 +- 14 files changed, 240 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..e214f8fbe 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · Insert Pages… opens InDesign's dialog: Pages, Insert (After Page, Before Page, At Start of Document, At End of Document) with the page by name, and Parent ([None] or a parent), set to the page in view and its parent. It was a form asking for a page index and a parent id, and pages could not go before the first page; `layout.pages.insert` takes `before` (0: at the start). Found in the Learn Skills Daily course, chapter 25. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 615e7c1df..3ddaaf4c6 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -112,16 +112,22 @@ pub fn specs() -> Vec { "Insert Pages…", ["Layout", "Pages"], Some("Cmd+Shift+P"), - "{count?: 1, after?: page index (default: last), parent?: id|\"A-Parent\"|\"A\"|null}", + "{count?: 1, after?: page index (default: last), before?: page index (instead of `after`; 0: at the start), parent?: id|\"A-Parent\"|\"A\"|null}", has_doc, |s, p| { let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 9999) as usize; let st = s.doc()?; let n = st.doc.page_count(); - let after = p.get("after").and_then(Value::as_u64).map(|v| usize::try_from(v).unwrap_or(usize::MAX)).unwrap_or(n.saturating_sub(1)); + let index = |k: &str| p.get(k).and_then(Value::as_u64).map(|v| usize::try_from(v).unwrap_or(usize::MAX)); + // The page the new ones follow; none: they go first. + let after = match (index("after"), index("before")) { + (Some(_), Some(_)) => return Err(bad("layout.pages.insert", "give `after` or `before`, not both")), + (None, Some(b)) => b.checked_sub(1), + (a, None) => Some(a.unwrap_or(n.saturating_sub(1))), + }; let parent = parent_ref("layout.pages.insert", &st.doc, p.get("parent"))?.unwrap_or_else(|| st.doc.parents.first().map(|x| x.id)); s.edit(|d, _| { - let ids = d.insert_pages(Some(after), count, parent)?; + let ids = d.insert_pages(after, count, parent)?; Ok(json!({"pages": ids.len(), "total": d.page_count()})) }) } @@ -1852,6 +1858,38 @@ mod parent_margin_tests { assert!(s.execute("layout.marginsAndColumns", &json!({"parent": "Z", "margins": 10})).is_err()); } + /// Insert Pages before a page, or at the start of the document (`before: 0`), as InDesign's + /// dialog offers; `after` and `before` together are refused without an undo step. + #[test] + fn pages_go_in_before_a_page_or_at_the_start() { + let mut s = Session::new(); + // Facing: [1] [2 3]. + s.execute("file.new", &json!({"pages": 3})).unwrap(); + let b = s.execute("layout.parents.new", &json!({"prefix": "B"})).unwrap()["id"].as_u64().unwrap(); + let ids = |s: &Session| -> Vec { + let d = &s.doc().unwrap().doc; + (0..d.page_count()).map(|i| d.page(i).unwrap().id.0).collect() + }; + let parent = |s: &Session, i: usize| s.doc().unwrap().doc.page(i).unwrap().parent.map(|p| p.0); + let old = ids(&s); + s.execute("layout.pages.insert", &json!({"before": 0, "parent": "B"})).unwrap(); + let now = ids(&s); + assert_eq!(now.len(), 4); + assert_eq!(now[1..], old[..], "the old pages follow the new first page"); + assert_eq!(parent(&s, 0), Some(b)); + // Before page 4 (index 3, the old page 3): two pages without a parent. + s.execute("layout.pages.insert", &json!({"before": 3, "count": 2, "parent": null})).unwrap(); + let then = ids(&s); + assert_eq!(then.len(), 6); + assert_eq!(then[..3], now[..3]); + assert_eq!(then[5], old[2]); + assert_eq!((parent(&s, 3), parent(&s, 4)), (None, None)); + let undo = s.doc().unwrap().history.undo.len(); + assert!(s.execute("layout.pages.insert", &json!({"after": 0, "before": 1})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + assert_eq!(ids(&s), then); + } + #[test] fn applying_a_parent_gives_its_margins_and_local_margins_stay_local() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8b4043787..13ce41a6e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -137,6 +137,56 @@ fn text_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, w: f32) { } } +/// Insert Pages…, after page `abs` and with that page's parent, as InDesign's. +pub(crate) fn open_insert_pages(app: &mut DesignApp, abs: usize) { + let Some(st) = app.session.active() else { return }; + let abs = abs.min(st.doc.page_count().saturating_sub(1)); + let parent = st.doc.page(abs).and_then(|p| p.parent).map_or_else(String::new, |id| id.0.to_string()); + app.ui.dialog = Some(Dialog::new("insertPages", json!({"count": 1, "insert": "after", "page": abs, "parent": parent}))); +} + +/// Insert Pages, as InDesign's: how many, where (after or before a page, at the start or the end +/// of the document) and with which parent. +fn insert_pages_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let lang = app.ui.language.clone(); + let Some(st) = app.session.active() else { return }; + let pages: Vec = (0..st.doc.page_count()).map(|i| app.session.page_label(i)).collect(); + 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 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() { + d.fields.insert("count".into(), json!(n)); + } + ui.end_row(); + 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); + // 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( + ui, + |ui| { + for (i, name) in pages.iter().enumerate() { + if ui.selectable_label(i == cur, name).clicked() { + d.fields.insert("page".into(), json!(i)); + } + } + }, + ); + }); + }); + ui.end_row(); + 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); + ui.end_row(); + }); +} + /// Extract once when opened. Cancel is read-only; confirmation adds exactly these colours. pub(crate) fn open_color_theme(app: &mut DesignApp, params: Value) { match app.run("color.extractTheme", params) { @@ -2037,6 +2087,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "newDocument" => crate::i18n::tr(&app.ui.language, "New Document"), "frameSize" => crate::i18n::tr(&app.ui.language, "Rectangle"), "goToPage" => crate::i18n::tr(&app.ui.language, "Go to Page"), + "insertPages" => crate::i18n::tr(&app.ui.language, "Insert Pages"), "insertTable" => crate::i18n::tr(&app.ui.language, "Create Table"), "cellOptions" => crate::i18n::tr(&app.ui.language, "Cell Options"), "createGuides" => crate::i18n::tr(&app.ui.language, "Create Guides"), @@ -2101,6 +2152,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let preferred_width: f32 = match d.id.as_str() { "alert" => 440.0, "closeDocument" | "updateLink" | "deletePages" | "newWorkspace" | "pagesPanelOptions" => 380.0, + "insertPages" => 440.0, "newDocument" => 960.0, "numberingSection" => 560.0, crate::effects_dialog::ID => 720.0, @@ -2759,6 +2811,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { STEP_AND_REPEAT => step_and_repeat_dialog(app, ui, &mut d), TOC => toc_dialog(app, ui, &mut d), id if id.starts_with("cmd:") => command_form(app, ui, &mut d), + "insertPages" => insert_pages_dialog(app, ui, &mut d), "goToPage" => { ui.horizontal(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Page")); @@ -3077,6 +3130,20 @@ pub fn confirm(app: &mut DesignApp) -> Result { ) } "ruby" => app.run("type.ruby", style_cjk::ruby_params(&d)), + "insertPages" => { + let n = app.session.active().map_or(1, |st| st.doc.page_count()); + let page = d.n("page").unwrap_or(0.0).max(0.0) as usize; + let mut p = json!({"count": d.n("count").unwrap_or(1.0).clamp(1.0, 9999.0) as u64}); + match d.s("insert").as_str() { + "before" => p["before"] = json!(page), + "start" => p["before"] = json!(0), + "end" => p["after"] = json!(n.saturating_sub(1)), + _ => p["after"] = json!(page), + } + // A parent's id, or [None]. + p["parent"] = d.s("parent").parse::().map_or(Value::Null, |id| json!(id)); + app.run("layout.pages.insert", p) + } "goToPage" => { // A page name ("iv", "A-3"), a number, or "+n" for an absolute position. let typed = match d.fields.get("page") { @@ -7418,6 +7485,72 @@ mod tests { assert!(matches!(fill(&app, id), designcraft_color::Color::Rgb { .. }), "{:?}", fill(&app, id)); } + /// The course's chapter 25: Insert Pages as InDesign's. It opens after the page in view with + /// that page's parent; At Start of Document with B-Cover puts a B page first, and Before Page + /// with [None] puts a page without a parent before the page chosen. + #[test] + fn insert_pages_goes_before_a_page_or_at_the_start_with_the_chosen_parent() { + use egui::accesskit::Role; + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + // Facing: [1] [2 3] [4]. + app.run("file.new", json!({"pages": 4})).unwrap(); + let b = app.run("layout.parents.new", json!({"prefix": "B", "name": "Cover"})).unwrap()["id"].as_u64().unwrap(); + let ids = |app: &DesignApp| -> Vec { + let d = &app.session.doc().unwrap().doc; + (0..d.page_count()).map(|i| d.page(i).unwrap().id.0).collect() + }; + let old = ids(&app); + crate::menus::activate(&mut app, "layout.pages.insert", &Value::Null); + 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"); + 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); + for l in ["Pages:", "Insert:", "Parent:"] { + assert!(h.query_by_label(l).is_some(), "{l}"); + } + // The dropdown showing `shown`, then its item `choice`. + let pick = |h: &mut Harness<'_, DesignApp>, shown: &str, choice: &str| { + h.get_by(|n| n.role() == Role::ComboBox && n.value().as_deref() == Some(shown)).click(); + h.run_steps(2); + h.get_by_label(choice).click(); + h.run_steps(2); + }; + // After page 1 with A, at first; then At Start of Document, with B-Cover. + pick(&mut h, "After Page", "At Start of Document"); + pick(&mut h, "A-Parent", "B-Cover"); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert!(h.state().ui.dialog.is_none()); + let now = ids(h.state()); + assert_eq!(now[1..], old[..], "the new page is first"); + let parent = |app: &DesignApp, i: usize| app.session.doc().unwrap().doc.page(i).unwrap().parent.map(|p| p.0); + assert_eq!(parent(h.state(), 0), Some(b)); + // Before page 4, with [None]. + crate::dialogs::open_insert_pages(h.state_mut(), 0); + h.run_steps(3); + pick(&mut h, "After Page", "Before Page"); + pick(&mut h, "1", "4"); + pick(&mut h, "B-Cover", "[None]"); + h.get_by_label(" OK ").click(); + h.run_steps(3); + let then = ids(h.state()); + assert_eq!(then.len(), 6); + assert_eq!(then[..3], now[..3], "the pages before it stay"); + assert_eq!(then[4..], now[3..], "page 4 and those after it follow the new page"); + assert_eq!(parent(h.state(), 3), None); + } + #[test] fn ok_without_edits_leaves_the_fill_alone() { let (mut app, _, _, id) = picker(json!({"target": "fill", "hex": "#ff8000", "model": "cmyk", "cmyk": [0, 50, 100, 0]})); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index efe089ef9..ddc27e2eb 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1846,6 +1846,12 @@ const TABLE: &[(&str, [&str; 5])] = &[ ["Insert Math Expression…", "Insérer une expression mathématique…", "Insert Math Expression…", "Insert Math Expression…", "插入数学公式…"], ), ("Insert Pages…", ["Insert Pages…", "Insérer des pages…", "Insert Pages…", "Insert Pages…", "插入页面…"]), + ("Insert:", ["Einfügen:", "Insérer :", "Insertar:", "挿入:", "插入:"]), + ("Parent:", ["Vorlagenseite:", "Gabarit :", "Página principal:", "親ページ:", "主页:"]), + ("After Page", ["Nach Seite", "Après la page", "Después de la página", "ページの後", "页面后"]), + ("Before Page", ["Vor Seite", "Avant la page", "Antes de la página", "ページの前", "页面前"]), + ("At Start of Document", ["Am Dokumentanfang", "Au début du document", "Al principio del documento", "ドキュメントの先頭", "文档开头"]), + ("At End of Document", ["Am Dokumentende", "À la fin du document", "Al final del documento", "ドキュメントの末尾", "文档末尾"]), ("Insert Variable", ["Insert Variable", "Insérer une variable", "Insert Variable", "Insert Variable", "插入变量"]), ("Inspect Document", ["Inspect Document", "Inspecter le document", "Inspect Document", "Inspect Document", "检查文档"]), ( diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..4a3a7b427 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("At End of Document", "في نهاية المستند"), + ("At Start of Document", "في بداية المستند"), + ("Before Page", "قبل الصفحة"), + ("After Page", "بعد الصفحة"), + ("Parent:", "الصفحة الأصلية:"), + ("Insert:", "إدراج:"), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), ("Fixed Number", "عدد ثابت"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..d299266f2 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("At End of Document", "Na konec dokumentu"), + ("At Start of Document", "Na začátek dokumentu"), + ("Before Page", "Před stránku"), + ("After Page", "Za stránku"), + ("Parent:", "Vzor:"), + ("Insert:", "Vložit:"), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..b0d49feaa 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("At End of Document", "Alla fine del documento"), + ("At Start of Document", "All'inizio del documento"), + ("Before Page", "Prima della pagina"), + ("After Page", "Dopo la pagina"), + ("Parent:", "Pagina mastro:"), + ("Insert:", "Inserisci:"), + ("Insert Pages", "Inserisci pagine"), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index d938c2dc7..fb802b878 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("At End of Document", "დოკუმენტის ბოლოს"), + ("At Start of Document", "დოკუმენტის დასაწყისში"), + ("Before Page", "გვერდამდე"), + ("After Page", "გვერდის შემდეგ"), + ("Parent:", "საბაზო გვერდი:"), + ("Insert:", "ჩასმა:"), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..68a44a7cd 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("At End of Document", "Na końcu dokumentu"), + ("At Start of Document", "Na początku dokumentu"), + ("Before Page", "Przed stroną"), + ("After Page", "Po stronie"), + ("Parent:", "Strona wzorcowa:"), + ("Insert:", "Wstaw:"), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..50706f734 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("At End of Document", "No final do documento"), + ("At Start of Document", "No início do documento"), + ("Before Page", "Antes da página"), + ("After Page", "Após a página"), + ("Parent:", "Página principal:"), + ("Insert:", "Inserir:"), + ("Insert Pages", "Inserir páginas"), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), ("Fixed Number", "Número fixo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..4e4839a65 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("At End of Document", "В конец документа"), + ("At Start of Document", "В начало документа"), + ("Before Page", "Перед страницей"), + ("After Page", "После страницы"), + ("Parent:", "Шаблон:"), + ("Insert:", "Вставить:"), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..ab11cf1c3 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("At End of Document", "У кінець документа"), + ("At Start of Document", "На початок документа"), + ("Before Page", "Перед сторінкою"), + ("After Page", "Після сторінки"), + ("Parent:", "Шаблон:"), + ("Insert:", "Вставити:"), + ("Insert Pages", "Вставити сторінки"), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 79b8b2c4e..321991a5e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2505,6 +2505,11 @@ fn activate_inner(app: &mut DesignApp, id: &str, params: &Value) { crate::section_options::open(app); return; } + if params.is_null() && id == "layout.pages.insert" { + let cur = crate::canvas::current_page(app).unwrap_or(0); + crate::dialogs::open_insert_pages(app, cur); + return; + } if params.is_null() && id == "layout.createGuides" { crate::dialogs::open_create_guides(app); return; diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 4b2360f8f..4a65abd4d 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -646,7 +646,7 @@ fn empty_menu(app: &mut DesignApp, ui: &mut egui::Ui) { let cur = crate::canvas::current_page(app).unwrap_or(0); let item = |ui: &mut egui::Ui, label: &str| ui.button(crate::rtl::widget(ui, crate::i18n::tr(&lang, label))).clicked(); if item(ui, "Insert Pages…") { - app.ui.dialog = Some(crate::dialogs::Dialog::new("cmd:layout.pages.insert", json!({"after": cur, "count": 1}))); + crate::dialogs::open_insert_pages(app, cur); ui.close(); } if item(ui, "Move Pages…") { @@ -680,7 +680,7 @@ fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Docu let item = |ui: &mut egui::Ui, label: &str| ui.button(crate::rtl::widget(ui, tr(label))).clicked(); // InDesign's order: pages, parents, layouts and numbering, shuffling. if item(ui, "Insert Pages…") { - app.ui.dialog = Some(crate::dialogs::Dialog::new("cmd:layout.pages.insert", json!({"after": abs, "count": 1}))); + crate::dialogs::open_insert_pages(app, abs); ui.close(); } if item(ui, "Move Pages…") { @@ -1123,7 +1123,7 @@ mod tests { 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 == "cmd:layout.pages.insert")); + assert!(h.state().ui.dialog.as_ref().is_some_and(|d| d.id == "insertPages")); } #[test] From c0e38160d6f187becb656543a2bbde9b89bbf650 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:50:19 +0200 Subject: [PATCH 095/431] Spelling: "we", "if", "email" and curly-apostrophe contractions are known MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spell checker uses the Moby Hyphenator II word list, which lacks some very common short words (we, us, if, Mr, pm, vs) and most computer and web vocabulary (email, online, website, database, desktop, download...). Contractions typed with a typographer's apostrophe (don’t, we’re), which Use Typographer's Quotes produces by default, were looked up with the curly apostrophe and flagged too. Dynamic Spelling underlined most words of an ordinary sentence. `known` now reads U+2019 as a straight apostrophe (also in the user dictionary) and accepts a short sorted list of such common words, with the existing inflection rules applied to them. Found in the Learn Skills Daily course, chapter 26. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/spelling.rs | 135 ++++++++++++++++++++++++++++-- 2 files changed, 131 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..d4cf65258 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · Spelling (Check Spelling and Dynamic Spelling) no longer flags everyday words the bundled hyphenation word list lacks: "we", "us", "if", titles such as "Mr", and words such as email, online, website, database, desktop and download, with their plurals and other forms. Contractions typed with a typographer's apostrophe (don’t, we’re), which Use Typographer's Quotes produces by default, count like their straight-apostrophe forms. Dynamic Spelling used to underline most words of an ordinary sentence. Found in the Learn Skills Daily course, chapter 26. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/engine/src/cmd/spelling.rs b/crates/engine/src/cmd/spelling.rs index 914165387..c799f9b1f 100644 --- a/crates/engine/src/cmd/spelling.rs +++ b/crates/engine/src/cmd/spelling.rs @@ -81,13 +81,91 @@ pub fn specs() -> Vec { ] } +/// Common words the hyphenation word list (from the early 1990s) lacks: everyday short words +/// such as "we", "us" and "if", titles, and words that came with computers and the web. Sorted, +/// lowercase, base forms (the inflections below reach "emails", "downloaded", "bloggers"). +const COMMON: &[&str] = &[ + "ah", + "bitmap", + "blog", + "bluetooth", + "chatbot", + "checkbox", + "cybersecurity", + "database", + "dataset", + "desktop", + "download", + "dr", + "dropdown", + "ebook", + "ecommerce", + "eh", + "email", + "emoji", + "firewall", + "gigabyte", + "hashtag", + "hm", + "hmm", + "homepage", + "hyperlink", + "if", + "inbox", + "internet", + "jr", + "kilobyte", + "laptop", + "livestream", + "login", + "logout", + "malware", + "megabyte", + "metadata", + "mr", + "offline", + "ok", + "online", + "ow", + "pm", + "podcast", + "popup", + "printout", + "screenshot", + "selfie", + "smartphone", + "smartwatch", + "spreadsheet", + "sr", + "startup", + "terabyte", + "touchscreen", + "um", + "upload", + "us", + "username", + "voicemail", + "vs", + "we", + "webinar", + "webmaster", + "webpage", + "website", + "wifi", + "workflow", + "yay", +]; + /// Is `w` (lowercase) a known word, allowing common inflections of dictionary stems? pub fn known(dict: &Dictionary, user: &[String], w: &str) -> bool { - if w.len() <= 1 || dict.get(w).is_some() || user.iter().any(|u| u == w) { + // A typographer's apostrophe (don’t, we’re) is the word list's straight one. + let w = &w.replace('’', "'"); + let has = |w: &str| dict.get(w).is_some() || COMMON.binary_search(&w).is_ok(); + if w.len() <= 1 || has(w) || user.iter().any(|u| u.replace('’', "'") == *w) { return true; } - let w = w.trim_end_matches("'s").trim_end_matches("’s"); - if dict.get(w).is_some() { + let w = w.trim_end_matches("'s"); + if has(w) { return true; } let stems: [(&str, &[&str]); 9] = [ @@ -105,14 +183,14 @@ pub fn known(dict: &Dictionary, user: &[String], w: &str) -> bool { if let Some(base) = w.strip_suffix(suf) { for add in adds { let cand = format!("{base}{add}"); - if cand.len() > 1 && dict.get(&cand).is_some() { + if cand.len() > 1 && has(&cand) { return true; } // Doubled consonant: "stopped" → "stop". if let Some(c) = base.chars().last() && base.len() > 2 && base.ends_with(&format!("{c}{c}")) - && dict.get(&base[..base.len() - c.len_utf8()]).is_some() + && has(&base[..base.len() - c.len_utf8()]) { return true; } @@ -253,6 +331,53 @@ mod tests { assert_eq!(bad, vec![6..10]); } + /// Everyday words the hyphenation list lacks aren't misspelled ("we", "us", "if", "email"…), + /// with their inflections, and neither are contractions typed with a typographer's + /// apostrophe, which Use Typographer's Quotes turns ' into. + #[test] + fn common_words_and_curly_apostrophes_are_known() { + let d = Dictionary::en_us(); + if d.is_empty() { + return; // data not bundled in this build + } + assert!(COMMON.is_sorted(), "COMMON is binary-searched"); + for w in [ + "we", + "us", + "if", + "ok", + "mr", + "email", + "emails", + "emailed", + "online", + "website", + "websites", + "downloaded", + "database", + "databases", + "blogger", + "don’t", + "can’t", + "i’m", + "we’re", + "they’ve", + "it’s", + "don't", + ] { + assert!(known(d, &[], w), "{w}"); + } + for w in ["dont", "wee’re", "emial"] { + assert!(!known(d, &[], w), "{w}"); + } + // A user word added with a typographer's apostrophe matches either form. + assert!(known(d, &["zorbo’s".into()], "zorbo's") && known(d, &["zorbo's".into()], "zorbo’s")); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "We don’t email us if we’re online."})).unwrap(); + assert_eq!(s.execute("spelling.check", &json!({})).unwrap(), json!([])); + } + #[test] fn check_finds_misspellings() { let mut s = Session::new(); From 1d11e0a8c41b9fa43d8537613295ec8c01759871 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:57:24 +0200 Subject: [PATCH 096/431] =?UTF-8?q?Preferences=20=E2=80=BA=20Interface:=20?= =?UTF-8?q?Color=20Theme=20swatches=20and=20the=20Tools=20panel's=20column?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preferences › Interface held only UI Size, and the interface theme could be changed only from Window › Interface Color Theme. As in InDesign, Interface now has Appearance, with a Color Theme swatch per theme (a click shows it at once, Cancel goes back to the theme Preferences opened with), and Panels, where Tools Panel sets a single or double column. UI Size moves to a UI Scaling section of its own, as in InDesign's list. Found in the Learn Skills Daily course, chapter 26. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/dialogs.rs | 126 ++++++++++++++++++++++++++++++- crates/ui-egui/src/i18n.rs | 3 + crates/ui-egui/src/i18n/ar.rs | 3 + crates/ui-egui/src/i18n/cs.rs | 3 + crates/ui-egui/src/i18n/it.rs | 3 + crates/ui-egui/src/i18n/ka.rs | 3 + crates/ui-egui/src/i18n/pl.rs | 3 + crates/ui-egui/src/i18n/pt_br.rs | 3 + crates/ui-egui/src/i18n/ru.rs | 3 + crates/ui-egui/src/i18n/uk.rs | 3 + crates/ui-egui/src/menus.rs | 4 + 12 files changed, 155 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..62a641894 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · Preferences › Interface follows InDesign's: Appearance has a Color Theme swatch per interface theme (a click shows it at once, Cancel goes back) and Panels sets the Tools panel to a single or double column; UI Size moves to a UI Scaling section of its own. Interface held only UI Size, and the theme could be changed only from the Window menu. Found in the Learn Skills Daily course, chapter 26. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8b4043787..e73ac5c49 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1570,6 +1570,7 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he &[ ("general", "General"), ("interface", "Interface"), + ("uiScaling", "UI Scaling"), ("type", "Type"), ("advancedType", "Advanced Type"), ("composition", "Composition"), @@ -1590,6 +1591,7 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he &[ ("general", "General"), ("interface", "Interface"), + ("uiScaling", "UI Scaling"), ("type", "Type"), ("composition", "Composition"), ("spelling", "Spelling"), @@ -1809,6 +1811,40 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he } } "interface" => { + let lang = app.ui.language.clone(); + let t = crate::theme::Tokens::get(ui.ctx()); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Appearance")).font(semibold(12.0))); + // Color Theme: a swatch per theme, as InDesign's. A click shows it at once; + // Cancel goes back to the theme Preferences opened with. + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&lang, "Color Theme:")); + let cur = d.s("brightness"); + for b in crate::theme::Brightness::ALL { + let (r, resp) = ui.allocate_exact_size(egui::vec2(24.0, 24.0), egui::Sense::click()); + let on = cur == b.id(); + let swatch = r.shrink(4.0); + ui.painter().rect_filled(swatch, 1.0, crate::theme::Tokens::for_brightness(b).panel); + ui.painter().rect_stroke(swatch, 1.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); + if on || resp.hovered() { + let ring = if on { t.accent_strong } else { t.field_border }; + 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)); + if resp.on_hover_text(&name).clicked() && !on { + d.fields.insert("brightness".into(), json!(b.id())); + d.fields.insert("applyBrightness".into(), json!(b.id())); + } + } + }); + 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")]); + }); + } + "uiScaling" => { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "UI Scaling")).font(semibold(12.0))); ui.horizontal(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "UI Size:")); @@ -2868,6 +2904,12 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { { d.fields.insert("status".into(), json!(e)); } + // So does Preferences › Interface › Color Theme, as InDesign's. + if let Some(b) = d.fields.remove("applyBrightness") + && let Err(e) = app.run("window.brightness", json!({"brightness": b})) + { + d.fields.insert("status".into(), json!(e)); + } if d.id == "newDocument" && result.is_none() { crate::new_document::preview(app, &mut d, ctx); } @@ -3011,10 +3053,18 @@ pub fn cancel(app: &mut DesignApp) { app.cancel_pdf_import(); if let Some(d) = d && d.id == "preferences" - && let Some(lang) = d.fields.get("openedLanguage").and_then(Value::as_str) - && lang != app.ui.language { - let _ = app.run("app.language", json!({"lang": lang})); + if let Some(lang) = d.fields.get("openedLanguage").and_then(Value::as_str) + && lang != app.ui.language + { + let _ = app.run("app.language", json!({"lang": lang})); + } + // The Color Theme shown while choosing goes back too. + if let Some(b) = d.fields.get("openedBrightness").and_then(Value::as_str) + && b != app.ui.brightness.id() + { + let _ = app.run("window.brightness", json!({"brightness": b})); + } } } @@ -3371,6 +3421,13 @@ pub fn confirm(app: &mut DesignApp) -> Result { }; app.run(view, json!({}))?; app.run("window.richBlack", json!({"on": d.b("richBlack")}))?; + // Interface: the theme (already showing, unless set without a click) and the Tools panel. + if let Some(b) = d.fields.get("brightness").and_then(Value::as_str).filter(|b| *b != app.ui.brightness.id()) { + app.run("window.brightness", json!({"brightness": b}))?; + } + if d.fields.contains_key("toolsColumns") { + app.ui.tools_double_column = d.s("toolsColumns") == "double"; + } if let Some(v) = d.n("uiScale") { app.run("window.uiScale", json!({"scale": v / 100.0}))?; } @@ -7418,6 +7475,69 @@ mod tests { assert!(matches!(fill(&app, id), designcraft_color::Color::Rgb { .. }), "{:?}", fill(&app, id)); } + /// 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); + // 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 ok_without_edits_leaves_the_fill_alone() { let (mut app, _, _, id) = picker(json!({"target": "fill", "hex": "#ff8000", "model": "cmyk", "cmyk": [0, 50, 100, 0]})); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index efe089ef9..b61e0257c 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2162,6 +2162,9 @@ const TABLE: &[(&str, [&str; 5])] = &[ ), ("Typical Display", ["Typical Display", "Affichage standard", "Typical Display", "Typical Display", "典型显示"]), ("UI Scaling", ["UI Scaling", "Mise à l'échelle de l'interface", "UI Scaling", "UI Scaling", "UI 缩放"]), + ("Color Theme:", ["Farbdesign:", "Thème de couleur :", "Tema de color:", "カラーテーマ:", "颜色主题:"]), + ("Tools Panel:", ["Werkzeugbedienfeld:", "Panneau Outils :", "Panel Herramientas:", "ツールパネル:", "工具面板:"]), + ("Double Column", ["Doppelte Spalte", "Double colonne", "Columna doble", "2 列", "双栏"]), ("Update Link", ["Update Link", "Mettre à jour le lien", "Update Link", "Update Link", "更新链接"]), ( "User Dictionary Words", diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..53f5ce1eb 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Double Column", "عمود مزدوج"), + ("Tools Panel:", "لوحة الأدوات:"), + ("Color Theme:", "سمة الألوان:"), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), ("Fixed Number", "عدد ثابت"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..4bc7ce13b 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Double Column", "Dvojitý sloupec"), + ("Tools Panel:", "Panel nástrojů:"), + ("Color Theme:", "Barevný motiv:"), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..33fbe5787 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Double Column", "Colonna doppia"), + ("Tools Panel:", "Pannello Strumenti:"), + ("Color Theme:", "Tema colore:"), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index d938c2dc7..9f6a8f5f1 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Double Column", "ორმაგი სვეტი"), + ("Tools Panel:", "ხელსაწყოების პანელი:"), + ("Color Theme:", "ფერთა თემა:"), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..731683d7e 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Double Column", "Podwójna kolumna"), + ("Tools Panel:", "Panel narzędzi:"), + ("Color Theme:", "Motyw kolorów:"), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..af15d07ec 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Double Column", "Coluna dupla"), + ("Tools Panel:", "Painel Ferramentas:"), + ("Color Theme:", "Tema de cores:"), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), ("Fixed Number", "Número fixo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..4fff60238 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Double Column", "Двойной столбец"), + ("Tools Panel:", "Панель инструментов:"), + ("Color Theme:", "Цветовая тема:"), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..ac7d599e1 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Double Column", "Подвійна колонка"), + ("Tools Panel:", "Панель інструментів:"), + ("Color Theme:", "Колірна тема:"), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 79b8b2c4e..fbf5467f5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1411,6 +1411,10 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option "high", }); f["uiScale"] = json!((app.ui.ui_scale * 100.0).round()); + // Interface: Cancel goes back to the theme Preferences opened with. + f["brightness"] = json!(app.ui.brightness.id()); + f["openedBrightness"] = json!(app.ui.brightness.id()); + f["toolsColumns"] = json!(if app.ui.tools_double_column { "double" } else { "single" }); f["richBlack"] = json!(app.ui.rich_black); f["dynamicSpelling"] = json!(app.ui.dynamic_spelling); f["storyEditorSize"] = json!(app.ui.story_editor_size); From e5e3f091d34f8508d9cddd21c79ac1f42234aa23 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:02:24 +0200 Subject: [PATCH 097/431] =?UTF-8?q?Preferences=20=E2=80=BA=20File=20Handli?= =?UTF-8?q?ng:=20the=20recent=20items=20count=20is=20a=20whole=20number?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Number of Recent Items to Display read "20.0": its field's drag speed of 0.2 gave it a decimal place. It now shows "20", as InDesign's does. Found in the Learn Skills Daily course, chapter 26. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/dialogs.rs | 24 +++++++++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..a03b7845b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · Preferences › File Handling shows Number of Recent Items to Display as a whole number ("20"; it read "20.0"). Found in the Learn Skills Daily course, chapter 26. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8b4043787..bed4cdf04 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1695,7 +1695,10 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he ui.horizontal(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Number of Recent Items to Display:")); let mut v = d.n("recentFilesMax").unwrap_or(20.0); - if ui.add(egui::DragValue::new(&mut v).range(0.0..=f64::from(designcraft_engine::MAX_RECENT_FILES)).speed(0.2)).changed() { + // A count: a whole number ("20", not "20.0"). + let field = + egui::DragValue::new(&mut v).range(0.0..=f64::from(designcraft_engine::MAX_RECENT_FILES)).speed(0.2).fixed_decimals(0); + if ui.add(field).changed() { d.fields.insert("recentFilesMax".into(), json!(v.round())); } }); @@ -7418,6 +7421,25 @@ mod tests { assert!(matches!(fill(&app, id), designcraft_color::Color::Rgb { .. }), "{:?}", fill(&app, id)); } + /// 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.fract(), 0.0); + visible_label(&labels, screen, &format!("{count}")); + assert!(!labels.iter().any(|(t, _, _)| t.trim() == format!("{count:.1}")), "no decimal shown"); + } + #[test] fn ok_without_edits_leaves_the_fill_alone() { let (mut app, _, _, id) = picker(json!({"target": "fill", "hex": "#ff8000", "model": "cmyk", "cmyk": [0, 50, 100, 0]})); From d21459ff3ea1c807940596a76561a37a5d3f9883 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:25:29 +0200 Subject: [PATCH 098/431] =?UTF-8?q?File=20=E2=80=BA=20Package:=20InDesign'?= =?UTF-8?q?s=20Package=20dialog=20and=20Printing=20Instructions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Package… went straight to a folder picker and packaged with fixed options. As in InDesign it now opens the Package dialog: Summary counts the fonts, links and images and inks the package carries (a warning where some are missing or modified), and Fonts, Links and Images and Colors and Inks list them, with Show Problems Only. Create Printing Instructions leads to the Printing Instructions dialog (contact, company, address, phone, fax, email, instructions), whose text goes into the package's Instructions.txt; Include IDML and Include PDF (Print) choose the copies the package carries. Found in the Learn Skills Daily course, chapter 27. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/dialogs.rs | 10 + crates/ui-egui/src/i18n.rs | 85 +++++ crates/ui-egui/src/i18n/ar.rs | 37 +++ crates/ui-egui/src/i18n/cs.rs | 36 +++ crates/ui-egui/src/i18n/it.rs | 38 +++ crates/ui-egui/src/i18n/ka.rs | 39 +++ crates/ui-egui/src/i18n/pl.rs | 39 +++ crates/ui-egui/src/i18n/pt_br.rs | 38 +++ crates/ui-egui/src/i18n/ru.rs | 39 +++ crates/ui-egui/src/i18n/uk.rs | 38 +++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/menus.rs | 24 +- crates/ui-egui/src/package_dialog.rs | 457 +++++++++++++++++++++++++++ 14 files changed, 866 insertions(+), 16 deletions(-) create mode 100644 crates/ui-egui/src/package_dialog.rs diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..b0e33a381 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · File › Package… opens InDesign's Package dialog: Summary counts the fonts, links and images and inks the package carries, with a warning where some are missing or modified, and Fonts, Links and Images and Colors and Inks list them (Show Problems Only). Create Printing Instructions asks for the contact details and instructions that go into the package's Instructions.txt, and Include IDML / Include PDF (Print) choose the copies it carries. Package… went straight to the folder picker with fixed options. Found in the Learn Skills Daily course, chapter 27. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8b4043787..9182f5843 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2087,6 +2087,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "colorGroupOptions" => crate::i18n::tr(&app.ui.language, "Color Group Options"), "layerSettings" => crate::i18n::tr(&app.ui.language, "Layer Options"), crate::effects_dialog::ID => crate::i18n::tr(&app.ui.language, "Effects"), + crate::package_dialog::ID => crate::i18n::tr(&app.ui.language, "Package"), + crate::package_dialog::INSTRUCTIONS => crate::i18n::tr(&app.ui.language, "Printing Instructions"), id => match id.strip_prefix("cmd:").and_then(designcraft_engine::find_command) { Some(c) => crate::i18n::tr(&app.ui.language, c.label.trim_end_matches('…')), None => crate::i18n::tr(&app.ui.language, "Dialog"), @@ -2104,6 +2106,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "newDocument" => 960.0, "numberingSection" => 560.0, crate::effects_dialog::ID => 720.0, + crate::package_dialog::ID => 720.0, + crate::package_dialog::INSTRUCTIONS => 520.0, _ => 640.0, }; let width = preferred_width.min(available.x); @@ -2361,6 +2365,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "colorGroupOptions" => options::color_group_options(app, ui, &mut d), "layerSettings" => options::layer_options(app, ui, &mut d), crate::effects_dialog::ID => crate::effects_dialog::show(app, ui, &mut d), + crate::package_dialog::ID => crate::package_dialog::show(app, ui, &mut d), + crate::package_dialog::INSTRUCTIONS => crate::package_dialog::show_instructions(app, ui, &mut d), "preferences" => preferences(app, ui, &mut d, body_height), "print" => print_dialog(app, ui, &mut d), "pdfImport" => { @@ -2819,6 +2825,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "updateLink" => crate::i18n::tr(&app.ui.language, " Update ").to_string(), "findFont" => crate::i18n::tr(&app.ui.language, "Replace All").to_string(), "newDocument" => format!(" {} ", crate::i18n::tr(&app.ui.language, "Create")), + crate::package_dialog::ID => crate::i18n::tr(&app.ui.language, "Package…").to_string(), + crate::package_dialog::INSTRUCTIONS => crate::i18n::tr(&app.ui.language, "Continue").to_string(), _ => crate::i18n::tr(&app.ui.language, " OK ").to_string(), }; let cancel_label = match d.id.as_str() { @@ -3573,6 +3581,8 @@ pub fn confirm(app: &mut DesignApp) -> Result { } "alert" => Ok(Value::Null), crate::effects_dialog::ID => crate::effects_dialog::confirm(app, &d), + crate::package_dialog::ID => crate::package_dialog::confirm(app, &d), + crate::package_dialog::INSTRUCTIONS => crate::package_dialog::confirm_instructions(app, &d), "findChange" => { let result = if d.b("objectMode") { Ok(Value::Null) } else { app.run("find.change", find_change_params(&d)) }; // Confirmation consumes retained bounds before closing, even on an error. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index efe089ef9..eccf6ca89 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -83,6 +83,91 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Print…", ["Drucken …", "Imprimer…", "Imprimir…", "プリント…", "打印…"]), ("Document Setup…", ["Dokument einrichten …", "Format de document…", "Ajustar documento…", "ドキュメント設定…", "文档设置…"]), ("Package…", ["Verpacken …", "Assemblage…", "Empaquetar…", "パッケージ…", "打包…"]), + ("Summary", ["Übersicht", "Résumé", "Resumen", "概要", "摘要"]), + ("Fonts", ["Schriften", "Polices", "Fuentes", "フォント", "字体"]), + ("Links and Images", ["Verknüpfungen und Bilder", "Liens et images", "Vínculos e imágenes", "リンクと画像", "链接和图像"]), + ("Colors and Inks", ["Farben und Druckfarben", "Couleurs et encres", "Colores y tintas", "カラーとインキ", "颜色和油墨"]), + ("Process Cyan", ["Prozess-Cyan", "Cyan quadri", "Cian de cuatricromía", "プロセスシアン", "印刷青色"]), + ("Process Magenta", ["Prozess-Magenta", "Magenta quadri", "Magenta de cuatricromía", "プロセスマゼンタ", "印刷洋红色"]), + ("Process Yellow", ["Prozess-Gelb", "Jaune quadri", "Amarillo de cuatricromía", "プロセスイエロー", "印刷黄色"]), + ("Process Black", ["Prozess-Schwarz", "Noir quadri", "Negro de cuatricromía", "プロセスブラック", "印刷黑色"]), + ("Contact:", ["Kontakt:", "Contact :", "Contacto:", "連絡先:", "联系人:"]), + ("Company:", ["Firma:", "Société :", "Empresa:", "会社名:", "公司:"]), + ("Address:", ["Adresse:", "Adresse :", "Dirección:", "住所:", "地址:"]), + ("Phone:", ["Telefon:", "Téléphone :", "Teléfono:", "電話:", "电话:"]), + ("Fax:", ["Fax:", "Fax :", "Fax:", "FAX:", "传真:"]), + ("Email:", ["E-Mail:", "E-mail :", "Correo electrónico:", "メール:", "电子邮件:"]), + ("Instructions:", ["Anweisungen:", "Instructions :", "Instrucciones:", "指示:", "说明:"]), + ( + "Fonts: {used} Fonts Used, {missing} Missing", + [ + "Schriften: {used} verwendet, {missing} fehlen", + "Polices : {used} utilisées, {missing} manquantes", + "Fuentes: {used} utilizadas, {missing} ausentes", + "フォント : {used} 個使用、{missing} 個見つかりません", + "字体:使用 {used} 种,缺失 {missing} 种", + ], + ), + ( + "Links and Images: {found} Links Found; {modified} Modified, {missing} Missing", + [ + "Verknüpfungen und Bilder: {found} gefunden; {modified} geändert, {missing} fehlen", + "Liens et images : {found} liens trouvés ; {modified} modifiés, {missing} manquants", + "Vínculos e imágenes: {found} vínculos encontrados; {modified} modificados, {missing} ausentes", + "リンクと画像 : {found} 個のリンク、{modified} 個変更、{missing} 個見つかりません", + "链接和图像:找到 {found} 个链接;{modified} 个已修改,{missing} 个缺失", + ], + ), + ( + "Images: {embedded} Embedded", + [ + "Bilder: {embedded} eingebettet", + "Images : {embedded} incorporées", + "Imágenes: {embedded} incrustadas", + "画像 : {embedded} 個埋め込み", + "图像:嵌入 {embedded} 个", + ], + ), + ( + "Colors and Inks: {process} Process Inks; {spot} Spot Inks", + [ + "Farben und Druckfarben: {process} Prozessfarben; {spot} Volltonfarben", + "Couleurs et encres : {process} encres quadri ; {spot} encres d’accompagnement", + "Colores y tintas: {process} tintas de cuatricromía; {spot} tintas planas", + "カラーとインキ : プロセスインキ {process} 個、スポットインキ {spot} 個", + "颜色和油墨:{process} 种印刷色油墨;{spot} 种专色油墨", + ], + ), + ("Missing", ["Fehlt", "Manquant", "Ausente", "見つかりません", "缺失"]), + ("Modified", ["Geändert", "Modifié", "Modificado", "変更", "已修改"]), + ("Substituted", ["Ersetzt", "Remplacée", "Sustituida", "代替", "已替换"]), + ("Bundled", ["Mitgeliefert", "Fournie", "Incluida", "同梱", "内置"]), + ("Installed", ["Installiert", "Installée", "Instalada", "インストール済み", "已安装"]), + ("Source", ["Quelle", "Source", "Origen", "ソース", "来源"]), + ("Scope:", ["Bereich:", "Étendue :", "Ámbito:", "範囲:", "范围:"]), + ("Entire Publication", ["Gesamte Publikation", "Publication entière", "Toda la publicación", "出版物全体", "整个出版物"]), + ( + "Create Printing Instructions", + ["Druckanweisungen erstellen", "Créer des instructions d’impression", "Crear instrucciones de impresión", "印刷指示を作成", "创建打印说明"], + ), + ("Include IDML", ["IDML einschließen", "Inclure l’IDML", "Incluir IDML", "IDML を含める", "包括 IDML"]), + ( + "Include PDF (Print)", + ["PDF einschließen (Druck)", "Inclure le PDF (impression)", "Incluir PDF (impresión)", "PDF を含める(印刷)", "包括 PDF(打印)"], + ), + ("Show Problems Only", ["Nur Probleme anzeigen", "Afficher uniquement les problèmes", "Mostrar solo problemas", "問題のみ表示", "仅显示问题"]), + ("Printing Instructions", ["Druckanweisungen", "Instructions d’impression", "Instrucciones de impresión", "印刷指示", "打印说明"]), + ("Continue", ["Weiter", "Continuer", "Continuar", "続行", "继续"]), + ( + "Packaged {count} files into {dir}", + [ + "{count} Dateien in {dir} verpackt", + "{count} fichiers assemblés dans {dir}", + "{count} archivos empaquetados en {dir}", + "{count} 個のファイルを {dir} にパッケージしました", + "已将 {count} 个文件打包到 {dir}", + ], + ), // Edit. ("Undo", ["Rückgängig", "Annuler", "Deshacer", "取り消し", "还原"]), ("Redo", ["Wiederholen", "Rétablir", "Rehacer", "やり直し", "重做"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..268b799d1 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,43 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Packaged {count} files into {dir}", "تم حزم {count} ملفات في {dir}"), + ("Continue", "متابعة"), + ("Printing Instructions", "تعليمات الطباعة"), + ("Show Problems Only", "إظهار المشكلات فقط"), + ("Include PDF (Print)", "تضمين PDF (للطباعة)"), + ("Include IDML", "تضمين IDML"), + ("Create Printing Instructions", "إنشاء تعليمات الطباعة"), + ("Entire Publication", "المنشور بالكامل"), + ("Scope:", "النطاق:"), + ("Source", "المصدر"), + ("Installed", "مثبت"), + ("Bundled", "مضمن مع التطبيق"), + ("Substituted", "مستبدل"), + ("Modified", "معدل"), + ("Missing", "مفقود"), + ("Colors and Inks: {process} Process Inks; {spot} Spot Inks", "الألوان والأحبار: {process} أحبار معالجة؛ {spot} أحبار مخصصة"), + ("Images: {embedded} Embedded", "الصور: {embedded} مضمنة"), + ( + "Links and Images: {found} Links Found; {modified} Modified, {missing} Missing", + "الروابط والصور: {found} روابط؛ {modified} معدلة، {missing} مفقودة", + ), + ("Fonts: {used} Fonts Used, {missing} Missing", "الخطوط: {used} مستخدمة، {missing} مفقودة"), + ("Instructions:", "التعليمات:"), + ("Email:", "البريد الإلكتروني:"), + ("Fax:", "الفاكس:"), + ("Phone:", "الهاتف:"), + ("Address:", "العنوان:"), + ("Company:", "الشركة:"), + ("Contact:", "جهة الاتصال:"), + ("Process Black", "أسود معالجة"), + ("Process Yellow", "أصفر معالجة"), + ("Process Magenta", "أرجواني معالجة"), + ("Process Cyan", "سماوي معالجة"), + ("Colors and Inks", "الألوان والأحبار"), + ("Links and Images", "الروابط والصور"), + ("Fonts", "الخطوط"), + ("Summary", "الملخص"), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), ("Fixed Number", "عدد ثابت"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..a51121541 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,42 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Continue", "Pokračovat"), + ("Printing Instructions", "Pokyny k tisku"), + ("Show Problems Only", "Zobrazit jen problémy"), + ("Include PDF (Print)", "Zahrnout PDF (tisk)"), + ("Include IDML", "Zahrnout IDML"), + ("Create Printing Instructions", "Vytvořit pokyny k tisku"), + ("Entire Publication", "Celá publikace"), + ("Scope:", "Rozsah:"), + ("Spot", "Přímá"), + ("Process", "Procesní"), + ("Source", "Zdroj"), + ("Installed", "Nainstalováno"), + ("Bundled", "Přibaleno"), + ("Substituted", "Nahrazeno"), + ("Modified", "Změněno"), + ("Missing", "Chybí"), + ("Colors and Inks: {process} Process Inks; {spot} Spot Inks", "Barvy a inkousty: procesní {process}; přímé {spot}"), + ("Images: {embedded} Embedded", "Obrazy: vloženo {embedded}"), + ( + "Links and Images: {found} Links Found; {modified} Modified, {missing} Missing", + "Odkazy a obrazy: nalezeno {found}; změněno {modified}, chybí {missing}", + ), + ("Fonts: {used} Fonts Used, {missing} Missing", "Písma: použito {used}, chybí {missing}"), + ("Instructions:", "Pokyny:"), + ("Fax:", "Fax:"), + ("Address:", "Adresa:"), + ("Company:", "Společnost:"), + ("Contact:", "Kontakt:"), + ("Process Black", "Procesní černá"), + ("Process Yellow", "Procesní žlutá"), + ("Process Magenta", "Procesní purpurová"), + ("Process Cyan", "Procesní azurová"), + ("Colors and Inks", "Barvy a inkousty"), + ("Links and Images", "Odkazy a obrazy"), + ("Fonts", "Písma"), + ("Summary", "Souhrn"), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..0d0d49112 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,44 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Packaged {count} files into {dir}", "{count} file raccolti in {dir}"), + ("Continue", "Continua"), + ("Printing Instructions", "Istruzioni di stampa"), + ("Package", "Pacchetto"), + ("Show Problems Only", "Mostra solo problemi"), + ("Include PDF (Print)", "Includi PDF (stampa)"), + ("Include IDML", "Includi IDML"), + ("Create Printing Instructions", "Crea istruzioni di stampa"), + ("Entire Publication", "Intera pubblicazione"), + ("Scope:", "Ambito:"), + ("Source", "Origine"), + ("Installed", "Installato"), + ("Bundled", "Incluso"), + ("Substituted", "Sostituito"), + ("Modified", "Modificato"), + ("Missing", "Mancante"), + ("Colors and Inks: {process} Process Inks; {spot} Spot Inks", "Colori e inchiostri: {process} quadricromia; {spot} tinte piatte"), + ("Images: {embedded} Embedded", "Immagini: {embedded} incorporate"), + ( + "Links and Images: {found} Links Found; {modified} Modified, {missing} Missing", + "Collegamenti e immagini: {found} trovati; {modified} modificati, {missing} mancanti", + ), + ("Fonts: {used} Fonts Used, {missing} Missing", "Font: {used} utilizzati, {missing} mancanti"), + ("Instructions:", "Istruzioni:"), + ("Email:", "E-mail:"), + ("Fax:", "Fax:"), + ("Phone:", "Telefono:"), + ("Address:", "Indirizzo:"), + ("Company:", "Società:"), + ("Contact:", "Contatto:"), + ("Process Black", "Nero quadricromia"), + ("Process Yellow", "Giallo quadricromia"), + ("Process Magenta", "Magenta quadricromia"), + ("Process Cyan", "Cyan quadricromia"), + ("Colors and Inks", "Colori e inchiostri"), + ("Links and Images", "Collegamenti e immagini"), + ("Fonts", "Font"), + ("Summary", "Riepilogo"), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index d938c2dc7..142f806ac 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,45 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Packaged {count} files into {dir}", "{count} ფაილი შეიფუთა {dir}-ში"), + ("Continue", "გაგრძელება"), + ("Printing Instructions", "ბეჭდვის ინსტრუქციები"), + ("Show Problems Only", "მხოლოდ პრობლემების ჩვენება"), + ("Include PDF (Print)", "PDF-ის ჩართვა (ბეჭდვა)"), + ("Include IDML", "IDML-ის ჩართვა"), + ("Create Printing Instructions", "ბეჭდვის ინსტრუქციების შექმნა"), + ("Entire Publication", "მთელი პუბლიკაცია"), + ("Scope:", "არეალი:"), + ("Spot", "ლაქა"), + ("Process", "პროცესი"), + ("Source", "წყარო"), + ("Installed", "დაყენებული"), + ("Bundled", "ჩაშენებული"), + ("Substituted", "ჩანაცვლებული"), + ("Modified", "შეცვლილი"), + ("Missing", "აკლია"), + ("Colors and Inks: {process} Process Inks; {spot} Spot Inks", "ფერები და მელნები: {process} პროცესის; {spot} ლაქის"), + ("Images: {embedded} Embedded", "გამოსახულებები: ჩაშენებულია {embedded}"), + ( + "Links and Images: {found} Links Found; {modified} Modified, {missing} Missing", + "ბმულები და გამოსახულებები: ნაპოვნია {found}; შეცვლილია {modified}, აკლია {missing}", + ), + ("Fonts: {used} Fonts Used, {missing} Missing", "შრიფტები: გამოყენებულია {used}, აკლია {missing}"), + ("Instructions:", "ინსტრუქციები:"), + ("Email:", "ელფოსტა:"), + ("Fax:", "ფაქსი:"), + ("Phone:", "ტელეფონი:"), + ("Address:", "მისამართი:"), + ("Company:", "კომპანია:"), + ("Contact:", "საკონტაქტო პირი:"), + ("Process Black", "პროცესის შავი"), + ("Process Yellow", "პროცესის ყვითელი"), + ("Process Magenta", "პროცესის მეწამული"), + ("Process Cyan", "პროცესის ცისფერი"), + ("Colors and Inks", "ფერები და მელნები"), + ("Links and Images", "ბმულები და გამოსახულებები"), + ("Fonts", "შრიფტები"), + ("Summary", "შეჯამება"), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..ca8c99e33 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,45 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Packaged {count} files into {dir}", "Spakowano {count} plików do {dir}"), + ("Continue", "Kontynuuj"), + ("Printing Instructions", "Instrukcje drukowania"), + ("Show Problems Only", "Pokaż tylko problemy"), + ("Include PDF (Print)", "Dołącz PDF (druk)"), + ("Include IDML", "Dołącz IDML"), + ("Create Printing Instructions", "Utwórz instrukcje drukowania"), + ("Entire Publication", "Cała publikacja"), + ("Scope:", "Zakres:"), + ("Spot", "Dodatkowy"), + ("Process", "Procesowy"), + ("Source", "Źródło"), + ("Installed", "Zainstalowana"), + ("Bundled", "Dołączona"), + ("Substituted", "Zastąpiona"), + ("Modified", "Zmieniony"), + ("Missing", "Brak"), + ("Colors and Inks: {process} Process Inks; {spot} Spot Inks", "Kolory i farby: procesowe {process}; dodatkowe {spot}"), + ("Images: {embedded} Embedded", "Obrazy: osadzone {embedded}"), + ( + "Links and Images: {found} Links Found; {modified} Modified, {missing} Missing", + "Łącza i obrazy: znaleziono {found}; zmienione {modified}, brakujące {missing}", + ), + ("Fonts: {used} Fonts Used, {missing} Missing", "Czcionki: użyte {used}, brakujące {missing}"), + ("Instructions:", "Instrukcje:"), + ("Email:", "E-mail:"), + ("Fax:", "Faks:"), + ("Phone:", "Telefon:"), + ("Address:", "Adres:"), + ("Company:", "Firma:"), + ("Contact:", "Kontakt:"), + ("Process Black", "Czarny procesowy"), + ("Process Yellow", "Żółty procesowy"), + ("Process Magenta", "Magenta procesowa"), + ("Process Cyan", "Cyjan procesowy"), + ("Colors and Inks", "Kolory i farby"), + ("Links and Images", "Łącza i obrazy"), + ("Fonts", "Czcionki"), + ("Summary", "Podsumowanie"), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..9c4317506 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,44 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Packaged {count} files into {dir}", "{count} arquivos empacotados em {dir}"), + ("Continue", "Continuar"), + ("Printing Instructions", "Instruções de impressão"), + ("Package", "Empacotar"), + ("Show Problems Only", "Mostrar apenas problemas"), + ("Include PDF (Print)", "Incluir PDF (impressão)"), + ("Include IDML", "Incluir IDML"), + ("Create Printing Instructions", "Criar instruções de impressão"), + ("Entire Publication", "Publicação inteira"), + ("Scope:", "Escopo:"), + ("Source", "Origem"), + ("Installed", "Instalada"), + ("Bundled", "Incluída"), + ("Substituted", "Substituída"), + ("Modified", "Modificado"), + ("Missing", "Ausente"), + ("Colors and Inks: {process} Process Inks; {spot} Spot Inks", "Cores e tintas: {process} de escala; {spot} especiais"), + ("Images: {embedded} Embedded", "Imagens: {embedded} incorporadas"), + ( + "Links and Images: {found} Links Found; {modified} Modified, {missing} Missing", + "Vínculos e imagens: {found} encontrados; {modified} modificados, {missing} ausentes", + ), + ("Fonts: {used} Fonts Used, {missing} Missing", "Fontes: {used} usadas, {missing} ausentes"), + ("Instructions:", "Instruções:"), + ("Email:", "E-mail:"), + ("Fax:", "Fax:"), + ("Phone:", "Telefone:"), + ("Address:", "Endereço:"), + ("Company:", "Empresa:"), + ("Contact:", "Contato:"), + ("Process Black", "Preto de escala"), + ("Process Yellow", "Amarelo de escala"), + ("Process Magenta", "Magenta de escala"), + ("Process Cyan", "Ciano de escala"), + ("Colors and Inks", "Cores e tintas"), + ("Links and Images", "Vínculos e imagens"), + ("Fonts", "Fontes"), + ("Summary", "Resumo"), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), ("Fixed Number", "Número fixo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..4888924ee 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,45 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Packaged {count} files into {dir}", "Упаковано файлов: {count} в {dir}"), + ("Continue", "Продолжить"), + ("Printing Instructions", "Инструкции для печати"), + ("Show Problems Only", "Показывать только проблемы"), + ("Include PDF (Print)", "Включить PDF (печать)"), + ("Include IDML", "Включить IDML"), + ("Create Printing Instructions", "Создать инструкции для печати"), + ("Entire Publication", "Вся публикация"), + ("Scope:", "Область:"), + ("Spot", "Плашечный"), + ("Process", "Триадный"), + ("Source", "Источник"), + ("Installed", "Установлен"), + ("Bundled", "Встроенный"), + ("Substituted", "Заменен"), + ("Modified", "Изменено"), + ("Missing", "Отсутствует"), + ("Colors and Inks: {process} Process Inks; {spot} Spot Inks", "Цвета и краски: триадных {process}; плашечных {spot}"), + ("Images: {embedded} Embedded", "Изображения: внедрено {embedded}"), + ( + "Links and Images: {found} Links Found; {modified} Modified, {missing} Missing", + "Связи и изображения: найдено {found}; изменено {modified}, отсутствует {missing}", + ), + ("Fonts: {used} Fonts Used, {missing} Missing", "Шрифты: используется {used}, отсутствует {missing}"), + ("Instructions:", "Инструкции:"), + ("Email:", "Эл. почта:"), + ("Fax:", "Факс:"), + ("Phone:", "Телефон:"), + ("Address:", "Адрес:"), + ("Company:", "Компания:"), + ("Contact:", "Контакт:"), + ("Process Black", "Триадный черный"), + ("Process Yellow", "Триадный желтый"), + ("Process Magenta", "Триадный пурпурный"), + ("Process Cyan", "Триадный голубой"), + ("Colors and Inks", "Цвета и краски"), + ("Links and Images", "Связи и изображения"), + ("Fonts", "Шрифты"), + ("Summary", "Сводка"), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..88d72cb57 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,44 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Packaged {count} files into {dir}", "Запаковано файлів: {count} у {dir}"), + ("Continue", "Продовжити"), + ("Printing Instructions", "Інструкції для друку"), + ("Package", "Пакування"), + ("Show Problems Only", "Показувати лише проблеми"), + ("Include PDF (Print)", "Включити PDF (друк)"), + ("Include IDML", "Включити IDML"), + ("Create Printing Instructions", "Створити інструкції для друку"), + ("Entire Publication", "Уся публікація"), + ("Scope:", "Область:"), + ("Source", "Джерело"), + ("Installed", "Встановлений"), + ("Bundled", "Вбудований"), + ("Substituted", "Замінено"), + ("Modified", "Змінено"), + ("Missing", "Бракує"), + ("Colors and Inks: {process} Process Inks; {spot} Spot Inks", "Кольори й фарби: тріадних {process}; плашкових {spot}"), + ("Images: {embedded} Embedded", "Зображення: вбудовано {embedded}"), + ( + "Links and Images: {found} Links Found; {modified} Modified, {missing} Missing", + "Зв’язки й зображення: знайдено {found}; змінено {modified}, бракує {missing}", + ), + ("Fonts: {used} Fonts Used, {missing} Missing", "Шрифти: використано {used}, бракує {missing}"), + ("Instructions:", "Інструкції:"), + ("Email:", "Ел. пошта:"), + ("Fax:", "Факс:"), + ("Phone:", "Телефон:"), + ("Address:", "Адреса:"), + ("Company:", "Компанія:"), + ("Contact:", "Контакт:"), + ("Process Black", "Тріадний чорний"), + ("Process Yellow", "Тріадний жовтий"), + ("Process Magenta", "Тріадний пурпуровий"), + ("Process Cyan", "Тріадний блакитний"), + ("Colors and Inks", "Кольори й фарби"), + ("Links and Images", "Зв’язки й зображення"), + ("Fonts", "Шрифти"), + ("Summary", "Зведення"), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cb98cac7f..758ec7988 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -21,6 +21,7 @@ pub mod menus; mod modal_drag; mod new_document; mod object_control; +pub mod package_dialog; mod panel_docking; pub mod panels; mod prefs; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 79b8b2c4e..794e58f2d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -39,7 +39,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ None, "{page?: transparency|dropShadow|innerShadow|outerGlow|innerGlow|bevel|satin|feather|directionalFeather|gradientFeather} — the Effects dialog for the selected objects", ), - ("app.packageDialog", "Package…", Some("Cmd+Alt+Shift+P"), "{} — choose the package folder to create (file.package)"), + ( + "app.packageDialog", + "Package…", + Some("Cmd+Alt+Shift+P"), + "{} — the Package dialog (what the package carries, printing instructions), then the folder to create (file.package)", + ), ("app.saveSwatches", "Save Swatches for Exchange…", None, "{path?} — write the colour swatches as .ase"), ("app.exportPng", "Export Page as PNG…", Some("Cmd+E"), "{}"), ("app.exportIdml", "Export IDML…", None, "{path?} — InDesign Markup (IDML) package"), @@ -1145,21 +1150,8 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option export_bytes(app, p, "rtf", "file.exportText"), "app.packageDialog" => { - let name = app.session.active().map(|d| format!("{} Folder", d.doc.title)).unwrap_or_default(); - match app.services.pick_save.as_mut().and_then(|f| f(&name)) { - Some(dir) => { - let r = app.run("file.package", json!({"dir": dir})); - if let Ok(v) = &r { - app.status( - crate::i18n::tr(&app.ui.language, "Packaged {count} files into {dir}") - .replace("{count}", &v["files"].as_array().map_or(0, |f| f.len()).to_string()) - .replace("{dir}", v["dir"].as_str().unwrap_or("")), - ); - } - r - } - None => Ok(Value::Null), - } + crate::package_dialog::open(app); + Ok(Value::Null) } "app.language" => { let Some(v) = p.get("lang").and_then(Value::as_str) else { return Some(app.run("app.preferences", json!({}))) }; diff --git a/crates/ui-egui/src/package_dialog.rs b/crates/ui-egui/src/package_dialog.rs new file mode 100644 index 000000000..6c00ca060 --- /dev/null +++ b/crates/ui-egui/src/package_dialog.rs @@ -0,0 +1,457 @@ +//! File › Package…: InDesign's Package dialog. Summary counts what the package carries (fonts, +//! links and images, inks), with a warning where something is missing or modified; Fonts, Links +//! and Images and Colors and Inks list them (Show Problems Only). Package… asks for the folder to +//! create and writes the package (`file.package`), after the Printing Instructions when Create +//! Printing Instructions is on. + +use serde_json::{Value, json}; + +use crate::DesignApp; +use crate::dialogs::Dialog; +use crate::theme::semibold; + +/// The Package dialog's id. +pub const ID: &str = "package"; +/// The Printing Instructions dialog's id. +pub const INSTRUCTIONS: &str = "printingInstructions"; + +/// The dialog's pages: (key, label). +const PAGES: [(&str, &str); 4] = [("summary", "Summary"), ("fonts", "Fonts"), ("links", "Links and Images"), ("inks", "Colors and Inks")]; + +/// The process inks every package prints with. +const PROCESS_INKS: [&str; 4] = ["Process Cyan", "Process Magenta", "Process Yellow", "Process Black"]; + +/// The Printing Instructions' fields: (key, label, lines). +const INSTRUCTION_FIELDS: [(&str, &str, usize); 7] = [ + ("contact", "Contact:", 1), + ("company", "Company:", 1), + ("address", "Address:", 3), + ("phone", "Phone:", 1), + ("fax", "Fax:", 1), + ("email", "Email:", 1), + ("instructions", "Instructions:", 4), +]; + +/// Open the Package dialog on its Summary, with the document's fonts, links and spot inks. +pub fn open(app: &mut DesignApp) { + if app.session.active().is_none() { + return; + } + let fonts: Vec = app + .session + .execute("font.list", &json!({})) + .ok() + .and_then(|v| v.as_array().cloned()) + .unwrap_or_default() + .iter() + .map(|f| { + let status = if f["missing"].as_bool().unwrap_or(false) { + "missing" + } else if f["styleMissing"].as_bool().unwrap_or(false) || f["matchStatus"] == "styleSubstitute" { + "substituted" + } else { + "ok" + }; + let name = format!("{} {}", f["family"].as_str().unwrap_or(""), f["style"].as_str().unwrap_or("")).trim().to_string(); + json!({"name": name, "source": f["source"].as_str().unwrap_or(""), "status": status}) + }) + .collect(); + let links: Vec = app + .session + .execute("links.list", &json!({})) + .ok() + .and_then(|v| v.as_array().cloned()) + .unwrap_or_default() + .iter() + .map(|l| { + let page = l["uses"].as_array().and_then(|u| u.iter().find_map(|u| u["page"].as_str())).unwrap_or(""); + json!({"name": l["name"].as_str().unwrap_or(""), "page": page, "status": l["status"].as_str().unwrap_or("ok")}) + }) + .collect(); + // Spot inks print on their own plates unless the Ink Manager makes them process. + let inks = app.session.execute("ink.list", &json!({})).unwrap_or_default(); + let all_process = inks["allToProcess"].as_bool().unwrap_or(false); + let spot: Vec = inks["inks"] + .as_array() + .map(|a| { + a.iter() + .filter(|i| !all_process && !i["process"].as_bool().unwrap_or(false)) + .filter_map(|i| i["name"].as_str()) + .map(|n| json!(n)) + .collect() + }) + .unwrap_or_default(); + app.ui.dialog = Some(Dialog::new( + ID, + json!({"page": "summary", "fonts": fonts, "links": links, "spot": spot, "instructions": false, "idml": true, "pdf": false, "problemsOnly": false}), + )); +} + +fn rows(d: &Dialog, key: &str) -> Vec { + d.fields.get(key).and_then(Value::as_array).cloned().unwrap_or_default() +} + +/// How many of `rows` have one of `statuses`. +fn count(rows: &[Value], statuses: &[&str]) -> usize { + rows.iter().filter(|r| r["status"].as_str().is_some_and(|s| statuses.contains(&s))).count() +} + +/// The Summary's lines: (text, whether it warns). +fn summary_lines(lang: &str, d: &Dialog) -> Vec<(String, bool)> { + let tr = |s: &str| crate::i18n::tr(lang, s).to_string(); + let (fonts, links) = (rows(d, "fonts"), rows(d, "links")); + let missing_fonts = count(&fonts, &["missing"]); + let (modified, missing) = (count(&links, &["modified"]), count(&links, &["missing"])); + vec![ + ( + tr("Fonts: {used} Fonts Used, {missing} Missing") + .replace("{used}", &fonts.len().to_string()) + .replace("{missing}", &missing_fonts.to_string()), + missing_fonts > 0, + ), + ( + tr("Links and Images: {found} Links Found; {modified} Modified, {missing} Missing") + .replace("{found}", &links.len().to_string()) + .replace("{modified}", &modified.to_string()) + .replace("{missing}", &missing.to_string()), + modified + missing > 0, + ), + (tr("Images: {embedded} Embedded").replace("{embedded}", &count(&links, &["embedded"]).to_string()), false), + ( + tr("Colors and Inks: {process} Process Inks; {spot} Spot Inks") + .replace("{process}", &PROCESS_INKS.len().to_string()) + .replace("{spot}", &rows(d, "spot").len().to_string()), + false, + ), + ] +} + +/// The warning sign InDesign shows beside a line with problems, and the colours of statuses. +const WARN: egui::Color32 = egui::Color32::from_rgb(0xe8, 0xb3, 0x2c); +const BAD: egui::Color32 = egui::Color32::from_rgb(0xe5, 0x4b, 0x4b); + +fn status_text(lang: &str, status: &str) -> (String, Option) { + let tr = |s: &str| crate::i18n::tr(lang, s).to_string(); + match status { + "missing" => (format!("\u{26A0} {}", tr("Missing")), Some(BAD)), + "modified" => (format!("\u{25B2} {}", tr("Modified")), Some(WARN)), + "substituted" => (tr("Substituted"), Some(WARN)), + "embedded" => (tr("Embedded"), None), + _ => (tr("OK"), None), + } +} + +/// A table: a header row and `cells` rows (each with an optional colour for its last cell). +fn table(ui: &mut egui::Ui, id: &str, header: &[String], cells: &[(Vec, Option)]) { + let t = crate::theme::Tokens::get(ui.ctx()); + egui::Frame::NONE.stroke(egui::Stroke::new(1.0, t.field_border)).inner_margin(6.0).show(ui, |ui| { + egui::ScrollArea::vertical().id_salt(id).max_height(220.0).auto_shrink([false, true]).show(ui, |ui| { + egui::Grid::new(id).num_columns(header.len()).spacing([18.0, 4.0]).striped(true).show(ui, |ui| { + for h in header { + crate::rtl::label(ui, egui::RichText::new(h).font(semibold(11.5))); + } + ui.end_row(); + for (row, color) in cells { + for (i, c) in row.iter().enumerate() { + let mut text = egui::RichText::new(c); + if i + 1 == row.len() + && let Some(color) = color + { + text = text.color(*color); + } + crate::rtl::label(ui, text); + } + ui.end_row(); + } + }); + }); + }); +} + +/// The Package dialog: the pages on the left, the page on the right. +pub fn show(app: &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(); + let page = d.fields.get("page").and_then(Value::as_str).unwrap_or("summary").to_string(); + ui.horizontal_top(|ui| { + // A fixed height, as Preferences': the divider between the pages and the page fills it, + // and the dialog keeps its size from page to page. + ui.set_min_height(320.0); + ui.set_max_height(320.0); + ui.vertical(|ui| { + ui.set_width(150.0); + for (key, label) in PAGES { + if ui.selectable_label(page == key, crate::rtl::widget(ui, tr(label))).clicked() { + d.fields.insert("page".into(), json!(key)); + } + } + }); + ui.separator(); + ui.vertical(|ui| { + ui.set_min_width(460.0); + let title = PAGES.iter().find(|p| p.0 == page).map_or("Summary", |p| p.1); + crate::rtl::label(ui, egui::RichText::new(tr(title)).font(semibold(13.0))); + ui.add_space(6.0); + let problems_only = d.b("problemsOnly"); + match page.as_str() { + "fonts" => { + let fonts = rows(d, "fonts"); + let cells: Vec<_> = fonts + .iter() + .filter(|f| !problems_only || f["status"] != "ok") + .map(|f| { + let (status, color) = status_text(&lang, f["status"].as_str().unwrap_or("ok")); + let source = match f["source"].as_str().unwrap_or("") { + "bundled" => tr("Bundled"), + "installed" => tr("Installed"), + "document" => tr("Document"), + "added" => tr("Added"), + _ => String::new(), + }; + (vec![f["name"].as_str().unwrap_or("").to_string(), source, status], color) + }) + .collect(); + table(ui, "package_fonts", &[tr("Name"), tr("Source"), tr("Status")], &cells); + } + "links" => { + let links = rows(d, "links"); + let cells: Vec<_> = links + .iter() + .filter(|l| !problems_only || matches!(l["status"].as_str(), Some("missing" | "modified"))) + .map(|l| { + let (status, color) = status_text(&lang, l["status"].as_str().unwrap_or("ok")); + (vec![l["name"].as_str().unwrap_or("").to_string(), l["page"].as_str().unwrap_or("").to_string(), status], color) + }) + .collect(); + table(ui, "package_links", &[tr("Name"), tr("Page"), tr("Status")], &cells); + } + "inks" => { + let mut cells: Vec<_> = PROCESS_INKS.iter().map(|n| (vec![tr(n), tr("Process")], None)).collect(); + cells.extend(rows(d, "spot").iter().filter_map(Value::as_str).map(|n| (vec![n.to_string(), tr("Spot")], None))); + table(ui, "package_inks", &[tr("Name"), tr("Type")], &cells); + } + _ => { + ui.horizontal(|ui| { + crate::rtl::label(ui, tr("Scope:")); + crate::rtl::label(ui, tr("Entire Publication")); + }); + ui.add_space(6.0); + for (line, warn) in summary_lines(&lang, d) { + ui.horizontal(|ui| { + let sign = egui::RichText::new(if warn { "\u{26A0}" } else { " " }).color(WARN); + ui.add_sized([14.0, 16.0], egui::Label::new(sign)); + crate::rtl::label(ui, line); + }); + } + ui.add_space(10.0); + for (key, label) in [("instructions", "Create Printing Instructions"), ("idml", "Include IDML"), ("pdf", "Include PDF (Print)")] { + let mut on = d.b(key); + if ui.checkbox(&mut on, crate::rtl::widget(ui, tr(label))).changed() { + d.fields.insert(key.into(), json!(on)); + } + } + } + } + if matches!(page.as_str(), "fonts" | "links") { + ui.add_space(4.0); + let mut on = problems_only; + if ui.checkbox(&mut on, crate::rtl::widget(ui, tr("Show Problems Only"))).changed() { + d.fields.insert("problemsOnly".into(), json!(on)); + } + } + }); + }); +} + +/// Package…: the Printing Instructions first when asked for, else the folder. +pub fn confirm(app: &mut DesignApp, d: &Dialog) -> Result { + let (idml, pdf) = (d.b("idml"), d.b("pdf")); + if d.b("instructions") { + app.ui.dialog = Some(Dialog::new(INSTRUCTIONS, json!({"idml": idml, "pdf": pdf}))); + return Ok(Value::Null); + } + package_to_folder(app, idml, pdf, None) +} + +/// The Printing Instructions dialog, as InDesign's: who to contact and what to do. +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 mut text = d.s(key); + let edit = if lines > 1 { egui::TextEdit::multiline(&mut text).desired_rows(lines) } else { egui::TextEdit::singleline(&mut text) }; + if ui.add(edit.desired_width(320.0)).changed() { + d.fields.insert(key.into(), json!(text)); + } + ui.end_row(); + } + }); +} + +/// The instructions as they go into the package's Instructions.txt: the contact lines, then the +/// instructions. Empty fields are left out. +fn instructions_text(d: &Dialog) -> String { + let mut out = String::new(); + for (key, label) in + [("contact", "Contact"), ("company", "Company"), ("address", "Address"), ("phone", "Phone"), ("fax", "Fax"), ("email", "Email")] + { + let v = d.s(key); + let v = v.trim(); + if !v.is_empty() { + out += &format!("{label}: {}\n", v.replace('\n', "\n ")); + } + } + let ins = d.s("instructions"); + if !ins.trim().is_empty() { + if !out.is_empty() { + out.push('\n'); + } + out += ins.trim(); + out.push('\n'); + } + out +} + +/// Continue: the folder, with the instructions. +pub fn confirm_instructions(app: &mut DesignApp, d: &Dialog) -> Result { + let text = instructions_text(d); + package_to_folder(app, d.b("idml"), d.b("pdf"), Some(text).filter(|t| !t.is_empty())) +} + +/// Ask for the folder to create (" Folder") and package the document into it. +pub fn package_to_folder(app: &mut DesignApp, idml: bool, pdf: bool, instructions: Option<String>) -> Result<Value, String> { + let name = app.session.active().map(|d| format!("{} Folder", d.doc.title)).unwrap_or_default(); + let Some(dir) = app.services.pick_save.as_mut().and_then(|f| f(&name)) else { return Ok(Value::Null) }; + let mut p = json!({"dir": dir, "idml": idml, "pdf": pdf}); + if let Some(t) = instructions { + p["instructions"] = json!(t); + } + let r = app.run("file.package", p); + if let Ok(v) = &r { + app.status( + crate::i18n::tr(&app.ui.language, "Packaged {count} files into {dir}") + .replace("{count}", &v["files"].as_array().map_or(0, |f| f.len()).to_string()) + .replace("{dir}", v["dir"].as_str().unwrap_or("")), + ); + } + r +} + +#[cfg(test)] +mod tests { + use egui_kittest::{Harness, kittest::Queryable}; + + use super::*; + + /// A document with a text frame and an image whose file has gone, in a temporary folder. + fn app_with_missing_link(dir: &std::path::Path) -> DesignApp { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"title": "Packed"})).unwrap(); + app.run("frame.create", json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Hello"})).unwrap(); + let png = designcraft_render::Rendered { width: 4, height: 3, pixels: [30, 90, 180, 255].repeat(12) }.to_png(); + let img = dir.join("gone.png"); + std::fs::write(&img, &png).unwrap(); + app.run("file.place", json!({"path": img.to_string_lossy(), "x": 40, "y": 260, "width": 40})).unwrap(); + std::fs::remove_file(&img).unwrap(); + app + } + + 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"); + if ui.data(|d| d.get_temp::<bool>(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + crate::dialogs::show(app, ui.ctx()); + }, + app, + ) + } + + fn temp_dir(tag: &str) -> std::path::PathBuf { + 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-dialog-{tag}-{}-{nonce}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + /// The course's chapter 27: File › Package opens InDesign's Package dialog. Summary counts the + /// fonts, links (one missing, with a warning) and inks; Links and Images lists the link, and + /// Show Problems Only keeps it; Create Printing Instructions leads to Printing Instructions, + /// whose Continue asks for the folder and writes the package with the instructions. + #[test] + fn package_shows_a_summary_then_the_printing_instructions() { + let dir = temp_dir("summary"); + let mut app = app_with_missing_link(&dir); + let out = dir.join("Packed Folder"); + let picked = out.to_string_lossy().to_string(); + app.services.pick_save = Some(Box::new(move |_| Some(picked.clone()))); + crate::menus::activate(&mut app, "app.packageDialog", &Value::Null); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some(ID)); + let mut h = harness(app); + h.run_steps(3); + assert!(h.query_by_label("Scope:").is_some()); + assert!(h.query_by_label("Links and Images: 1 Links Found; 0 Modified, 1 Missing").is_some()); + assert!(h.query_by_label("Colors and Inks: 4 Process Inks; 0 Spot Inks").is_some()); + let fonts = h.state().ui.dialog.as_ref().unwrap().fields["fonts"].as_array().unwrap().len(); + assert!(h.query_by_label(&format!("Fonts: {fonts} Fonts Used, 0 Missing")).is_some()); + // Links and Images: the link, missing; Show Problems Only keeps it. + h.get_by_label("Links and Images").click(); + h.run_steps(2); + assert!(h.query_by_label("gone.png").is_some()); + assert!(h.query_by_label("\u{26A0} Missing").is_some()); + h.get_by_label("Show Problems Only").click(); + h.run_steps(2); + assert!(h.query_by_label("gone.png").is_some()); + // Back on Summary: Create Printing Instructions, then Package…. + h.get_by_label("Summary").click(); + h.run_steps(2); + h.get_by_label("Create Printing Instructions").click(); + h.run_steps(2); + h.get_by_label("Package…").click(); + h.run_steps(3); + assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.id.as_str()), Some(INSTRUCTIONS)); + for (key, value) in [("contact", "Dave"), ("email", "dave@example.com"), ("instructions", "Call me if anything is wrong.")] { + h.state_mut().ui.dialog.as_mut().unwrap().fields.insert(key.into(), json!(value)); + } + h.run_steps(2); + h.get_by_label("Continue").click(); + h.run_steps(3); + assert!(h.state().ui.dialog.is_none()); + let report = std::fs::read_to_string(out.join("Instructions.txt")).unwrap(); + assert!( + report.contains("Contact: Dave") && report.contains("Email: dave@example.com") && report.contains("Call me if anything is wrong."), + "{report}" + ); + assert!(out.join("Packed.idml").exists(), "Include IDML is on by default"); + assert!(!out.join("Packed.pdf").exists(), "Include PDF is off by default"); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// Without instructions, Package… goes straight to the folder; Include PDF adds the PDF and + /// Include IDML off leaves the IDML out. + #[test] + fn package_options_choose_the_idml_and_pdf() { + let dir = temp_dir("options"); + let mut app = app_with_missing_link(&dir); + let out = dir.join("Packed Folder"); + let picked = out.to_string_lossy().to_string(); + app.services.pick_save = Some(Box::new(move |_| Some(picked.clone()))); + open(&mut app); + let mut h = harness(app); + h.run_steps(3); + h.get_by_label("Include IDML").click(); + h.run_steps(2); + h.get_by_label("Include PDF (Print)").click(); + h.run_steps(2); + h.get_by_label("Package…").click(); + h.run_steps(3); + assert!(h.state().ui.dialog.is_none()); + assert!(out.join("Packed.pdf").exists() && !out.join("Packed.idml").exists()); + std::fs::remove_dir_all(dir).unwrap(); + } +} From 4c3f24e43d3ec8aa240456a5a252956b14aa4701 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:39:43 +0200 Subject: [PATCH 099/431] Package: the packaged PDF uses a chosen PDF preset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Package dialog's Include PDF (Print) gets a PDF Preset, as InDesign's Select PDF Preset: the Export PDF presets, the last one used first. The packaged PDF always had the default settings. `file.package` takes `pdf: {…file.exportPdf options}` besides true/false, read with the same `export::options` before anything is written, so bad options leave no partial package. The Export PDF dialog's field-to-params code is shared (`pdf_export_params`, `pdf_preset_params`). Found in the Learn Skills Daily course, chapter 27. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/engine/src/cmd/package.rs | 33 +++++++++++++++-- crates/ui-egui/src/dialogs.rs | 54 ++++++++++++++++++---------- crates/ui-egui/src/i18n.rs | 1 + crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + crates/ui-egui/src/package_dialog.rs | 49 +++++++++++++++++++++---- 13 files changed, 118 insertions(+), 28 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b0e33a381..28cb3d4e9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · The Package dialog's Include PDF (Print) has a PDF Preset (the Export PDF presets, the last one used first), as InDesign's Select PDF Preset; `file.package` takes `pdf: {…file.exportPdf options}`, refusing bad options before writing anything. The packaged PDF always had the default settings. Found in the Learn Skills Daily course, chapter 27. No readiness percentage change. - **2026-10-21** · File › Package… opens InDesign's Package dialog: Summary counts the fonts, links and images and inks the package carries, with a warning where some are missing or modified, and Fonts, Links and Images and Colors and Inks list them (Show Problems Only). Create Printing Instructions asks for the contact details and instructions that go into the package's Instructions.txt, and Include IDML / Include PDF (Print) choose the copies it carries. Package… went straight to the folder picker with fixed options. Found in the Learn Skills Daily course, chapter 27. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index f2d33ccfa..69b3ed379 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -202,7 +202,7 @@ pub fn specs() -> Vec<CommandSpec> { "Package…", ["File"], None, - "{dir: folder to create, idml?: true, pdf?: false, instructions?: text} — the document (its placed files relinked to Links/), Links/, the font files that draw its text in Document Fonts/ (fallback fonts included; unless their licence restricts it), an IDML copy (linking the files in Links/, nothing embedded), an optional PDF and a report → {dir, files, report}", + "{dir: folder to create, idml?: true, pdf?: false|true|{…options of file.exportPdf}, instructions?: text} — the document (its placed files relinked to Links/), Links/, the font files that draw its text in Document Fonts/ (fallback fonts included; unless their licence restricts it), an IDML copy (linking the files in Links/, nothing embedded), an optional PDF and a report → {dir, files, report}", has_doc, package ), @@ -389,6 +389,13 @@ fn drawn_faces(cs: &ComposedStory, depth: usize, out: &mut Vec<FaceRef>) { fn package(s: &mut Session, p: &Value) -> Result<Value> { let dir = PathBuf::from(str_param(p, "dir").ok_or_else(|| bad("file.package", "missing `dir`"))?); let st = s.doc()?; + // The PDF: with the default settings, or with the options of a PDF preset (as + // file.exportPdf's). Read first, so that bad options write nothing. + let pdf = match p.get("pdf") { + Some(o @ Value::Object(_)) => Some(super::export::options(o, st.doc.page_count())?), + Some(Value::Bool(true)) => Some(Default::default()), + _ => None, + }; let root = OutputRoot::new(dir.clone())?; let display_title = st.doc.title.clone(); let title = safe_component(&display_title, "Untitled"); @@ -433,9 +440,9 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> { root.write(&path, &designcraft_idml::export_idml_with(&packed, &opts))?; files.push(path); } - if p.get("pdf").and_then(Value::as_bool).unwrap_or(false) { + if let Some(opts) = pdf { let path = package_output_path(&dir, &title, "pdf")?; - let bytes = designcraft_pdf::export_pdf(&super::interchange::with_pdf_boxes(&packed), &s.cache, &Default::default()) + let bytes = designcraft_pdf::export_pdf(&super::interchange::with_pdf_boxes(&packed), &s.cache, &opts) .map_err(|e| EngineError::Other(e.to_string()))?; root.write(&path, &bytes)?; files.push(path); @@ -509,6 +516,26 @@ mod tests { 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")); + assert!(s.execute("file.package", &json!({"dir": dir.join("bad").to_string_lossy(), "pdf": {"standard": "nope"}})).is_err()); + assert!(!dir.join("bad").exists(), "bad options write nothing"); + std::fs::remove_dir_all(dir).unwrap(); + } + /// The package's IDML links its images to the files in the package's Links folder and embeds /// no copies of them. #[test] diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9182f5843..0f19550bc 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -399,6 +399,41 @@ fn print_dialog(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { /// English name; the caption is translated. const PDF_EXPORT_PRESETS: &[&str] = &["Desktop Printing", "Commercial Printing", "Screen and Email", "PDF/X-4", "PDF/A-2b", "Custom"]; +/// The `file.exportPdf` options the Export PDF dialog's fields stand for (pages and title aside). +fn pdf_export_params(d: &Dialog) -> Value { + let mut params = json!({ + "standard": d.s("standard"), + "compressImages": d.b("compressImages"), + "spreads": d.b("spreads"), + "bleed": d.b("bleed"), + "tagged": d.b("tagged"), + "marks": { + "crop": d.b("marksCrop"), + "bleed": d.b("marksBleed"), + "pageInfo": d.b("marksPageInfo"), + "weight": d.pt("marksWeight").unwrap_or(0.25), + "offset": d.pt("marksOffset").unwrap_or(6.0), + }, + }); + let flatten = d.s("flatten"); + if !flatten.is_empty() { + params["flatten"] = json!(flatten); + } + params +} + +/// The PDF presets a document can be exported with, by name (Custom aside). +pub(crate) fn pdf_presets() -> impl Iterator<Item = &'static str> { + PDF_EXPORT_PRESETS.iter().copied().filter(|p| *p != "Custom") +} + +/// The `file.exportPdf` options of PDF preset `preset`, as the Export PDF dialog sets them. +pub(crate) fn pdf_preset_params(preset: &str) -> Value { + let mut d = Dialog::new("pdfExport", json!({})); + apply_pdf_preset(&mut d, preset); + pdf_export_params(&d) +} + /// Apply a preset's dependent fields. Flatten is only touched by "Screen and Email"; "Custom" /// (and any unknown value) changes nothing. fn apply_pdf_preset(d: &mut Dialog, preset: &str) { @@ -3311,24 +3346,7 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> { app.ui.pdf_export.marks_offset = d.s("marksOffset"); app.ui.pdf_export.tagged = d.b("tagged"); - let mut params = json!({ - "standard": d.s("standard"), - "compressImages": d.b("compressImages"), - "spreads": d.b("spreads"), - "bleed": d.b("bleed"), - "tagged": d.b("tagged"), - "marks": { - "crop": d.b("marksCrop"), - "bleed": d.b("marksBleed"), - "pageInfo": d.b("marksPageInfo"), - "weight": d.pt("marksWeight").unwrap_or(0.25), - "offset": d.pt("marksOffset").unwrap_or(6.0), - }, - }); - let flatten = d.s("flatten"); - if !flatten.is_empty() { - params["flatten"] = json!(flatten); - } + let mut params = pdf_export_params(&d); let pages = d.s("pages"); if !pages.trim().is_empty() && pages != "all" { params["pages"] = json!(pages); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index eccf6ca89..4eb433c18 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -155,6 +155,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ "Include PDF (Print)", ["PDF einschließen (Druck)", "Inclure le PDF (impression)", "Incluir PDF (impresión)", "PDF を含める(印刷)", "包括 PDF(打印)"], ), + ("PDF Preset:", ["PDF-Vorgabe:", "Paramètre prédéfini PDF :", "Ajuste preestablecido de PDF:", "PDF プリセット:", "PDF 预设:"]), ("Show Problems Only", ["Nur Probleme anzeigen", "Afficher uniquement les problèmes", "Mostrar solo problemas", "問題のみ表示", "仅显示问题"]), ("Printing Instructions", ["Druckanweisungen", "Instructions d’impression", "Instrucciones de impresión", "印刷指示", "打印说明"]), ("Continue", ["Weiter", "Continuer", "Continuar", "続行", "继续"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 268b799d1..e1fa7080d 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", "لصق"), + ("PDF Preset:", "إعداد PDF المسبق:"), ("Packaged {count} files into {dir}", "تم حزم {count} ملفات في {dir}"), ("Continue", "متابعة"), ("Printing Instructions", "تعليمات الطباعة"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index a51121541..2c7d8f090 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("PDF Preset:", "Předvolba PDF:"), ("Continue", "Pokračovat"), ("Printing Instructions", "Pokyny k tisku"), ("Show Problems Only", "Zobrazit jen problémy"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 0d0d49112..70e1e2e47 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"), + ("PDF Preset:", "Predefinito PDF:"), ("Packaged {count} files into {dir}", "{count} file raccolti in {dir}"), ("Continue", "Continua"), ("Printing Instructions", "Istruzioni di stampa"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 142f806ac..9bd18a169 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("PDF Preset:", "PDF-ის წინასწარი პარამეტრი:"), ("Packaged {count} files into {dir}", "{count} ფაილი შეიფუთა {dir}-ში"), ("Continue", "გაგრძელება"), ("Printing Instructions", "ბეჭდვის ინსტრუქციები"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index ca8c99e33..ccf7d68c1 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("PDF Preset:", "Ustawienie predefiniowane PDF:"), ("Packaged {count} files into {dir}", "Spakowano {count} plików do {dir}"), ("Continue", "Kontynuuj"), ("Printing Instructions", "Instrukcje drukowania"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 9c4317506..7f971648a 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"), + ("PDF Preset:", "Predefinição de PDF:"), ("Packaged {count} files into {dir}", "{count} arquivos empacotados em {dir}"), ("Continue", "Continuar"), ("Printing Instructions", "Instruções de impressão"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 4888924ee..b555c9c90 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("PDF Preset:", "Стиль PDF:"), ("Packaged {count} files into {dir}", "Упаковано файлов: {count} в {dir}"), ("Continue", "Продолжить"), ("Printing Instructions", "Инструкции для печати"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 88d72cb57..41e6591eb 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("PDF Preset:", "Набір параметрів PDF:"), ("Packaged {count} files into {dir}", "Запаковано файлів: {count} у {dir}"), ("Continue", "Продовжити"), ("Printing Instructions", "Інструкції для друку"), diff --git a/crates/ui-egui/src/package_dialog.rs b/crates/ui-egui/src/package_dialog.rs index 6c00ca060..b2e5cdd75 100644 --- a/crates/ui-egui/src/package_dialog.rs +++ b/crates/ui-egui/src/package_dialog.rs @@ -81,9 +81,13 @@ pub fn open(app: &mut DesignApp) { .collect() }) .unwrap_or_default(); + // The packaged PDF's preset: the one last used for Export PDF, as InDesign suggests it. + let last = app.ui.pdf_export.preset.clone(); + let preset = crate::dialogs::pdf_presets().find(|p| *p == last).unwrap_or("Desktop Printing"); app.ui.dialog = Some(Dialog::new( ID, - json!({"page": "summary", "fonts": fonts, "links": links, "spot": spot, "instructions": false, "idml": true, "pdf": false, "problemsOnly": false}), + json!({"page": "summary", "fonts": fonts, "links": links, "spot": spot, "instructions": false, "idml": true, "pdf": false, + "pdfPreset": preset, "problemsOnly": false}), )); } @@ -250,6 +254,24 @@ pub fn show(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { d.fields.insert(key.into(), json!(on)); } } + // The PDF's preset, as InDesign's Select PDF Preset. + ui.add_enabled_ui(d.b("pdf"), |ui| { + ui.horizontal(|ui| { + ui.add_space(24.0); + 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( + 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)); + } + } + }, + ); + }); + }); } } if matches!(page.as_str(), "fonts" | "links") { @@ -263,9 +285,14 @@ pub fn show(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { }); } +/// The `pdf` of `file.package`: false, or the options of the chosen PDF preset. +fn pdf_param(d: &Dialog) -> Value { + if d.b("pdf") { crate::dialogs::pdf_preset_params(&d.s("pdfPreset")) } else { json!(false) } +} + /// Package…: the Printing Instructions first when asked for, else the folder. pub fn confirm(app: &mut DesignApp, d: &Dialog) -> Result<Value, String> { - let (idml, pdf) = (d.b("idml"), d.b("pdf")); + let (idml, pdf) = (d.b("idml"), pdf_param(d)); if d.b("instructions") { app.ui.dialog = Some(Dialog::new(INSTRUCTIONS, json!({"idml": idml, "pdf": pdf}))); return Ok(Value::Null); @@ -316,11 +343,12 @@ fn instructions_text(d: &Dialog) -> String { /// Continue: the folder, with the instructions. pub fn confirm_instructions(app: &mut DesignApp, d: &Dialog) -> Result<Value, String> { let text = instructions_text(d); - package_to_folder(app, d.b("idml"), d.b("pdf"), Some(text).filter(|t| !t.is_empty())) + package_to_folder(app, d.b("idml"), d.fields.get("pdf").cloned().unwrap_or(json!(false)), Some(text).filter(|t| !t.is_empty())) } -/// Ask for the folder to create ("<title> Folder") and package the document into it. -pub fn package_to_folder(app: &mut DesignApp, idml: bool, pdf: bool, instructions: Option<String>) -> Result<Value, String> { +/// Ask for the folder to create ("<title> Folder") and package the document into it. `pdf`: +/// false, true or the PDF's `file.exportPdf` options. +pub fn package_to_folder(app: &mut DesignApp, idml: bool, pdf: Value, instructions: Option<String>) -> Result<Value, String> { let name = app.session.active().map(|d| format!("{} Folder", d.doc.title)).unwrap_or_default(); let Some(dir) = app.services.pick_save.as_mut().and_then(|f| f(&name)) else { return Ok(Value::Null) }; let mut p = json!({"dir": dir, "idml": idml, "pdf": pdf}); @@ -432,8 +460,8 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } - /// Without instructions, Package… goes straight to the folder; Include PDF adds the PDF and - /// Include IDML off leaves the IDML out. + /// Without instructions, Package… goes straight to the folder; Include PDF adds the PDF, made + /// with the PDF preset chosen, and Include IDML off leaves the IDML out. #[test] fn package_options_choose_the_idml_and_pdf() { let dir = temp_dir("options"); @@ -448,10 +476,17 @@ mod tests { h.run_steps(2); h.get_by_label("Include PDF (Print)").click(); h.run_steps(2); + // The PDF's preset: PDF/X-4. + h.get_by_value("Desktop Printing").click(); + h.run_steps(2); + h.get_by_label("PDF/X-4").click(); + h.run_steps(2); h.get_by_label("Package…").click(); h.run_steps(3); assert!(h.state().ui.dialog.is_none()); assert!(out.join("Packed.pdf").exists() && !out.join("Packed.idml").exists()); + let pdf = std::fs::read(out.join("Packed.pdf")).unwrap(); + assert!(pdf.windows(8).any(|w| w == b"GTS_PDFX"), "the PDF/X-4 preset's PDF"); std::fs::remove_dir_all(dir).unwrap(); } } From 304930164374f1ddd4915a04349ec2b704749f9a Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:45:03 +0200 Subject: [PATCH 100/431] Paragraph Border and Shading: link the four sides, as InDesign's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The course's chapter 40 (Applying Borders to Styles). Paragraph Style Options › Paragraph Border and Paragraph Shading get InDesign's "Make all settings the same" link for the stroke weights, the offsets and the corners. It starts on when the four are the same, so a side typed in sets all four; with the link off a side is set alone, as the lesson gives a border only a thick left side. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/ui-egui/src/dialogs.rs | 108 ++++++++++++++++++++++++++++++---- 2 files changed, 97 insertions(+), 12 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..ca615a7e1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · Paragraph Border and Paragraph Shading link their four sides as InDesign's: the stroke weights, offsets and corners have the "Make all settings the same" link, on when the four are the same, so a side typed in sets all four; with the link off a side is set alone (a border with only a thick left side). Found in the Learn Skills Daily course, chapter 40. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8b4043787..4ebb424b1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4477,18 +4477,27 @@ fn para_choice(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: & /// edited side stores the whole array. fn para_sides(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &str, field: &dyn Fn(&mut egui::Ui, &str, Option<f64>) -> Option<f64>) { let mut sides: [f64; 4] = serde_json::from_value(para_value(d, pv, key)).unwrap_or_default(); + let linked = linked(d, key, sides.iter().all(|v| (v - sides[0]).abs() < 1e-9)); let mut changed = false; - egui::Grid::new(("pssides", key)).num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { + // As InDesign's: Top and Bottom, the link, Left and Right. + egui::Grid::new(("pssides", key)).num_columns(5).spacing([8.0, 6.0]).show(ui, |ui| { for (i, label) in ["Top:", "Left:", "Bottom:", "Right:"].into_iter().enumerate() { crate::rtl::label(ui, crate::i18n::tr(lang, label)); - if let Some(side) = sides.get_mut(i) - && let Some(v) = field(ui, &format!("ps{key}{i}"), Some(*side)) - { - *side = v; + if let Some(v) = field(ui, &format!("ps{key}{i}"), sides.get(i).copied()) { + // Linked, a side typed in sets all four. + if linked { + sides = [v; 4]; + } else if let Some(side) = sides.get_mut(i) { + *side = v; + } changed = true; } - if i % 2 == 1 { - ui.end_row(); + match i { + 0 => link_toggle(lang, ui, d, key, linked), + 2 => { + ui.label(""); + } + _ => ui.end_row(), } } }); @@ -4497,6 +4506,20 @@ fn para_sides(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &s } } +/// Whether the four values of `key` are linked ("Make all settings the same"): as the user left +/// the link, else when the four are the same, as InDesign starts it. +fn linked(d: &Dialog, key: &str, same: bool) -> bool { + d.fields.get(&format!("_linked.{key}")).and_then(Value::as_bool).unwrap_or(same) +} + +/// The link button between a border's or shading's four sides or corners. +fn link_toggle(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, key: &str, linked: bool) { + let tip = crate::i18n::tr(lang, "Make all settings the same"); + if crate::widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, tip, egui::vec2(22.0, 21.0)).clicked() { + d.fields.insert(format!("_linked.{key}"), json!(!linked)); + } +} + /// A swatch menu with a colour chip per swatch (hidden swatches left out); returns the swatch /// picked. fn swatch_menu(ui: &mut egui::Ui, doc: &designcraft_doc::Document, id: impl egui::AsIdSalt, current: &str, width: f32) -> Option<String> { @@ -4551,14 +4574,15 @@ fn border_type_menu(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, d fn para_corners(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &str, units: Unit) { use designcraft_geom::corners::{Corner, CornerShape}; let mut corners: [Corner; 4] = serde_json::from_value(para_value(d, pv, key)).unwrap_or_default(); - let mut changed = false; - egui::Grid::new(("pscorners", key)).num_columns(3).spacing([8.0, 6.0]).show(ui, |ui| { + let linked = linked(d, key, corners.iter().all(|c| *c == corners[0])); + let mut changed = None; + egui::Grid::new(("pscorners", key)).num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { for (i, label) in ["Top Left:", "Top Right:", "Bottom Right:", "Bottom Left:"].into_iter().enumerate() { let Some(c) = corners.get_mut(i) else { continue }; crate::rtl::label(ui, crate::i18n::tr(lang, label)); if let Some(v) = crate::widgets::measure(ui, &format!("ps{key}{i}"), Some(c.size), units, 70.0) { c.size = if v.is_finite() { v.clamp(0.0, 10_000.0) } else { 0.0 }; - changed = true; + changed = Some(i); } egui::ComboBox::from_id_salt(("pscornershape", key, i)) .selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, c.shape.label()))) @@ -4567,14 +4591,21 @@ fn para_corners(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: for shape in CornerShape::ALL { if ui.selectable_label(c.shape == shape, crate::rtl::widget(ui, crate::i18n::tr(lang, shape.label()))).clicked() { c.shape = shape; - changed = true; + changed = Some(i); } } }); + if i == 0 { + link_toggle(lang, ui, d, key, linked); + } ui.end_row(); } }); - if changed { + if let Some(i) = changed { + // Linked, the corner changed sets all four. + if linked { + corners = [corners[i]; 4]; + } para_set(d, key, serde_json::to_value(corners).unwrap_or_default()); } } @@ -9165,6 +9196,59 @@ mod tests { } } + /// 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. + let links: Vec<egui::Rect> = 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"); + crate::test_window::click_at(&mut h, links[0].center()); + 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}"); + 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 paragraph_style_options_tabs_section_adds_a_stop_on_its_ruler() { use egui_kittest::kittest::Queryable; From 8e980b1f87b974af3c9933f5f8684fb21d28f3b9 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Wed, 21 Oct 2026 23:52:23 +0200 Subject: [PATCH 101/431] Paragraph rules: Gap Tint shows 100% at its default A full gap tint is left out of the rule's JSON, so the field read nothing even after a Gap Color was chosen. It now reads a missing gap tint as 100%. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/dialogs.rs | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 3d984fc13..9e03c6b09 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -5956,9 +5956,9 @@ fn paragraph_rules(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, prefix: & }); crate::rtl::label(ui, crate::i18n::tr(lang, "Gap Tint:")); ui.add_enabled_ui(gaps && gap != designcraft_color::swatch::NONE, |ui| { - if let Some(v) = - sized(ui, &mut |ui| crate::widgets::number(ui, &key("gapTint"), rule["gapTint"].as_f64().map(|t| t * 100.0), "%", 80.0, 0)) - { + // A full gap tint is left out of the rule's JSON: it reads 100%. + let gap_tint = rule["gapTint"].as_f64().unwrap_or(1.0) * 100.0; + if let Some(v) = sized(ui, &mut |ui| crate::widgets::number(ui, &key("gapTint"), Some(gap_tint), "%", 80.0, 0)) { d.fields.insert(key("gapTint"), json!((v / 100.0).clamp(0.0, 1.0))); } }); @@ -7651,6 +7651,29 @@ mod tests { 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 paragraph_style_options_edit_one_rule_field() { let (mut app, rule) = ruled_paragraph(); From e108408c31cffb7ec86aa359cf2628d8ba873582 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Wed, 21 Oct 2026 23:58:07 +0200 Subject: [PATCH 102/431] Insert Pages: a placement given must be a page index; the dialog's page is clamped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `layout.pages.insert` refuses `after` and `before` given together (null counts as given) and a placement that isn't a page index, instead of inserting at the default position; the default applies only when neither is given. The dialog sends the page its menu shows (a page past the end is the last). The dialog title is in the shared table, and Georgian "Before Page" reads "გვერდის წინ". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/layout.rs | 36 ++++++++++++++++++++++++++------- crates/ui-egui/src/dialogs.rs | 21 ++++++++++++++++++- crates/ui-egui/src/i18n.rs | 1 + crates/ui-egui/src/i18n/fr.rs | 1 - crates/ui-egui/src/i18n/ka.rs | 2 +- 5 files changed, 51 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 3ddaaf4c6..5e1879573 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -118,11 +118,21 @@ pub fn specs() -> Vec<CommandSpec> { let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 9999) as usize; let st = s.doc()?; let n = st.doc.page_count(); - let index = |k: &str| p.get(k).and_then(Value::as_u64).map(|v| usize::try_from(v).unwrap_or(usize::MAX)); + // A placement given (null included) must be a page index; only when neither is + // given do the pages go after the last. + if p.get("after").is_some() && p.get("before").is_some() { + return Err(bad("layout.pages.insert", "give `after` or `before`, not both")); + } + let index = |k: &str| match p.get(k) { + None => Ok(None), + Some(v) => v + .as_u64() + .map(|v| Some(usize::try_from(v).unwrap_or(usize::MAX))) + .ok_or_else(|| bad("layout.pages.insert", format!("`{k}` must be a page index, not {v}"))), + }; // The page the new ones follow; none: they go first. - let after = match (index("after"), index("before")) { - (Some(_), Some(_)) => return Err(bad("layout.pages.insert", "give `after` or `before`, not both")), - (None, Some(b)) => b.checked_sub(1), + let after = match (index("after")?, index("before")?) { + (_, Some(b)) => b.checked_sub(1), (a, None) => Some(a.unwrap_or(n.saturating_sub(1))), }; let parent = parent_ref("layout.pages.insert", &st.doc, p.get("parent"))?.unwrap_or_else(|| st.doc.parents.first().map(|x| x.id)); @@ -1884,10 +1894,22 @@ mod parent_margin_tests { assert_eq!(then[..3], now[..3]); assert_eq!(then[5], old[2]); assert_eq!((parent(&s, 3), parent(&s, 4)), (None, None)); + // Refused without a change or an undo step: both placements, even one of them null, and a + // placement that isn't a page index. let undo = s.doc().unwrap().history.undo.len(); - assert!(s.execute("layout.pages.insert", &json!({"after": 0, "before": 1})).is_err()); - assert_eq!(s.doc().unwrap().history.undo.len(), undo); - assert_eq!(ids(&s), then); + for bad in [ + json!({"after": 0, "before": 1}), + json!({"after": 0, "before": null}), + json!({"after": null, "before": 1}), + json!({"before": -1}), + json!({"before": "0"}), + json!({"before": null}), + json!({"after": 1.5}), + ] { + assert!(s.execute("layout.pages.insert", &bad).is_err(), "{bad}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{bad}"); + assert_eq!(ids(&s), then, "{bad}"); + } } #[test] diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 13ce41a6e..640888f24 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3132,7 +3132,8 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> { "ruby" => app.run("type.ruby", style_cjk::ruby_params(&d)), "insertPages" => { let n = app.session.active().map_or(1, |st| st.doc.page_count()); - let page = d.n("page").unwrap_or(0.0).max(0.0) as usize; + // The page the menu shows: a page past the end is the last. + let page = (d.n("page").unwrap_or(0.0).max(0.0) as usize).min(n.saturating_sub(1)); let mut p = json!({"count": d.n("count").unwrap_or(1.0).clamp(1.0, 9999.0) as u64}); match d.s("insert").as_str() { "before" => p["before"] = json!(page), @@ -7485,6 +7486,24 @@ mod tests { assert!(matches!(fill(&app, id), designcraft_color::Color::Rgb { .. }), "{:?}", fill(&app, id)); } + /// A page past the end shows as the last page in the dialog, and Before Page puts the new + /// page before that one rather than at the end. + #[test] + fn insert_pages_before_a_page_past_the_end_goes_before_the_last() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 4, "facingPages": false})).unwrap(); + let ids = |app: &DesignApp| { + let d = &app.session.doc().unwrap().doc; + (0..d.page_count()).map(|i| d.page(i).unwrap().id).collect::<Vec<_>>() + }; + let old = ids(&app); + app.ui.dialog = Some(Dialog::new("insertPages", json!({"count": 1, "insert": "before", "page": 100, "parent": ""}))); + confirm(&mut app).unwrap(); + let now = ids(&app); + assert_eq!(now.len(), 5); + assert_eq!((now[..3].to_vec(), now[4]), (old[..3].to_vec(), old[3]), "the new page is fourth, before the old last page"); + } + /// The course's chapter 25: Insert Pages as InDesign's. It opens after the page in view with /// that page's parent; At Start of Document with B-Cover puts a B page first, and Before Page /// with [None] puts a page without a parent before the page chosen. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index ddc27e2eb..ee60b7d27 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1846,6 +1846,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ["Insert Math Expression…", "Insérer une expression mathématique…", "Insert Math Expression…", "Insert Math Expression…", "插入数学公式…"], ), ("Insert Pages…", ["Insert Pages…", "Insérer des pages…", "Insert Pages…", "Insert Pages…", "插入页面…"]), + ("Insert Pages", ["Seiten einfügen", "Insérer des pages", "Insertar páginas", "ページを挿入", "插入页面"]), ("Insert:", ["Einfügen:", "Insérer :", "Insertar:", "挿入:", "插入:"]), ("Parent:", ["Vorlagenseite:", "Gabarit :", "Página principal:", "親ページ:", "主页:"]), ("After Page", ["Nach Seite", "Après la page", "Después de la página", "ページの後", "页面后"]), diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 78c5d5948..46558bb58 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -84,7 +84,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Insert Anchored Object", "Insérer un objet ancré"), ("Insert Cross-Reference", "Insérer une référence croisée"), ("Insert Math Expression", "Insérer une expression mathématique"), - ("Insert Pages", "Insérer des pages"), ("Internet Standard RGB (sRGB)", "RVB standard Internet (sRGB)"), ("Join the ArtCraft Discord", "Rejoindre le Discord d'ArtCraft"), ("Join the community", "Rejoindre la communauté"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index fb802b878..35561798a 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -33,7 +33,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paste", "ჩასმა"), ("At End of Document", "დოკუმენტის ბოლოს"), ("At Start of Document", "დოკუმენტის დასაწყისში"), - ("Before Page", "გვერდამდე"), + ("Before Page", "გვერდის წინ"), ("After Page", "გვერდის შემდეგ"), ("Parent:", "საბაზო გვერდი:"), ("Insert:", "ჩასმა:"), From c5bca17a9bec4c36c27bd288ec487ee5b1aabd10 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 00:02:56 +0200 Subject: [PATCH 103/431] New Paragraph Style: apply to the selection once the dialog knows the style The dialog notes that the created style exists (so a second OK edits it) before Apply Style to Selection runs, so a failure there can't lead to a duplicate style. The Justification check's doc comment is back above its function. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/dialogs.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 491b82866..4c5ed581a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3643,8 +3643,6 @@ fn grep_error(p: &str) -> Option<String> { designcraft_compose::grep::Grep::new(p, Default::default()).err().map(|e| e.to_string()) } -/// The Justification spacing check of Paragraph Style Options, against what the section showed: -/// the style's own values, or a new style's Based On. /// What paragraph style `style` resolves to when based on `based` (the Based On chosen in its /// dialog, which may not be its own yet): `based`'s chain with the style's own settings over it. fn style_over( @@ -3660,6 +3658,8 @@ fn style_over( }) } +/// The Justification spacing check of Paragraph Style Options, against what the section showed: +/// the style's own values, or a new style's Based On. fn check_paragraph_style_justification(app: &DesignApp, d: &Dialog) -> Result<(), String> { // After Reset To Base the style starts from its Based On (the one chosen here, if any). let base_of = |st: &designcraft_engine::DocState| { @@ -3726,10 +3726,6 @@ fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Resul } let r = app.run("style.paragraph.create", params)?; let style = r["name"].as_str().unwrap_or_default().to_string(); - // Apply Style to Selection. - if d.b("apply") && app.session.active().is_some_and(|st| st.selection.text.is_some()) { - app.run("style.paragraph.apply", json!({"name": style}))?; - } (r, style) } else { let mut params = json!({"name": name, "para": para, "chars": chars}); @@ -3750,9 +3746,14 @@ fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Resul }; // The style now exists under `style`: if a later step fails, the dialog stays open on it and // a second OK edits it rather than creating another one. + let created = d.b("new"); d.fields.insert("new".into(), json!(false)); d.fields.insert("name".into(), json!(&style)); d.fields.insert("rename".into(), json!(&style)); + // Apply Style to Selection. + if created && d.b("apply") && app.session.active().is_some_and(|st| st.selection.text.is_some()) { + app.run("style.paragraph.apply", json!({"name": style}))?; + } if d.fields.contains_key("x.tag") { let tag = d.s("x.tag"); let tag = if tag == "[Automatic]" { String::new() } else { tag }; From 0ecb63229cfad89fca8169a01fb93b07631469ac Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 00:17:07 +0200 Subject: [PATCH 104/431] Find Format: match on the attributes as validated, numbers at any depth, one pass over the runs - The Find Format's character attributes are read through `CharAttrs::set_json` and matched as the document writes them (camelCase keys, its number forms), so `font_style` finds what `fontStyle` finds. A null attribute asks for nothing. - Values compare numbers within rounding inside objects and lists too, so a 14 pt leading written as `{"kind": "points", "value": 14}` matches. - `FindFormat::spans` passes over a story's runs once, with a cursor that only moves forward across the paragraphs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/find.rs | 53 +++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index eb821b0ae..11a30e86b 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -122,12 +122,16 @@ impl FindFormat { let Some(f) = p.get("findFormat").filter(|f| !f.is_null()) else { return Ok(None) }; let o = f.as_object().ok_or_else(|| bad("find", "`findFormat` is an object"))?; let name = |k: &str| o.get(k).and_then(Value::as_str).map(str::to_string); - let chars = o.get("chars").and_then(Value::as_object).cloned().unwrap_or_default(); - // Only attributes text has. + // Only attributes text has, kept as the document writes them: `font_style` and + // `fontStyle`, or 12 and 12.0, then find the same text. A null asks for nothing. let mut known = designcraft_doc::CharAttrs::default(); - for (k, v) in &chars { + for (k, v) in o.get("chars").and_then(Value::as_object).into_iter().flatten() { known.set_json(k, v).map_err(|e| bad("find", format!("findFormat: {e}")))?; } + let chars = match serde_json::to_value(&known) { + Ok(Value::Object(m)) => m, + _ => serde_json::Map::new(), + }; let f = Self { char_style: name("charStyle"), para_style: name("paraStyle"), chars }; Ok((f != Self::default()).then_some(f)) } @@ -147,14 +151,20 @@ impl FindFormat { /// stretches, each paragraph's apart (a paragraph's `\n` isn't in one). fn spans(&self, styles: &Styles, story: &Story, r: &Range<usize>) -> Vec<Range<usize>> { let mut out: Vec<Range<usize>> = Vec::new(); + // Paragraphs and runs both go in text order: the runs before a paragraph are passed once. + let runs: Vec<_> = story.runs().collect(); + let mut first = 0; for (pi, pr) in story.para_ranges().into_iter().enumerate() { let (a, b) = (pr.start.max(r.start), pr.end.min(r.end)); let Some(pf) = story.paras.get(pi) else { continue }; if a >= b || self.para_style.as_ref().is_some_and(|n| *n != pf.style) { continue; } + while runs.get(first).is_some_and(|(run, _)| run.end <= a) { + first += 1; + } let base = (!self.chars.is_empty()).then(|| styles.resolve_para(pf).1); - for (run, cf) in story.runs() { + for (run, cf) in runs[first..].iter().take_while(|(run, _)| run.start < b) { let (x, y) = (run.start.max(a), run.end.min(b)); if x >= y || self.char_style.as_ref().is_some_and(|n| *n != cf.style) { continue; @@ -175,10 +185,16 @@ impl FindFormat { } } -/// Attribute values alike: numbers to within rounding, the rest exactly. +/// Attribute values alike: numbers to within rounding (inside objects and lists too), the rest +/// exactly. fn same_value(a: &Value, b: &Value) -> bool { - match (a.as_f64(), b.as_f64()) { - (Some(x), Some(y)) => (x - y).abs() < 1e-6, + match (a, b) { + (Value::Number(_), Value::Number(_)) => match (a.as_f64(), b.as_f64()) { + (Some(x), Some(y)) => (x - y).abs() < 1e-6, + _ => a == b, + }, + (Value::Array(x), Value::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(a, b)| same_value(a, b)), + (Value::Object(x), Value::Object(y)) => x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| same_value(v, w))), _ => a == b, } } @@ -700,14 +716,33 @@ mod tests { 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"}, "charStyle": "[None]", "attrs": {"underline": true}}), + &json!({"find": "", "change": "", "findFormat": {"charStyle": "eggs", "chars": {"font_style": "Bold"}}, "charStyle": "[None]", "attrs": {"underline": true}}), ) .unwrap(); - assert_eq!(r["count"], 2); + 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. From 33f496a05b883affc2e1be952112bc543b9c3d83 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 00:23:35 +0200 Subject: [PATCH 105/431] Find/Change: Change takes the match Find Next selected; Selection is translated Change used to accept a selection only when Change All's matches (from the start of the target) included it. Find Next can select text those never produce: the rest of a Find Format stretch from a caret inside it, or an overlapping match ("aa" in "aaa" from the second a). Both commands now share one definition (`Search::found_edit`). Without a pattern, a selection inside one stretch is a match; with one, the pattern's match from the selection's start must be the selection, and a GREP replacement is expanded for that match (`Grep::replacement_at`). "Selection" has a row in the shared table (Spanish and Chinese fell back to English); the French entry moves there from `fr.rs`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/compose/src/grep.rs | 10 ++++ crates/engine/src/cmd/find.rs | 109 +++++++++++++++++++++++++++------- crates/ui-egui/src/i18n.rs | 1 + crates/ui-egui/src/i18n/fr.rs | 1 - 4 files changed, 100 insertions(+), 21 deletions(-) diff --git a/crates/compose/src/grep.rs b/crates/compose/src/grep.rs index 1d7e0e6a9..0b4d2eca3 100644 --- a/crates/compose/src/grep.rs +++ b/crates/compose/src/grep.rs @@ -378,6 +378,16 @@ impl Grep { self.re.find_iter(text).take(limit).map(|m| m.map(|m| m.range()).map_err(run_err)).collect() } + /// The first match starting at or after byte `start`, with its replacement: `template` + /// expanded as [`Self::replacements`] does. + pub fn replacement_at(&self, text: &str, start: usize, template: &str) -> Result<Option<(Range<usize>, String)>, GrepError> { + if start > text.len() || !text.is_char_boundary(start) { + return Ok(None); + } + let Some(caps) = self.re.captures_from_pos(text, start).map_err(run_err)? else { return Ok(None) }; + Ok(caps.get(0).map(|m| (m.range(), fancy_regex::Expander::default().expansion(template, &caps)))) + } + /// Matches in `text` and their replacements, `template` expanded per match (`$1`…; see /// [`translate_replacement`]). Stops after the first match when `first_only`. pub fn replacements(&self, text: &str, template: &str, first_only: bool) -> Result<Vec<(Range<usize>, String)>, GrepError> { diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index e2efd0732..4e3591c7b 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -260,6 +260,33 @@ impl Search { } } + /// The edit Change makes when the selection `range` (offsets in `text`, target `r` of story + /// `sid`) is what Find Next found there, so the two commands agree on what a match is. With + /// no pattern: a selection inside one stretch with the Find Format (Find Next selects the rest + /// of a stretch from inside it). With a pattern: the match it makes from the selection's start + /// must be the selection, overlapping matches Find Next can select included. + #[allow(clippy::too_many_arguments)] + fn found_edit( + &self, + d: &Document, + sid: StoryId, + r: &Range<usize>, + text: &str, + range: &Range<usize>, + rep: &str, + grep: bool, + ) -> Result<Option<(Range<usize>, String)>> { + let spans = self.spans(d, sid, r); + let Some(re) = &self.re else { + let inside = !range.is_empty() && spans.unwrap_or_default().iter().any(|s| s.start <= range.start && range.end <= s.end); + return Ok(inside.then(|| (range.clone(), rep.to_string()))); + }; + let found = + if grep { re.replacement_at(text, range.start, rep) } else { re.find_at(text, range.start).map(|m| m.map(|m| (m, rep.to_string()))) }; + let found = found.map_err(|e| bad("find.change", e))?; + Ok(found.filter(|(m, _)| m == range && spans.as_ref().is_none_or(|s| within(s, m)))) + } + /// The edits Change All makes in `text` (target `r` of story `sid`): `rep` expanded for each /// GREP match, else `rep` itself. #[allow(clippy::too_many_arguments)] @@ -531,26 +558,28 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> { let format_only = str_param(p, "change").unwrap_or("").is_empty() && (char_style.is_some() || para_style.is_some() || !over.is_empty()); let context = scope(s, p)?; let targets = context.targets.clone(); - // Change: only the text Find Next found, the selection, while it is still a match. - let found = if bool_or(p, "found", false) { + // Change: only the text Find Next found, the selection, while it is still a match (as Find + // Next defines one), with its replacement. + let (found, found_out) = if bool_or(p, "found", false) { let st = s.doc()?; let selected = st.selection.text.filter(|t| t.cell.is_none() && !t.is_caret()).map(|t| (t.story, t.range())); - let hit = selected.filter(|(sid, range)| { - targets.iter().any(|(tid, r)| { - tid == sid - && r.start <= range.start - && range.end <= r.end - && target_text(&st.doc, *tid, r) - .and_then(|text| re.replacements(&st.doc, *tid, r, text, &rep, grep, false)) - .is_ok_and(|edits| edits.iter().any(|(m, _)| r.start + m.start == range.start && r.start + m.end == range.end)) + let hit = selected.and_then(|(sid, range)| { + targets.iter().find_map(|(tid, r)| { + if *tid != sid || range.start < r.start || r.end < range.end { + return None; + } + let text = target_text(&st.doc, *tid, r).ok()?; + let local = range.start - r.start..range.end - r.start; + let (m, out) = re.found_edit(&st.doc, *tid, r, text, &local, &rep, grep).ok()??; + Some(((sid, r.start + m.start..r.start + m.end), out)) }) }); match hit { - Some(hit) => Some(hit), + Some((hit, out)) => (Some(hit), Some(out)), None => return Ok(json!({"count": 0})), } } else { - None + (None, None) }; let (count, changed) = s.edit(|d, sel| { let styles = d.styles.clone(); @@ -563,14 +592,21 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> { let Some(story) = d.story(sid) else { continue }; // Collect matches first (positions refer to the original text), apply back to front. let text = story.text.get(r.clone()).ok_or_else(|| bad("find", "invalid selection bounds or UTF-8 boundary"))?; - let mut edits: Vec<(std::ops::Range<usize>, String)> = re - .replacements(d, sid, &r, text, &rep, grep, first_only)? - .into_iter() - .map(|(m, out)| (r.start + m.start..r.start + m.end, out)) - .collect(); - if let Some((_, range)) = &found { - edits.retain(|(m, _)| m == range); - } + let edits: Vec<(std::ops::Range<usize>, String)> = match (&found, &found_out) { + // Change: the found text alone, in the target it lies in. + (Some((_, range)), Some(out)) => { + if r.start <= range.start && range.end <= r.end { + vec![(range.clone(), out.clone())] + } else { + Vec::new() + } + } + _ => re + .replacements(d, sid, &r, text, &rep, grep, first_only)? + .into_iter() + .map(|(m, out)| (r.start + m.start..r.start + m.end, out)) + .collect(), + }; let Some(st) = d.story_mut(sid) else { continue }; for (range, new) in edits.iter().rev() { let changed = if format_only { @@ -853,6 +889,39 @@ mod tests { 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<a>" } else { "ab" }); + } + } + #[test] fn change_first_changes_one_match_in_the_whole_document() { for grep in [false, true] { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 0bfb01e7e..08d6d57c6 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1058,6 +1058,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ], ), ("Load Styles", ["Formate laden", "Charger les styles", "Cargar estilos", "スタイルを読み込み", "加载样式"]), + ("Selection", ["Auswahl", "Sélection", "Selección", "選択範囲", "选区"]), ( "Differs from this document", ["Weicht von diesem Dokument ab", "Diffère de ce document", "Difiere de este documento", "このドキュメントと異なります", "与此文档不同"], diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 78c5d5948..c2e29dc20 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -160,7 +160,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Scripts", "Scripts"), ("Section Marker", "Marque de section"), ("Section Symbol", "Symbole de section"), - ("Selection", "Sélection"), ("Sentence case", "Majuscule en début de phrase"), ("Simulate Paper Color", "Simuler la couleur du papier"), ("Single Left Quotation Mark", "Guillemet simple ouvrant"), From 22bb814c9d80bbed0131aaa72fbce2cb7fc5bfcd Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 00:30:40 +0200 Subject: [PATCH 106/431] Eyedropper: sample the colour as the canvas shows it; picker strings in every catalog - `screen_post` holds what the canvas does to a render before showing it (Proof Colors, the composite ink-limit preview). The texture and the eyedropper's one-pixel sample both go through it, so with either view on, the eyedropper takes the colour on screen. - The picker's OK uses the footer's padded " OK " key, which every catalog has (Chinese showed "OK"). - "RGB Color Space View", "Lab Color Space View", "Add CMYK Swatch" and "Added swatch" are in the ar, cs, it, ka, pl, pt-br, ru and uk catalogs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/canvas.rs | 16 +++++++--- crates/ui-egui/src/dialogs.rs | 54 +++++++++++++++++++++++++++++++- crates/ui-egui/src/i18n/ar.rs | 4 +++ crates/ui-egui/src/i18n/cs.rs | 4 +++ crates/ui-egui/src/i18n/it.rs | 4 +++ crates/ui-egui/src/i18n/ka.rs | 4 +++ crates/ui-egui/src/i18n/pl.rs | 4 +++ crates/ui-egui/src/i18n/pt_br.rs | 4 +++ crates/ui-egui/src/i18n/ru.rs | 4 +++ crates/ui-egui/src/i18n/uk.rs | 4 +++ 10 files changed, 97 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 74c5284d5..7f96aac17 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -660,7 +660,9 @@ 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 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, or marked over the ink limit. + screen_post(app, &mut img); let [r, g, b, a] = <[u8; 4]>::try_from(img.pixels.get(..4)?).ok()?; // Premultiplied over the paper. let paper = doc.resolve_color(designcraft_color::swatch::PAPER, 1.0).unwrap_or(designcraft_color::Color::WHITE).to_rgba8(1.0); @@ -934,14 +936,20 @@ fn render_worker_spawn(ctx: &egui::Context) -> Option<crate::render_worker::Work crate::render_worker::Worker::spawn(ctx.clone()) } -fn upload(app: &mut DesignApp, ctx: &egui::Context, mut img: designcraft_render::Rendered) { +/// What the canvas does to a render before showing it: Proof Colors, and the composite ink-limit +/// preview. The texture and the eyedropper's sample both go through it. +fn screen_post(app: &DesignApp, img: &mut designcraft_render::Rendered) { if app.ui.proof_colors { - designcraft_render::proof_view(&mut img, &app.ui.proof_setup); + designcraft_render::proof_view(img, &app.ui.proof_setup); } // Plates render from source colours; the ink limit is estimated from the composite. if app.ui.separation.is_none() && app.ui.ink_limit.is_some() { - designcraft_render::separation_view(&mut img, None, app.ui.ink_limit); + designcraft_render::separation_view(img, None, app.ui.ink_limit); } +} + +fn upload(app: &mut DesignApp, ctx: &egui::Context, mut img: designcraft_render::Rendered) { + screen_post(app, &mut img); let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); let ci = crate::widgets::fit_texture(ci, ctx.input(|i| i.max_texture_side)); match &mut app.canvas.texture { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5b3849243..b9a85d5ba 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -6775,7 +6775,8 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { 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)) { + // The footer's padded OK key, which every catalog has. + if pill(ui, 0, tr(" OK ").trim(), 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)) { @@ -10033,6 +10034,57 @@ mod tests { /// 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 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] fn the_color_pickers_eyedropper_takes_a_colour_off_the_page() { use egui_kittest::{Harness, kittest::Queryable}; diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b74df5167..8ff1269d4 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", "تمت إضافة عينة اللون"), ("Eyedropper: drag it onto the page", "القطارة: اسحبها إلى الصفحة"), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e16f71a0b..f81957415 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("RGB Color Space View", "Zobrazení barevného prostoru RGB"), + ("Lab Color Space View", "Zobrazení barevného prostoru Lab"), + ("Add CMYK Swatch", "Přidat vzorek CMYK"), + ("Added swatch", "Vzorek přidán"), ("Eyedropper: drag it onto the page", "Kapátko: přetáhněte ho na stránku"), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 59caf152a..59c44df6e 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"), ("Eyedropper: drag it onto the page", "Contagocce: trascinalo sulla pagina"), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 99c455519..99e6e8e54 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", "ფერის ნიმუში დამატებულია"), ("Eyedropper: drag it onto the page", "პიპეტი: გადაიტანეთ გვერდზე"), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index ec533dbb0..9546497f4 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("RGB Color Space View", "Widok przestrzeni barw RGB"), + ("Lab Color Space View", "Widok przestrzeni barw Lab"), + ("Add CMYK Swatch", "Dodaj próbkę CMYK"), + ("Added swatch", "Dodano próbkę"), ("Eyedropper: drag it onto the page", "Kroplomierz: przeciągnij go na stronę"), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index f8ed98d04..2f861e53b 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"), ("Eyedropper: drag it onto the page", "Conta-gotas: arraste-o para a página"), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 13b74df76..25de858ef 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,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", "Образец добавлен"), ("Eyedropper: drag it onto the page", "Пипетка: перетащите её на страницу"), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index a5652335c..562d527a6 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,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", "Зразок додано"), ("Eyedropper: drag it onto the page", "Піпетка: перетягніть її на сторінку"), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), From ea5a2f790faa516f574dc80fcef11da86d20e431 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 00:49:45 +0200 Subject: [PATCH 107/431] =?UTF-8?q?Preferences=20=E2=80=BA=20Interface:=20?= =?UTF-8?q?Single=20Column=20in=20Czech,=20Georgian,=20Polish=20and=20Russ?= =?UTF-8?q?ian?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Tools Panel menu's Single Column fell back to English in these catalogs. Russian Double Column reads "Две колонки", the table's word for layout columns. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 3 ++- 4 files changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 4bc7ce13b..d4ebe17d3 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -32,6 +32,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Copy", "Kopírovat"), ("Paste", "Vložit"), ("Double Column", "Dvojitý sloupec"), + ("Single Column", "Jednoduchý sloupec"), ("Tools Panel:", "Panel nástrojů:"), ("Color Theme:", "Barevný motiv:"), ("Reverse Path", "Obrátit cestu"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 9f6a8f5f1..9bbdfdf87 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -32,6 +32,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Copy", "კოპირება"), ("Paste", "ჩასმა"), ("Double Column", "ორმაგი სვეტი"), + ("Single Column", "ერთმაგი სვეტი"), ("Tools Panel:", "ხელსაწყოების პანელი:"), ("Color Theme:", "ფერთა თემა:"), ("Inches Decimal", "ინჩები (ათობითი)"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 731683d7e..4db68518d 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -30,6 +30,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Copy", "Kopiuj"), ("Paste", "Wklej"), ("Double Column", "Podwójna kolumna"), + ("Single Column", "Pojedyncza kolumna"), ("Tools Panel:", "Panel narzędzi:"), ("Color Theme:", "Motyw kolorów:"), ("Clear", "Wyczyść"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 4fff60238..befbe21d4 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,7 +33,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), - ("Double Column", "Двойной столбец"), + ("Double Column", "Две колонки"), + ("Single Column", "Одна колонка"), ("Tools Panel:", "Панель инструментов:"), ("Color Theme:", "Цветовая тема:"), ("Clear", "Очистить"), From cb9d23dd521ccffa634402e0eda795320ff2dd89 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 00:51:01 +0200 Subject: [PATCH 108/431] Preferences test: the recent items count is 20 The test checked only that the count was whole, so another default would pass. It asserts 20, InDesign's default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 bed4cdf04..a0ce16e47 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -7435,7 +7435,7 @@ mod tests { 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.fract(), 0.0); + 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 c04fa61d17ef932a95817db628a3f2d51ff38e88 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 00:53:21 +0200 Subject: [PATCH 109/431] Package: a failed package keeps the dialog open; the ink table's Type column; no document is an error - When `file.package` fails, the Package or Printing Instructions dialog comes back with its options and instructions, and the status line says why. Before, they were lost. - The Colors and Inks table heads its kind column with the "Type:" key (without its colon). The "Type" key is the menu's and reads Text in most languages. - `app.packageDialog` with no document open is an error rather than a silent success. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/dialogs.rs | 18 +++++++++++++-- crates/ui-egui/src/menus.rs | 5 +--- crates/ui-egui/src/package_dialog.rs | 34 ++++++++++++++++++++++++---- 3 files changed, 47 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9182f5843..41b1d0504 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3581,8 +3581,22 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> { } "alert" => Ok(Value::Null), crate::effects_dialog::ID => crate::effects_dialog::confirm(app, &d), - crate::package_dialog::ID => crate::package_dialog::confirm(app, &d), - crate::package_dialog::INSTRUCTIONS => crate::package_dialog::confirm_instructions(app, &d), + crate::package_dialog::ID | crate::package_dialog::INSTRUCTIONS => { + let r = if d.id == crate::package_dialog::ID { + crate::package_dialog::confirm(app, &d) + } else { + crate::package_dialog::confirm_instructions(app, &d) + }; + // Packaging failed: the dialog stays open with its options and instructions, and the + // status line says why. + if let Err(e) = &r + && app.ui.dialog.is_none() + { + app.status(e.clone()); + app.ui.dialog = Some(d); + } + r + } "findChange" => { let result = if d.b("objectMode") { Ok(Value::Null) } else { app.run("find.change", find_change_params(&d)) }; // Confirmation consumes retained bounds before closing, even on an error. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 794e58f2d..923ff2a86 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1149,10 +1149,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value Ok(Value::Null) } "app.exportText" => export_bytes(app, p, "rtf", "file.exportText"), - "app.packageDialog" => { - crate::package_dialog::open(app); - Ok(Value::Null) - } + "app.packageDialog" => crate::package_dialog::open(app).map(|()| Value::Null), "app.language" => { let Some(v) = p.get("lang").and_then(Value::as_str) else { return Some(app.run("app.preferences", json!({}))) }; if !crate::i18n::LANGUAGES.iter().any(|(c, _)| *c == v) { diff --git a/crates/ui-egui/src/package_dialog.rs b/crates/ui-egui/src/package_dialog.rs index 6c00ca060..741de6b44 100644 --- a/crates/ui-egui/src/package_dialog.rs +++ b/crates/ui-egui/src/package_dialog.rs @@ -33,9 +33,9 @@ const INSTRUCTION_FIELDS: [(&str, &str, usize); 7] = [ ]; /// Open the Package dialog on its Summary, with the document's fonts, links and spot inks. -pub fn open(app: &mut DesignApp) { +pub fn open(app: &mut DesignApp) -> Result<(), String> { if app.session.active().is_none() { - return; + return Err("no document open".into()); } let fonts: Vec<Value> = app .session @@ -85,6 +85,7 @@ pub fn open(app: &mut DesignApp) { ID, json!({"page": "summary", "fonts": fonts, "links": links, "spot": spot, "instructions": false, "idml": true, "pdf": false, "problemsOnly": false}), )); + Ok(()) } fn rows(d: &Dialog, key: &str) -> Vec<Value> { @@ -228,7 +229,9 @@ pub fn show(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { "inks" => { let mut cells: Vec<_> = PROCESS_INKS.iter().map(|n| (vec![tr(n), tr("Process")], None)).collect(); cells.extend(rows(d, "spot").iter().filter_map(Value::as_str).map(|n| (vec![n.to_string(), tr("Spot")], None))); - table(ui, "package_inks", &[tr("Name"), tr("Type")], &cells); + // The ink's kind: the "Type:" key (the "Type" key is the menu's, which reads Text). + let kind = tr("Type:").trim_end_matches([':', ':']).trim_end().to_string(); + table(ui, "package_inks", &[tr("Name"), kind], &cells); } _ => { ui.horizontal(|ui| { @@ -441,7 +444,7 @@ mod tests { let out = dir.join("Packed Folder"); let picked = out.to_string_lossy().to_string(); app.services.pick_save = Some(Box::new(move |_| Some(picked.clone()))); - open(&mut app); + open(&mut app).unwrap(); let mut h = harness(app); h.run_steps(3); h.get_by_label("Include IDML").click(); @@ -454,4 +457,27 @@ mod tests { assert!(out.join("Packed.pdf").exists() && !out.join("Packed.idml").exists()); std::fs::remove_dir_all(dir).unwrap(); } + + /// Packaging that fails (here, into a folder under a file) keeps the dialog open with its + /// options, and the status line says why. Without a document there's nothing to open. + #[test] + fn a_failed_package_keeps_the_dialog_open() { + let dir = temp_dir("fails"); + let mut app = app_with_missing_link(&dir); + let blocker = dir.join("blocker"); + std::fs::write(&blocker, b"a file, not a folder").unwrap(); + let picked = blocker.join("Packed Folder").to_string_lossy().to_string(); + app.services.pick_save = Some(Box::new(move |_| Some(picked.clone()))); + open(&mut app).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("pdf".into(), json!(true)); + assert!(crate::dialogs::confirm(&mut app).is_err()); + let d = app.ui.dialog.as_ref().expect("the dialog stays open"); + assert_eq!((d.id.as_str(), d.b("pdf")), (ID, true), "with its options"); + assert!(!app.ui.status.is_empty()); + app.run("file.close", json!({})).unwrap(); + app.ui.dialog = None; + assert!(app.run("app.packageDialog", json!({})).is_err()); + assert!(app.ui.dialog.is_none()); + std::fs::remove_dir_all(dir).unwrap(); + } } From 63d3f5a19d1488e6ea9404c1f60d9d2f1bc57ff0 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 00:59:00 +0200 Subject: [PATCH 110/431] Package the PDF as file.exportPdf makes it; refuse a bad `pdf` file.package now writes its PDF through export's `pdf_report`, so a preset's transparency flattening, the effects and hidden-layer rasterising, the blend space, the view options and the transitions all reach the packaged PDF. A `pdf` that is neither a switch nor export options is refused, as is a bad flattener, before anything is written. Georgian: the printing term for spot inks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/export.rs | 42 +++++++++++++++++---------- crates/engine/src/cmd/package.rs | 50 +++++++++++++++++++++++++------- crates/ui-egui/src/i18n/ka.rs | 4 +-- 3 files changed, 68 insertions(+), 28 deletions(-) diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 36fd3d52a..ad90d312e 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -710,9 +710,20 @@ fn first_page_ref(pdf: &[u8]) -> Option<usize> { fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> { let st = s.doc()?; - let opts = options(p, st.doc.page_count())?; - // Transparency Flattener presets: High / Medium / Low Resolution, or a ppi. - let ppi = match p.get("flatten") { + let r = pdf_report(&st.doc, &s.cache, p)?; + match str_param(p, "path") { + Some(path) => { + write_file(path, &r.bytes)?; + Ok(json!({"path": path, "bytes": r.bytes.len(), "pages": r.pages, "warnings": r.warnings})) + } + None => Ok(json!({"base64": super::base64_encode(&r.bytes), "bytes": r.bytes.len(), "pages": r.pages, "warnings": r.warnings})), + } +} + +/// The Transparency Flattener's resolution `p.flatten` asks for: High, Medium or Low Resolution, +/// or a ppi; `None` when the PDF isn't flattened. +pub(super) fn flatten_ppi(p: &Value) -> Result<Option<f64>> { + Ok(match p.get("flatten") { None | Some(Value::Null) | Some(Value::Bool(false)) => None, Some(Value::String(s)) if s == "high" => Some(300.0), Some(Value::String(s)) if s == "medium" => Some(150.0), @@ -721,17 +732,24 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> { Some(v) => Some( v.as_f64().filter(|v| (36.0..=1200.0).contains(v)).ok_or_else(|| bad("file.exportPdf", "`flatten`: high|medium|low or 36–1200 ppi"))?, ), - }; - let flattened = ppi.map(|ppi| flatten(&st.doc, &s.cache, ppi)); + }) +} + +/// The PDF of `doc` as `file.exportPdf` makes it from options `p`: flattened when asked, placed +/// PDFs with hidden layers and soft effects as images, the blend space, the view options and the +/// page transitions. `file.package` writes its PDF with it too. +pub(super) fn pdf_report(doc: &designcraft_doc::Document, cache: &designcraft_compose::Cache, p: &Value) -> Result<designcraft_pdf::ExportReport> { + let opts = options(p, doc.page_count())?; + let flattened = flatten_ppi(p)?.map(|ppi| flatten(doc, cache, ppi)); // Placed PDFs with hidden layers go out as images showing just their visible layers. - let base: &designcraft_doc::Document = flattened.as_ref().map_or(&st.doc, |f| &f.0); + let base: &designcraft_doc::Document = flattened.as_ref().map_or(doc, |f| &f.0); let (layered, layer_count) = rasterize_layered(base)?; let base2: &designcraft_doc::Document = layered.as_ref().unwrap_or(base); - let (fx_doc, fx_count) = rasterize_effects(base2, &s.cache); + let (fx_doc, fx_count) = rasterize_effects(base2, cache); let doc: &designcraft_doc::Document = fx_doc.as_ref().unwrap_or(base2); let boxed = super::interchange::with_pdf_boxes(doc); let doc: &designcraft_doc::Document = &boxed; - let r = designcraft_pdf::export_pdf_with_report(doc, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?; + let r = designcraft_pdf::export_pdf_with_report(doc, cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?; let mut r = r; // Transparency Blend Space: the page group of pages with transparency. let spreads = designcraft_pdf::sheet_spreads(doc, &opts); @@ -805,13 +823,7 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> { { r.warnings.push(format!("transparency flattened: {n} region(s) rasterised")); } - match str_param(p, "path") { - Some(path) => { - write_file(path, &r.bytes)?; - Ok(json!({"path": path, "bytes": r.bytes.len(), "pages": r.pages, "warnings": r.warnings})) - } - None => Ok(json!({"base64": super::base64_encode(&r.bytes), "bytes": r.bytes.len(), "pages": r.pages, "warnings": r.warnings})), - } + Ok(r) } #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 69b3ed379..107b859ae 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -389,12 +389,17 @@ fn drawn_faces(cs: &ComposedStory, depth: usize, out: &mut Vec<FaceRef>) { fn package(s: &mut Session, p: &Value) -> Result<Value> { let dir = PathBuf::from(str_param(p, "dir").ok_or_else(|| bad("file.package", "missing `dir`"))?); let st = s.doc()?; - // The PDF: with the default settings, or with the options of a PDF preset (as - // file.exportPdf's). Read first, so that bad options write nothing. + // The PDF: with the default settings, or with the options of a PDF preset, made as + // file.exportPdf makes it. Read first, so that bad options write nothing. let pdf = match p.get("pdf") { - Some(o @ Value::Object(_)) => Some(super::export::options(o, st.doc.page_count())?), - Some(Value::Bool(true)) => Some(Default::default()), - _ => None, + None | Some(Value::Null) | Some(Value::Bool(false)) => None, + Some(Value::Bool(true)) => Some(json!({})), + Some(o @ Value::Object(_)) => { + super::export::options(o, st.doc.page_count())?; + super::export::flatten_ppi(o)?; + Some(o.clone()) + } + Some(v) => return Err(bad("file.package", format!("`pdf` is true, false or PDF export options, not {v}"))), }; let root = OutputRoot::new(dir.clone())?; let display_title = st.doc.title.clone(); @@ -440,11 +445,9 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> { root.write(&path, &designcraft_idml::export_idml_with(&packed, &opts))?; files.push(path); } - if let Some(opts) = pdf { + if let Some(params) = pdf { let path = package_output_path(&dir, &title, "pdf")?; - let bytes = designcraft_pdf::export_pdf(&super::interchange::with_pdf_boxes(&packed), &s.cache, &opts) - .map_err(|e| EngineError::Other(e.to_string()))?; - root.write(&path, &bytes)?; + root.write(&path, &super::export::pdf_report(&packed, &s.cache, ¶ms)?.bytes)?; files.push(path); } // The report: fonts (missing first), links, preflight, the user's instructions. @@ -531,8 +534,33 @@ mod tests { 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")); - assert!(s.execute("file.package", &json!({"dir": dir.join("bad").to_string_lossy(), "pdf": {"standard": "nope"}})).is_err()); - assert!(!dir.join("bad").exists(), "bad options write nothing"); + // Refused before anything is written: bad options, a bad flattener and a `pdf` that is + // neither a switch nor options. + for bad in [json!({"standard": "nope"}), json!({"flatten": "extreme"}), 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(); } diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 9bd18a169..121f8295b 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -41,7 +41,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Create Printing Instructions", "ბეჭდვის ინსტრუქციების შექმნა"), ("Entire Publication", "მთელი პუბლიკაცია"), ("Scope:", "არეალი:"), - ("Spot", "ლაქა"), + ("Spot", "სპოტ-საღებავი"), ("Process", "პროცესი"), ("Source", "წყარო"), ("Installed", "დაყენებული"), @@ -49,7 +49,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Substituted", "ჩანაცვლებული"), ("Modified", "შეცვლილი"), ("Missing", "აკლია"), - ("Colors and Inks: {process} Process Inks; {spot} Spot Inks", "ფერები და მელნები: {process} პროცესის; {spot} ლაქის"), + ("Colors and Inks: {process} Process Inks; {spot} Spot Inks", "ფერები და მელნები: {process} პროცესის მელანი; {spot} სპოტ-საღებავი"), ("Images: {embedded} Embedded", "გამოსახულებები: ჩაშენებულია {embedded}"), ( "Links and Images: {found} Links Found; {modified} Modified, {missing} Missing", From 4d968d8adc16abed149558c1aeba1243effd4ddc Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 01:06:12 +0200 Subject: [PATCH 111/431] Paragraph border links: linking again makes the four the same Clicking "Make all settings the same" back on gives the four weights or offsets Top's value, and the four corners Top Left's size and shape, so OK never saves sides that differ under a closed link. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/dialogs.rs | 56 +++++++++++++++++++++++++++++------ 1 file changed, 47 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 4ebb424b1..737bf1d9c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4493,7 +4493,12 @@ fn para_sides(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &s changed = true; } match i { - 0 => link_toggle(lang, ui, d, key, linked), + // Linked again, the four take Top's value. + 0 if link_toggle(lang, ui, d, key, linked) => { + sides = [sides[0]; 4]; + changed = true; + } + 0 => {} 2 => { ui.label(""); } @@ -4512,12 +4517,15 @@ fn linked(d: &Dialog, key: &str, same: bool) -> bool { d.fields.get(&format!("_linked.{key}")).and_then(Value::as_bool).unwrap_or(same) } -/// The link button between a border's or shading's four sides or corners. -fn link_toggle(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, key: &str, linked: bool) { +/// The link button between a border's or shading's four sides or corners; true when it was +/// clicked to link them, and the caller makes the four the same. +fn link_toggle(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, key: &str, linked: bool) -> bool { let tip = crate::i18n::tr(lang, "Make all settings the same"); - if crate::widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, tip, egui::vec2(22.0, 21.0)).clicked() { + let clicked = crate::widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, tip, egui::vec2(22.0, 21.0)).clicked(); + if clicked { d.fields.insert(format!("_linked.{key}"), json!(!linked)); } + clicked && !linked } /// A swatch menu with a colour chip per swatch (hidden swatches left out); returns the swatch @@ -4576,6 +4584,7 @@ fn para_corners(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: let mut corners: [Corner; 4] = serde_json::from_value(para_value(d, pv, key)).unwrap_or_default(); let linked = linked(d, key, corners.iter().all(|c| *c == corners[0])); let mut changed = None; + let mut relinked = false; egui::Grid::new(("pscorners", key)).num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { for (i, label) in ["Top Left:", "Top Right:", "Bottom Right:", "Bottom Left:"].into_iter().enumerate() { let Some(c) = corners.get_mut(i) else { continue }; @@ -4595,12 +4604,17 @@ fn para_corners(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: } } }); - if i == 0 { - link_toggle(lang, ui, d, key, linked); + // Linked again, the four take Top Left's size and shape. + if i == 0 && link_toggle(lang, ui, d, key, linked) { + relinked = true; } ui.end_row(); } }); + if relinked { + corners = [corners[0]; 4]; + changed = Some(0); + } if let Some(i) = changed { // Linked, the corner changed sets all four. if linked { @@ -9235,15 +9249,39 @@ mod tests { 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. - let links: Vec<egui::Rect> = 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"); - crate::test_window::click_at(&mut h, links[0].center()); + // 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<egui::Rect> = 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 c84ce7a545dd510981a4c4e28991f194c9c38959 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 01:25:12 +0200 Subject: [PATCH 112/431] Swatches: a click on another swatch keeps it picked and cancels a waiting rename Ending a rename by clicking another swatch keeps that swatch picked (for Delete Swatch) instead of the renamed one. A click on another swatch while the rename's double-click wait runs cancels the rename. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/panels/swatches.rs | 37 +++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 1abe1d6c9..a41b71d5b 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -260,8 +260,12 @@ fn rename_field(app: &mut DesignApp, ui: &mut egui::Ui, sw: &designcraft_color:: let to = text.trim(); if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && !to.is_empty() && to != name { match app.run("swatch.options", json!({"name": name, "to": to})) { + // Still picked under its new name, unless the click that ended the rename picked + // another swatch. Ok(_) => ui.data_mut(|d| { - d.insert_temp(egui::Id::new(PICKED), to.to_string()); + if d.get_temp::<String>(egui::Id::new(PICKED)).is_none_or(|p| p == name) { + d.insert_temp(egui::Id::new(PICKED), to.to_string()); + } }), Err(e) => { app.status_error("Swatch Options", e); @@ -334,6 +338,8 @@ fn swatch_row( // 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::<String>(egui::Id::new(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))); 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))); @@ -348,7 +354,10 @@ fn swatch_row( } if let Some((name, at)) = ui.data(|d| d.get_temp::<(String, f64)>(egui::Id::new(RENAME_CLICK))).filter(|(n, _)| *n == sw.name) { let wait = ui.ctx().options(|o| o.input_options.max_double_click_delay); - if ui.input(|i| i.time) - at > wait { + if ui.data(|d| d.get_temp::<String>(egui::Id::new(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))); + } 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)); @@ -1279,6 +1288,30 @@ mod swatches_header_tests { click(&mut h, at); h.run_steps(10); 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::<String>(egui::Id::new(super::PICKED))); + let (lemon, paper) = (name_at(&h, "Lemon Yellow Sun"), name_at(&h, "[Paper]")); + h.run_steps(20); + click(&mut h, lemon); + h.run_steps(10); + click(&mut h, lemon); + click(&mut h, paper); + h.run_steps(10); + assert!(!field(&h), "the waiting rename is cancelled"); + assert_eq!(picked(&h).as_deref(), Some("[Paper]")); + // Renaming, a click on another swatch renames, and picks that one (for Delete Swatch). + click(&mut h, lemon); + h.run_steps(10); + click(&mut h, lemon); + h.run_steps(10); + assert!(field(&h)); + h.event(egui::Event::Text("Lemon".into())); + h.step(); + click(&mut h, paper); + h.run_steps(5); + assert!(has(&h, "Lemon") && !field(&h)); + assert_eq!(picked(&h).as_deref(), Some("[Paper]"), "the swatch clicked stays picked"); } #[test] From 9ab1122688ad014c0e53bd27df5d8bc43555069e Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 01:28:37 +0200 Subject: [PATCH 113/431] Package: check the PDF's view options before writing anything `export::check_pdf_options` refuses what `pdf_report` would: the export options, the flattener, and now `pageLayout` and `view`, which `pdf_report` reads up front too. `file.package` checks with it before the output folder is made, so an unknown page layout or view leaves no partial package. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/export.rs | 73 ++++++++++++++++++++------------ crates/engine/src/cmd/package.rs | 16 ++++--- 2 files changed, 56 insertions(+), 33 deletions(-) diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index ad90d312e..e20ebaae2 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -722,7 +722,7 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> { /// The Transparency Flattener's resolution `p.flatten` asks for: High, Medium or Low Resolution, /// or a ppi; `None` when the PDF isn't flattened. -pub(super) fn flatten_ppi(p: &Value) -> Result<Option<f64>> { +fn flatten_ppi(p: &Value) -> Result<Option<f64>> { Ok(match p.get("flatten") { None | Some(Value::Null) | Some(Value::Bool(false)) => None, Some(Value::String(s)) if s == "high" => Some(300.0), @@ -735,11 +735,49 @@ pub(super) fn flatten_ppi(p: &Value) -> Result<Option<f64>> { }) } +/// The catalog entries of the interactive view options in `p` (page mode and layout) and the +/// destination the first page opens at (`view`); unknown values are refused. +fn view_options(p: &Value) -> Result<(String, Option<&'static str>)> { + let mut cat = String::new(); + if p.get("fullScreen").and_then(Value::as_bool) == Some(true) { + cat.push_str("/PageMode/FullScreen"); + } else if p.get("bookmarksPanel").and_then(Value::as_bool) == Some(true) { + cat.push_str("/PageMode/UseOutlines"); + } + match str_param(p, "pageLayout") { + Some("single") => cat.push_str("/PageLayout/SinglePage"), + Some("continuous") => cat.push_str("/PageLayout/OneColumn"), + Some("twoUp") => cat.push_str("/PageLayout/TwoPageLeft"), + Some("twoUpCover") => cat.push_str("/PageLayout/TwoPageRight"), + Some("twoUpContinuous") => cat.push_str("/PageLayout/TwoColumnRight"), + Some(o) => return Err(bad("file.exportPdf", format!("unknown pageLayout `{o}`"))), + None => {} + } + let dest = match str_param(p, "view") { + None => None, + Some("fitPage") => Some("/Fit"), + Some("fitWidth") => Some("/FitH null"), + Some("actual") => Some("/XYZ null null 1"), + Some(o) => return Err(bad("file.exportPdf", format!("unknown view `{o}` (fitPage, fitWidth, actual)"))), + }; + Ok((cat, dest)) +} + +/// Refuses PDF options `p` that `pdf_report` would refuse for a document of `pages` pages, +/// without exporting: `file.package` checks them before it writes anything. +pub(super) fn check_pdf_options(p: &Value, pages: usize) -> Result<()> { + options(p, pages)?; + flatten_ppi(p)?; + view_options(p)?; + Ok(()) +} + /// The PDF of `doc` as `file.exportPdf` makes it from options `p`: flattened when asked, placed /// PDFs with hidden layers and soft effects as images, the blend space, the view options and the /// page transitions. `file.package` writes its PDF with it too. pub(super) fn pdf_report(doc: &designcraft_doc::Document, cache: &designcraft_compose::Cache, p: &Value) -> Result<designcraft_pdf::ExportReport> { let opts = options(p, doc.page_count())?; + let (mut cat, dest) = view_options(p)?; let flattened = flatten_ppi(p)?.map(|ppi| flatten(doc, cache, ppi)); // Placed PDFs with hidden layers go out as images showing just their visible layers. let base: &designcraft_doc::Document = flattened.as_ref().map_or(doc, |f| &f.0); @@ -760,33 +798,12 @@ pub(super) fn pdf_report(doc: &designcraft_doc::Document, cache: &designcraft_co None => r.warnings.push("the transparency blend space couldn't be set".into()), } } - // Interactive PDF view options. - let mut cat = String::new(); - if p.get("fullScreen").and_then(Value::as_bool) == Some(true) { - cat.push_str("/PageMode/FullScreen"); - } else if p.get("bookmarksPanel").and_then(Value::as_bool) == Some(true) { - cat.push_str("/PageMode/UseOutlines"); - } - match str_param(p, "pageLayout") { - Some("single") => cat.push_str("/PageLayout/SinglePage"), - Some("continuous") => cat.push_str("/PageLayout/OneColumn"), - Some("twoUp") => cat.push_str("/PageLayout/TwoPageLeft"), - Some("twoUpCover") => cat.push_str("/PageLayout/TwoPageRight"), - Some("twoUpContinuous") => cat.push_str("/PageLayout/TwoColumnRight"), - Some(o) => return Err(bad("file.exportPdf", format!("unknown pageLayout `{o}`"))), - None => {} - } - if let Some(v) = str_param(p, "view") { - // The first page opens with this view (pages are objects in order, so refer by index 0). - let dest = match v { - "fitPage" => "/Fit", - "fitWidth" => "/FitH null", - "actual" => "/XYZ null null 1", - o => return Err(bad("file.exportPdf", format!("unknown view `{o}` (fitPage, fitWidth, actual)"))), - }; - if let Some(first) = first_page_ref(&r.bytes) { - cat.push_str(&format!("/OpenAction[{first} 0 R{dest}]")); - } + // Interactive PDF view options; the first page opens with the view (pages are objects in + // order, so refer by index 0). + if let Some(dest) = dest + && let Some(first) = first_page_ref(&r.bytes) + { + cat.push_str(&format!("/OpenAction[{first} 0 R{dest}]")); } if !cat.is_empty() { match designcraft_pdf::add_catalog_entries(&r.bytes, &cat) { diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 107b859ae..8c72411f9 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -395,8 +395,7 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> { None | Some(Value::Null) | Some(Value::Bool(false)) => None, Some(Value::Bool(true)) => Some(json!({})), Some(o @ Value::Object(_)) => { - super::export::options(o, st.doc.page_count())?; - super::export::flatten_ppi(o)?; + super::export::check_pdf_options(o, st.doc.page_count())?; Some(o.clone()) } Some(v) => return Err(bad("file.package", format!("`pdf` is true, false or PDF export options, not {v}"))), @@ -534,9 +533,16 @@ mod tests { 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 and a `pdf` that is - // neither a switch nor options. - for bad in [json!({"standard": "nope"}), json!({"flatten": "extreme"}), json!("print"), json!(3)] { + // 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"); From 6b100a5791f35b3fc528f12c322f76605e5937fd Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 01:30:47 +0200 Subject: [PATCH 114/431] Find Format: refuse malformed fields rather than search without them `findFormat.chars` must be an object and `charStyle` / `paraStyle` style names (or null); any other value is an error instead of being skipped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/find.rs | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index 11a30e86b..1838207b8 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -121,18 +121,29 @@ impl FindFormat { fn from_params(p: &Value) -> Result<Option<Self>> { let Some(f) = p.get("findFormat").filter(|f| !f.is_null()) else { return Ok(None) }; let o = f.as_object().ok_or_else(|| bad("find", "`findFormat` is an object"))?; - let name = |k: &str| o.get(k).and_then(Value::as_str).map(str::to_string); + // A style is named; a null asks for none. Any other value is refused rather than + // searched without. + let name = |k: &str| match o.get(k) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(n)) => Ok(Some(n.clone())), + Some(v) => Err(bad("find", format!("findFormat: `{k}` is a style name, not {v}"))), + }; + let chars = match o.get("chars") { + None | Some(Value::Null) => None, + Some(Value::Object(m)) => Some(m), + Some(v) => return Err(bad("find", format!("findFormat: `chars` is an object of character attributes, not {v}"))), + }; // Only attributes text has, kept as the document writes them: `font_style` and // `fontStyle`, or 12 and 12.0, then find the same text. A null asks for nothing. let mut known = designcraft_doc::CharAttrs::default(); - for (k, v) in o.get("chars").and_then(Value::as_object).into_iter().flatten() { + for (k, v) in chars.into_iter().flatten() { known.set_json(k, v).map_err(|e| bad("find", format!("findFormat: {e}")))?; } let chars = match serde_json::to_value(&known) { Ok(Value::Object(m)) => m, _ => serde_json::Map::new(), }; - let f = Self { char_style: name("charStyle"), para_style: name("paraStyle"), chars }; + let f = Self { char_style: name("charStyle")?, para_style: name("paraStyle")?, chars }; Ok((f != Self::default()).then_some(f)) } @@ -749,6 +760,10 @@ mod tests { 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 8f9c9dd6cc49331991ff6caa8a2aa1168799fa93 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 01:38:43 +0200 Subject: [PATCH 115/431] Color panel: the spectrum's doc comment back above `spectrum` Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/panels/swatches.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 8ae422e06..02d5c51c1 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -548,7 +548,6 @@ fn cmyk_ramp(k: usize, x: f32) -> egui::Color32 { egui::Color32::from_rgb((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8) } -/// The spectrum ramp: hue across, light to dark down; click to apply. /// The Color panel on a gradient fill (or stroke): the stop selected in the Gradient panel (the /// stop before a selected midpoint), in the panel's colour mode, as InDesign's. Its fields and the /// spectrum colour that stop. @@ -613,6 +612,7 @@ fn gradient_stop(app: &mut DesignApp, ui: &mut egui::Ui, g: &designcraft_color:: } } +/// The spectrum ramp: hue across, light to dark down; click to apply. fn spectrum(app: &mut DesignApp, ui: &mut egui::Ui, target: &str) { if let Some([r, g, b]) = spectrum_pick(ui) { let text = super::colors_affect_text(app); From 1a11284bc72205a05e49ccc8baddeafff89368a2 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 01:39:01 +0200 Subject: [PATCH 116/431] Properties tests: the corner shape list's doc comment back on its test Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/panels/properties.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index e3e12257f..f3705f9b5 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -3388,8 +3388,6 @@ mod tests { assert!((r.x0 - 200.0).abs() < 0.5 && (r.y1 - 200.0).abs() < 0.5 && (r.x1 - 300.0).abs() < 0.5, "{r:?}"); } - /// 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. /// 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() { @@ -3408,6 +3406,8 @@ mod tests { 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()); From 2663cfec6870a0d031df0a3b0ca861954cfc6111 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 09:36:02 +0200 Subject: [PATCH 117/431] Canvas: main's guide snapping contexts have no loaded graphic Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/canvas.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index bc790df06..404045c9d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3099,6 +3099,7 @@ fn guide_spot(app: &DesignApp, xf: &Xf, g: &GuideDrag) -> Option<GuideSpot> { snap: app.ui.snap_view(), unit: app.ui.units, stroke_in_front: false, + loaded: None, }; let s = designcraft_tools::snap::snap( &cx, @@ -3357,6 +3358,7 @@ fn over_item(app: &DesignApp, xf: &Xf, p: Pos2) -> bool { snap: app.ui.snap_view(), unit: app.ui.units, stroke_in_front: false, + loaded: None, }; cx.hit(xf.to_canvas(p)).is_some() } @@ -3465,6 +3467,7 @@ fn snap_gutter(app: &DesignApp, g: &ColumnDrag, want: f64, shift: bool, zoom: f6 snap: designcraft_tools::SnapView { smart_dimensions: false, smart_spacing: false, ..app.ui.snap_view() }, unit: app.ui.units, stroke_in_front: false, + loaded: None, }; let rect = DRect::new(want, m.y0, want + width, m.y1); let s = designcraft_tools::snap::snap( From a74ea0d3c98c1f9bc3bf49342c5cf364f28018dd Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 09:44:13 +0200 Subject: [PATCH 118/431] Styles: a non-string Based On or Next Style is refused on creation too `style.paragraph.create` and `style.character.create` refuse a `basedOn` or `nextStyle` that is neither a style name nor null, instead of dropping it and creating the style without the reference, as `style.paragraph.edit` does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/style.rs | 15 ++++++++++++--- crates/engine/src/tests.rs | 7 +++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 699b01ea8..cfd2bbbe9 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -841,8 +841,19 @@ fn continue_numbering(p: Option<&Value>, para: &mut ParaAttrs) { } } +/// 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<Option<String>> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(n)) => Ok(Some(n.clone())), + Some(_) => Err(bad(cmd, format!("`{key}` is a style name or null"))), + } +} + fn create_para(s: &mut Session, p: &Value) -> Result<Value> { 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")?; let mut para: ParaAttrs = attrs("style.paragraph.create", "para", p, |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, &designcraft_doc::ParaProps::default()))?; continue_numbering(p.get("para"), &mut para); @@ -866,8 +877,6 @@ fn create_para(s: &mut Session, p: &Value) -> Result<Value> { chars = full; } } - let based_on = str_param(p, "basedOn").map(str::to_string); - let next = str_param(p, "nextStyle").map(str::to_string); s.edit(|d, _| { let name = Styles::unique_name(|n| d.styles.para(n).is_some(), &base); if let Some(b) = &based_on @@ -1231,7 +1240,7 @@ fn create_char(s: &mut Session, p: &Value) -> Result<Value> { chars = captured; } // Based on [None] is based on nothing. - let based_on = str_param(p, "basedOn").filter(|b| *b != designcraft_doc::NO_CHAR_STYLE).map(str::to_string); + let based_on = style_name_param("style.character.create", p, "basedOn")?.filter(|b| b != designcraft_doc::NO_CHAR_STYLE); s.edit(|d, _| { if let Some(b) = &based_on && d.styles.char_style(b).is_none() diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 67ad4626c..769051740 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1675,6 +1675,13 @@ fn paragraph_style_references_must_name_styles() { s.execute("style.paragraph.create", &json!({"name": "Body"})).unwrap(); assert!(s.execute("style.paragraph.create", &json!({"name": "Lost", "nextStyle": "Nowhere"})).is_err()); assert!(s.doc().unwrap().doc.styles.para("Lost").is_none()); + // On creation too, a reference that isn't a name or null is refused, not dropped. + for p in [json!({"name": "Lost", "nextStyle": true}), json!({"name": "Lost", "basedOn": 3})] { + assert!(s.execute("style.paragraph.create", &p).is_err(), "{p}"); + assert!(s.doc().unwrap().doc.styles.para("Lost").is_none(), "{p}"); + } + assert!(s.execute("style.character.create", &json!({"name": "Lost", "basedOn": ["Body"]})).is_err()); + assert!(s.doc().unwrap().doc.styles.char_style("Lost").is_none()); s.execute("style.paragraph.create", &json!({"name": "Self", "nextStyle": "Self"})).unwrap(); let before = s.doc().unwrap().doc.clone(); for p in [json!({"name": "Body", "basedOn": "Nowhere"}), json!({"name": "Body", "basedOn": 3}), json!({"name": "Body", "nextStyle": true})] { From 02749d3b7b9b9297b2c8615213416fc5c8cb93ba Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 09:57:50 +0200 Subject: [PATCH 119/431] Paragraph rules: Keep In Frame stops at the frame's edge; Text Color gaps import and export Keep In Frame holds a rule above inside the frame (its top), not below the top inset. A rule's gap colour in Text Color (`Text Color` or `$ID/Text Color`) imports as Text Color, as the rule's colour already did, and exports as a string property rather than as no swatch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/compose/src/lib.rs | 5 +++-- crates/compose/src/tests.rs | 11 +++++++++++ crates/idml/src/export.rs | 7 ++++++- crates/idml/src/import.rs | 26 +++++++++++++++++--------- crates/idml/src/tests.rs | 12 ++++++++++++ 5 files changed, 49 insertions(+), 12 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 111910929..0e875e77b 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1873,8 +1873,9 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C .map(|l| if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) }) .unwrap_or((0.0, 0.0)); let (color, tint) = rule_color(r, &above); - // Keep In Frame: not above the frame's text area. - let top = frames.get(fi).map_or(f64::NEG_INFINITY, |f| f.area.y0); + // Keep In Frame: not above the frame, as InDesign's (its top, not the text area's + // below the top inset). + let top = frames.get(fi).map_or(f64::NEG_INFINITY, |f| f.area.y0 - if f.vertical { 0.0 } else { f.opts.inset[0] }); let y = if r.keep_in_frame && y - r.weight < top { top + r.weight } else { y }; let rect = Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y); ft.decos.extend(rule_decos(doc, r, rect, (color, tint), &above)); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 6ea012270..2fc53cc0b 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -3731,6 +3731,17 @@ fn paragraph_rules_have_a_type_a_gap_colour_and_keep_in_frame() { }; assert!(above(false).y0 < 50.0, "{:?}", above(false)); assert!((above(true).y0 - 50.0).abs() < 1e-6, "{:?}", above(true)); + // With a top inset the rule may still rise into it: raised out of the frame, it comes back + // to the frame's edge, not to the text area's below the inset. + let r = Rule { on: true, weight: 2.0, offset: 60.0, keep_in_frame: true, ..Default::default() }; + let para = ParaAttrs { rule_above: Some(r), ..Default::default() }; + let (mut d, sid, fid) = doc_with("Title", Rect::new(0.0, 50.0, 300.0, 200.0), para); + if let Some(tf) = d.item_mut(fid).and_then(|it| it.text_frame_mut()) { + tf.options.inset = [20.0, 0.0, 0.0, 0.0]; + } + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let rule = cs.frame(fid).unwrap().decos[0].rect; + assert!((rule.y0 - 50.0).abs() < 1e-6, "kept at the frame's top, in the inset: {rule:?}"); } /// A rule in Text Color takes the colour of the paragraph's text: the first character's for the diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index b247ac709..9f8298760 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1747,7 +1747,12 @@ impl<'a> Ex<'a> { el.set(&format!("{k}LeftIndent"), num(r.left_indent)); el.set(&format!("{k}RightIndent"), num(r.right_indent)); el.set(&format!("{k}Type"), names::stroke_type_out(&r.kind)); - el.set(&format!("{k}GapColor"), self.sw(&r.gap_color)); + // A gap in Text Color is a string property, as the rule's own colour is. + if r.gap_color == designcraft_doc::TEXT_COLOR { + props.push(p(&format!("{k}GapColor"), "string", designcraft_doc::TEXT_COLOR)); + } else { + el.set(&format!("{k}GapColor"), self.sw(&r.gap_color)); + } el.set(&format!("{k}GapTint"), pct(r.gap_tint as f64)); if k == "RuleAbove" { el.set("KeepRuleAboveInFrame", bool_s(r.keep_in_frame)); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index c60380a27..7ee7c0e8a 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1970,8 +1970,10 @@ impl<'r> Importer<'r> { if let Some(t) = self.para_setting(e, &format!("{k}Type")) { r.kind = names::stroke_type_in(t.trim()); } - if let Some(c) = self.para_setting(e, &format!("{k}GapColor")).filter(|c| c.contains('/')) { - r.gap_color = self.swatch_ref(c.trim()); + if let Some(c) = self.para_setting(e, &format!("{k}GapColor")) + && let Some(c) = self.rule_color_ref(&c) + { + r.gap_color = c; } if let Some(t) = tint(self.para_setting(e, &format!("{k}GapTint")).and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite())) { r.gap_tint = t; @@ -1979,17 +1981,23 @@ impl<'r> Importer<'r> { if k == "RuleAbove" { r.keep_in_frame = self.para_setting(e, "KeepRuleAboveInFrame").is_some_and(|v| v.trim() == "true"); } - if let Some(c) = get(2) { - let c = c.trim(); - if c == designcraft_doc::TEXT_COLOR { - r.color = c.to_string(); - } else if c.contains('/') { - r.color = self.swatch_ref(c); - } + if let Some(c) = get(2).and_then(|c| self.rule_color_ref(&c)) { + r.color = c; } Some(r) } + /// A rule's colour or gap colour: Text Color (written `Text Color` or `$ID/Text Color`), or + /// a swatch reference; anything else leaves the default. + fn rule_color_ref(&mut self, c: &str) -> Option<String> { + let c = c.trim(); + if c == designcraft_doc::TEXT_COLOR || c.strip_prefix("$ID/") == Some(designcraft_doc::TEXT_COLOR) { + Some(designcraft_doc::TEXT_COLOR.to_string()) + } else { + c.contains('/').then(|| self.swatch_ref(c)) + } + } + /// Paragraph setting `k` of `e` (a paragraph style or a range): its own, else from its /// paragraph style chain (`BasedOn` of a style, the applied style of a range). fn para_setting(&self, e: &El, k: &str) -> Option<String> { diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index ba0acb78d..924eba437 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -3733,6 +3733,9 @@ fn a_style_setting_only_a_rules_type_gap_or_keep_in_frame_overrides_it() { <ParagraphStyle Self="ParagraphStyle/Kept" Name="Kept" KeepRuleAboveInFrame="true"> <Properties><BasedOn type="string">$ID/[No paragraph style]</BasedOn></Properties> </ParagraphStyle> + <ParagraphStyle Self="ParagraphStyle/TextGap" Name="TextGap" RuleBelowGapColor="$ID/Text Color" RuleAboveColor="$ID/Text Color"> + <Properties><BasedOn type="string">$ID/[No paragraph style]</BasedOn><RuleAboveGapColor type="string">Text Color</RuleAboveGapColor></Properties> + </ParagraphStyle> </RootParagraphStyleGroup>"#, ); let d = import_idml(&fixture_with_table_styles(&styles, STORY)).unwrap(); @@ -3742,6 +3745,15 @@ fn a_style_setting_only_a_rules_type_gap_or_keep_in_frame_overrides_it() { let gap = style("Gap").rule_below.expect("the gap colour alone, with no on or weight in the chain"); assert!(!gap.on && gap.gap_color != designcraft_color::swatch::NONE, "{gap:?}"); assert!(style("Kept").rule_above.expect("Keep In Frame alone").keep_in_frame); + // Text Color, in either spelling, for a gap and a rule colour. + let text = style("TextGap"); + let (below, above) = (text.rule_below.expect("rule below"), text.rule_above.expect("rule above")); + assert_eq!(below.gap_color, designcraft_doc::TEXT_COLOR); + assert_eq!((above.gap_color.as_str(), above.color.as_str()), (designcraft_doc::TEXT_COLOR, designcraft_doc::TEXT_COLOR)); + // And it is written back as Text Color, not as no swatch. + let back = import_idml(&export_idml(&d)).unwrap(); + let below = back.styles.paragraph.iter().find(|s| s.name == "TextGap").unwrap().para.rule_below.clone().unwrap(); + assert_eq!(below.gap_color, designcraft_doc::TEXT_COLOR, "round-trips"); } #[test] From 638824413211b54f04b9e1d8da61eeb76cd3d4b2 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 10:00:12 +0200 Subject: [PATCH 120/431] Line tool: the readout measures the line as drawn The Line tool keeps the line it previews (in spread space, Shift and snapping included) for the readout, instead of measuring from the press to the pointer in canvas coordinates. Nothing shows before the first drag step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/tools/src/frame.rs | 14 ++++++++++---- crates/tools/src/tests.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/crates/tools/src/frame.rs b/crates/tools/src/frame.rs index de2d410a2..86f621c61 100644 --- a/crates/tools/src/frame.rs +++ b/crates/tools/src/frame.rs @@ -405,6 +405,9 @@ impl Tool for FrameTool { snap_free_line(cx, sr, sa, b, &mut self.guides) }; } + // The readout measures the line as drawn (Shift and snapping included), in + // spread space. + self.last = Some((spread_json(sr), Rect::from_points(sa, b))); out.push(Action::Preview("line.create".into(), json!({"spread": spread_json(sr), "a": [sa.x, sa.y], "b": [b.x, b.y]}))); } else { let end = cx.layout.to_spread(sr, ev.pos); @@ -458,10 +461,13 @@ impl Tool for FrameTool { } fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> { - if let (true, Some(a)) = (self.active, self.start) { - // The frame being drawn (Shift, Alt and snapping included), in the document's units - // to two decimals, on two lines: InDesign's readout ("W: 3.13 cm", "H: 3.13 cm"). - let r = self.last.as_ref().map(|(_, r)| *r).unwrap_or_else(|| Rect::from_points(a, self.cur)); + // The frame or line being drawn (Shift, Alt and snapping included, in spread space), in the + // document's units to two decimals, on two lines: InDesign's readout ("W: 3.13 cm", + // "H: 3.13 cm"). Nothing before the first drag step. + if self.active + && self.start.is_some() + && let Some((_, r)) = self.last.as_ref() + { let units = &cx.doc.settings; let w = designcraft_geom::units::format_measure_prec(r.width().abs(), units.horizontal_units, 2); let h = designcraft_geom::units::format_measure_prec(r.height().abs(), units.vertical_units, 2); diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index d39fb6433..42543848e 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -93,6 +93,32 @@ fn frame_tool_readout_shows_the_size_in_the_document_units() { assert_eq!(texts, vec!["W: 6.08 cm\nH: 6.08 cm".to_string()]); } +/// The Line tool's readout measures the line as drawn: Shift makes it level, so it has no +/// height, wherever the pointer is. +#[test] +fn line_tool_readout_measures_the_line_as_drawn() { + let mut d = Document::new(&NewDocument::default()); + d.settings.horizontal_units = Unit::Centimeters; + d.settings.vertical_units = Unit::Centimeters; + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let mut t = create("line"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + let mut drag = PointerEvent::new(PointerKind::Drag, 182.35, 15.0); + drag.mods.shift = true; + t.pointer(&cx, &drag); + let texts: Vec<String> = t + .overlays(&cx) + .into_iter() + .filter_map(|o| match o { + Overlay::Measure { text, .. } => Some(text), + _ => None, + }) + .collect(); + assert_eq!(texts.len(), 1, "{texts:?}"); + assert_eq!(texts[0], "W: 6.08 cm\nH: 0 cm", "level, as drawn, not 5 pt down to the pointer"); +} + #[test] fn selection_click_and_marquee() { let mut d = Document::new(&NewDocument::default()); From 88020e0914e7499706658e841f8a751f8566d323 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 11:03:03 +0200 Subject: [PATCH 121/431] =?UTF-8?q?Properties=20=E2=80=BA=20Text=20Wrap:?= =?UTF-8?q?=20one=20side=20edits=20that=20side=20on=20each=20object;=20the?= =?UTF-8?q?=20link=20follows=20the=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `object.textWrap`'s `offset` takes null for a side to keep each object's own offset there. Unlinked, Properties sends only the edited side, so with several objects selected each keeps its other three offsets. The link's state is kept per selection: another selection whose four offsets are the same starts linked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/object.rs | 14 +++++++--- crates/ui-egui/src/panels/properties.rs | 36 +++++++++++++++++++++---- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 82f33a6d8..eb2144416 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -616,7 +616,7 @@ pub fn specs() -> Vec<CommandSpec> { "Text Wrap", ["Window"], Some("Cmd+Alt+W"), - "{mode?: none|boundingBox|contour|jumpObject|jumpToNextColumn (boundingBox when absent, unless side or contour is given: then unchanged), offset?: number|[t,l,b,r], invert?: bool, side?: bothSides|leftSide|rightSide|towardsSpine|awayFromSpine|largestArea, contour?: boundingBox|graphicFrame|sameAsClipping, content?: bool (the placed graphic instead of its frame; default: whether the selection is the frame's content), ids?}; offset or contour drops an imported custom wrap boundary", + "{mode?: none|boundingBox|contour|jumpObject|jumpToNextColumn (boundingBox when absent, unless side or contour is given: then unchanged), offset?: number|[t,l,b,r] (a null side keeps each object's own), invert?: bool, side?: bothSides|leftSide|rightSide|towardsSpine|awayFromSpine|largestArea, contour?: boundingBox|graphicFrame|sameAsClipping, content?: bool (the placed graphic instead of its frame; default: whether the selection is the frame's content), ids?}; offset or contour drops an imported custom wrap boundary", has_selection, text_wrap ), @@ -2104,8 +2104,10 @@ fn text_wrap(s: &mut Session, p: &Value) -> Result<Value> { None if side.is_some() || contour.is_some() => None, None => Some(WrapMode::BoundingBox), }; - let off = match p.get("offset") { - Some(Value::Number(n)) => Some([n.as_f64().unwrap_or(0.0); 4]), + // Per side [top, left, bottom, right]; a null side keeps each object's own offset there, so + // an edit of one side keeps the others of every object. + let off: Option<[Option<f64>; 4]> = match p.get("offset") { + Some(Value::Number(n)) => Some([n.as_f64(); 4]), Some(v) => serde_json::from_value(v.clone()).ok(), None => None, }; @@ -2126,7 +2128,11 @@ fn text_wrap(s: &mut Session, p: &Value) -> Result<Value> { w.mode = m; } if let Some(o) = off { - w.offsets = o; + for (w, v) in w.offsets.iter_mut().zip(o) { + if let Some(v) = v { + *w = v; + } + } } if let Some(v) = invert { w.invert = v; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 9f3e23eca..302855e06 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1064,9 +1064,14 @@ fn wrap_offsets(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { ui.add_space(4.0); let b = block(ui, 2.0 * FIELD_H + 5.0); let mut set = None; + // The link's state belongs to the selection: another selection starts linked again when its + // four offsets are the same. + let selected: Vec<u64> = 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)); ui.add_enabled_ui(enabled, |ui| { // Linked while the four are the same, until the link is clicked. - let linked = temp_bool(ui, "prop_wrap_linked", offsets.iter().all(|v| (v - offsets[0]).abs() < 1e-9)); + let same = offsets.iter().all(|v| (v - offsets[0]).abs() < 1e-9); + let linked = ui.data(|d| d.get_temp::<bool>(link_id)).unwrap_or(same); // Offsets are [top, left, bottom, right]. for (k, key, icon, tip, x, y) in [ (0, "wrapTop", "margin-top", "Top", 0.0, 0.0), @@ -1083,11 +1088,12 @@ fn wrap_offsets(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { NumField::measure(key, Some(offsets[k]), u).width(76.0).spinner().range(-8000.0, 8000.0).enabled(enabled).show(ui) }); if let Some(v) = nv { - let mut o = offsets; + // Linked, all four; else this side alone, each object keeping its other offsets. + let mut o = [None; 4]; if linked { - o = [v; 4]; + o = [Some(v); 4]; } else { - o[k] = v; + o[k] = Some(v); } set = Some(o); } @@ -1096,7 +1102,7 @@ fn wrap_offsets(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, &tr("Make all settings the same"), vec2(22.0, 21.0)) .clicked() }) { - set_temp_bool(ui, "prop_wrap_linked", !linked); + ui.data_mut(|d| d.insert_temp(link_id, !linked)); } }); if let Some(o) = set { @@ -3471,6 +3477,26 @@ mod tests { 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": "boundingBox", "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"); + // 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 a1bbf1c7a105aeca70bef3856128018688eb3e7f Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 11:26:09 +0200 Subject: [PATCH 122/431] =?UTF-8?q?Properties=20=E2=80=BA=20Text=20Wrap:?= =?UTF-8?q?=20an=20offset=20or=20Invert=20edit=20keeps=20each=20object's?= =?UTF-8?q?=20wrap=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `object.textWrap` takes `mode: null` for each object's own mode. Properties sends it with an offset or Invert edit, so objects with another wrap mode than the first keep theirs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/object.rs | 4 +++- crates/ui-egui/src/panels/properties.rs | 15 ++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index eb2144416..1543b6d5d 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -616,7 +616,7 @@ pub fn specs() -> Vec<CommandSpec> { "Text Wrap", ["Window"], Some("Cmd+Alt+W"), - "{mode?: none|boundingBox|contour|jumpObject|jumpToNextColumn (boundingBox when absent, unless side or contour is given: then unchanged), offset?: number|[t,l,b,r] (a null side keeps each object's own), invert?: bool, side?: bothSides|leftSide|rightSide|towardsSpine|awayFromSpine|largestArea, contour?: boundingBox|graphicFrame|sameAsClipping, content?: bool (the placed graphic instead of its frame; default: whether the selection is the frame's content), ids?}; offset or contour drops an imported custom wrap boundary", + "{mode?: none|boundingBox|contour|jumpObject|jumpToNextColumn (boundingBox when absent, unless side or contour is given; null or those: each object's own, unchanged), offset?: number|[t,l,b,r] (a null side keeps each object's own), invert?: bool, side?: bothSides|leftSide|rightSide|towardsSpine|awayFromSpine|largestArea, contour?: boundingBox|graphicFrame|sameAsClipping, content?: bool (the placed graphic instead of its frame; default: whether the selection is the frame's content), ids?}; offset or contour drops an imported custom wrap boundary", has_selection, text_wrap ), @@ -2100,6 +2100,8 @@ fn text_wrap(s: &mut Session, p: &Value) -> Result<Value> { None => None, }; let mode: Option<WrapMode> = match p.get("mode") { + // Null keeps each object's own mode (an edit of the offsets or Invert alone). + Some(Value::Null) => None, Some(v) => Some(serde_json::from_value(v.clone()).map_err(|_| bad("object.textWrap", "unknown mode"))?), None if side.is_some() || contour.is_some() => None, None => Some(WrapMode::BoundingBox), diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 302855e06..08bb64c04 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1043,7 +1043,8 @@ fn wrap_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { let enabled = i.wrap != "none"; let r = ui.add_enabled_ui(enabled, |ui| widgets::checkbox(ui, &mut inv, crate::i18n::tr(&app.ui.language, "Invert"))).inner; if r.changed() { - let _ = app.run("object.textWrap", json!({"mode": i.wrap, "invert": inv})); + // Each object keeps its own mode. + let _ = app.run("object.textWrap", json!({"mode": null, "invert": inv})); } }); wrap_offsets(app, ui, i); @@ -1106,7 +1107,8 @@ fn wrap_offsets(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { } }); if let Some(o) = set { - let _ = app.run("object.textWrap", json!({"mode": i.wrap, "offset": o})); + // Each object keeps its own mode: only the offsets change. + let _ = app.run("object.textWrap", json!({"mode": null, "offset": o})); } } @@ -3480,7 +3482,7 @@ mod tests { // 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": "boundingBox", "offset": [3, 4, 5, 6], "ids": [other]})).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 }; @@ -3489,6 +3491,13 @@ mod tests { 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(); From 3537343622d387ad215da0de422c55fe5e0b57a3 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 11:38:06 +0200 Subject: [PATCH 123/431] Preferences: Units & Increments with no document open set new documents' units As in InDesign, Preferences with no document open has Units & Increments: the horizontal and vertical ruler units, the ruler origin and the cursor-key and kerning increments. They are kept with the application preferences (`prefs.set {defaultUnits}`) and new documents start with them; New Document opens in those units, and its [Default] card uses them. A document keeps its own Units & Increments once made, and Preferences with a document open still edits that document's. `file.new` without a preset or `units` uses the default units; units picked in New Document set both rulers unless they are the default horizontal ones, which keep the default vertical units. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/engine/src/cmd/file.rs | 16 ++++++-- crates/engine/src/cmd/prefs.rs | 48 ++++++++++++++++++++++- crates/engine/src/lib.rs | 56 +++++++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 62 ++++++++++++++++++++++++++++-- crates/ui-egui/src/menus.rs | 13 ++++++- crates/ui-egui/src/new_document.rs | 11 +++++- 7 files changed, 196 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a2352e86..264336ad8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-22** · Preferences › Units & Increments with no document open, as in InDesign: the ruler units (horizontal and vertical), the ruler origin and the cursor-key and kerning increments set there are what new documents start with, and New Document opens in those units. They are kept with the application preferences (`prefs.set {defaultUnits}`); a document keeps its own once made. Reported by a reader who works in inches and had to change every file. No readiness percentage change. - **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. - **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change. diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index da38b8132..f658d4b92 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -221,10 +221,11 @@ fn find_preset(s: &Session, name: &str) -> Option<NewDocument> { } /// The settings `file.new` makes a document with for `p`: its preset, then the settings over it. +/// Without a preset or `units`, it is in the default units (Preferences with no document). fn new_document_settings(s: &Session, p: &Value) -> Result<NewDocument> { let mut nd = match str_param(p, "preset") { Some(name) => find_preset(s, name).ok_or_else(|| bad("file.new", format!("unknown preset `{name}`")))?, - None => NewDocument::default(), + None => s.default_new_document(), }; apply_settings(&mut nd, p); check_size("file.new", &nd)?; @@ -235,12 +236,19 @@ fn file_new(s: &mut Session, p: &Value) -> Result<Value> { let mut nd = new_document_settings(s, p)?; s.untitled += 1; nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| format!("Untitled-{}", s.untitled)); - let d = Document::new(&nd); + let mut d = Document::new(&nd); + s.prefs.default_units.apply(&mut d.settings); let i = s.add_document(DocState::new(d, None)); Ok(json!({"index": i})) } impl Session { + /// What New Document starts with: the factory settings, in the default units (Preferences + /// with no document open). + pub fn default_new_document(&self) -> NewDocument { + NewDocument { units: self.prefs.default_units.horizontal_units, ..NewDocument::default() } + } + /// New Document's Preview: a document made as `file.new` would make it with `p`, shown while /// the New Document window is open. It replaces the previous preview document in its tab and /// takes no Untitled number, no undo step and no recovery entry. Settings `file.new` refuses @@ -259,7 +267,9 @@ impl Session { self.untitled += 1; } nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| self.next_untitled_title()); - let st = DocState::new(Document::new(&nd), None); + let mut d = Document::new(&nd); + self.prefs.default_units.apply(&mut d.settings); + let st = DocState::new(d, None); let uid = st.uid; match replace.and_then(|i| self.docs.get_mut(i).map(|d| (i, d))) { Some((i, d)) => { diff --git a/crates/engine/src/cmd/prefs.rs b/crates/engine/src/cmd/prefs.rs index 989494507..781d3ef13 100644 --- a/crates/engine/src/cmd/prefs.rs +++ b/crates/engine/src/cmd/prefs.rs @@ -50,12 +50,14 @@ pub fn specs() -> Vec<CommandSpec> { "Preferences", [], None, - "{showHiddenCharacters?, typographersQuotes?, polygonSides?, starInset?, scaleStrokes?, dimensionsIncludeStroke?, transformationsAreTotals?, absolutePageNumbers?, highlightHj?, highlightKeeps?, highlightCustomTracking?, highlightSubstitutedFonts?, richBlackOutput?, favoriteFonts?: [family], recentFonts?: [family], showFontNamesInEnglish?, smartTextReflow?, autocorrect?, autocorrectList?: [[word, correction]], showAddedText?, showNoteAnchors?, cjkFeatures?: bool|null (CJK-only menu items and dialogs shown; null follows the interface language), linkTextFiles? (Create Links When Placing Text Files), recoveryMinutes?, recentFilesMax?: 0–100} → all application preferences", + "{showHiddenCharacters?, typographersQuotes?, polygonSides?, starInset?, scaleStrokes?, dimensionsIncludeStroke?, transformationsAreTotals?, absolutePageNumbers?, highlightHj?, highlightKeeps?, highlightCustomTracking?, highlightSubstitutedFonts?, richBlackOutput?, favoriteFonts?: [family], recentFonts?: [family], showFontNamesInEnglish?, smartTextReflow?, autocorrect?, autocorrectList?: [[word, correction]], showAddedText?, showNoteAnchors?, cjkFeatures?: bool|null (CJK-only menu items and dialogs shown; null follows the interface language), linkTextFiles? (Create Links When Placing Text Files), recoveryMinutes?, recentFilesMax?: 0–100, defaultUnits?: {horizontalUnits?, verticalUnits?, rulerOrigin?, keyboardIncrement? (pt), kerningIncrement? (1/1000 em)} (Units & Increments of new documents)} → all application preferences", super::always, |s, p| { let cur = serde_json::to_value(&s.prefs).map_err(|e| bad("prefs.set", e.to_string()))?; let new = merge("prefs.set", cur, p, None)?; - s.prefs = serde_json::from_value(new).map_err(|e| bad("prefs.set", e.to_string()))?; + let prefs: crate::Prefs = serde_json::from_value(new).map_err(|e| bad("prefs.set", e.to_string()))?; + prefs.default_units.check().map_err(|e| bad("prefs.set", e))?; + s.prefs = prefs; s.prefs.trim_recent_files(); serde_json::to_value(&s.prefs).map_err(|e| bad("prefs.set", e.to_string())) } @@ -141,6 +143,48 @@ mod tests { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc.settings.horizontal_units, designcraft_geom::Unit::Picas); } + + /// Units & Increments set with no document open are what new documents start with, as in + /// InDesign: a reader who works in inches sets them once, not in every file. + #[test] + fn default_units_are_what_new_documents_start_with() { + use designcraft_geom::Unit; + let mut s = Session::new(); + let r = s + .execute( + "prefs.set", + &json!({"defaultUnits": {"horizontalUnits": "inches", "verticalUnits": "inches", "keyboardIncrement": 0.5, "rulerOrigin": "page"}}), + ) + .unwrap(); + assert_eq!(r["defaultUnits"]["horizontalUnits"], "inches"); + assert_eq!(r["defaultUnits"]["kerningIncrement"], 20.0, "the rest kept"); + s.execute("file.new", &json!({})).unwrap(); + let st = s.doc().unwrap().doc.settings.clone(); + assert_eq!((st.horizontal_units, st.vertical_units), (Unit::Inches, Unit::Inches)); + assert_eq!((st.keyboard_increment, st.ruler_origin), (0.5, designcraft_doc::RulerOrigin::Page)); + assert!(!s.doc().unwrap().is_dirty(), "made so, not edited"); + // Inches across, millimetres down: New Document's units (the horizontal ones) keep the + // vertical preference; other units picked there set both rulers. + s.execute("prefs.set", &json!({"defaultUnits": {"verticalUnits": "millimeters"}})).unwrap(); + s.execute("file.new", &json!({"units": "inches"})).unwrap(); + let st = s.doc().unwrap().doc.settings.clone(); + assert_eq!((st.horizontal_units, st.vertical_units), (Unit::Inches, Unit::Millimeters)); + s.execute("file.new", &json!({"units": "points"})).unwrap(); + let st = s.doc().unwrap().doc.settings.clone(); + assert_eq!((st.horizontal_units, st.vertical_units), (Unit::Points, Unit::Points)); + // A preset keeps its own units. + s.execute("file.new", &json!({"preset": "A4"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.settings.horizontal_units, Unit::Millimeters); + // New Document opens with them; bad values are refused and change nothing. + assert_eq!(s.default_new_document().units, Unit::Inches); + for bad in [json!({"horizontalUnits": "furlongs"}), json!({"keyboardIncrement": 0}), json!({"kerningIncrement": -5})] { + assert!(s.execute("prefs.set", &json!({"defaultUnits": bad})).is_err(), "{bad}"); + } + assert_eq!(s.prefs.default_units.keyboard_increment, 0.5); + // Kept with the application preferences. + let saved: crate::Prefs = serde_json::from_slice(&serde_json::to_vec(&s.prefs).unwrap()).unwrap(); + assert_eq!(saved.default_units, s.prefs.default_units); + } } #[cfg(test)] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d16dc9dd1..9c5c64a35 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -243,6 +243,61 @@ pub struct Prefs { pub recent_files: Vec<String>, /// Preferences › File Handling › Number of Recent Items to Display (0 keeps no list). pub recent_files_max: u32, + /// Preferences › Units & Increments set with no document open: what new documents start + /// with, as InDesign's application defaults. A document keeps its own once made. + pub default_units: DefaultUnits, +} + +/// Units & Increments for new documents (Preferences with no document open), in the keys +/// `document.preferences` uses. +#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct DefaultUnits { + pub horizontal_units: designcraft_geom::Unit, + pub vertical_units: designcraft_geom::Unit, + pub ruler_origin: designcraft_doc::RulerOrigin, + /// Cursor Key, in points. + pub keyboard_increment: f64, + /// Kerning/Tracking, in 1/1000 em. + pub kerning_increment: f64, +} + +impl Default for DefaultUnits { + /// A new document's own settings. + fn default() -> Self { + let d = designcraft_doc::DocSettings::default(); + DefaultUnits { + horizontal_units: designcraft_geom::Unit::Picas, + vertical_units: designcraft_geom::Unit::Picas, + ruler_origin: d.ruler_origin, + keyboard_increment: d.keyboard_increment, + kerning_increment: d.kerning_increment, + } + } +} + +impl DefaultUnits { + /// Gives a new document made in `units` these defaults. Its vertical ruler follows the + /// preference while the document keeps the default horizontal units (units picked in New + /// Document set both rulers). + pub fn apply(&self, settings: &mut designcraft_doc::DocSettings) { + if settings.horizontal_units == self.horizontal_units { + settings.vertical_units = self.vertical_units; + } + settings.ruler_origin = self.ruler_origin; + settings.keyboard_increment = self.keyboard_increment; + settings.kerning_increment = self.kerning_increment; + } + + /// Refuses increments that aren't finite and positive (at most 1000). + pub fn check(&self) -> std::result::Result<(), String> { + for (name, v) in [("keyboardIncrement", self.keyboard_increment), ("kerningIncrement", self.kerning_increment)] { + if !(v.is_finite() && v > 0.0 && v <= 1000.0) { + return Err(format!("`defaultUnits.{name}` must be more than 0 and at most 1000")); + } + } + Ok(()) + } } /// The most recent files Preferences lets File › Open Recent keep. @@ -317,6 +372,7 @@ impl Default for Prefs { link_text_files: false, recent_files: Vec::new(), recent_files_max: 20, + default_units: DefaultUnits::default(), } } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8b4043787..502b8e0e7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1562,10 +1562,11 @@ fn confirm_document_setup(app: &mut DesignApp, d: &Dialog) -> Result<Value, Stri app.run("layout.documentSetup", p) } -/// Preferences: a section list and the section's options. Application options always; units and -/// increments when a document is open (InDesign keeps those with the document). +/// Preferences: a section list and the section's options. Application options always; with a +/// document open, its own settings (InDesign keeps those with the document); with none, Units & +/// Increments for new documents. fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_height: f32) { - let has_doc = d.fields.contains_key("horizontalUnits"); + let has_doc = d.fields.contains_key("horizontalUnits") && !d.b("defaults"); let sections: &[(&str, &str)] = if has_doc { &[ ("general", "General"), @@ -1592,6 +1593,7 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he ("interface", "Interface"), ("type", "Type"), ("composition", "Composition"), + ("units", "Units & Increments"), ("spelling", "Spelling"), ("autocorrect", "Autocorrect"), ("notes", "Notes"), @@ -3386,6 +3388,23 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> { } app.run("view.snapPreferences", snap)?; } + if d.b("defaults") { + // No document open: the Units & Increments new documents start with. + let mut du = json!({}); + for k in ["horizontalUnits", "verticalUnits", "rulerOrigin"] { + if let Some(v) = d.fields.get(k).filter(|v| v.is_string()) { + du[k] = v.clone(); + } + } + if let Some(v) = d.fields.get("keyboardIncrement").and_then(Value::as_str).and_then(|s| parse_measure(s, Unit::Points).ok()) { + du["keyboardIncrement"] = json!(v.clamp(0.001, 1000.0)); + } + if let Some(v) = d.n("kerningIncrement").filter(|v| v.is_finite()) { + du["kerningIncrement"] = json!(v.clamp(1.0, 1000.0)); + } + app.run("prefs.set", json!({"defaultUnits": du}))?; + return Ok(Value::Null); + } let Some(horizontal) = d.fields.get("horizontalUnits") else { return Ok(Value::Null) }; let mut doc = json!({"horizontalUnits": horizontal}); // Fields can come from `ui.dialog.open`; rulers it doesn't name keep their units. @@ -7218,6 +7237,43 @@ mod tests { } } + /// 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"); + // 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 preferences_ok_sets_only_the_ruler_units_it_is_given() { // `ui.dialog.open` takes any fields, so the vertical units can be missing. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index fb43f8cb9..6f514dd9f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1024,7 +1024,9 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value Ok(Value::Null) } "app.newDocumentDialog" => { - app.ui.dialog = Some(crate::dialogs::Dialog::new("newDocument", json!({}))); + // In the default units (Preferences with no document open). + let fields = crate::new_document::defaults_for(&app.session.default_new_document()); + app.ui.dialog = Some(crate::dialogs::Dialog::new("newDocument", fields)); Ok(Value::Null) } "app.openDialog" => app.pick_and_open("open"), @@ -1394,6 +1396,15 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value } f["pasteboard.h"] = m(&doc["pasteboard"][0]); f["pasteboard.v"] = m(&doc["pasteboard"][1]); + } else { + // No document: Units & Increments are what new documents start with, as InDesign's. + let du = app.session.prefs.default_units; + f["defaults"] = json!(true); + f["horizontalUnits"] = json!(du.horizontal_units); + f["verticalUnits"] = json!(du.vertical_units); + f["rulerOrigin"] = json!(du.ruler_origin); + f["keyboardIncrement"] = json!(designcraft_geom::format_measure(du.keyboard_increment, designcraft_geom::Unit::Points)); + f["kerningIncrement"] = json!(format!("{}", du.kerning_increment)); } f["snap.alignEdges"] = json!(app.ui.align_edges); f["snap.alignCenters"] = json!(app.ui.align_centers); diff --git a/crates/ui-egui/src/new_document.rs b/crates/ui-egui/src/new_document.rs index ee59de800..1b1c77a77 100644 --- a/crates/ui-egui/src/new_document.rs +++ b/crates/ui-egui/src/new_document.rs @@ -59,7 +59,13 @@ fn fields_for(nd: &NewDocument) -> Map<String, Value> { /// The fields a new New Document dialog starts with: the factory settings on the Recent tab. pub(crate) fn defaults() -> Value { - let mut f = fields_for(&NewDocument::default()); + defaults_for(&NewDocument::default()) +} + +/// The fields New Document starts with for `nd` (the session's default new document, in the +/// default units), on the Recent tab. +pub(crate) fn defaults_for(nd: &NewDocument) -> Value { + let mut f = fields_for(nd); f.insert("tab".into(), json!("recent")); f.insert("preset".into(), json!(DEFAULT_CARD)); f.insert("name".into(), json!("")); @@ -325,7 +331,8 @@ fn cards(app: &DesignApp, tab: &str) -> Vec<Card> { Card { key, label, nd, custom } }; // Recent and Saved both start with the factory settings, as in InDesign. - let default = Card { key: DEFAULT_CARD.into(), label: crate::i18n::tr(lang, DEFAULT_CARD).into(), nd: NewDocument::default(), custom: false }; + let default = + Card { key: DEFAULT_CARD.into(), label: crate::i18n::tr(lang, DEFAULT_CARD).into(), nd: app.session.default_new_document(), custom: false }; match tab { "recent" => std::iter::once(default) .chain(app.ui.recent_new_documents.iter().enumerate().map(|(i, nd)| named(format!("recent:{i}"), nd.clone()))) From c87e9d261d42410d4df301c64282b4dfe5939c9a Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 11:47:29 +0200 Subject: [PATCH 124/431] Insert Pages: `before` and `after` must name a page of the document An index past the last page was clamped to the end; now it is refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/layout.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index c5cbb36ed..4362ae339 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -172,12 +172,15 @@ pub fn specs() -> Vec<CommandSpec> { if p.get("after").is_some() && p.get("before").is_some() { return Err(bad("layout.pages.insert", "give `after` or `before`, not both")); } + // A page of the document: one past the last isn't, and would insert at the end. let index = |k: &str| match p.get(k) { None => Ok(None), Some(v) => v .as_u64() - .map(|v| Some(usize::try_from(v).unwrap_or(usize::MAX))) - .ok_or_else(|| bad("layout.pages.insert", format!("`{k}` must be a page index, not {v}"))), + .and_then(|v| usize::try_from(v).ok()) + .filter(|&i| i < n) + .map(Some) + .ok_or_else(|| bad("layout.pages.insert", format!("`{k}` must be the index of one of the {n} pages, not {v}"))), }; // The page the new ones follow; none: they go first. let after = match (index("after")?, index("before")?) { @@ -2207,6 +2210,9 @@ mod parent_margin_tests { json!({"before": "0"}), json!({"before": null}), json!({"after": 1.5}), + json!({"before": 6}), + json!({"after": 6}), + json!({"before": 99}), ] { assert!(s.execute("layout.pages.insert", &bad).is_err(), "{bad}"); assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{bad}"); From f615508ea59f28b0606d69aac4d6d6e8b5247b9f Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 11:56:33 +0200 Subject: [PATCH 125/431] Parity: Find/Change lists Find Format Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c7e0678d7..b9801ec11 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -269,7 +269,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - FID2: IDML underline/strikethrough Text Color resets survive style inheritance and roundtrip. Raster and PDF paint underlines behind glyphs, retain strikethrough above, and center rules on their offsets; synthetic overlap tests cover both backends. - Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text. - UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts and the Fill/Stroke proxy (D, X, Shift+X, / as `color.*` commands), document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), document grid (View ▸ Grids & Guides, ⌘', on the snap positions, behind or in front of items), Lock Guides (⌥⌘;) and Lock Column Guides (View ▸ Grids & Guides; Lock Column Guides is saved with the document and in IDML), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches (InDesign's header: Fill/Stroke proxy, Formatting Affects Container/Text, Tint; a swatch goes to the fill or stroke in front; New Color Group / New Swatch / Delete Swatch row), Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. -- Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows, expandable groups and drag-and-drop reorder of layers and objects (the selection square moves the selection to another layer). +- Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP, with Find Format: text that has a character or paragraph style or character attributes), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows, expandable groups and drag-and-drop reorder of layers and objects (the selection square moves the selection to another layer). - Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type; Option/Alt+Left/Right kerns at the caret or tracks a selection by the Kerning/Tracking increment, ×5 with Cmd/Ctrl; words move with Cmd+Left/Right on macOS and Ctrl+Left/Right elsewhere), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. - PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions. - IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories; attributes a file leaves unset take InDesign's defaults (Keep Lines Together at start/end of paragraph, 12 pt corners, paragraph rules in Text Color, auto-sizing from the centre, numbered lists restarting in each story, alternating table fills Black 20 % / None; a tint of −1 is the colour's own; frames take the fill, stroke, corners and Text Frame Options they don't write from their object style chain, then [None]) — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`). From 94e414459e3a5e773be9510fbeec92bdd27422d0 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 11:56:53 +0200 Subject: [PATCH 126/431] Parity: Find/Change lists Change and Change/Find Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b9801ec11..fb43a6a3c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -269,7 +269,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - FID2: IDML underline/strikethrough Text Color resets survive style inheritance and roundtrip. Raster and PDF paint underlines behind glyphs, retain strikethrough above, and center rules on their offsets; synthetic overlap tests cover both backends. - Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text. - UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts and the Fill/Stroke proxy (D, X, Shift+X, / as `color.*` commands), document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), document grid (View ▸ Grids & Guides, ⌘', on the snap positions, behind or in front of items), Lock Guides (⌥⌘;) and Lock Column Guides (View ▸ Grids & Guides; Lock Column Guides is saved with the document and in IDML), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches (InDesign's header: Fill/Stroke proxy, Formatting Affects Container/Text, Tint; a swatch goes to the fill or stroke in front; New Color Group / New Swatch / Delete Swatch row), Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. -- Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP, with Find Format: text that has a character or paragraph style or character attributes), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows, expandable groups and drag-and-drop reorder of layers and objects (the selection square moves the selection to another layer). +- Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP, with Find Format: text that has a character or paragraph style or character attributes; Change and Change/Find on the text Find Next found), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows, expandable groups and drag-and-drop reorder of layers and objects (the selection square moves the selection to another layer). - Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type; Option/Alt+Left/Right kerns at the caret or tracks a selection by the Kerning/Tracking increment, ×5 with Cmd/Ctrl; words move with Cmd+Left/Right on macOS and Ctrl+Left/Right elsewhere), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. - PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions. - IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories; attributes a file leaves unset take InDesign's defaults (Keep Lines Together at start/end of paragraph, 12 pt corners, paragraph rules in Text Color, auto-sizing from the centre, numbered lists restarting in each story, alternating table fills Black 20 % / None; a tint of −1 is the colour's own; frames take the fill, stroke, corners and Text Frame Options they don't write from their object style chain, then [None]) — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`). From d9e8438cbbae386c5dfca0bedf1f2f638e16e33d Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 12:02:44 +0200 Subject: [PATCH 127/431] Preferences: the Tools Panel columns go through window.toolsDoubleColumn The command takes `on` (default: toggle), and Preferences OK runs it with the chosen state instead of setting the flag itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/dialogs.rs | 6 +++++- crates/ui-egui/src/menus.rs | 7 +++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e73ac5c49..4fe09bbd1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3426,7 +3426,7 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> { app.run("window.brightness", json!({"brightness": b}))?; } if d.fields.contains_key("toolsColumns") { - app.ui.tools_double_column = d.s("toolsColumns") == "double"; + app.run("window.toolsDoubleColumn", json!({"on": d.s("toolsColumns") == "double"}))?; } if let Some(v) = d.n("uiScale") { app.run("window.uiScale", json!({"scale": v / 100.0}))?; @@ -7531,6 +7531,10 @@ mod tests { 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")); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 97401b1c5..5b943319a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -291,7 +291,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ None, "{open?: true, tab?: \"about\"|\"contributors\"|\"models\"} — the About window: splash with community links, contributor and model credits (open: false closes it)", ), - ("window.toolsDoubleColumn", "Tools: Double Column", None, "{}"), + ("window.toolsDoubleColumn", "Tools: Double Column", None, "{on?: bool (default: toggle)} → whether the Tools panel has two columns"), ( "window.workspace", "Workspace", @@ -1829,7 +1829,10 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value app.ui.pending_urls.push(url.clone()); Ok(json!({"url": url})) } - "window.toolsDoubleColumn" => flag(&mut app.ui.tools_double_column), + "window.toolsDoubleColumn" => { + app.ui.tools_double_column = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.tools_double_column); + Ok(json!(app.ui.tools_double_column)) + } "window.collapseToIcons" => { app.ui.dock_expanded = false; app.ui.open_panel = None; From 1dc1dbf31aae6a657e4a90d8dc749bb46d33d2da Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 12:10:36 +0200 Subject: [PATCH 128/431] =?UTF-8?q?Direct=20Selection:=20a=20Shift-drag=20?= =?UTF-8?q?keeps=20a=20selected=20point;=20snapping=20keeps=20the=2045?= =?UTF-8?q?=C2=B0=20constraint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shift pressed on a selected anchor point keeps it selected, so the press can become a constrained drag of all the selected points; a Shift-click still takes it away, on release. With Shift, a snap moves the points only along the constrained direction. The canvas looks selected points up in a set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/tools/src/select.rs | 35 +++++++++++++++++++++++------------ crates/tools/src/tests.rs | 14 ++++++++++++-- crates/ui-egui/src/canvas.rs | 4 +++- 3 files changed, 38 insertions(+), 15 deletions(-) diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index d8c6ad12e..76a81d8f2 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -279,6 +279,8 @@ pub struct SelectionTool { guides: Vec<Overlay>, /// Selected item to toggle if this Shift press never becomes a drag. shift_release: Option<u64>, + /// Selected anchor point to take away if this Shift press never becomes a drag. + anchor_release: Option<(ItemId, usize, usize)>, /// The loaded text cursor. loaded: Option<Loaded>, /// Frame whose live corners are being edited (diamonds shown). @@ -287,7 +289,7 @@ pub struct SelectionTool { impl SelectionTool { pub fn new(direct: bool) -> Self { - Self { direct, drag: Drag::None, hover_handle: None, guides: vec![], shift_release: None, loaded: None, live: None } + Self { direct, drag: Drag::None, hover_handle: None, guides: vec![], shift_release: None, anchor_release: None, loaded: None, live: None } } /// [`bracket_at`] for this tool. With Direct Selection an anchor under the pointer wins: the @@ -811,7 +813,9 @@ impl Tool for SelectionTool { let mut points = cx.selection.anchors.clone(); let was = points.contains(&me); if ev.mods.shift && was { - points.retain(|a| *a != me); + // 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 { @@ -820,10 +824,6 @@ impl Tool for SelectionTool { if points != cx.selection.anchors { out.push(Action::Exec("selection.set".into(), anchor_selection(cx, &points))); } - if ev.mods.shift && was { - self.drag = Drag::None; - return out; - } others = points.iter().filter(|a| **a != me).map(|(i, s, a)| (i.0, *s, *a)).collect(); } let label = if handle.is_some() { @@ -1165,6 +1165,9 @@ impl Tool for SelectionTool { } 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; + } // Shift: horizontal, vertical or at 45°. if ev.mods.shift { d = constrain_45(d); @@ -1196,13 +1199,15 @@ impl Tool for SelectionTool { }, ); // dx and dy stay in canvas space. Snap.delta is spread space. - let corr = spread_delta_to_canvas(cx.layout.xf(spread), hit.delta); - if corr.x.is_finite() { - dx += corr.x; - } - if corr.y.is_finite() { - dy += corr.y; + 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; self.guides = hit.guides; } let mut params = json!({"id": id, "anchors": [[si, ai]], "dx": dx, "dy": dy}); @@ -1219,8 +1224,14 @@ impl Tool for SelectionTool { PointerKind::Up => { self.guides.clear(); let release = self.shift_release.take(); + let anchor_release = self.anchor_release.take(); let d = std::mem::replace(&mut self.drag, Drag::None); match d { + // A Shift-click on a selected point that never became a drag takes it away. + Drag::Anchor { .. } if anchor_release.is_some() => { + let points: Vec<_> = cx.selection.anchors.iter().copied().filter(|a| Some(*a) != anchor_release).collect(); + vec![Action::Commit, Action::Exec("selection.set".into(), anchor_selection(cx, &points))] + } Drag::Move { .. } | Drag::Resize { .. } | Drag::ContentResize { .. } diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index a8f98b7e1..10267224a 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -1980,9 +1980,19 @@ fn direct_selection_marquee_selects_points_of_several_frames_and_drags_them_toge // Shift keeps the move horizontal. assert_eq!((p["dx"].as_f64(), p["dy"].as_f64()), (Some(36.0), Some(0.0))); assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x + 36.0, at.y)), vec![Action::Commit]); - // Shift+click on a selected point takes it out of the selection; the arrow keys move the points. + // A Shift-drag that starts on a selected point keeps it: all four move, constrained. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(shift)); + let drag = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, at.x + 3.0, at.y + 30.0).with_mods(shift)); + let Some(Action::Preview(_, p)) = drag.last() else { panic!("{drag:?}") }; + assert_eq!(p["points"].as_array().map(Vec::len), Some(4), "{p}"); + assert!(p["dx"].as_f64().unwrap().abs() < 1e-9 && (p["dy"].as_f64().unwrap() - 30.0).abs() < 1e-9, "vertical: {p}"); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x, at.y + 30.0).with_mods(shift)), vec![Action::Commit]); + // Shift+click on a selected point takes it out of the selection, on release; the arrow keys + // move the points. let out = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(shift)); - let Some(Action::Exec(cmd, p)) = out.first() else { panic!("{out:?}") }; + assert!(!out.iter().any(|a| matches!(a, Action::Exec(..))), "kept while it may become a drag: {out:?}"); + let out = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x, at.y).with_mods(shift)); + let Some(Action::Exec(cmd, p)) = out.last() else { panic!("{out:?}") }; assert_eq!(cmd, "selection.set"); assert_eq!(p["anchors"].as_array().map(Vec::len), Some(3), "{p}"); let keys = t.key(&cx, ToolKey::Right, Mods::default()); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d20327cee..6172a5948 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1279,6 +1279,8 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum } let direct = matches!(app.session.tool_id(), "directSelection" | "pen" | "addAnchor" | "deleteAnchor" | "convertDirection"); if direct { + // Looked up once per anchor drawn: a large marquee selection stays linear. + let chosen_set: std::collections::HashSet<(designcraft_doc::ItemId, usize, usize)> = sel.anchors.iter().copied().collect(); for id in &sel.items { let (Some(it), Some((a, _))) = (doc.item(*id), item_canvas_xf(doc, layout, *id)) else { continue }; let m = a * it.xf; @@ -1287,7 +1289,7 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum for (ai, an) in sp.anchors.iter().enumerate() { let p = xf.to_screen(m * an.p); // A selected anchor point is solid, the others hollow (InDesign). - let chosen = sel.anchors.contains(&(*id, si, ai)); + let chosen = chosen_set.contains(&(*id, si, ai)); for (h, has) in [(an.h_in, an.has_in()), (an.h_out, an.has_out())] { if has { let hp = xf.to_screen(m * h); From 66b51ee15f4dde88482965a1215aaaea946bef3f Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 12:11:55 +0200 Subject: [PATCH 129/431] Merge fix: a Shift-click release keeps the other selected objects Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/tools/src/select.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 99b45a0ee..cafddc699 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -1232,7 +1232,7 @@ impl Tool for SelectionTool { // A Shift-click on a selected point that never became a drag takes it away. Drag::Anchor { .. } if anchor_release.is_some() => { let points: Vec<_> = cx.selection.anchors.iter().copied().filter(|a| Some(*a) != anchor_release).collect(); - vec![Action::Commit, Action::Exec("selection.set".into(), anchor_selection(cx, &points))] + vec![Action::Commit, Action::Exec("selection.set".into(), anchor_selection(cx, &points, true))] } Drag::Move { .. } | Drag::Resize { .. } From d744b760fe5153f0bc082c20b1bd142fc296c2b2 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 12:20:44 +0200 Subject: [PATCH 130/431] Direct Selection points: each moves in its own spread; Delete deletes them; Transform measures them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `path.moveAnchors` points may carry their own spread-space movement (`[id, subpath, index, dx, dy]`). Dragging and the arrow keys send each selected point's movement converted for its spread, so on a spread turned on screen a point moves the way the pointer or arrow does. Delete and Backspace with points selected run `path.deletePoints`. Properties › Transform for selected points shows the points' bounds; X/Y move them and W/H spread or gather them about the reference point, in one step (`transform_anchors`). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/anchors.rs | 21 ++++++ crates/engine/src/cmd/object.rs | 26 +++++-- crates/tools/src/select.rs | 33 +++++++-- crates/tools/src/tests.rs | 22 +++++- crates/ui-egui/src/panels/mod.rs | 23 +++++- crates/ui-egui/src/panels/properties.rs | 99 ++++++++++++++++++++++++- 6 files changed, 205 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/cmd/anchors.rs b/crates/engine/src/cmd/anchors.rs index 472d54494..6adc9b5c6 100644 --- a/crates/engine/src/cmd/anchors.rs +++ b/crates/engine/src/cmd/anchors.rs @@ -501,6 +501,27 @@ mod tests { } /// Join Path closes one open path, and joins two open paths at their nearest ends. + /// `path.moveAnchors` points may each carry their own movement (Direct Selection sends each + /// point's in its own spread); without one a point takes the shared `dx`, `dy`. + #[test] + fn move_anchors_takes_a_movement_per_point() { + let mut s = crate::Session::new(); + s.execute("file.new", &serde_json::json!({})).unwrap(); + let id = s.execute("frame.create", &serde_json::json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let at = |s: &crate::Session, i: usize| { + let d = &s.doc().unwrap().doc; + let it = d.item(designcraft_doc::ItemId(id)).unwrap(); + it.xf * it.path.subpaths[0].anchors[i].p + }; + let (a0, a1) = (at(&s, 0), at(&s, 1)); + s.execute("path.moveAnchors", &serde_json::json!({"points": [[id, 0, 0, 5, 0], [id, 0, 1]], "dx": 0, "dy": 7})).unwrap(); + assert_eq!(at(&s, 0) - a0, designcraft_geom::Vec2::new(5.0, 0.0), "its own"); + assert_eq!(at(&s, 1) - a1, designcraft_geom::Vec2::new(0.0, 7.0), "the shared one"); + for bad in [serde_json::json!([[id, 0, 0, "x", 1]]), serde_json::json!([[id, 0, 0, 5]])] { + assert!(s.execute("path.moveAnchors", &serde_json::json!({"points": bad})).is_err(), "{bad}"); + } + } + #[test] fn join_path_closes_one_path_and_joins_two() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 498dd5674..25ff69eac 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -685,7 +685,7 @@ pub fn specs() -> Vec<CommandSpec> { "Move Anchors", [], None, - "{id, anchors: [[subpath, index]], dx, dy, handle?: in|out} | {points: [[id, subpath, index]], dx, dy} (anchors of several paths, as Direct Selection drags them: one step)", + "{id, anchors: [[subpath, index]], dx, dy, handle?: in|out} | {points: [[id, subpath, index, dx?, dy?]], dx?, dy?} (anchors of several paths, as Direct Selection drags them: one step; a point's own dx, dy win over the shared ones) — spread space", has_doc, path_move_anchors ), @@ -3352,22 +3352,31 @@ fn path_close(s: &mut Session, p: &Value) -> Result<Value> { fn path_move_anchors(s: &mut Session, p: &Value) -> Result<Value> { // The anchors to move, by path: `points` (several paths) or `id` with `anchors`. - let mut targets: Vec<(ItemId, Vec<(usize, usize)>)> = Vec::new(); + // Each anchor with its own spread-space movement, when the points give one. + let mut targets: Vec<(ItemId, Vec<(usize, usize, Option<Vec2>)>)> = Vec::new(); if let Some(points) = p.get("points").and_then(Value::as_array) { for v in points { let n = |i: usize| v.get(i).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok()); let (Some(id), Some(si), Some(ai)) = (v.get(0).and_then(Value::as_u64), n(1), n(2)) else { - return Err(bad("path.moveAnchors", "each point is [id, subpath, index]")); + return Err(bad("path.moveAnchors", "each point is [id, subpath, index] or [id, subpath, index, dx, dy]")); + }; + let own = match (v.get(3), v.get(4)) { + (None, None) => None, + (Some(x), Some(y)) => match (x.as_f64(), y.as_f64()) { + (Some(x), Some(y)) if x.is_finite() && y.is_finite() => Some(Vec2::new(x, y)), + _ => return Err(bad("path.moveAnchors", "a point's dx and dy are finite numbers")), + }, + _ => return Err(bad("path.moveAnchors", "a point gives both dx and dy, or neither")), }; match targets.iter_mut().find(|(i, _)| i.0 == id) { - Some((_, which)) => which.push((si, ai)), - None => targets.push((ItemId(id), vec![(si, ai)])), + Some((_, which)) => which.push((si, ai, own)), + None => targets.push((ItemId(id), vec![(si, ai, own)])), } } } else { let id = super::id_param(p, "id").ok_or_else(|| bad("path.moveAnchors", "missing id"))?; let which: Vec<(usize, usize)> = p.get("anchors").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); - targets.push((id, which)); + targets.push((id, which.into_iter().map(|(s, a)| (s, a, None)).collect())); } let d0 = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0)); let handle = str_param(p, "handle").map(str::to_string); @@ -3381,9 +3390,10 @@ fn path_move_anchors(s: &mut Session, p: &Value) -> Result<Value> { let m = d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default(); // Spread-space delta → inner-space delta (linear part only). let inv = m.inverse(); - let dv = (inv * Point::new(d0.x, d0.y)) - (inv * Point::ZERO); + let inner = |d: Vec2| (inv * Point::new(d.x, d.y)) - (inv * Point::ZERO); let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; - for (si, ai) in which { + for (si, ai, own) in which { + let dv = inner(own.unwrap_or(d0)); let Some(a) = it.path.anchor_mut(*si, *ai) else { continue }; match handle.as_deref() { Some("in") => { diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index cafddc699..3d52913de 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -263,6 +263,18 @@ fn anchor_selection(cx: &ToolContext, points: &[(ItemId, usize, usize)], keep: b json!({"ids": ids, "anchors": anchors}) } +/// `path.moveAnchors` points for `points` moved `d` on the canvas: each with its own movement in +/// its spread (a rotated spread view turns it). +fn moved_points(cx: &ToolContext, points: impl IntoIterator<Item = (ItemId, usize, usize)>, d: Vec2) -> Vec<Value> { + points + .into_iter() + .map(|(i, s, a)| { + let v = cx.doc.find(i).map_or(d, |loc| cx.layout.delta_to_spread(loc.spread, d)); + json!([i.0, s, a, v.x, v.y]) + }) + .collect() +} + /// `d` held to the nearest multiple of 45°, as Shift does. fn constrain_45(d: Vec2) -> Vec2 { if d.hypot() < 1e-9 { @@ -1212,12 +1224,14 @@ impl Tool for SelectionTool { dy += corr.y; self.guides = hit.guides; } - let mut params = json!({"id": id, "anchors": [[si, ai]], "dx": dx, "dy": dy}); + // 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: Vec<Value> = 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}); + 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)] } @@ -1300,15 +1314,20 @@ impl Tool for SelectionTool { if key == ToolKey::Escape && self.live.take().is_some_and(|id| cx.selection.items == [id]) { return vec![Action::Handled]; } - let points: Vec<Value> = cx.selection.anchors.iter().map(|(i, s, a)| json!([i.0, s, a])).collect(); - let direct_points = self.direct && !points.is_empty(); + let direct_points = self.direct && !cx.selection.anchors.is_empty(); let mv = |dx: f64, dy: f64| { - // Direct Selection with anchor points selected: the arrow keys move the points. + // Direct Selection with anchor points selected: the arrow keys move the points, each + // the way the arrow points on screen. if direct_points { - return vec![Action::Exec("path.moveAnchors".into(), json!({"points": points.clone(), "dx": dx, "dy": dy}))]; + let points = moved_points(cx, cx.selection.anchors.iter().copied(), Vec2::new(dx, dy)); + return vec![Action::Exec("path.moveAnchors".into(), json!({"points": points}))]; } vec![Action::Exec("transform.move".into(), json!({"dx": dx, "dy": dy, "copy": mods.alt}))] }; + // Selected points: Delete and Backspace delete them, not their paths. + if direct_points && matches!(key, ToolKey::Delete | ToolKey::Backspace) { + return vec![Action::Exec("path.deletePoints".into(), json!({}))]; + } if cx.selection.items.is_empty() { // Selected ruler guides: the arrows nudge them, Delete deletes them. if cx.selection.guides.is_empty() { diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 9a60be54c..a6ddb26c2 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -1990,14 +1990,18 @@ fn direct_selection_marquee_selects_points_of_several_frames_and_drags_them_toge assert_eq!(cmd, "path.moveAnchors"); assert_eq!(p["points"].as_array().map(Vec::len), Some(4), "{p}"); // Shift keeps the move horizontal. - assert_eq!((p["dx"].as_f64(), p["dy"].as_f64()), (Some(36.0), Some(0.0))); + // Each point carries its own movement (in its spread): here all the same. + let moves = |p: &serde_json::Value| -> Vec<(f64, f64)> { + p["points"].as_array().unwrap().iter().map(|v| (v[3].as_f64().unwrap(), v[4].as_f64().unwrap())).collect() + }; + assert!(moves(p).iter().all(|&(x, y)| (x - 36.0).abs() < 1e-9 && y.abs() < 1e-9), "{p}"); assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x + 36.0, at.y)), vec![Action::Commit]); // A Shift-drag that starts on a selected point keeps it: all four move, constrained. t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(shift)); let drag = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, at.x + 3.0, at.y + 30.0).with_mods(shift)); let Some(Action::Preview(_, p)) = drag.last() else { panic!("{drag:?}") }; assert_eq!(p["points"].as_array().map(Vec::len), Some(4), "{p}"); - assert!(p["dx"].as_f64().unwrap().abs() < 1e-9 && (p["dy"].as_f64().unwrap() - 30.0).abs() < 1e-9, "vertical: {p}"); + assert!(moves(p).iter().all(|&(x, y)| x.abs() < 1e-9 && (y - 30.0).abs() < 1e-9), "vertical: {p}"); assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x, at.y + 30.0).with_mods(shift)), vec![Action::Commit]); // Shift+click on a selected point takes it out of the selection, on release; the arrow keys // move the points. @@ -2012,4 +2016,18 @@ fn direct_selection_marquee_selects_points_of_several_frames_and_drags_them_toge matches!(keys.as_slice(), [Action::Exec(cmd, p)] if cmd == "path.moveAnchors" && p["points"].as_array().map(Vec::len) == Some(4)), "{keys:?}" ); + // Delete deletes the selected points, not their paths. + assert_eq!(t.key(&cx, ToolKey::Delete, Mods::default()), vec![Action::Exec("path.deletePoints".into(), serde_json::json!({}))]); + // On a spread turned a quarter on screen, → moves the points right on screen: up or down in + // the spread, not across it. + let mut turned = d.clone(); + std::sync::Arc::make_mut(&mut turned.spreads[0]).pages[0].view_rotation = 1; + let lt = CanvasLayout::new(&turned, false); + let cxt = ctx(&turned, &sel, &c, <); + let keys = t.key(&cxt, ToolKey::Right, Mods::default()); + let [Action::Exec(_, p)] = keys.as_slice() else { panic!("{keys:?}") }; + let inc = turned.settings.keyboard_increment; + let want = lt.delta_to_spread(SpreadRef::Doc(0), designcraft_geom::Vec2::new(inc, 0.0)); + assert!(want.x.abs() < 1e-9 && (want.y.abs() - inc).abs() < 1e-9, "a quarter turn: {want:?}"); + assert!(moves(p).iter().all(|&(x, y)| (x - want.x).abs() < 1e-9 && (y - want.y).abs() < 1e-9), "{p}"); } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index f2229b604..d1b532ff8 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -160,6 +160,22 @@ pub fn guide_field(app: &DesignApp) -> Option<GuideField> { value.is_finite().then_some(GuideField { guide: *g, orientation: guide.orientation, value, origin, unit }) } +/// The anchor points selected with Direct Selection, each with where it is in its spread. +pub fn selected_anchor_points(app: &DesignApp) -> Vec<((designcraft_doc::ItemId, usize, usize), designcraft_geom::Point)> { + let Some(st) = app.session.active() else { return vec![] }; + let d = &st.doc; + st.selection + .anchors + .iter() + .filter_map(|&(id, si, ai)| { + let loc = d.find(id)?; + let it = d.item(id)?; + let a = it.path.subpaths.get(si)?.anchors.get(ai)?; + Some(((id, si, ai), (d.parent_xf(&loc) * it.xf) * a.p)) + }) + .collect() +} + pub fn sel_info(app: &DesignApp) -> Option<SelInfo> { let st = app.session.active()?; if st.selection.items.is_empty() { @@ -181,7 +197,12 @@ pub fn sel_info(app: &DesignApp) -> Option<SelInfo> { }; b = Some(b.map_or(ib, |r| r.union(ib))); } - let b = b?; + // Points selected with Direct Selection: Transform measures the points, not their paths. + let points = selected_anchor_points(app); + let b = match points.split_first() { + Some(((_, p0), rest)) => rest.iter().fold(Rect::from_points(*p0, *p0), |r, (_, p)| r.union_pt(*p)), + None => b?, + }; let first = d.item(st.selection.items[0])?; let loc = d.find(first.id)?; let o = d.ruler_origin(loc.spread, b.center().x)?; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 4fb578f06..2a1d29f13 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -630,6 +630,72 @@ 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) { + let u = units(app); + let rp = app.ui.ref_point; + let b = block(ui, 2.0 * FIELD_H + 4.0); + let r = i.page_rect; + let (fx, fy) = ((rp % 3) as f64 / 2.0, (rp / 3) as f64 / 2.0); + let (x, y) = (r.x0 + r.width() * fx, r.y0 + r.height() * fy); + if let Some(p) = ref_point_proxy(ui, sub(b, 5.0, 10.0, 26.0, 26.0), rp) { + app.ui.ref_point = p; + } + let mut set = serde_json::Map::new(); + for (row, (lx, vx, kx, lw, vw, kw)) in + [("X:", x, "x", "W:", r.width(), "width"), ("Y:", y, "y", "H:", r.height(), "height")].into_iter().enumerate() + { + let yy = row as f32 * 25.0; + place(ui, sub(b, 40.0, yy, 18.0, FIELD_H), |ui| widgets::caption_w(ui, lx, 18.0)); + if let Some(v) = place(ui, sub(b, 59.0, yy, 54.0, FIELD_H), |ui| measure(ui, &format!("pt_{kx}"), Some(vx), u, 54.0)) { + set.insert(kx.into(), json!(v)); + } + place(ui, sub(b, 120.0, yy, 19.0, FIELD_H), |ui| widgets::caption_w(ui, lw, 19.0)); + if let Some(v) = place(ui, sub(b, 140.0, yy, 54.0, FIELD_H), |ui| measure(ui, &format!("pt_{kw}"), Some(vw), u, 54.0)) { + set.insert(kw.into(), json!(v)); + } + } + if !set.is_empty() { + let _ = transform_anchors(app, &Value::Object(set)); + } + if more_options(ui, &app.ui.language).clicked() { + show_panel(app, "info"); + } +} + +/// Move the selected points as Properties › Transform's `set` asks ({x?, y?, width?, height?}, +/// 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<Value, String> { + let points = super::selected_anchor_points(app); + let Some(i) = super::sel_info(app) else { return Ok(Value::Null) }; + if points.is_empty() { + return Ok(Value::Null); + } + let rp = app.ui.ref_point; + let r = i.page_rect; + let (fx, fy) = ((rp % 3) as f64 / 2.0, (rp / 3) as f64 / 2.0); + // Page coordinates and spread coordinates differ by the ruler origin only. + let b = points.iter().skip(1).fold(designcraft_geom::Rect::from_points(points[0].1, points[0].1), |b, (_, p)| b.union_pt(*p)); + let origin = (b.x0 - r.x0, b.y0 - r.y0); + let reference = (r.x0 + r.width() * fx + origin.0, r.y0 + r.height() * fy + origin.1); + let num = |k: &str| set.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()); + let scale = |new: Option<f64>, old: f64| new.filter(|_| old > 1e-9).map_or(1.0, |v| v.max(0.0) / old); + let (kx, ky) = (scale(num("width"), r.width()), scale(num("height"), r.height())); + let shift = (num("x").map_or(0.0, |v| v - (r.x0 + r.width() * fx)), num("y").map_or(0.0, |v| v - (r.y0 + r.height() * fy))); + let moved: Vec<Value> = points + .iter() + .map(|&((id, s, a), p)| { + let nx = reference.0 + (p.x - reference.0) * kx + shift.0; + let ny = reference.1 + (p.y - reference.1) * ky + shift.1; + json!([id.0, s, a, nx - p.x, ny - p.y]) + }) + .collect(); + app.run("path.moveAnchors", json!({"points": moved})) +} + /// Properties › Transform for selected ruler guides: X for one vertical guide, Y for one /// horizontal guide; with several guides both fields are empty. fn guide_section(app: &mut DesignApp, ui: &mut Ui) { @@ -2256,7 +2322,7 @@ const CONVERT_SHAPE: [(&str, &str, &str); 9] = [ /// 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) { section(ui, crate::i18n::tr(&app.ui.language, "Transform"), true); - transform_section(app, ui, i); + anchor_transform_section(app, ui, i); divider(ui); section(ui, crate::i18n::tr(&app.ui.language, "Appearance"), true); appearance_section(app, ui, i); @@ -3512,6 +3578,37 @@ mod tests { } } + /// 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"); + } + #[test] fn transform_flips_and_rotates_from_properties() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From ebb428619a6bf469e987840c9c344e43833bd835 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 12:34:43 +0200 Subject: [PATCH 131/431] Insert Pages bounds test: an out-of-range placement is refused `after` or `before` past the last page is refused without overflowing and changes nothing; inserting after the last page still appends. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/tests/insert_pages_bounds.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/crates/engine/tests/insert_pages_bounds.rs b/crates/engine/tests/insert_pages_bounds.rs index e96a6cc4b..340e2fc81 100644 --- a/crates/engine/tests/insert_pages_bounds.rs +++ b/crates/engine/tests/insert_pages_bounds.rs @@ -1,16 +1,21 @@ -//! `layout.pages.insert` clamps an out-of-range `after` to the end instead of overflowing. +//! `layout.pages.insert` refuses an out-of-range `after` or `before` (they must name a page) +//! instead of overflowing, and changes nothing. use designcraft_engine::Session; use serde_json::json; #[test] -fn insert_after_u64_max_appends_at_the_end() { +fn insert_after_u64_max_is_refused_without_overflow() { let mut s = Session::new(); s.execute("file.new", &json!({"pages":2})).unwrap(); let before: Vec<_> = s.doc().unwrap().doc.pages().map(|(_, _, p)| p.id).collect(); - let out = s.execute("layout.pages.insert", &json!({"after": u64::MAX, "count": 2})).unwrap(); - assert_eq!(out["total"], 4); + let undo = s.doc().unwrap().history.undo.len(); + for p in [json!({"after": u64::MAX, "count": 2}), json!({"before": u64::MAX, "count": 2})] { + assert!(s.execute("layout.pages.insert", &p).is_err(), "{p}"); + } let after: Vec<_> = s.doc().unwrap().doc.pages().map(|(_, _, p)| p.id).collect(); - assert_eq!(after.len(), 4); - assert_eq!(after[..2], before[..], "the original pages must stay first; new pages go at the end"); - assert!(after[2..].iter().all(|id| !before.contains(id))); + assert_eq!(after, before, "no pages added"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step"); + // The last page is still a valid place: the new pages go at the end. + let out = s.execute("layout.pages.insert", &json!({"after": 1, "count": 2})).unwrap(); + assert_eq!(out["total"], 4); } From fed464d9da5f7a7c8708466a501bd9ad6e49804f Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 12:38:20 +0200 Subject: [PATCH 132/431] Direct Selection: a Shift-click on a selected point previews no move While the press is still a click (inside the drag tolerance), a drag event moves nothing, so the release takes the point away without moving it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/tools/src/select.rs | 3 +++ crates/tools/src/tests.rs | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 76a81d8f2..81589885f 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -1167,6 +1167,9 @@ impl Tool for SelectionTool { 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 { diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 10267224a..974c38ee9 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -1991,7 +1991,10 @@ fn direct_selection_marquee_selects_points_of_several_frames_and_drags_them_toge // move the points. let out = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(shift)); assert!(!out.iter().any(|a| matches!(a, Action::Exec(..))), "kept while it may become a drag: {out:?}"); - let out = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x, at.y).with_mods(shift)); + // A jiggle inside the drag tolerance moves nothing: it is still a click. + let jiggle = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, at.x + 1.0, at.y + 1.0).with_mods(shift)); + assert!(jiggle.is_empty(), "{jiggle:?}"); + let out = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x + 1.0, at.y + 1.0).with_mods(shift)); let Some(Action::Exec(cmd, p)) = out.last() else { panic!("{out:?}") }; assert_eq!(cmd, "selection.set"); assert_eq!(p["anchors"].as_array().map(Vec::len), Some(3), "{p}"); From 6f8a0bf09eb42a34bff3512b71d0a677b5ae8590 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 12:43:28 +0200 Subject: [PATCH 133/431] Default units: a preset keeps both its units; bad increments are refused A document made from a preset keeps the preset's units on both rulers; the default origin and increments still apply. Preferences with no document open passes the increments it reads to `prefs.set` unclamped: a Cursor Key of 0, or text that isn't a measure or number, is refused and the dialog stays open with the reason; a Kerning/Tracking of 0.5 is kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/file.rs | 4 ++-- crates/engine/src/cmd/prefs.rs | 9 ++++++-- crates/engine/src/lib.rs | 10 ++++----- crates/ui-egui/src/dialogs.rs | 41 ++++++++++++++++++++++++++++------ 4 files changed, 48 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index f658d4b92..5b5cb2827 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -237,7 +237,7 @@ fn file_new(s: &mut Session, p: &Value) -> Result<Value> { s.untitled += 1; nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| format!("Untitled-{}", s.untitled)); let mut d = Document::new(&nd); - s.prefs.default_units.apply(&mut d.settings); + s.prefs.default_units.apply(&mut d.settings, p.get("preset").is_some_and(|v| !v.is_null())); let i = s.add_document(DocState::new(d, None)); Ok(json!({"index": i})) } @@ -268,7 +268,7 @@ impl Session { } nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| self.next_untitled_title()); let mut d = Document::new(&nd); - self.prefs.default_units.apply(&mut d.settings); + self.prefs.default_units.apply(&mut d.settings, p.get("preset").is_some_and(|v| !v.is_null())); let st = DocState::new(d, None); let uid = st.uid; match replace.and_then(|i| self.docs.get_mut(i).map(|d| (i, d))) { diff --git a/crates/engine/src/cmd/prefs.rs b/crates/engine/src/cmd/prefs.rs index 781d3ef13..a6b53d605 100644 --- a/crates/engine/src/cmd/prefs.rs +++ b/crates/engine/src/cmd/prefs.rs @@ -172,9 +172,14 @@ mod tests { s.execute("file.new", &json!({"units": "points"})).unwrap(); let st = s.doc().unwrap().doc.settings.clone(); assert_eq!((st.horizontal_units, st.vertical_units), (Unit::Points, Unit::Points)); - // A preset keeps its own units. + // A preset keeps its own units, both of them, even when the default horizontal units are + // the preset's. + s.execute("prefs.set", &json!({"defaultUnits": {"horizontalUnits": "millimeters", "verticalUnits": "inches"}})).unwrap(); s.execute("file.new", &json!({"preset": "A4"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.settings.horizontal_units, Unit::Millimeters); + let st = s.doc().unwrap().doc.settings.clone(); + assert_eq!((st.horizontal_units, st.vertical_units), (Unit::Millimeters, Unit::Millimeters)); + assert_eq!(st.keyboard_increment, 0.5, "the increments still apply"); + s.execute("prefs.set", &json!({"defaultUnits": {"horizontalUnits": "inches", "verticalUnits": "millimeters"}})).unwrap(); // New Document opens with them; bad values are refused and change nothing. assert_eq!(s.default_new_document().units, Unit::Inches); for bad in [json!({"horizontalUnits": "furlongs"}), json!({"keyboardIncrement": 0}), json!({"kerningIncrement": -5})] { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9c5c64a35..0dcd348f3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -277,11 +277,11 @@ impl Default for DefaultUnits { } impl DefaultUnits { - /// Gives a new document made in `units` these defaults. Its vertical ruler follows the - /// preference while the document keeps the default horizontal units (units picked in New - /// Document set both rulers). - pub fn apply(&self, settings: &mut designcraft_doc::DocSettings) { - if settings.horizontal_units == self.horizontal_units { + /// Gives a new document these defaults. Unless it was made from a preset (whose units are its + /// own), its vertical ruler follows the preference while it keeps the default horizontal + /// units (units picked in New Document set both rulers). + pub fn apply(&self, settings: &mut designcraft_doc::DocSettings, preset: bool) { + if !preset && settings.horizontal_units == self.horizontal_units { settings.vertical_units = self.vertical_units; } settings.ruler_origin = self.ruler_origin; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 502b8e0e7..eb11c1367 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3389,20 +3389,32 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> { app.run("view.snapPreferences", snap)?; } if d.b("defaults") { - // No document open: the Units & Increments new documents start with. + // No document open: the Units & Increments new documents start with. A value that + // doesn't read, or that `prefs.set` refuses, keeps the dialog open to be fixed. let mut du = json!({}); for k in ["horizontalUnits", "verticalUnits", "rulerOrigin"] { if let Some(v) = d.fields.get(k).filter(|v| v.is_string()) { du[k] = v.clone(); } } - if let Some(v) = d.fields.get("keyboardIncrement").and_then(Value::as_str).and_then(|s| parse_measure(s, Unit::Points).ok()) { - du["keyboardIncrement"] = json!(v.clamp(0.001, 1000.0)); + let mut read = || -> Result<(), String> { + if let Some(t) = d.fields.get("keyboardIncrement").and_then(Value::as_str) { + du["keyboardIncrement"] = json!(parse_measure(t, Unit::Points).map_err(|_| format!("Cursor Key: `{t}` is not a measurement"))?); + } + if let Some(v) = d.fields.get("kerningIncrement").filter(|v| !v.is_null()) { + let n = d.n("kerningIncrement").filter(|v| v.is_finite()); + du["kerningIncrement"] = json!(n.ok_or_else(|| format!("Kerning/Tracking: `{}` is not a number", v.as_str().unwrap_or_default()))?); + } + Ok(()) + }; + let r = read().and_then(|()| app.run("prefs.set", json!({"defaultUnits": du}))); + if let Err(e) = &r { + let mut d = d.clone(); + d.fields.insert("status".into(), json!(e)); + app.status(e.clone()); + app.ui.dialog = Some(d); + return Err(e.clone()); } - if let Some(v) = d.n("kerningIncrement").filter(|v| v.is_finite()) { - du["kerningIncrement"] = json!(v.clamp(1.0, 1000.0)); - } - app.run("prefs.set", json!({"defaultUnits": du}))?; return Ok(Value::Null); } let Some(horizontal) = d.fields.get("horizontalUnits") else { return Ok(Value::Null) }; @@ -7261,6 +7273,21 @@ mod tests { 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 value; a Kerning/Tracking of 0.5 is kept as it is. + for bad in [json!({"keyboardIncrement": "0 pt"}), json!({"keyboardIncrement": "wide"}), json!({"kerningIncrement": "lots"})] { + let mut d = Dialog::new("preferences", json!({"defaults": true, "horizontalUnits": "inches"})); + 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}"); + } + 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"); From 880c90a85fb03c6d362822852b36efc8a490745b Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 13:41:10 +0200 Subject: [PATCH 134/431] Hyphenation: French and Italian, and words with an elision French and Italian text is hyphenated with hyph-utf8's patterns, through hypher's "french" and "italian" features (hyph-fr.tex 2.13, MIT; hyph-it.tex 4.9, LPPL or MIT, taken under MIT; NOTICE has both). The Character panel's French, French: Canadian, French: Swiss and Italian, and fr / it locale codes, select them. A word with an elision hyphenates after its apostrophe (l'im-pri-me-rie, dell'im-pa-gi-na-zio-ne) in the patterned languages; before, any word with an apostrophe was left whole. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- NOTICE | 28 ++++++++++++++++++ ROADMAP.md | 1 + crates/compose/Cargo.toml | 2 +- crates/compose/src/hyphen/mod.rs | 49 +++++++++++++++++++++++++++++--- crates/compose/src/tests.rs | 17 +++++++---- docs/gaps.md | 2 +- docs/localization-parity.md | 2 +- docs/typography-parity.md | 2 +- 8 files changed, 90 insertions(+), 13 deletions(-) diff --git a/NOTICE b/NOTICE index f1e997156..38d74f1de 100644 --- a/NOTICE +++ b/NOTICE @@ -68,6 +68,34 @@ list (author, source, license) is in ASSETS.md. 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. +- French hyphenation patterns, embedded through the hypher crate (feature "french"): + hyph-fr.tex 2.13 from hyph-utf8, https://github.com/hyphenation/tex-hyphen. + Copyright (C) 1994-2002 Daniel Flipo, Bernard Gaulle, 2016 Arthur Reutenauer. 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. +- Italian hyphenation patterns, embedded through the hypher crate (feature "italian"): + hyph-it.tex 4.9 from hyph-utf8, https://github.com/hyphenation/tex-hyphen. + Copyright (C) 2008-2011 Claudio Beccari. Available under the LPPL (1.3 or later) or the MIT + License; taken here under the 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. - Norwegian hyphenation patterns, embedded through the hypher crate (feature "norwegian"): hyph-no.tex from hyph-utf8, https://github.com/hyphenation/tex-hyphen. Copyright (C) Rune Kleveland and Ole Michael Selberg. Copying and distribution of this file, diff --git a/ROADMAP.md b/ROADMAP.md index 8a2352e86..3002b86e3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-22** · French and Italian text is hyphenated, with hyph-utf8's patterns through hypher (hyph-fr 2.13 and hyph-it 4.9, MIT): for the Language set in the Character panel (French, French: Canadian, French: Swiss, Italian, or fr / it locale codes). A word with an elision hyphenates after its apostrophe (l'im-pri-me-rie, dell'im-pa-gi-na-zio-ne), in every patterned language. Gap 6 shrinks. No readiness percentage change. - **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. - **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change. diff --git a/crates/compose/Cargo.toml b/crates/compose/Cargo.toml index b7017f80f..884795e5c 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", "french", "greek", "italian", "portuguese", "spanish", "norwegian"] } [dev-dependencies] designcraft-fonts = { workspace = true, features = ["testing"] } diff --git a/crates/compose/src/hyphen/mod.rs b/crates/compose/src/hyphen/mod.rs index 57b75d63b..584116bdb 100644 --- a/crates/compose/src/hyphen/mod.rs +++ b/crates/compose/src/hyphen/mod.rs @@ -5,7 +5,10 @@ //! `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. +//! polytonic text hyphenates by them as well. French is `hyph-fr.tex` 2.13 from hyph-utf8 (Daniel +//! Flipo, Bernard Gaulle, Arthur Reutenauer), MIT; Italian is `hyph-it.tex` 4.9 from hyph-utf8 +//! (Claudio Beccari), LPPL or MIT, taken under MIT. A word with an elision (`l'imprimerie`) +//! hyphenates after its apostrophe. //! //! English lookup order for each word: //! 1. the **dictionary** ([`Dictionary::en_us`]) — the public-domain Moby Hyphenator II word list @@ -119,8 +122,11 @@ fn core_breaks(core: &[char], lang: Lang) -> (Vec<usize>, Source) { return (vec![], Source::None); } if let Lang::Patterns(l) = lang { - if core.contains(&'\'') { - return (vec![], Source::None); + // An elision (French "l'", Italian "dell'"): the word after the apostrophe hyphenates, + // with no break up to it. + if let Some(ap) = core.iter().rposition(|&c| c == '\'') { + let (pts, src) = if ap + 1 < n { core_breaks(&core[ap + 1..], lang) } else { (vec![], Source::None) }; + return (pts.into_iter().map(|p| p + ap + 1).collect(), src); } let s: String = core.iter().collect(); let mut out = Vec::new(); @@ -393,7 +399,7 @@ mod tests { assert_eq!(h("desesperación"), "de-ses-pe-ra-ción"); assert_eq!(h("teatro"), "tea-tro", "a vowel run stays whole"); assert_eq!(h("calle"), "ca-lle", "ll stays whole"); - assert!(Lang::for_language("French").is_none(), "only languages built in"); + assert!(Lang::for_language("Swedish").is_none(), "only languages built in"); } #[test] @@ -435,6 +441,41 @@ mod tests { assert_eq!(h("beveger"), "be-ve-ger"); } + /// A word with an elision ("l'", "dell'") hyphenates as the word after it. + fn marked(w: &str, l: Lang) -> String { + let pts = hyphen_points_in(w, &Limits::default(), l); + w.chars().enumerate().map(|(i, c)| if pts.contains(&i) { format!("-{c}") } else { c.to_string() }).collect() + } + + #[test] + fn french_uses_the_hyph_fr_patterns() { + let fr = Lang::for_language("French").unwrap(); + for name in ["fr", "fr_FR", "French: Canadian", "French: Swiss"] { + assert_eq!(Lang::for_language(name), Some(fr), "{name}"); + } + assert_eq!(marked("hyphénation", fr), "hy-phé-na-tion"); + assert_eq!(marked("typographie", fr), "ty-po-gra-phie"); + assert_eq!(marked("Développement", fr), "Dé-ve-lop-pe-ment"); + assert_eq!(marked("anticonstitutionnellement", fr), "an-ti-cons-ti-tu-tion-nel-le-ment"); + assert_eq!(marked("paragraphe", fr), "pa-ra-graphe", "no break before a final mute syllable of two letters"); + assert_eq!(marked("l'imprimerie", fr), "l'im-pri-me-rie", "after an elision"); + assert_eq!(marked("l’imprimerie", fr), "l’im-pri-me-rie", "a typographer's apostrophe too"); + } + + #[test] + fn italian_uses_the_hyph_it_patterns() { + let it = Lang::for_language("Italian").unwrap(); + for name in ["it", "it_IT"] { + assert_eq!(Lang::for_language(name), Some(it), "{name}"); + } + assert_ne!(it, Lang::for_language("French").unwrap()); + assert_eq!(marked("sillabazione", it), "sil-la-ba-zio-ne"); + assert_eq!(marked("tipografia", it), "ti-po-gra-fia"); + assert_eq!(marked("Repubblica", it), "Re-pub-bli-ca"); + assert_eq!(marked("impaginazione", it), "im-pa-gi-na-zio-ne"); + assert_eq!(marked("dell'impaginazione", it), "dell'im-pa-gi-na-zio-ne", "after an elision"); + } + #[test] fn heuristic_fallback() { let c: Vec<char> = "happen".chars().collect(); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 15c412ada..5ff5f785e 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2642,11 +2642,18 @@ fn only_english_text_gets_english_hyphenation() { let hyphenated = |d: &Document, sid| all_lines(&compose_story(d, sid, &ComposeOptions::default())).iter().filter(|l| l.hyphenated).count(); let (d, sid, _) = doc_with(text, narrow, ParaAttrs::default()); assert!(hyphenated(&d, sid) > 0, "English hyphenates"); - let mut d2 = d.clone(); - let st = d2.story_mut(sid).unwrap(); - let n = st.len(); - st.format_chars(0..n, |f| f.over.language = Some("French".into())); - assert_eq!(hyphenated(&d2, sid), 0, "French isn't hyphenated with English rules"); + // A language without bundled patterns isn't hyphenated with English rules. + let in_language = |lang: &str| { + let mut d2 = d.clone(); + let st = d2.story_mut(sid).unwrap(); + let n = st.len(); + st.format_chars(0..n, |f| f.over.language = Some(lang.into())); + hyphenated(&d2, sid) + }; + assert_eq!(in_language("Swedish"), 0, "Swedish isn't hyphenated with English rules"); + // French and Italian hyphenate with their own patterns. + assert!(in_language("French") > 0, "French hyphenates (hyph-fr)"); + assert!(in_language("Italian") > 0, "Italian hyphenates (hyph-it)"); } // A text frame's bottom inset constrains its last baseline. The full font descent diff --git a/docs/gaps.md b/docs/gaps.md index 5738d14c4..eabe39736 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -143,7 +143,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, French and Italian; 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 fa75a20d8..a29b5d54f 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -64,7 +64,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, French, Greek, Italian, Norwegian, Portuguese and Spanish** 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/typography-parity.md b/docs/typography-parity.md index 68d7a5d9b..d8ab90d2a 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 | ~ | 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), French (hyph-fr) and Italian (hyph-it), hyphenating after an elision (l'im-pri-me-rie); zone, limits, exceptions in the user dictionary | ~ | 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 7c813dd169788d5b06d7bdc661eb83fd686ad49a Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 14:56:44 +0200 Subject: [PATCH 135/431] Docs: the French row of localization-parity says French hyphenates Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/localization-parity.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/localization-parity.md b/docs/localization-parity.md index a29b5d54f..3e3270495 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -32,7 +32,7 @@ come as Middle East editions with an English or French interface and right-to-le | Spanish | es | ✓ | 1,438 (83%) | menus, panels, most dialogs; no help | Latin ✓; Spanish hyphenation ✓ (#222) | no | partial | 5–8 | | Hindi | hi | ✗ | 0 (0%) | none | Devanagari shaped by harfrust in documents but untested; no Hindi hyphenation or spelling | no | none | 20–30 | | Arabic | ar | Middle East edition (English/French UI) | 1,459 (85%) | menus, panels, most dialogs; no help | RTL UI text (`rtl.rs`: bidi + shaping); Arabic composition, kashida, digits, RTL stories and tables ✓ (arabic-typography.md); list bullets and multi-column RTL open (#100) | no | partial | 15–25 | -| French | fr | ✓ | 1,438 (83%) | menus, panels, most dialogs; no help | Latin ✓; no French hyphenation | no | partial | 6–10 | +| French | fr | ✓ | 1,438 (83%) | menus, panels, most dialogs; no help | Latin ✓; French hyphenation ✓ (hyph-fr) | no | partial | 6–10 | | Portuguese (Brazil) | pt-br | ✓ (pt) | 1,057 (61%) | menus, panels, some dialogs; no help | Latin ✓; Portuguese hyphenation ✓ | no | partial | 8–12 | | Indonesian | id | ✗ | 0 (0%) | none | Latin ✓ | no | none | 12–18 | | Japanese | ja | ✓ | 1,644 (95%) | whole interface (#272), vertical-type commands; no help | vertical text, kinsoku, aki, ruby, warichu ✓ (partial depth, cjk.md); canvas IME ✓, not checked by hand (#322); Japanese UI and Mincho fonts from craft-fonts | no | partial | 6–10 (+ IME checks, shared with zh) | @@ -90,3 +90,4 @@ locale shaping. See [typography-parity.md](typography-parity.md). | 2026-10-10 | major | Created: catalogs measured (1,724 strings), the twelve key languages plus Italian and Ukrainian | | 2026-10-11 | minor | Czech (cs) added: 1,722 strings, plural categories handled | | 2026-10-13 | minor | Polish interface added (1,717 strings) | +| 2026-10-22 | minor | French and Italian hyphenation (hyph-fr, hyph-it): the French row and Document languages updated | From dc973c5395287a8cd874545f6194b601937a13e9 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 17:19:54 +0200 Subject: [PATCH 136/431] Preferences with no document: the default units are saved with the other preferences Units & Increments for new documents go in the same prefs.set as the rest, which checks everything before it changes anything, so a refused increment keeps the dialog open with no preference saved. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/dialogs.rs | 74 +++++++++++++++++++++-------------- 1 file changed, 45 insertions(+), 29 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9cde3798f..fac5ca40d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3361,7 +3361,23 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> { if d.b("cjkFeatures") != d.b("cjkFeatures.initial") { prefs["cjkFeatures"] = json!(d.b("cjkFeatures")); } - app.run("prefs.set", prefs)?; + // No document open: the Units & Increments new documents start with, saved with the + // rest in one `prefs.set` (which checks everything before it changes anything). A value + // that doesn't read, or that it refuses, keeps the dialog open to be fixed, with no + // preference changed. + let saved = default_units_of(&d).and_then(|du| { + if let Some(du) = du { + prefs["defaultUnits"] = du; + } + app.run("prefs.set", prefs) + }); + if let Err(e) = saved { + let mut d = d.clone(); + d.fields.insert("status".into(), json!(e)); + app.status(e.clone()); + app.ui.dialog = Some(d); + return Err(e); + } app.ui.dynamic_spelling = d.b("dynamicSpelling"); if d.fields.contains_key("userWords") { let words: Vec<String> = d.s("userWords").lines().map(str::to_string).collect(); @@ -3396,32 +3412,7 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> { app.run("view.snapPreferences", snap)?; } if d.b("defaults") { - // No document open: the Units & Increments new documents start with. A value that - // doesn't read, or that `prefs.set` refuses, keeps the dialog open to be fixed. - let mut du = json!({}); - for k in ["horizontalUnits", "verticalUnits", "rulerOrigin"] { - if let Some(v) = d.fields.get(k).filter(|v| v.is_string()) { - du[k] = v.clone(); - } - } - let mut read = || -> Result<(), String> { - if let Some(t) = d.fields.get("keyboardIncrement").and_then(Value::as_str) { - du["keyboardIncrement"] = json!(parse_measure(t, Unit::Points).map_err(|_| format!("Cursor Key: `{t}` is not a measurement"))?); - } - if let Some(v) = d.fields.get("kerningIncrement").filter(|v| !v.is_null()) { - let n = d.n("kerningIncrement").filter(|v| v.is_finite()); - du["kerningIncrement"] = json!(n.ok_or_else(|| format!("Kerning/Tracking: `{}` is not a number", v.as_str().unwrap_or_default()))?); - } - Ok(()) - }; - let r = read().and_then(|()| app.run("prefs.set", json!({"defaultUnits": du}))); - if let Err(e) = &r { - let mut d = d.clone(); - d.fields.insert("status".into(), json!(e)); - app.status(e.clone()); - app.ui.dialog = Some(d); - return Err(e.clone()); - } + // The default units went with the preferences above. return Ok(Value::Null); } let Some(horizontal) = d.fields.get("horizontalUnits") else { return Ok(Value::Null) }; @@ -5170,6 +5161,29 @@ fn section_list(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cjk: bool, sectio }); } +/// Preferences with no document open: the Units & Increments new documents start with, as +/// `prefs.set`'s `defaultUnits` (None with a document open). An increment that doesn't read is an +/// error naming the field. +fn default_units_of(d: &Dialog) -> Result<Option<Value>, String> { + if !d.b("defaults") { + return Ok(None); + } + let mut du = json!({}); + for k in ["horizontalUnits", "verticalUnits", "rulerOrigin"] { + if let Some(v) = d.fields.get(k).filter(|v| v.is_string()) { + du[k] = v.clone(); + } + } + if let Some(t) = d.fields.get("keyboardIncrement").and_then(Value::as_str) { + du["keyboardIncrement"] = json!(parse_measure(t, Unit::Points).map_err(|_| format!("Cursor Key: `{t}` is not a measurement"))?); + } + if let Some(v) = d.fields.get("kerningIncrement").filter(|v| !v.is_null()) { + let n = d.n("kerningIncrement").filter(|v| v.is_finite()); + du["kerningIncrement"] = json!(n.ok_or_else(|| format!("Kerning/Tracking: `{}` is not a number", v.as_str().unwrap_or_default()))?); + } + Ok(Some(du)) +} + /// "Paragraph Style n" / "Character Style n": the first name no style has. fn next_style_name(styles: &designcraft_doc::Styles, para: bool) -> String { let (base, count) = if para { ("Paragraph Style", styles.paragraph.len()) } else { ("Character Style", styles.character.len()) }; @@ -7281,9 +7295,10 @@ mod tests { 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 value; a Kerning/Tracking of 0.5 is kept as it is. + // 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"})); + 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()); } @@ -7291,6 +7306,7 @@ mod tests { 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(); From 028e950f97aa3f310c02d9b01f51be298393a9c9 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 22 Oct 2026 19:00:30 +0200 Subject: [PATCH 137/431] Paragraph Shading and Border pages laid out as InDesign's, with Overprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paragraph Style Options › Paragraph Shading and Paragraph Border are headed with the page's name; Tint is a spinner with InDesign's presets and has Overprint beside it (shading, border stroke and gap: new paragraph attributes, read and written in IDML as ParagraphShadingOverprint, ParagraphBorderOverprint and ParagraphBorderGapOverprint; PDF export warns that it doesn't overprint yet, as for objects). Corner Size and Shape is InDesign's two-by-two grid of corner icons, size spinners and shape menus showing each shape's glyph, with the link in the middle; the offsets have spinners, with the edges and width beside them; the weights have spinners. Found in the Learn Skills Daily course, chapter 41. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 2 + crates/compose/src/parabox.rs | 25 ++- crates/doc/src/attrs.rs | 7 + crates/idml/src/export.rs | 3 + crates/idml/src/import.rs | 3 + crates/idml/src/tests.rs | 3 + crates/pdf/src/text.rs | 3 + crates/ui-egui/src/dialogs.rs | 244 ++++++++++++++++++------ crates/ui-egui/src/i18n.rs | 5 + crates/ui-egui/src/i18n/ar.rs | 5 + crates/ui-egui/src/i18n/cs.rs | 5 + crates/ui-egui/src/i18n/de.rs | 5 + crates/ui-egui/src/i18n/it.rs | 5 + crates/ui-egui/src/i18n/ja.rs | 5 + crates/ui-egui/src/i18n/ka.rs | 5 + crates/ui-egui/src/i18n/pl.rs | 5 + crates/ui-egui/src/i18n/pt_br.rs | 5 + crates/ui-egui/src/i18n/ru.rs | 5 + crates/ui-egui/src/i18n/uk.rs | 5 + crates/ui-egui/src/icons.rs | 12 ++ crates/ui-egui/src/panels/properties.rs | 12 +- docs/target-app-parity.md | 2 +- 23 files changed, 300 insertions(+), 72 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 518f8e387..ebfbbcc26 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-22** · Paragraph Style Options › Paragraph Shading and Paragraph Border are laid out as InDesign's: headed with the page's name; Tint is a spinner with InDesign's presets and has Overprint beside it (shading, border and gap; a new attribute each, kept through IDML; PDF export warns it doesn't overprint yet); Corner Size and Shape is InDesign's two-by-two grid of corner icons, sizes and shape menus showing each shape's glyph, with the link in the middle; offsets and weights have spinners. Found in the Learn Skills Daily course, chapter 41. No readiness percentage change. - **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. - **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 89ea89bd3..a9b712163 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -252,6 +252,8 @@ pub struct Deco { pub path: Option<designcraft_geom::BezPath>, /// Shown on screen only (not printed or exported). pub nonprinting: bool, + /// Set to overprint (paragraph shading or border); PDF export doesn't overprint yet. + pub overprint: bool, } #[derive(Clone, Debug, Default)] diff --git a/crates/compose/src/parabox.rs b/crates/compose/src/parabox.rs index b8b58cb44..314679d51 100644 --- a/crates/compose/src/parabox.rs +++ b/crates/compose/src/parabox.rs @@ -32,6 +32,7 @@ struct Shading { bottom: BoxBottom, clip: bool, nonprinting: bool, + overprint: bool, } #[derive(Clone, Debug, PartialEq)] @@ -51,6 +52,8 @@ struct Border { display_if_splits: bool, gap_color: String, gap_tint: f32, + overprint: bool, + gap_overprint: bool, } fn finite(v: f64) -> f64 { @@ -72,6 +75,7 @@ impl ParaBox { bottom: pp.shading_bottom, clip: pp.shading_clip, nonprinting: pp.shading_nonprinting, + overprint: pp.shading_overprint, }); let border = pp.border_on.then(|| Border { color: pp.border_color.clone(), @@ -88,6 +92,8 @@ impl ParaBox { display_if_splits: pp.border_display_if_splits, gap_color: pp.border_gap_color.clone(), gap_tint: pp.border_gap_tint, + overprint: pp.border_overprint, + gap_overprint: pp.border_gap_overprint, }); Some(ParaBox { shading, border, merge: pp.border_merge }) } @@ -149,7 +155,11 @@ fn group_decos(out: &ComposedStory, paras: std::ops::RangeInclusive<usize>, b: & // Corners stay square where the box is cut by a split. let corners = cut_corners(s.corners, !first_part, !last_part); let path = (!corners.iter().all(Corner::is_none)).then(|| box_outline(rect, &corners, [true; 4])); - decos.push((part.fi, true, Deco { rect, color: s.color.clone(), tint: s.tint, path, nonprinting: s.nonprinting })); + decos.push(( + part.fi, + true, + Deco { rect, color: s.color.clone(), tint: s.tint, path, nonprinting: s.nonprinting, overprint: s.overprint }, + )); } } if let Some(bd) = &b.border @@ -160,15 +170,14 @@ fn group_decos(out: &ComposedStory, paras: std::ops::RangeInclusive<usize>, b: & let open_top = !first_part && !bd.display_if_splits; let open_bottom = !last_part && !bd.display_if_splits; for (path, gap) in border_paths(bd, o, open_top, open_bottom) { - let (color, tint) = if gap { (bd.gap_color.clone(), bd.gap_tint) } else { (bd.color.clone(), bd.tint) }; - decos.push((part.fi, false, Deco { rect: path.bounding_box(), color, tint, path: Some(path), nonprinting: false })); + let (color, tint, overprint) = + if gap { (bd.gap_color.clone(), bd.gap_tint, bd.gap_overprint) } else { (bd.color.clone(), bd.tint, bd.overprint) }; + decos.push((part.fi, false, Deco { rect: path.bounding_box(), color, tint, path: Some(path), nonprinting: false, overprint })); } if is_plain(bd, open_top, open_bottom) { - decos.extend( - plain_edges(bd, o, open_top, open_bottom) - .into_iter() - .map(|rect| (part.fi, false, Deco { rect, color: bd.color.clone(), tint: bd.tint, path: None, nonprinting: false })), - ); + decos.extend(plain_edges(bd, o, open_top, open_bottom).into_iter().map(|rect| { + (part.fi, false, Deco { rect, color: bd.color.clone(), tint: bd.tint, path: None, nonprinting: false, overprint: bd.overprint }) + })); } } } diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index 077637672..ca90a2d31 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -1304,6 +1304,9 @@ attr_set! { shading_on: bool = false, shading_color: String = "[Black]".into(), shading_tint: f32 = 0.2, + /// Overprint (InDesign's Paragraph Shading › Overprint): stored and kept through IDML; + /// PDF export doesn't overprint yet. + shading_overprint: bool = false, shading_offsets: [f64; 4] = [0.0; 4], /// Paragraph border: stroke weights (top, left, bottom, right), colour, and offsets past the /// text (top, left, bottom, right). @@ -1311,6 +1314,8 @@ attr_set! { border_weights: [f64; 4] = [1.0; 4], border_color: String = "[Black]".into(), border_tint: f32 = 1.0, + /// Overprint the border's stroke (stored and kept through IDML, as `shading_overprint`). + border_overprint: bool = false, border_offsets: [f64; 4] = [0.0; 4], /// The border's stroke type (a document stroke style by name, or a built-in type), cap and /// join. @@ -1330,6 +1335,8 @@ attr_set! { /// The colour between the dashes, dots or stripes of a border that isn't solid. border_gap_color: String = "[None]".into(), border_gap_tint: f32 = 1.0, + /// Overprint the border's gap colour (stored and kept through IDML). + border_gap_overprint: bool = false, /// Shading corners: top left, top right, bottom right, bottom left. shading_corners: [designcraft_geom::corners::Corner; 4] = [designcraft_geom::corners::Corner::default(); 4], shading_width: BoxWidth = BoxWidth::Column, diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 99dcb383e..544b43566 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1641,6 +1641,7 @@ impl<'a> Ex<'a> { props.push(p("ParagraphShadingColor", "object", self.sw(c))); } n!(shading_tint, "ParagraphShadingTint", pct); + b!(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() { @@ -1652,6 +1653,7 @@ impl<'a> Ex<'a> { props.push(p("ParagraphBorderColor", "object", self.sw(c))); } n!(border_tint, "ParagraphBorderTint", pct); + b!(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])); @@ -1695,6 +1697,7 @@ impl<'a> Ex<'a> { props.push(p("ParagraphBorderGapColor", "object", self.sw(c))); } n!(border_gap_tint, "ParagraphBorderGapTint", pct); + b!(border_gap_overprint, "ParagraphBorderGapOverprint"); if let Some(w) = a.shading_width { el.set("ParagraphShadingWidth", names::box_width_out(w)); } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index ef504065e..fb51a6b4b 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1879,6 +1879,7 @@ impl<'r> Importer<'r> { a.shading_color = Some(self.swatch_ref(c.trim())); } a.shading_tint = tint(e.num("ParagraphShadingTint")); + a.shading_overprint = e.boolean("ParagraphShadingOverprint"); let sides = |f: &dyn Fn(&str) -> String| -> Option<[f64; 4]> { let v = ["Top", "Left", "Bottom", "Right"].map(|s| e.num(&f(s))); v.iter().any(Option::is_some).then(|| v.map(|x| x.unwrap_or(0.0))) @@ -1889,6 +1890,7 @@ impl<'r> Importer<'r> { a.border_color = Some(self.swatch_ref(c.trim())); } a.border_tint = tint(e.num("ParagraphBorderTint")); + a.border_overprint = e.boolean("ParagraphBorderOverprint"); a.border_weights = sides(&|s| format!("ParagraphBorder{s}LineWeight")); a.border_offsets = sides(&|s| format!("ParagraphBorder{s}Offset")); a.border_type = e.prop("ParagraphBorderType").map(|v| names::stroke_type_in(v.trim())); @@ -1904,6 +1906,7 @@ impl<'r> Importer<'r> { a.border_gap_color = Some(self.swatch_ref(c.trim())); } a.border_gap_tint = tint(e.num("ParagraphBorderGapTint")); + a.border_gap_overprint = e.boolean("ParagraphBorderGapOverprint"); a.shading_corners = corners(e, "ParagraphShading"); a.shading_width = e.prop("ParagraphShadingWidth").map(|v| names::box_width_in(v.trim())); a.shading_top = e.prop("ParagraphShadingTopOrigin").map(|v| names::box_top_in(v.trim())); diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 37fb2af50..13be526e5 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -333,6 +333,9 @@ fn indesign_text_defaults() -> Vec<AttrRow> { "ParagraphBorderOn": "false" => false, "true" => true, |r| r.0.border_on; "ParagraphBorderColor": "Color/Black" => "[Black]", "Swatch/None" => "[None]", |r| r.0.border_color; "ParagraphBorderTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.0.border_tint; + "ParagraphShadingOverprint": "false" => false, "true" => true, |r| r.0.shading_overprint; + "ParagraphBorderOverprint": "false" => false, "true" => true, |r| r.0.border_overprint; + "ParagraphBorderGapOverprint": "false" => false, "true" => true, |r| r.0.border_gap_overprint; "FontStyle": "Regular" => "Regular", "Bold" => "Bold", |r| r.1.font_style; "PointSize": "12" => 12.0, "9" => 9.0, |r| r.1.size; "Leading": "Auto" => Leading::Auto, "14" => Leading::Points(14.0), |r| r.1.leading; diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index 300a47d93..571a65e9b 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -237,6 +237,9 @@ impl Exporter<'_> { // Paragraph shading and rules under the text. self.as_artifact(s, |me, s| { for d in ft.decos.iter().filter(|d| !d.nonprinting) { + if d.overprint { + me.warn("overprint is not exported yet"); + } if let Some(c) = me.swatch_color(&d.color, d.tint) { match &d.path { Some(p) => me.fill_path(s, p, c), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 432c69194..891fc01b8 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4205,8 +4205,13 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo "openType" => open_type_features(&app.ui.language, ui, d, &CharFields { base: &cv, sparse: false }), "underline" => line_options(&app.ui.language, ui, d, &CharFields { base: &cv, sparse: false }, &doc, "underline"), "strikethrough" => line_options(&app.ui.language, ui, d, &CharFields { base: &cv, sparse: false }, &doc, "strikethrough"), - "border" => border_shading(&app.ui.language, ui, d, &pv, &doc, true), - "shading" => border_shading(&app.ui.language, ui, d, &pv, &doc, false), + "border" | "shading" => { + let border = d.s("section") == "border"; + let title = if border { "Paragraph Border" } else { "Paragraph Shading" }; + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, title)).size(14.0)); + ui.add_space(6.0); + border_shading(&app.ui.language, ui, d, &pv, &doc, border); + } "keep" => keep_options(&app.ui.language, ui, d, &pv), "span" => span_columns(&app.ui.language, ui, d, &pv, &doc), "bullets" => { @@ -4469,9 +4474,14 @@ fn para_measure(ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &str, unit: /// A paragraph attribute chosen from `opts` (serialized value, label). fn para_choice(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &str, opts: &[(&str, &str)]) { + para_choice_width(lang, ui, d, pv, key, opts, 200.0); +} + +/// [`para_choice`] `width` wide. +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(200.0).show_ui(ui, |ui| { + egui::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)); @@ -4585,36 +4595,63 @@ fn border_type_menu(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, d } /// Corner size and shape of a border or shading (`key` holds top left, top right, bottom right, -/// bottom left); an edited corner stores all four. +/// bottom left), as InDesign lays them out: Top Left and Top Right over Bottom Left and Bottom +/// Right, each a corner icon, a size and a shape menu showing the shapes' glyphs, with the link in +/// the middle. An edited corner stores all four. fn para_corners(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &str, units: Unit) { use designcraft_geom::corners::{Corner, CornerShape}; let mut corners: [Corner; 4] = serde_json::from_value(para_value(d, pv, key)).unwrap_or_default(); let linked = linked(d, key, corners.iter().all(|c| *c == corners[0])); let mut changed = None; let mut relinked = false; - egui::Grid::new(("pscorners", key)).num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { - for (i, label) in ["Top Left:", "Top Right:", "Bottom Right:", "Bottom Left:"].into_iter().enumerate() { - let Some(c) = corners.get_mut(i) else { continue }; - crate::rtl::label(ui, crate::i18n::tr(lang, label)); - if let Some(v) = crate::widgets::measure(ui, &format!("ps{key}{i}"), Some(c.size), units, 70.0) { - c.size = if v.is_finite() { v.clamp(0.0, 10_000.0) } else { 0.0 }; - changed = Some(i); - } - egui::ComboBox::from_id_salt(("pscornershape", key, i)) - .selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, c.shape.label()))) - .width(130.0) - .show_ui(ui, |ui| { - for shape in CornerShape::ALL { - if ui.selectable_label(c.shape == shape, crate::rtl::widget(ui, crate::i18n::tr(lang, shape.label()))).clicked() { - c.shape = shape; - changed = Some(i); - } - } + let mut corner = |ui: &mut egui::Ui, i: usize, icon: &str, tip: &str, changed: &mut Option<usize>| { + let Some(c) = corners.get_mut(i) else { return }; + 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.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) { + c.size = if v.is_finite() { v.clamp(0.0, 10_000.0) } else { 0.0 }; + *changed = Some(i); + } + let shape_label = crate::i18n::tr(lang, "Corner Shape"); + let shown = c.shape; + let resp = crate::panels::properties::preview_dropdown(ui, 52.0, shape_label, |p, r| { + crate::panels::properties::paint_corner_shape(p, r.shrink(2.0), shown, egui::Color32::BLACK) + }); + egui::Popup::menu(&resp).show(|ui| { + for shape in CornerShape::ALL { + let row = crate::panels::properties::preview_row(ui, c.shape == shape, crate::i18n::tr(lang, shape.label()), 22.0, |p, r, ink| { + crate::panels::properties::paint_corner_shape(p, r, shape, ink) }); - // Linked again, the four take Top Left's size and shape. - if i == 0 && link_toggle(lang, ui, d, key, linked) { - relinked = true; + if row.clicked() { + c.shape = shape; + *changed = Some(i); + ui.close(); + } } + }); + }; + egui::Grid::new(("pscorners", key)).num_columns(3).spacing([14.0, 6.0]).show(ui, |ui| { + for (row, [(li, licon, ltip), (ri, ricon, rtip)]) in [ + [(0, "corner-tl", "Top Left Corner"), (1, "corner-tr", "Top Right Corner")], + [(3, "corner-bl", "Bottom Left Corner"), (2, "corner-br", "Bottom Right Corner")], + ] + .into_iter() + .enumerate() + { + ui.horizontal(|ui| corner(ui, li, licon, ltip, &mut changed)); + // Linked again, the four take Top Left's size and shape. + if row == 0 { + if link_toggle(lang, ui, d, key, linked) { + relinked = true; + } + } else { + ui.label(""); + } + ui.horizontal(|ui| corner(ui, ri, ricon, rtip, &mut changed)); ui.end_row(); } }); @@ -4631,17 +4668,23 @@ fn para_corners(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: } } -/// The colour and tint rows of a border, its gap or shading (`keys`: colour, tint), in a grid. +/// Tint presets (%), as InDesign's tint menus list them. +const TINTS: &[f64] = &[0.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0]; + +/// The colour and tint rows of a border, its gap or shading (`keys`: colour, tint, overprint), in +/// a grid; Overprint follows the tint, as InDesign's. +#[allow(clippy::too_many_arguments)] fn para_color_tint( + lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, doc: &designcraft_doc::Document, - keys: (&str, &str), + keys: (&str, &str, &str), labels: (&str, &str), enabled: bool, ) { - let (color_key, tint_key) = keys; + let (color_key, tint_key, overprint_key) = keys; crate::rtl::label(ui, labels.0); let color = para_value(d, pv, color_key).as_str().unwrap_or("").to_string(); if let Some(w) = ui.add_enabled_ui(enabled, |ui| swatch_menu(ui, doc, ("pscolor", color_key), &color, 160.0)).inner { @@ -4649,10 +4692,18 @@ fn para_color_tint( } ui.end_row(); crate::rtl::label(ui, labels.1); - let tint = para_value(d, pv, tint_key).as_f64().map(|t| t * 100.0); - if let Some(v) = ui.add_enabled_ui(enabled, |ui| crate::widgets::number(ui, &format!("ps{tint_key}"), tint, "%", 60.0, 0)).inner { - para_set(d, tint_key, json!(if v.is_finite() { (v / 100.0).clamp(0.0, 1.0) } else { 1.0 })); - } + let tint = para_value(d, pv, tint_key).as_f64().map(|t| (t * 1000.0).round() / 10.0); + ui.add_enabled_ui(enabled, |ui| { + ui.horizontal(|ui| { + let id = format!("ps{tint_key}"); + let field = crate::widgets::NumField::number(&id, tint, "%", 0).width(96.0).spinner().presets(TINTS).step(10.0).range(0.0, 100.0); + if let Some(v) = field.show(ui) { + para_set(d, tint_key, json!(if v.is_finite() { (v / 100.0).clamp(0.0, 1.0) } else { 1.0 })); + } + ui.add_space(8.0); + para_check(ui, d, pv, overprint_key, crate::i18n::tr(lang, "Overprint")); + }); + }); ui.end_row(); } @@ -4670,12 +4721,17 @@ fn border_shading(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, doc crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, t)).font(semibold(12.0))); }; ui.add_space(4.0); - egui::ScrollArea::vertical().id_salt(("psbsscroll", border)).max_height(330.0).show(ui, |ui| { + egui::ScrollArea::vertical().id_salt(("psbsscroll", border)).max_height(400.0).show(ui, |ui| { ui.add_enabled_ui(on, |ui| { if border { head(ui, "Stroke"); para_sides(lang, ui, d, pv, &key("Weights"), &|ui: &mut egui::Ui, id: &str, v: Option<f64>| { - crate::widgets::number(ui, id, v, " pt", 60.0, 2).map(|w| if w.is_finite() { w.clamp(0.0, 1000.0) } else { 0.0 }) + crate::widgets::NumField::number(id, v, " pt", 2) + .width(84.0) + .spinner() + .range(0.0, 1000.0) + .show(ui) + .map(|w| if w.is_finite() { w.clamp(0.0, 1000.0) } else { 0.0 }) }); ui.add_space(4.0); } else { @@ -4688,12 +4744,12 @@ fn border_shading(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, doc ui.end_row(); } let (color, tint) = (crate::i18n::tr(lang, "Color:"), crate::i18n::tr(lang, "Tint:")); - para_color_tint(ui, d, pv, doc, (&key("Color"), &key("Tint")), (color, tint), true); + para_color_tint(lang, ui, d, pv, doc, (&key("Color"), &key("Tint"), &key("Overprint")), (color, tint), true); if border { // The gap colour shows between the dashes, dots or stripes. let solid = para_value(d, pv, "borderType").get("kind").and_then(Value::as_str).is_none_or(|k| k == "solid"); let (gap, gap_tint) = (crate::i18n::tr(lang, "Gap Color:"), crate::i18n::tr(lang, "Gap Tint:")); - para_color_tint(ui, d, pv, doc, ("borderGapColor", "borderGapTint"), (gap, gap_tint), !solid); + para_color_tint(lang, ui, d, pv, doc, ("borderGapColor", "borderGapTint", "borderGapOverprint"), (gap, gap_tint), !solid); crate::rtl::label(ui, crate::i18n::tr(lang, "Cap:")); para_choice(lang, ui, d, pv, "borderCap", &[("butt", "Butt"), ("round", "Round"), ("projecting", "Projecting")]); ui.end_row(); @@ -4704,28 +4760,45 @@ fn border_shading(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, doc }); head(ui, "Corner Size and Shape"); para_corners(lang, ui, d, pv, &key("Corners"), units); - head(ui, "Offsets"); - para_sides(lang, ui, d, pv, &key("Offsets"), &|ui: &mut egui::Ui, id: &str, v: Option<f64>| { - crate::widgets::measure(ui, id, v, units, 60.0) - }); - ui.add_space(4.0); - egui::Grid::new(("psbsedges", border)).num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(lang, "Top Edge:")); - para_choice( - lang, - ui, - d, - pv, - &key("Top"), - &[("ascent", "Ascent"), ("baseline", "Baseline"), ("emBox", "Em Box Top"), ("leading", "Leading")], - ); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(lang, "Bottom Edge:")); - para_choice(lang, ui, d, pv, &key("Bottom"), &[("descent", "Descent"), ("baseline", "Baseline"), ("emBox", "Em Box Bottom")]); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(lang, "Width:")); - para_choice(lang, ui, d, pv, &key("Width"), &[("column", "Column"), ("text", "Text")]); - ui.end_row(); + // The offsets, and beside them the edges and width the box follows, as InDesign's. + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + head(ui, "Offsets"); + para_sides(lang, ui, d, pv, &key("Offsets"), &|ui: &mut egui::Ui, id: &str, v: Option<f64>| { + crate::widgets::spin_measure(ui, id, v, units, 72.0) + }); + }); + ui.add_space(12.0); + ui.vertical(|ui| { + head(ui, ""); + egui::Grid::new(("psbsedges", border)).num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(lang, "Top Edge:")); + para_choice_width( + lang, + ui, + d, + pv, + &key("Top"), + &[("ascent", "Ascent"), ("baseline", "Baseline"), ("emBox", "Em Box Top"), ("leading", "Leading")], + 92.0, + ); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Bottom Edge:")); + para_choice_width( + lang, + ui, + d, + pv, + &key("Bottom"), + &[("descent", "Descent"), ("baseline", "Baseline"), ("emBox", "Em Box Bottom")], + 92.0, + ); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Width:")); + para_choice_width(lang, ui, d, pv, &key("Width"), &[("column", "Column"), ("text", "Text")], 92.0); + ui.end_row(); + }); + }); }); ui.add_space(4.0); if border { @@ -9276,13 +9349,13 @@ mod tests { 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"); + 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:", 0, "0p8"); + 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); @@ -9294,6 +9367,57 @@ mod tests { 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<egui::Rect> = 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 paragraph_style_options_tabs_section_adds_a_stop_on_its_ruler() { use egui_kittest::kittest::Queryable; diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index b75668873..3d0d0afb2 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1002,6 +1002,11 @@ const TABLE: &[(&str, [&str; 5])] = &[ ], ), ("Load Styles", ["Formate laden", "Charger les styles", "Cargar estilos", "スタイルを読み込み", "加载样式"]), + ("Overprint", ["Überdrucken", "Surimpression", "Sobreimprimir", "オーバープリント", "叠印"]), + ("Top Left Corner", ["Ecke oben links", "Angle supérieur gauche", "Esquina superior izquierda", "左上角", "左上角"]), + ("Top Right Corner", ["Ecke oben rechts", "Angle supérieur droit", "Esquina superior derecha", "右上角", "右上角"]), + ("Bottom Left Corner", ["Ecke unten links", "Angle inférieur gauche", "Esquina inferior izquierda", "左下角", "左下角"]), + ("Bottom Right Corner", ["Ecke unten rechts", "Angle inférieur droit", "Esquina inferior derecha", "右下角", "右下角"]), ( "Differs from this document", ["Weicht von diesem Dokument ab", "Diffère de ce document", "Difiere de este documento", "このドキュメントと異なります", "与此文档不同"], diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index fa40986ff..afcd8dfa3 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Overprint", "طباعة فوقية"), + ("Top Left Corner", "الزاوية العلوية اليسرى"), + ("Top Right Corner", "الزاوية العلوية اليمنى"), + ("Bottom Left Corner", "الزاوية السفلية اليسرى"), + ("Bottom Right Corner", "الزاوية السفلية اليمنى"), ("Stroke Type", "نوع الحد"), ("Corner Shape", "شكل الزاوية"), ("Paste Vector Art", "لصق رسم متجه"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 0e7ac2c28..261ee3af6 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Overprint", "Přetisk"), + ("Top Left Corner", "Levý horní roh"), + ("Top Right Corner", "Pravý horní roh"), + ("Bottom Left Corner", "Levý dolní roh"), + ("Bottom Right Corner", "Pravý dolní roh"), ("Stroke Type", "Typ tahu"), ("Corner Shape", "Tvar rohu"), ("Reverse Path", "Obrátit cestu"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index fc1b2c8a6..708943cd6 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -31,6 +31,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Ausschneiden"), ("Copy", "Kopieren"), ("Paste", "Einfügen"), + ("Overprint", "Überdrucken"), + ("Top Left Corner", "Ecke oben links"), + ("Top Right Corner", "Ecke oben rechts"), + ("Bottom Left Corner", "Ecke unten links"), + ("Bottom Right Corner", "Ecke unten rechts"), ("Stroke Type", "Konturtyp"), ("Corner Shape", "Eckenform"), ("Inches Decimal", "Zoll dezimal"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 56471912c..274127a76 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Overprint", "Sovrastampa"), + ("Top Left Corner", "Angolo superiore sinistro"), + ("Top Right Corner", "Angolo superiore destro"), + ("Bottom Left Corner", "Angolo inferiore sinistro"), + ("Bottom Right Corner", "Angolo inferiore destro"), ("Stroke Type", "Tipo traccia"), ("Corner Shape", "Forma angolo"), ("Paste Vector Art", "Incolla grafica vettoriale"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 7de0d4d3f..9a8d9e9f0 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Overprint", "オーバープリント"), + ("Top Left Corner", "左上角"), + ("Top Right Corner", "右上角"), + ("Bottom Left Corner", "左下角"), + ("Bottom Right Corner", "右下角"), ("Stroke Type", "線の種類"), ("Corner Shape", "角のシェイプ"), ("Paste Vector Art", "ベクトルアートをペースト"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index a9c1c245c..c77354343 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Overprint", "ზედაბეჭდვა"), + ("Top Left Corner", "ზედა მარცხენა კუთხე"), + ("Top Right Corner", "ზედა მარჯვენა კუთხე"), + ("Bottom Left Corner", "ქვედა მარცხენა კუთხე"), + ("Bottom Right Corner", "ქვედა მარჯვენა კუთხე"), ("Stroke Type", "კონტურის ტიპი"), ("Corner Shape", "კუთხის ფორმა"), ("Inches Decimal", "ინჩები (ათობითი)"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 74d0598cd..b60abd7d4 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Overprint", "Nadruk"), + ("Top Left Corner", "Lewy górny narożnik"), + ("Top Right Corner", "Prawy górny narożnik"), + ("Bottom Left Corner", "Lewy dolny narożnik"), + ("Bottom Right Corner", "Prawy dolny narożnik"), ("Stroke Type", "Rodzaj obrysu"), ("Corner Shape", "Kształt narożnika"), ("Clear", "Wyczyść"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index a9bd3ab39..6205265f2 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Overprint", "Superimprimir"), + ("Top Left Corner", "Canto superior esquerdo"), + ("Top Right Corner", "Canto superior direito"), + ("Bottom Left Corner", "Canto inferior esquerdo"), + ("Bottom Right Corner", "Canto inferior direito"), ("Stroke Type", "Tipo de traçado"), ("Corner Shape", "Forma do canto"), ("Paste Vector Art", "Colar arte vetorial"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 5903e7c2f..e859c1448 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Overprint", "Наложение"), + ("Top Left Corner", "Левый верхний угол"), + ("Top Right Corner", "Правый верхний угол"), + ("Bottom Left Corner", "Левый нижний угол"), + ("Bottom Right Corner", "Правый нижний угол"), ("Stroke Type", "Тип обводки"), ("Corner Shape", "Форма угла"), ("Clear", "Очистить"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 59e8872fc..ea3546a65 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Overprint", "Накладання"), + ("Top Left Corner", "Лівий верхній кут"), + ("Top Right Corner", "Правий верхній кут"), + ("Bottom Left Corner", "Лівий нижній кут"), + ("Bottom Right Corner", "Правий нижній кут"), ("Stroke Type", "Тип обведення"), ("Corner Shape", "Форма кута"), ("Paste Vector Art", "Вставити векторну графіку"), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index a90fe989e..544eca689 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -938,6 +938,18 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { pen.line(&[(15.0, 13.5), (17.0, 15.5), (15.0, 17.5)]); } } + // A box with one corner marked (Top Left, Top Right, Bottom Left, Bottom Right Corner). + "corner-tl" | "corner-tr" | "corner-bl" | "corner-br" => { + pen.w = 1.0; + pen.rect(4.0, 4.0, 16.0, 16.0); + let (x, y) = match name { + "corner-tl" => (2.5, 2.5), + "corner-tr" => (13.5, 2.5), + "corner-bl" => (2.5, 13.5), + _ => (13.5, 13.5), + }; + pen.frect(x, y, x + 4.0, y + 4.0); + } "corner" => { pen.w = 1.0; for (x, y) in [(3.0, 3.0), (14.0, 3.0), (3.0, 14.0), (14.0, 14.0)] { diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 65e087167..7c135baf5 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -753,7 +753,13 @@ fn paint_stroke_type(p: &egui::Painter, r: Rect, kind: &str, c: egui::Color32) { /// A row of a preview dropdown's list, as InDesign's: a small preview of the choice (`preview_w` /// wide), then its name. Selected rows are highlighted. -fn preview_row(ui: &mut Ui, selected: bool, label: &str, preview_w: f32, paint: impl FnOnce(&egui::Painter, Rect, egui::Color32)) -> egui::Response { +pub(crate) fn preview_row( + ui: &mut Ui, + selected: bool, + label: &str, + preview_w: f32, + paint: impl FnOnce(&egui::Painter, Rect, egui::Color32), +) -> egui::Response { let t = Tokens::get(ui.ctx()); // As wide as the preview and its name, translated (at least 130 pt for the name), and as the // popup once its widest row has set it: the rows' highlights then end together. @@ -779,7 +785,7 @@ fn preview_row(ui: &mut Ui, selected: bool, label: &str, preview_w: f32, paint: } /// A dropdown whose face is a light preview box (stroke type, corner shape). -fn preview_dropdown(ui: &mut Ui, w: f32, label: &str, paint: impl FnOnce(&egui::Painter, Rect)) -> egui::Response { +pub(crate) fn preview_dropdown(ui: &mut Ui, w: f32, label: &str, paint: impl FnOnce(&egui::Painter, Rect)) -> egui::Response { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(w, FIELD_H), Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, label)); @@ -795,7 +801,7 @@ fn preview_dropdown(ui: &mut Ui, w: f32, label: &str, paint: impl FnOnce(&egui:: /// A corner shape's glyph, as InDesign draws it in its corner lists: the top edge running into the /// corner and the right edge leaving it, in a square centred in `r`. -fn paint_corner_shape(p: &egui::Painter, r: Rect, shape: CornerShape, c: egui::Color32) { +pub(crate) fn paint_corner_shape(p: &egui::Painter, r: Rect, shape: CornerShape, c: egui::Color32) { let side = (r.width() - 3.0).min(r.height() - 3.0).max(4.0); let o = pos2(r.center().x - side / 2.0, r.center().y - side / 2.0); let at = |x: f32, y: f32| pos2(o.x + x * side, o.y + y * side); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index bb7c5eb80..2a776947c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -330,7 +330,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Drop caps (`type.para {dropCapLines, dropCapChars, dropCapStyle, dropCapAlignLeft, dropCapScaleDescenders}`, Paragraph panel, Paragraph Style Options › Drop Caps and Nested Styles; IDML DropCapLines/DropCapCharacters/DropcapDetail, the drop cap style as the leading "through 1 Dropcap" nested style): the first characters (code points, as InDesign counts them, never splitting a letter from its combining marks; up to 150) are enlarged from the first line's cap height down N − 1 leadings of the text after them (up to 25 lines; grid steps on a baseline grid), the lines beside them are indented by their advance (on the right in right-to-left paragraphs), spaces right after them are set at their size without widening the indent, baselines don't move; a one-line drop cap keeps a smaller size of its own and sets its tabs at the paragraph's tab stops; Align Left Edge (on by default) moves the drop cap left by its first letter's side bearing so its ink starts at the indent; Scale for Descenders is kept but doesn't change the layout yet; a drop cap character style, and nested styles through the drop cap (Dropcap delimiter). Drawn on screen and in PDF (text in order), with caret, hit testing and selection over the enlarged letters. Vertical text sets no drop caps. - User dictionary hyphenation exceptions (`hyphenation.addException/removeException/list`, Edit ▸ Spelling ▸ User Dictionary): `ex~am~ple` breaks only at `~`, a plain word never hyphenates. - Frame Fitting Options (`object.fittingOptions`, Object ▸ Fitting ▸ Frame Fitting Options…): Auto-Fit refits on resize, fitting, Align From reference point, crop amounts; IDML FrameFittingOption. -- Paragraph borders (per-side weights, colour, tint, offsets) and shading offsets, drawn per column part of split paragraphs (also when overset); Paragraph panel controls; IDML ParagraphBorder*/ParagraphShading*Offset. +- Paragraph borders (per-side weights, colour, tint, offsets) and shading offsets, drawn per column part of split paragraphs (also when overset); Paragraph panel controls; IDML ParagraphBorder*/ParagraphShading*Offset. Overprint for the shading, the border and its gap (`shadingOverprint`, `borderOverprint`, `borderGapOverprint`; IDML ParagraphShadingOverprint, ParagraphBorderOverprint, ParagraphBorderGapOverprint) is kept; PDF export warns that it does not overprint yet, as for objects. Paragraph Style Options › Paragraph Border and Paragraph Shading are laid out as InDesign's: the page's heading, tint spinners with Overprint, corners in a two-by-two grid with shape glyph menus and the link between, spinner offsets. - Font menu laid out like InDesign's: search, a Filters row (classification: serif, slab serif, sans serif, script, monospaced, decorative, symbol, read from PANOSE, the OS/2 family class, the name, else the shape of the "l"; Show Favorites Only; Show Recently Added, fonts added in the last 30 days), the sample text (the selected text, Sample, pangrams) in three sizes, Recently Used fonts at the top (`recentFonts` preference), favourites (stars, `favoriteFonts`), and each family previewed in its own face with its format (TrueType, OpenType, variable, colour). A family of several styles shows how many and opens to list them, each previewed in its own face, and a style picked is applied as it is; Show Similar Fonts (≈) lists the families of the same class nearest by PANOSE. Western (and other-script) families come first, then Japanese, Simplified Chinese, Traditional Chinese and Korean ones, each group alphabetical and set off by a separator; the group comes from the font's own data (`meta` languages, family names in a CJK language, OS/2 code pages, else what it covers), read once (installed fonts by the font scan, without loading them). CJK families show their native names, the English name on hover; Preferences › Type › Show Font Names in English (`showFontNamesInEnglish`) shows English names throughout. Search matches either name; documents store the English name. Every family list (Character panel, Control bar, Properties, style dialogs, Find Font, Glyphs) follows this order. - Application preferences (engine `Prefs`) now persist across launches (desktop: prefs.json beside ui.json). - View ▸ Fit Selection in Window (⌥⌘=) and Find/Change ▸ Object (`find.objects`, `find.changeObjects`: by fill, stroke, weight, opacity, kind, layer, label). From 3fbaf8dfb9726c51ef1c5511c9bd966b6776ad9c Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Fri, 23 Oct 2026 14:28:48 +0200 Subject: [PATCH 138/431] Direct Selection: handles around the selected anchors only, and no bounding box As InDesign: a selected anchor shows both its direction handles, and its neighbours the handle that shapes the segment to it; with no anchor selected no handle shows. Handles that don't show can't be grabbed (one rule, select::handle_shown, for drawing and hit testing). The pen and the anchor tools still show every handle. Under the anchor tools the selection is drawn as its path and points, without the bounding box. Found in the Learn Skills Daily course, chapter 43. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/engine/src/tests.rs | 42 ++++++++++++++++++++++++++++++ crates/tools/src/select.rs | 50 +++++++++++++++++++++++++++++++++--- crates/ui-egui/src/canvas.rs | 16 +++++++++--- 4 files changed, 102 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b4e89178a..a6719bbac 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-23** · Direct Selection shows a path's direction handles as InDesign: both handles of each selected anchor and, on its neighbours, the handle shaping the segment to it; no handles with no anchor selected, and handles that don't show can't be grabbed. The tool draws the path and its points without the bounding box. Found in the Learn Skills Daily course, chapter 43. No readiness percentage change. - **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. - **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 634e22cb8..2705500bf 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1652,6 +1652,48 @@ fn a_line_fits_when_its_baseline_does() { assert!(overset(&mut s, 50.0), "a baseline below the frame still oversets the line"); } +/// The course's chapter 43: with Direct Selection, a circle's selected anchor shows its handles +/// and they drag, reshaping the curve; the handles of the anchors away from it neither show nor +/// catch a press, as in InDesign. +#[test] +fn direct_selection_drags_only_the_handles_around_the_selected_anchor() { + use designcraft_tools::{PointerEvent, PointerKind}; + let mut s = session(); + let v = ViewInfo::at_zoom(1.0); + let id = s.execute("frame.create", &json!({"rect": [100, 300, 300, 500], "shape": "ellipse"})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [id]})).unwrap(); + s.set_tool("directSelection"); + // Each anchor and its two handles, in the spread. + let anchors = |s: &Session| { + let st = s.doc().unwrap(); + let it = st.doc.item(designcraft_doc::ItemId(id)).unwrap(); + it.path.subpaths[0].anchors.iter().map(|a| (it.xf * a.p, it.xf * a.h_in, it.xf * a.h_out)).collect::<Vec<_>>() + }; + let before = anchors(&s); + let lowest = |f: &dyn Fn(f64) -> f64| (0..before.len()).min_by(|a, b| f(before[*a].0.y).total_cmp(&f(before[*b].0.y))).unwrap(); + let (bottom, top) = (lowest(&|y| -y), lowest(&|y| y)); + let press = |s: &mut Session, from: designcraft_geom::Point, to: designcraft_geom::Point| { + s.pointer(&PointerEvent::new(PointerKind::Down, from.x, from.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, (from.x + to.x) / 2.0, (from.y + to.y) / 2.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, to.x, to.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, to.x, to.y), v).unwrap(); + }; + // Select the bottom anchor. + let b = before[bottom].0; + press(&mut s, b, b); + assert_eq!(s.doc().unwrap().selection.anchors, [(designcraft_doc::ItemId(id), 0, bottom)]); + // Its handle drags: the handle moves. + let h = before[bottom].2; + press(&mut s, h, h + designcraft_geom::Vec2::new(30.0, 0.0)); + let after = anchors(&s); + assert!((after[bottom].2 - before[bottom].2).hypot() > 20.0, "the bottom handle moved: {:?} → {:?}", before[bottom], after[bottom]); + // The top anchor's handle isn't shown, so a press on it doesn't take it. + let t = before[top].2; + press(&mut s, t, t + designcraft_geom::Vec2::new(0.0, -30.0)); + let after2 = anchors(&s); + assert!((after2[top].2 - before[top].2).hypot() < 1e-6, "the top handle stays: {:?} → {:?}", before[top], after2[top]); +} + /// Direct Selection: a marquee over the top edges of two frames selects their top points, and a /// drag on one of them slants both frames in one undo step (InDesign's way to make a row of /// parallelograms). diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 5853536aa..4b501fca9 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -145,21 +145,42 @@ pub fn anchor_at(cx: &ToolContext, p: Point) -> Option<(u64, usize, usize, Optio anchor_at_in(cx, &cx.selection.items, p) } -/// [`anchor_at`] over the given items. +/// Whether the Direct Selection tool shows (and lets you drag) a direction handle of anchor `ai` +/// of a sub-path of `n` anchors, as InDesign: both handles of a selected anchor, and on its +/// neighbours the handle that shapes the segment to it (`out`: the outgoing handle). With no +/// anchor selected no handle shows. +pub fn handle_shown(n: usize, closed: bool, ai: usize, out: bool, chosen: impl Fn(usize) -> bool) -> bool { + if chosen(ai) { + return true; + } + let neighbour = if out { + if ai + 1 < n { Some(ai + 1) } else { closed.then_some(0) } + } else if ai > 0 { + Some(ai - 1) + } else { + closed.then(|| n.saturating_sub(1)) + }; + neighbour.is_some_and(chosen) +} + +/// [`anchor_at`] over the given items. Of the direction handles, only those [`handle_shown`] +/// shows are found. pub fn anchor_at_in(cx: &ToolContext, ids: &[designcraft_doc::ItemId], p: Point) -> Option<(u64, usize, usize, Option<&'static str>)> { let tol = cx.tol(5.0); for id in ids { let (Some(it), Some(xf)) = (cx.doc.item(*id), cx.item_canvas_xf(*id)) else { continue }; let m = xf * it.xf; for (si, sp) in it.path.subpaths.iter().enumerate() { + let chosen = |a: usize| cx.selection.anchors.contains(&(*id, si, a)); for (ai, a) in sp.anchors.iter().enumerate() { if ((m * a.p) - p).hypot() <= tol { return Some((id.0, si, ai, None)); } - if a.has_in() && ((m * a.h_in) - p).hypot() <= tol { + let shown = |out: bool| handle_shown(sp.anchors.len(), sp.closed, ai, out, chosen); + if a.has_in() && shown(false) && ((m * a.h_in) - p).hypot() <= tol { return Some((id.0, si, ai, Some("in"))); } - if a.has_out() && ((m * a.h_out) - p).hypot() <= tol { + if a.has_out() && shown(true) && ((m * a.h_out) - p).hypot() <= tol { return Some((id.0, si, ai, Some("out"))); } } @@ -1467,3 +1488,26 @@ fn edit_text_at(cx: &ToolContext, p: Point) -> Option<Vec<Action>> { let (_, sp) = cx.layout.spread_at(p).unwrap_or((SpreadRef::Doc(0), p)); Some(vec![Action::SwitchTool("type".into()), Action::Exec("text.placeCaret".into(), json!({"frame": id.0, "point": [sp.x, sp.y]}))]) } + +#[cfg(test)] +mod handle_tests { + use super::handle_shown; + + /// InDesign's Direct Selection shows a selected anchor's two handles, and on its neighbours + /// the handle shaping the segment to it; nothing with no anchor selected. + #[test] + fn handles_show_around_the_selected_anchors() { + let shown = |closed: bool, chosen: &[usize]| { + (0..4) + .flat_map(|ai| [false, true].map(|out| (ai, out))) + .filter(|(ai, out)| handle_shown(4, closed, *ai, *out, |a| chosen.contains(&a))) + .collect::<Vec<_>>() + }; + assert_eq!(shown(true, &[]), vec![], "no anchor selected"); + assert_eq!(shown(true, &[2]), vec![(1, true), (2, false), (2, true), (3, false)]); + // Closed, the first anchor's neighbours wrap round. + assert_eq!(shown(true, &[0]), vec![(0, false), (0, true), (1, false), (3, true)]); + // Open, the end has no neighbour beyond it. + assert_eq!(shown(false, &[0]), vec![(0, false), (0, true), (1, false)]); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 6172a5948..099d9e3e2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1242,14 +1242,16 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum } let mut union: Option<Rect> = None; let mut color = Color32::from_rgb(79, 153, 255); + let direct = matches!(app.session.tool_id(), "directSelection" | "pen" | "addAnchor" | "deleteAnchor" | "convertDirection"); for id in &sel.items { let (Some(it), Some((a, _))) = (doc.item(*id), item_canvas_xf(doc, layout, *id)) else { continue }; color = layer_color(doc, it); for poly in path_screen(it, xf, a) { painter.add(egui::Shape::line(poly, Stroke::new(1.0, color))); } - if sel.items.len() == 1 && sel.text.is_none() { - // InDesign draws a 1 pt bounding box around the selection. + // InDesign draws a 1 pt bounding box around the selection; with the Direct Selection tool, + // only the path and its points. + if sel.items.len() == 1 && sel.text.is_none() && !direct { painter.rect_stroke(xf.rect(a.transform_rect_bbox(it.bounds())), 0.0, Stroke::new(1.0, color), StrokeKind::Middle); } let b = xf.rect(a.transform_rect_bbox(it.bounds())); @@ -1277,7 +1279,9 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum } } } - let direct = matches!(app.session.tool_id(), "directSelection" | "pen" | "addAnchor" | "deleteAnchor" | "convertDirection"); + // Direct Selection shows only the handles around the selected anchors, as InDesign; the pen + // and the anchor tools show every handle they work on. + let some_handles = app.session.tool_id() == "directSelection"; if direct { // Looked up once per anchor drawn: a large marquee selection stays linear. let chosen_set: std::collections::HashSet<(designcraft_doc::ItemId, usize, usize)> = sel.anchors.iter().copied().collect(); @@ -1290,7 +1294,11 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum let p = xf.to_screen(m * an.p); // A selected anchor point is solid, the others hollow (InDesign). let chosen = chosen_set.contains(&(*id, si, ai)); - for (h, has) in [(an.h_in, an.has_in()), (an.h_out, an.has_out())] { + let shown = |out: bool| { + !some_handles + || designcraft_tools::select::handle_shown(sp.anchors.len(), sp.closed, ai, out, |a| chosen_set.contains(&(*id, si, a))) + }; + for (h, has) in [(an.h_in, an.has_in() && shown(false)), (an.h_out, an.has_out() && shown(true))] { if has { let hp = xf.to_screen(m * h); painter.line_segment([p, hp], Stroke::new(1.0, col)); From 55606d29279ab0e3c2ea1ea7fb9acef6986bd077 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Fri, 23 Oct 2026 14:37:19 +0200 Subject: [PATCH 139/431] Anchor Point(s): one point shows its frame's W and H, greyed A single selected point has no size, so Transform's W and H read 0p0. InDesign shows the object's width and height there, greyed out and not editable; with two or more points they measure (and set) the points' bounds as before. SelInfo keeps the selected objects' size from before the points replace the bounds, and the anchor Transform section shows it in disabled W/H fields when one point is selected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/panels/mod.rs | 5 ++++ crates/ui-egui/src/panels/properties.rs | 33 ++++++++++++++++++++++--- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index d1b532ff8..6cdb99f36 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -34,6 +34,9 @@ use crate::theme::Tokens; pub struct SelInfo { /// Bounds measured from the rulers' zero point (as the X/Y fields show them). pub page_rect: Rect, + /// Width and height of the selected objects themselves (with points selected, `page_rect` + /// measures the points). + pub object_size: (f64, f64), pub count: usize, pub fill: String, pub stroke: String, @@ -197,6 +200,7 @@ pub fn sel_info(app: &DesignApp) -> Option<SelInfo> { }; b = Some(b.map_or(ib, |r| r.union(ib))); } + let object_size = b.map_or((0.0, 0.0), |r| (r.width(), r.height())); // Points selected with Direct Selection: Transform measures the points, not their paths. let points = selected_anchor_points(app); let b = match points.split_first() { @@ -220,6 +224,7 @@ pub fn sel_info(app: &DesignApp) -> Option<SelInfo> { }; Some(SelInfo { page_rect: Rect::new(b.x0 - o.x, b.y0 - o.y, b.x1 - o.x, b.y1 - o.y), + object_size, count: st.selection.items.len(), fill: first.fill.swatch.clone(), stroke: first.stroke.swatch.clone(), diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 2a1d29f13..b46131385 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -644,16 +644,19 @@ fn anchor_transform_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { app.ui.ref_point = p; } let mut set = serde_json::Map::new(); - for (row, (lx, vx, kx, lw, vw, kw)) in - [("X:", x, "x", "W:", r.width(), "width"), ("Y:", y, "y", "H:", r.height(), "height")].into_iter().enumerate() - { + // 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 (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; place(ui, sub(b, 40.0, yy, 18.0, FIELD_H), |ui| widgets::caption_w(ui, lx, 18.0)); if let Some(v) = place(ui, sub(b, 59.0, yy, 54.0, FIELD_H), |ui| measure(ui, &format!("pt_{kx}"), Some(vx), u, 54.0)) { set.insert(kx.into(), json!(v)); } place(ui, sub(b, 120.0, yy, 19.0, FIELD_H), |ui| widgets::caption_w(ui, lw, 19.0)); - if let Some(v) = place(ui, sub(b, 140.0, yy, 54.0, FIELD_H), |ui| measure(ui, &format!("pt_{kw}"), Some(vw), u, 54.0)) { + let id = format!("pt_{kw}"); + let field = NumField::measure(&id, Some(vw), u).width(54.0).enabled(!single); + if let Some(v) = place(ui, sub(b, 140.0, yy, 54.0, FIELD_H), |ui| field.show(ui)) { set.insert(kw.into(), json!(v)); } } @@ -3609,6 +3612,28 @@ mod tests { 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::<Vec<_>>() + }; + 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() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From 9442f5d9c83a40728cd739a9756139da6b95a67f Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Fri, 23 Oct 2026 16:51:31 +0200 Subject: [PATCH 140/431] Selected points follow their path: Toggle Selection and Add Anchor at the start - selection.toggle dropping an item from the selection now drops its selected points too. They stayed in selection.anchors, so the arrow keys still moved points of a frame that was no longer selected. - path.appendAnchor at the start moves the sub-path's selected points up one place, as path.reverse remaps them, so they keep naming the same anchors. Found by CodeRabbit on #1657. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/edit.rs | 2 ++ crates/engine/src/cmd/object.rs | 15 +++++++++------ crates/engine/src/tests.rs | 21 +++++++++++++++++++++ 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index d0ed5cacc..1d2a9d1df 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -246,6 +246,8 @@ pub fn specs() -> Vec<CommandSpec> { st.selection.cells = None; if let Some(i) = st.selection.items.iter().position(|x| *x == id) { st.selection.items.remove(i); + // Its points go with it: arrow keys and Delete act on the selected points. + st.selection.anchors.retain(|a| a.0 != id); } else { st.selection.items.push(id); } diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 498dd5674..7e1693f86 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -3208,22 +3208,25 @@ fn path_append(s: &mut Session, p: &Value) -> Result<Value> { Some(v) if v.as_str() == Some("start") => true, Some(_) => return Err(bad("path.appendAnchor", "`at` must be `start` or `end`")), }; - s.edit(|d, _| { + s.edit(|d, sel| { path_editable(d, id, "path.appendAnchor")?; let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse(); let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; - let sp = match subpath { - Some(i) => { - usize::try_from(i).ok().and_then(|i| it.path.subpaths.get_mut(i)).ok_or_else(|| bad("path.appendAnchor", "no such subpath"))? - } - None => it.path.subpaths.last_mut().ok_or_else(|| bad("path.appendAnchor", "empty path"))?, + let si = match subpath { + Some(i) => usize::try_from(i).ok().filter(|i| *i < it.path.subpaths.len()).ok_or_else(|| bad("path.appendAnchor", "no such subpath"))?, + None => it.path.subpaths.len().checked_sub(1).ok_or_else(|| bad("path.appendAnchor", "empty path"))?, }; + let sp = &mut it.path.subpaths[si]; if sp.closed { return Err(bad("path.appendAnchor", "the subpath is closed")); } if at_start { sp.anchors.insert(0, a.transform(inv)); + // The selected points of this sub-path move up one place with the others. + for a in sel.anchors.iter_mut().filter(|a| a.0 == id && a.1 == si) { + a.2 += 1; + } } else { sp.anchors.push(a.transform(inv)); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 634e22cb8..4d3af2f81 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1687,6 +1687,27 @@ fn direct_selection_moves_the_points_of_several_frames_at_once() { assert_eq!(corners(&s, b), vec![(200.0, 300.0), (300.0, 300.0), (300.0, 400.0), (200.0, 400.0)]); } +#[test] +fn selected_points_follow_their_path() { + let mut s = session(); + let a = s.execute("frame.create", &json!({"rect": [100, 300, 200, 400]})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [200, 300, 300, 400]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"anchors": [[a, 0, 1], [b, 0, 2]]})).unwrap(); + // Shift-clicking a frame off the selection drops its points too. + s.execute("selection.toggle", &json!({"id": a})).unwrap(); + let id = |n| designcraft_doc::ItemId(n); + assert_eq!(s.doc().unwrap().selection.anchors, vec![(id(b), 0, 2)]); + // A point added before the first one: the selected point keeps pointing at the same anchor. + let l = s.execute("path.create", &json!({"anchors": [{"p": [100, 100]}, {"p": [200, 100]}, {"p": [300, 150]}]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"anchors": [[l, 0, 1]]})).unwrap(); + s.execute("path.appendAnchor", &json!({"id": l, "anchor": {"p": [50, 120]}, "at": "start"})).unwrap(); + let st = s.doc().unwrap(); + let (_, si, ai) = st.selection.anchors[0]; + let it = st.doc.item(id(l)).unwrap(); + assert_eq!((it.xf * it.path.subpaths[si].anchors[ai].p).round(), designcraft_geom::Point::new(200.0, 100.0)); + assert!(s.execute("path.appendAnchor", &json!({"id": l, "anchor": {"p": [0, 0]}, "subpath": 3})).is_err(), "no such subpath"); +} + #[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, From c6821516e5582ea26e893f38c51e15dd8c33ebcf Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Fri, 23 Oct 2026 19:32:08 +0200 Subject: [PATCH 141/431] =?UTF-8?q?Edit=20=E2=80=BA=20Clear=20with=20point?= =?UTF-8?q?s=20selected=20deletes=20the=20points,=20not=20their=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Delete key already ran path.deletePoints under Direct Selection, but Edit › Clear (the menu item, a native accelerator, or the control channel) called edit.clear, which removed every path whose points were selected. With points selected and no ids given, edit.clear now deletes those points, as InDesign's Clear does. Found by CodeRabbit on #1657. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/anchors.rs | 4 ++++ crates/engine/src/cmd/edit.rs | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/anchors.rs b/crates/engine/src/cmd/anchors.rs index 6adc9b5c6..1307ddab1 100644 --- a/crates/engine/src/cmd/anchors.rs +++ b/crates/engine/src/cmd/anchors.rs @@ -577,6 +577,10 @@ mod tests { assert_eq!(r["removed"], json!([l])); assert!(s.doc().unwrap().doc.item(ItemId(l)).is_none()); assert!(s.execute("path.deletePoints", &json!({"points": []})).is_err()); + // Edit › Clear with points selected deletes the points, not their paths. + s.execute("selection.set", &json!({"anchors": [[a, 0, 0]]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.item(ItemId(a)).unwrap().path.subpaths[0].anchors.len(), 3, "one point gone, the frame stays"); } #[test] diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 1d2a9d1df..3205dd2ba 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -289,7 +289,7 @@ pub fn specs() -> Vec<CommandSpec> { "Clear", ["Edit"], Some("Delete"), - "{ids?} — delete selected items (or the selected text or ruler guides)", + "{ids?} — delete selected items (or the selected text, points or ruler guides)", has_doc, |s, p| { if s.doc()?.selection.text.is_some() && p.get("ids").is_none() { @@ -298,6 +298,10 @@ pub fn specs() -> Vec<CommandSpec> { if p.get("ids").is_none() && s.doc()?.selection.items.is_empty() && !s.doc()?.selection.guides.is_empty() { return s.execute("guide.delete", &json!({})); } + // Points selected with Direct Selection: they go, not their paths (as InDesign). + if p.get("ids").is_none() && !s.doc()?.selection.anchors.is_empty() { + return s.execute("path.deletePoints", &json!({})); + } let ids = super::targets(s, p)?; // With a placed graphic itself selected (Direct Selection), only the graphic goes: its // frame stays, empty and selected (as in InDesign). Other items are deleted. From 8daf036a3e64424dd6aa941b2a6fa68f209b4dcd Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Fri, 23 Oct 2026 19:38:24 +0200 Subject: [PATCH 142/431] Add Anchor: an invalid subpath is an error, not the last subpath path.appendAnchor read `subpath` with as_u64, so -1 or "0" counted as omitted and the point went onto the last subpath. Only an omitted (or null) subpath means the last one now; any other value must be a valid index. Found by CodeRabbit on #1657. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/object.rs | 6 +++++- crates/engine/src/tests.rs | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 7e1693f86..13bf985fd 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -3201,7 +3201,11 @@ fn path_create(s: &mut Session, p: &Value) -> Result<Value> { fn path_append(s: &mut Session, p: &Value) -> Result<Value> { let id = super::id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?; let a = p.get("anchor").and_then(anchor_from).ok_or_else(|| bad("path.appendAnchor", "missing anchor"))?; - let subpath = p.get("subpath").and_then(Value::as_u64); + // An omitted subpath is the last one; one given must name a subpath (never falls back). + let subpath = match p.get("subpath") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_u64().ok_or_else(|| bad("path.appendAnchor", "`subpath` must be a subpath index"))?), + }; let at_start = match p.get("at") { None => false, Some(v) if v.as_str() == Some("end") => false, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4d3af2f81..baeb1b089 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1705,7 +1705,10 @@ fn selected_points_follow_their_path() { let (_, si, ai) = st.selection.anchors[0]; let it = st.doc.item(id(l)).unwrap(); assert_eq!((it.xf * it.path.subpaths[si].anchors[ai].p).round(), designcraft_geom::Point::new(200.0, 100.0)); - assert!(s.execute("path.appendAnchor", &json!({"id": l, "anchor": {"p": [0, 0]}, "subpath": 3})).is_err(), "no such subpath"); + for bad in [json!(3), json!(-1), json!("0")] { + assert!(s.execute("path.appendAnchor", &json!({"id": l, "anchor": {"p": [0, 0]}, "subpath": bad})).is_err(), "subpath {bad}"); + } + assert_eq!(s.doc().unwrap().doc.item(id(l)).unwrap().path.subpaths[0].anchors.len(), 4, "nothing appended"); } #[test] From 3886ad2fd140f3bf4c4f6d0f812d7371080b2b09 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Fri, 23 Oct 2026 20:33:44 +0200 Subject: [PATCH 143/431] Tables: Shift+Tab goes back a cell and Esc toggles text and cell selection As in InDesign (Learn Skills Daily / Adobe "How to make a table" lesson): - With the Type tool in a table cell, Shift+Tab goes to the previous cell. It went on to the next one: the tool sent a plain Tab whatever the modifiers. - Esc in a cell toggles between the text and the cell: the insertion point or text in a cell selects that cell, and selected cells select the text of the first one. Esc left the table and selected the frame. New engine command table.toggleSelection does the toggle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/engine/src/cmd/table.rs | 36 ++++++++++++++++++++++++++++++++ crates/engine/src/tests_table.rs | 26 +++++++++++++++++++++++ crates/tools/src/tests.rs | 23 ++++++++++++++++++++ crates/tools/src/text.rs | 5 +++++ 5 files changed, 91 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index d3ef17e47..e9c2b6fa3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -349,6 +349,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. - **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. +- **2026-10-23** · Tables: with the Type tool in a cell, Shift+Tab goes back a cell (it went on to the next one) and Esc toggles between the text and the cell, as in InDesign: the insertion point selects its cell, and selected cells select the text of the first one (`table.toggleSelection`). Esc left the table for the frame. No readiness percentage change. - **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. - **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change. diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 7049c2f39..5c662834c 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -403,6 +403,15 @@ fn table_specs() -> Vec<CommandSpec> { }), cmd!(noundo "table.nextCell", "Next Cell", [], None, "{}", in_cell, |s, _| step_cell(s, true)), cmd!(noundo "table.prevCell", "Previous Cell", [], None, "{}", in_cell, |s, _| step_cell(s, false)), + cmd!( + noundo "table.toggleSelection", + "Toggle Text and Cell Selection", + [], + None, + "{} — Esc in a table, as InDesign: the insertion point or text in a cell selects that cell; selected cells select the text of the first one", + in_cell, + |s, _| toggle_selection(s) + ), cmd!(query "table.get", "Get Table", [], None, "{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?} → rows, columns, cells (text, spans, fill), options", in_table_or_ids, get), ] } @@ -1537,6 +1546,33 @@ pub(crate) fn step_cell(s: &mut Session, forward: bool) -> Result<Value> { Ok(json!({"row": row, "col": col})) } +/// Esc in a table: text in a cell becomes that cell's selection, and selected cells the text of +/// the first one (InDesign toggles between the two). +fn toggle_selection(s: &mut Session) -> Result<Value> { + const ID: &str = "table.toggleSelection"; + let st = s.doc_mut()?; + let t = st.selection.text.ok_or_else(|| bad(ID, "no caret"))?; + let c = t.cell.ok_or_else(|| bad(ID, "not in a cell"))?; + let cells = match st.selection.cells { + Some(cs) => { + let cell = CellAddr { table: cs.table, row: cs.range.r0, col: cs.range.c0 }; + let len = st.doc.text_story(cs.story, Some(cell)).map_or(0, |x| x.len()); + st.selection = Selection::text(TextSel { story: cs.story, anchor: 0, focus: len, cell: Some(cell), ..t }); + false + } + None => { + st.selection = Selection { + cells: Some(TableSel { story: t.story, table: c.table, range: CellRange::cell(c.row, c.col) }), + text: Some(TextSel { anchor: 0, focus: 0, ..t }), + ..Default::default() + }; + true + } + }; + st.bump_revision(); + Ok(json!({"cells": cells})) +} + fn get(s: &mut Session, p: &Value) -> Result<Value> { let g = target(s, p, "table.get")?; let st = s.doc()?; diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index 59d2f267b..3933bd0d0 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -127,6 +127,32 @@ fn table_edit_after_anchor_removal_floors_parent_caret() { } } +/// Esc in a table toggles between the text and the cell (InDesign's Esc): the caret in a cell +/// selects that cell, and selected cells select the first one's text; Shift+Tab goes back a cell. +#[test] +fn escape_toggles_text_and_cell_selection() { + let (mut s, sid, _) = session_with_frame(); + let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + s.execute("table.nextCell", &json!({})).unwrap(); + s.execute("text.insert", &json!({"text": "Phone"})).unwrap(); + let cell = designcraft_doc::CellAddr { table: tid, row: 0, col: 1 }; + assert_eq!(s.execute("table.toggleSelection", &json!({})).unwrap(), json!({"cells": true})); + let sel = s.doc().unwrap().selection.clone(); + let cells = sel.cells.unwrap(); + assert_eq!((cells.story.0, cells.table, cells.range), (sid, tid, designcraft_doc::CellRange::cell(0, 1))); + assert_eq!(sel.text.unwrap().cell, Some(cell)); + assert_eq!(s.execute("table.toggleSelection", &json!({})).unwrap(), json!({"cells": false})); + let sel = s.doc().unwrap().selection.clone(); + assert!(sel.cells.is_none()); + let t = sel.text.unwrap(); + assert_eq!((t.cell, t.range()), (Some(cell), 0..5), "the cell's text is selected"); + s.execute("table.prevCell", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().selection.text.unwrap().cell, Some(designcraft_doc::CellAddr { col: 0, ..cell })); + // Outside a table there is nothing to toggle. + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + assert!(s.execute("table.toggleSelection", &json!({})).is_err()); +} + fn selected_merged_table(column: bool) -> (Session, u64, u64, u64) { let (mut s, sid, fid) = session_with_frame(); let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index ee3b362f1..dce3629b6 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -1452,6 +1452,29 @@ fn table_frame(degrees: f64) -> (Document, ItemId) { (d, id) } +/// In a table cell Shift+Tab goes back a cell and Esc toggles cell selection, as InDesign; out of +/// a table Tab types a tab and Esc leaves the text. +#[test] +fn type_tool_keys_in_a_table_cell() { + let (d, id) = table_frame(0.0); + let sid = d.item(id).unwrap().text_frame().unwrap().story; + let table = *d.story(sid).unwrap().tables.keys().next().unwrap(); + let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); + let cell = designcraft_doc::CellAddr { table, row: 1, col: 1 }; + let s = Selection::text(designcraft_doc::TextSel { cell: Some(cell), ..designcraft_doc::TextSel::caret(sid, 0) }); + let cx = ctx(&d, &s, &c, &l); + let mut t = create("type"); + let shift = Mods { shift: true, ..Default::default() }; + let exec = |c: &str, p: serde_json::Value| vec![Action::Exec(c.into(), p)]; + assert_eq!(t.key(&cx, ToolKey::Tab, shift), exec("table.prevCell", serde_json::json!({}))); + assert_eq!(t.key(&cx, ToolKey::Tab, Mods::default()), exec("text.insert", serde_json::json!({"text": "\t"}))); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), exec("table.toggleSelection", serde_json::json!({}))); + let s = Selection::text(designcraft_doc::TextSel::caret(sid, 0)); + let cx = ctx(&d, &s, &c, &l); + assert_eq!(t.key(&cx, ToolKey::Tab, shift), exec("text.insert", serde_json::json!({"text": "\t"}))); + assert!(matches!(t.key(&cx, ToolKey::Escape, Mods::default()).first(), Some(Action::SwitchTool(_)))); +} + #[test] fn type_tool_drags_a_table_column_border() { for degrees in [0.0, 90.0] { diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 56658f41e..79d0e9e09 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -359,6 +359,7 @@ impl Tool for TypeTool { let steps = if mods.cmd || mods.ctrl { 5.0 } else { 1.0 }; vec![Action::Exec("type.kernStep".into(), json!({"steps": sign * steps}))] }; + let in_cell = cx.selection.text.is_some_and(|t| t.cell.is_some()); match key { ToolKey::Left if mods.alt && !mods.shift => kern(-1.0), ToolKey::Right if mods.alt && !mods.shift => kern(1.0), @@ -371,7 +372,11 @@ impl Tool for TypeTool { ToolKey::Backspace => vec![Action::Exec("text.delete".into(), json!({"forward": false, "word": mods.alt}))], ToolKey::Delete => vec![Action::Exec("text.delete".into(), json!({"forward": true, "word": mods.alt}))], ToolKey::Enter => vec![Action::Exec("text.insert".into(), json!({"text": if mods.shift { "\u{2028}" } else { "\n" }}))], + // In a table cell Shift+Tab goes back a cell (Tab goes on through `text.insert`). + ToolKey::Tab if mods.shift && in_cell => vec![Action::Exec("table.prevCell".into(), json!({}))], ToolKey::Tab => vec![Action::Exec("text.insert".into(), json!({"text": "\t"}))], + // In a table Esc toggles between the cell's text and the cell, as InDesign. + ToolKey::Escape if in_cell => vec![Action::Exec("table.toggleSelection".into(), json!({}))], ToolKey::Escape => vec![Action::SwitchTool("selection".into()), Action::Exec("text.exitToFrame".into(), json!({}))], _ => vec![], } From 10cd0cac3c22862460d9e1dd024435474d32ed47 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Fri, 23 Oct 2026 20:57:03 +0200 Subject: [PATCH 144/431] Table menu in InDesign's order; Insert Table dialog laid out as InDesign's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Table menu: Insert Table… (Cmd+Alt+Shift+T), Convert Text to Table, Convert Table to Text | Cell Options | Insert, Delete, Select | Merge, Unmerge, Split Horizontally/Vertically, Convert Cell Type, Convert Rows | Distribute Rows Evenly, Distribute Columns Evenly. The Table panel item left the menu (it is in Window › Type & Tables, Shift+F9). - Cmd+Alt+Shift+T opens the Insert Table dialog, as InDesign's; it ran table.insert with its defaults, so the shortcut and the menu did different things. - The Insert Table dialog: InDesign's title, a narrow window, Body Rows and Columns then Header and Footer Rows as numbers with spinners and right-aligned labels, and a Table Style applied to the new table. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/engine/src/cmd/table.rs | 3 +- crates/ui-egui/src/dialogs.rs | 109 +++++++++++++++++++++++++-------- crates/ui-egui/src/menus.rs | 60 +++++++++++++++--- docs/target-app-parity.md | 2 +- 5 files changed, 139 insertions(+), 36 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d3ef17e47..03691beec 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -349,6 +349,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. - **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. +- **2026-10-23** · Tables: the Table menu follows InDesign's order (Cell Options before Insert, Delete and Select; Merge, Unmerge and Split before Convert Cell Type and Convert Rows; Distribute Rows Evenly before Distribute Columns Evenly), and its first item is Insert Table… with InDesign's Cmd+Alt+Shift+T, which opens the dialog (the shortcut ran `table.insert` with its defaults, behind the dialog). The Table panel stays in Window › Type & Tables (Shift+F9). The Insert Table dialog is laid out as InDesign's: Body Rows and Columns, then Header and Footer Rows, as numbers with spinners and right-aligned labels, and a Table Style applied to the new table. No readiness percentage change. - **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. - **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change. diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 7049c2f39..09f433723 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -88,7 +88,8 @@ fn table_specs() -> Vec<CommandSpec> { "table.insert", "Create Table", [], - Some("Cmd+Alt+Shift+T"), + // InDesign's Cmd+Alt+Shift+T opens the Insert Table dialog (`app.insertTableDialog`). + None, "{rows?: body rows (4), cols? | columns? (4), headerRows? (0), footerRows? (0), width?} — at the text insertion point", has_text, insert diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 40787954f..de490a157 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -137,6 +137,49 @@ fn text_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, w: f32) { } } +/// Table › Insert Table, laid out as InDesign's: Table Dimensions (body rows and columns, then +/// header and footer rows, each a number with a spinner, labels on the right edge of their +/// column) and the Table Style to apply. +fn insert_table_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let lang = &app.ui.language; + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "Table Dimensions")).font(semibold(12.0))); + let rows = [ + ("bodyRows", crate::i18n::tr(lang, "Body Rows"), 1.0), + ("columns", crate::i18n::tr_context(lang, "Columns", "table"), 1.0), + ("headerRows", crate::i18n::tr(lang, "Header Rows"), 0.0), + ("footerRows", crate::i18n::tr(lang, "Footer Rows"), 0.0), + ]; + // Body rows and columns, then (after a gap) header and footer rows; one label width for both. + for (g, pair) in rows.chunks(2).enumerate() { + if g > 0 { + ui.add_space(8.0); + } + egui::Grid::new(("ins_table", g)).num_columns(2).min_col_width(100.0).spacing([8.0, 6.0]).show(ui, |ui| { + for (key, label, min) in pair { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| crate::rtl::label(ui, format!("{label}:"))); + let field = crate::widgets::NumField::number(key, d.n(key), "", 0).width(70.0).range(*min, 10_000.0).spinner(); + if let Some(v) = field.show(ui) { + d.fields.insert((*key).into(), json!(v.round())); + } + ui.end_row(); + } + }); + } + ui.add_space(8.0); + let names: Vec<String> = app.session.active().map(|st| st.doc.styles.table.iter().map(|s| s.name.clone()).collect()).unwrap_or_default(); + let cur = Some(d.s("tableStyle")).filter(|s| !s.is_empty()).unwrap_or_else(|| designcraft_doc::BASIC_TABLE.to_string()); + ui.horizontal(|ui| { + crate::rtl::label(ui, format!("{}:", crate::i18n::tr(lang, "Table Style"))); + egui::ComboBox::from_id_salt("ins_table_style").selected_text(&cur).width(160.0).show_ui(ui, |ui| { + for n in &names { + if ui.selectable_label(*n == cur, n).clicked() { + d.fields.insert("tableStyle".into(), json!(n)); + } + } + }); + }); +} + /// Extract once when opened. Cancel is read-only; confirmation adds exactly these colours. pub(crate) fn open_color_theme(app: &mut DesignApp, params: Value) { match app.run("color.extractTheme", params) { @@ -2044,7 +2087,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "newDocument" => crate::i18n::tr(&app.ui.language, "New Document"), "frameSize" => crate::i18n::tr(&app.ui.language, "Rectangle"), "goToPage" => crate::i18n::tr(&app.ui.language, "Go to Page"), - "insertTable" => crate::i18n::tr(&app.ui.language, "Create Table"), + "insertTable" => crate::i18n::tr(&app.ui.language, "Insert Table…").trim_end_matches('…').trim_end(), "cellOptions" => crate::i18n::tr(&app.ui.language, "Cell Options"), "createGuides" => crate::i18n::tr(&app.ui.language, "Create Guides"), "marginsAndColumns" => crate::i18n::tr(&app.ui.language, "Margins and Columns"), @@ -2107,7 +2150,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let available = (ctx.content_rect().size() - frame_margin - egui::vec2(16.0, 16.0)).max(egui::Vec2::splat(1.0)); let preferred_width: f32 = match d.id.as_str() { "alert" => 440.0, - "closeDocument" | "updateLink" | "deletePages" | "newWorkspace" | "pagesPanelOptions" => 380.0, + "closeDocument" | "updateLink" | "deletePages" | "newWorkspace" | "pagesPanelOptions" | "insertTable" => 380.0, "newDocument" => 960.0, "numberingSection" => 560.0, crate::effects_dialog::ID => 720.0, @@ -2773,21 +2816,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }); } "ruby" => style_cjk::ruby_dialog(app, ui, &mut d), - "insertTable" => { - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Table Dimensions")).font(semibold(12.0))); - egui::Grid::new("ins_table").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Body Rows")); - text_field(ui, &mut d, "bodyRows", 60.0); - crate::rtl::label(ui, crate::i18n::tr_context(&app.ui.language, "Columns", "table")); - text_field(ui, &mut d, "columns", 60.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Header Rows")); - text_field(ui, &mut d, "headerRows", 60.0); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Footer Rows")); - text_field(ui, &mut d, "footerRows", 60.0); - ui.end_row(); - }); - } + "insertTable" => insert_table_dialog(app, ui, &mut d), "cellOptions" => cell_options(app, ui, &mut d), "createGuides" => create_guides_dialog(app, ui, &mut d), "marginsAndColumns" => margins_and_columns_dialog(app, ui, &mut d), @@ -3099,11 +3128,20 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> { None => Err(format!("no page \"{typed}\"")), } } - "insertTable" => app.run( - "table.insert", - json!({"rows": d.n("bodyRows").unwrap_or(4.0).max(1.0) as u64, "cols": d.n("columns").unwrap_or(4.0).max(1.0) as u64, - "headerRows": d.n("headerRows").unwrap_or(0.0).max(0.0) as u64, "footerRows": d.n("footerRows").unwrap_or(0.0).max(0.0) as u64}), - ), + "insertTable" => app + .run( + "table.insert", + json!({"rows": d.n("bodyRows").unwrap_or(4.0).max(1.0) as u64, "cols": d.n("columns").unwrap_or(4.0).max(1.0) as u64, + "headerRows": d.n("headerRows").unwrap_or(0.0).max(0.0) as u64, "footerRows": d.n("footerRows").unwrap_or(0.0).max(0.0) as u64}), + ) + .and_then(|r| { + // A table style other than [Basic Table] (which a new table has) is applied after. + let style = d.s("tableStyle"); + if style.is_empty() || style == designcraft_doc::BASIC_TABLE { + return Ok(r); + } + app.run("style.table.apply", json!({"name": style, "story": r["story"], "table": r["table"]})).map(|_| r) + }), "numberingSection" => { // On an error the dialog stays open to be corrected. let r = crate::section_options::confirm(app, &d); @@ -9608,6 +9646,27 @@ mod tests { app.session.active().and_then(|st| st.doc.styles.char_style(name).cloned()) } + /// Insert Table: the dimensions as typed, and the Table Style chosen is applied to the new + /// table; [Basic Table] (the default) leaves it unstyled. + #[test] + fn insert_table_applies_its_dimensions_and_table_style() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("style.table.create", json!({"name": "Grid"})).unwrap(); + for (style, expected) in [("Grid", "Grid"), (designcraft_doc::BASIC_TABLE, "")] { + app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text"})).unwrap(); + assert_eq!(app.run("app.insertTableDialog", json!({})).unwrap(), Value::Null); + for (field, value) in [("bodyRows", json!(5)), ("columns", json!(3)), ("headerRows", json!(1)), ("tableStyle", json!(style))] { + ctl(&mut app, "ui.dialog.set", json!({"field": field, "value": value})); + } + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + let t = app.run("table.get", json!({})).unwrap(); + let len = |k: &str| t[k].as_array().map_or(0, Vec::len); + assert_eq!((len("rows"), len("columns"), t["headerRows"].as_u64()), (6, 3, Some(1)), "{t}"); + assert_eq!(t["style"].as_str().unwrap_or(""), expected, "{t}"); + } + } + #[test] fn character_style_options_set_exactly_the_edited_attributes() { let mut app = styled_app(); @@ -9782,7 +9841,7 @@ mod tests { assert!((centred.center() - egui::pos2(600.0, 400.0)).length() < 1.0, "{centred:?}"); assert!(centred.width() < 1000.0 && centred.height() < 700.0, "the area is the dialog, not the backdrop: {centred:?}"); - let title = h.get_by_label("Create Table").rect().center(); + let title = h.get_by_label("Insert Table").rect().center(); drag(&mut h, title, title + vec2(100.0, 60.0)); let moved = rect(&h); assert!((moved.min - centred.min - vec2(100.0, 60.0)).length() < 2.0, "{centred:?} -> {moved:?}"); @@ -9792,7 +9851,7 @@ mod tests { assert_eq!(rect(&h), moved, "a drag in the body leaves the dialog in place"); // The title row stays inside the top edge. - let title = h.get_by_label("Create Table").rect().center(); + let title = h.get_by_label("Insert Table").rect().center(); drag(&mut h, title, title - vec2(0.0, 1000.0)); assert!(rect(&h).top().abs() < 1.0, "{:?}", rect(&h)); @@ -9804,7 +9863,7 @@ mod tests { assert!((rect(&h).center() - centred.center()).length() < 1.0, "{:?}", rect(&h)); // Past the left edge, about 100 px of the title row stays inside. - let title = h.get_by_label("Create Table").rect().center(); + let title = h.get_by_label("Insert Table").rect().center(); drag(&mut h, title, title - vec2(900.0, 0.0)); assert!((rect(&h).right() - 100.0).abs() < 2.0, "{:?}", rect(&h)); } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 87537abd7..f6c8bd02a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -92,7 +92,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ "{path | text} — a set saved by InDesign's Show Set (.txt): matched commands take its Default shortcuts (or none), other commands lose keys it takes → {set, imported, changed, conflictsCleared, skipped (an earlier item whose keys a later one took), unmatched, unmatchedWithoutShortcut, unreadable}", ), ("app.findChange", "Find/Change…", Some("Cmd+F"), "{}"), - ("app.insertTableDialog", "Create Table…", None, "{} — Insert Table dialog (body/header/footer rows, columns)"), + ("app.insertTableDialog", "Insert Table…", Some("Cmd+Alt+Shift+T"), "{} — Insert Table dialog (body/header/footer rows, columns, table style)"), ( "app.cellOptionsDialog", "Strokes and Fills…", @@ -713,12 +713,17 @@ pub const MENUS: &[(&str, &[&str])] = &[ ], ), ( + // InDesign's order. The Table panel is in Window › Type & Tables (Shift+F9). "Table", &[ "ui:app.insertTableDialog", "cmd:table.convertFromText", "cmd:table.convertToText", "-", + ">Cell Options", + "ui:app.cellOptionsDialog", + "<", + "-", ">Insert", "cmd:table.insertRowAbove", "cmd:table.insertRowBelow", @@ -736,11 +741,10 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:table.selectTable", "<", "-", - ">Cell Options", - "ui:app.cellOptionsDialog", - "<", "cmd:table.merge", "cmd:table.unmerge", + "cmd:table.splitHorizontally", + "cmd:table.splitVertically", ">Convert Cell Type", "ui:app.graphicCell|Convert Cell to Graphic Cell…", "cmd:table.textCell", @@ -750,12 +754,9 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:table.convertRowsToBody", "cmd:table.convertRowsToFooter", "<", - "cmd:table.splitHorizontally", - "cmd:table.splitVertically", - "cmd:table.distributeColumns", - "cmd:table.distributeRows", "-", - "ui:app.tablePanel", + "cmd:table.distributeRows", + "cmd:table.distributeColumns", ], ), ( @@ -4944,6 +4945,47 @@ mod tests { } } + #[test] + fn the_table_menu_follows_indesigns_order() { + // InDesign's Table menu with a text insertion point (Adobe's "How to make a table"). + let table = menu_tree().into_iter().find(|(m, _)| *m == "Table").unwrap().1; + let labels: Vec<String> = table + .iter() + .map(|i| match i { + Item::Sep => "-".to_string(), + Item::Sub(n, _) => format!("{n} >"), + Item::Cmd { label, .. } => label.clone(), + }) + .collect(); + let expected = [ + "Insert Table…", + "Convert Text to Table", + "Convert Table to Text", + "-", + "Cell Options >", + "-", + "Insert >", + "Delete >", + "Select >", + "-", + "Merge Cells", + "Unmerge Cells", + "Split Cell Horizontally", + "Split Cell Vertically", + "Convert Cell Type >", + "Convert Rows >", + "-", + "Distribute Rows Evenly", + "Distribute Columns Evenly", + ]; + assert_eq!(labels, expected); + // Cmd+Alt+Shift+T opens the dialog, as InDesign's; the engine command has no shortcut of + // its own. The Table panel is in Window › Type & Tables. + assert_eq!(ui_label("app.insertTableDialog").and_then(|c| c.1), Some("Cmd+Alt+Shift+T")); + assert_eq!(designcraft_engine::find_command("table.insert").unwrap().shortcut, None); + assert_eq!(ui_label("app.tablePanel").and_then(|c| c.1), Some("Shift+F9")); + } + #[test] fn the_type_menu_follows_indesigns_order() { // InDesign's Type menu, as Chris Nielsen opens it in "Numbering Page Sections" (2:09). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index bb7c5eb80..77bd6d505 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -273,7 +273,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type; Option/Alt+Left/Right kerns at the caret or tracks a selection by the Kerning/Tracking increment, ×5 with Cmd/Ctrl; words move with Cmd+Left/Right on macOS and Ctrl+Left/Right elsewhere), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. - PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions. - IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories; attributes a file leaves unset take InDesign's defaults (Keep Lines Together at start/end of paragraph, 12 pt corners, paragraph rules in Text Color, auto-sizing from the centre, numbered lists restarting in each story, alternating table fills Black 20 % / None; a tint of −1 is the colour's own; frames take the fill, stroke, corners and Text Frame Options they don't write from their object style chain, then [None]) — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`). -- Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu, Table panel, Create Table dialog, caret/typing/Tab navigation and cell selection in cells; Type tool drags of row and column borders (Shift keeps the table size, or scales every row/column on the outer edge; `table.resizeBorder`); `table.*` commands. +- Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu (in InDesign's order), Table panel, Insert Table dialog (with Table Style), caret/typing/Tab navigation and cell selection in cells; Type tool drags of row and column borders (Shift keeps the table size, or scales every row/column on the outer edge; `table.resizeBorder`); `table.*` commands. - Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline. - EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`). - Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid, with a previewing dialog), snippets, object styles, Numbering & Section Options. From cbb2a974d15057b0470aed3969e4880e50c80241 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Fri, 23 Oct 2026 21:21:32 +0200 Subject: [PATCH 145/431] Tables: InDesign's Convert Text to Table and Convert Table to Text dialogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Table › Convert Text to Table… and Convert Table to Text… open dialogs laid out as InDesign's instead of converting at once with tabs and paragraphs: - Column and Row Separator: Tab, Comma, Paragraph from the field's menu, or any characters typed. - Number of Columns (Convert Text to Table), available when both separators are the same: the cells fill rows of that many. - Table Style (Convert Text to Table), applied to the new table in the same undo step. table.convertFromText takes rowSeparator, columns and tableStyle, and any characters as separators; table.convertToText takes both separators (Table::text_with). Convert Text to Table is available only with text selected outside a table, as in InDesign. New strings are translated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/doc/src/table.rs | 9 ++- crates/engine/src/cmd/table.rs | 66 ++++++++++++++--- crates/engine/src/tests_table.rs | 44 +++++++++++ crates/ui-egui/src/dialogs.rs | 121 ++++++++++++++++++++++++++++++- crates/ui-egui/src/i18n.rs | 13 ++++ crates/ui-egui/src/i18n/ar.rs | 6 ++ crates/ui-egui/src/i18n/cs.rs | 6 ++ crates/ui-egui/src/i18n/de.rs | 6 ++ crates/ui-egui/src/i18n/fr.rs | 1 - crates/ui-egui/src/i18n/it.rs | 7 ++ crates/ui-egui/src/i18n/ja.rs | 6 ++ crates/ui-egui/src/i18n/ka.rs | 6 ++ crates/ui-egui/src/i18n/pl.rs | 6 ++ crates/ui-egui/src/i18n/pt_br.rs | 7 ++ crates/ui-egui/src/i18n/ru.rs | 6 ++ crates/ui-egui/src/i18n/uk.rs | 7 ++ crates/ui-egui/src/menus.rs | 38 +++++++++- docs/target-app-parity.md | 2 +- 19 files changed, 340 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 03691beec..28e3c17d8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -349,6 +349,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. - **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. +- **2026-10-23** · Tables: Convert Text to Table… and Convert Table to Text… open InDesign's dialogs (they converted at once, with tabs and paragraphs only). Column and Row Separator take Tab, Comma, Paragraph or any characters typed; Number of Columns, available when both separators are the same, fills rows of that many cells (a list of paragraphs into two columns); Convert Text to Table also applies a Table Style. `table.convertFromText` takes `rowSeparator`, `columns` and `tableStyle`, and `table.convertToText` its separators. Convert Text to Table is available only with text selected outside a table. No readiness percentage change. - **2026-10-23** · Tables: the Table menu follows InDesign's order (Cell Options before Insert, Delete and Select; Merge, Unmerge and Split before Convert Cell Type and Convert Rows; Distribute Rows Evenly before Distribute Columns Evenly), and its first item is Insert Table… with InDesign's Cmd+Alt+Shift+T, which opens the dialog (the shortcut ran `table.insert` with its defaults, behind the dialog). The Table panel stays in Window › Type & Tables (Shift+F9). The Insert Table dialog is laid out as InDesign's: Body Rows and Columns, then Header and Footer Rows, as numbers with spinners and right-aligned labels, and a Table Style applied to the new table. No readiness percentage change. - **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index e96aac4b7..bc9c857aa 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -1011,14 +1011,19 @@ impl Table { /// Cell texts, rows separated by `\n`, cells by `\t` (Convert Table to Text, search, agents). pub fn plain_text(&self) -> String { + self.text_with("\t", "\n") + } + + /// The cells' text, columns separated by `col` and rows by `row` (Convert Table to Text). + pub fn text_with(&self, col: &str, row: &str) -> String { let mut out = String::new(); for r in 0..self.nrows() { if r > 0 { - out.push('\n'); + out.push_str(row); } for c in 0..self.ncols() { if c > 0 { - out.push('\t'); + out.push_str(col); } if let Some(cell) = self.cell(r, c) { out.push_str(&cell.text.text.replace('\n', " ")); diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 09f433723..9b6e551a9 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -99,7 +99,7 @@ fn table_specs() -> Vec<CommandSpec> { "Convert Text to Table", ["Table"], None, - "{columnSeparator?: tab|comma, width?: table width (default: the column)} — selected paragraphs become rows", + "{columnSeparator?: tab|comma|paragraph|text (tab), rowSeparator?: paragraph|tab|comma|text (paragraph), columns?: number of columns when both separators are the same (1), tableStyle?: name, width?: table width (default: the column)} — the selected paragraphs become a table, as InDesign's Convert Text to Table", has_text, convert_from_text ), @@ -108,7 +108,7 @@ fn table_specs() -> Vec<CommandSpec> { "Convert Table to Text", ["Table"], None, - "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — cells separated by tabs, rows by paragraphs", + "{columnSeparator?: tab|comma|paragraph|text (tab), rowSeparator?: paragraph|tab|comma|text (paragraph), story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the table becomes its cells' text, as InDesign's Convert Table to Text", in_table, convert_to_text ), @@ -594,12 +594,49 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> { }) } -fn convert_from_text(s: &mut Session, p: &Value) -> Result<Value> { - let t = s.doc()?.selection.text.ok_or_else(|| bad("table.convertFromText", "no text selected"))?; - if t.cell.is_some() { - return Err(bad("table.convertFromText", "select text outside tables")); +/// A Convert Text to Table / Convert Table to Text separator: `tab`, `comma`, `paragraph` or the +/// separator's own text (InDesign's fields take any characters). +fn separator(p: &Value, key: &str, default: &str, cmd: &str) -> Result<String> { + let sep = match p.get(key) { + None | Some(Value::Null) => return Ok(default.to_string()), + Some(Value::String(s)) => match s.as_str() { + "tab" => "\t", + "comma" => ",", + "paragraph" => "\n", + other => other, + }, + Some(_) => "", + }; + if sep.is_empty() { + return Err(bad(cmd, format!("`{key}` must be tab, comma, paragraph or the separator's characters"))); } - let sep = if str_param(p, "columnSeparator") == Some("comma") { ',' } else { '\t' }; + Ok(sep.to_string()) +} + +fn convert_from_text(s: &mut Session, p: &Value) -> Result<Value> { + const ID: &str = "table.convertFromText"; + let t = s.doc()?.selection.text.ok_or_else(|| bad(ID, "no text selected"))?; + if t.cell.is_some() { + return Err(bad(ID, "select text outside tables")); + } + let (col_sep, row_sep) = (separator(p, "columnSeparator", "\t", ID)?, separator(p, "rowSeparator", "\n", ID)?); + // With one separator for both, InDesign fills rows of Number of Columns cells. + let per_row = match p.get("columns") { + None | Some(Value::Null) => 1, + Some(v) => { + v.as_u64().filter(|n| (1..=10_000).contains(n)).ok_or_else(|| bad(ID, "`columns` must be a whole number from 1 to 10000"))? as usize + } + }; + let style = match str_param(p, "tableStyle").filter(|n| *n != designcraft_doc::BASIC_TABLE) { + Some(name) => { + let styles = &s.doc()?.doc.styles; + if !styles.table.iter().any(|x| x.name == name) { + return Err(bad(ID, format!("no table style `{name}`"))); + } + Some((styles.resolve_table_style(name), styles.cell.clone())) + } + None => None, + }; let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t)); s.edit(|d, sel| { let id = d.alloc(); @@ -611,11 +648,20 @@ fn convert_from_text(s: &mut Session, p: &Value) -> Result<Value> { return Err(bad("table.convertFromText", "the selection contains a table")); } let (a, b) = (ranges[pa].start, ranges[pb].end); - let data: Vec<Vec<String>> = st.text[a..b].split('\n').map(|line| line.split(sep).map(|c| c.trim().to_string()).collect()).collect(); + let text = &st.text[a..b]; + let data: Vec<Vec<String>> = if col_sep == row_sep { + let cells: Vec<String> = text.split(col_sep.as_str()).map(|c| c.trim().to_string()).collect(); + cells.chunks(per_row).map(<[String]>::to_vec).collect() + } else { + text.split(row_sep.as_str()).map(|row| row.split(col_sep.as_str()).map(|c| c.trim().to_string()).collect()).collect() + }; let pf = st.paras[pa].clone(); let cf = st.format_after(a).clone(); let mut table = Table::from_strings(id, &data, width, &pf, &cf); table.options.direction = direction; + if let Some((ts, cells)) = &style { + ts.apply_to(&mut table, cells); + } st.delete(a..b); let anchor = st.insert_table(a, table); *sel = Selection::text(TextSel::caret(t.story, anchor)); @@ -625,9 +671,11 @@ fn convert_from_text(s: &mut Session, p: &Value) -> Result<Value> { fn convert_to_text(s: &mut Session, p: &Value) -> Result<Value> { let g = target(s, p, "table.convertToText")?; + let col_sep = separator(p, "columnSeparator", "\t", "table.convertToText")?; + let row_sep = separator(p, "rowSeparator", "\n", "table.convertToText")?; s.edit(|d, sel| { let st = d.story_mut(g.story).ok_or_else(|| bad("table.convertToText", "no story"))?; - let text = st.tables.get(&g.table).map(|t| t.plain_text()).unwrap_or_default(); + let text = st.tables.get(&g.table).map(|t| t.text_with(&col_sep, &row_sep)).unwrap_or_default(); let a = st.table_anchor(g.table).ok_or_else(|| bad("table.convertToText", "no anchor"))?; st.replace(a..a + designcraft_doc::TABLE_ANCHOR.len_utf8(), &text); *sel = Selection::text(TextSel::caret(g.story, a + text.len())); diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index 59d2f267b..8c5c98e76 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -444,6 +444,50 @@ fn convert_text_to_table_and_back() { assert_eq!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text, "Ink\tCoverage\nPlum\t80%\nSunset\t45%\nAfter"); } +/// InDesign's Convert Text to Table and Convert Table to Text separators: tab, comma, paragraph or +/// any characters; with one separator for both, rows of Number of Columns cells; a Table Style. +#[test] +fn convert_text_to_table_with_indesigns_separators() { + let (mut s, sid, _) = session_with_frame(); + let convert = |s: &mut Session, text: &str, p: Value| -> Result<Value> { + s.execute("story.setText", &json!({"story": sid, "text": format!("{text}\nAfter")})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": text.len()})).unwrap(); + s.execute("table.convertFromText", &p) + }; + let cells = |s: &mut Session, r: &Value| -> (usize, Vec<String>) { + let t = s.execute("table.get", &json!({"table": r["table"]})).unwrap(); + let texts = t["cells"].as_array().unwrap().iter().map(|c| c["text"].as_str().unwrap().to_string()).collect(); + (t["columns"].as_array().unwrap().len(), texts) + }; + // Comma-separated columns, paragraphs for rows. + let r = convert(&mut s, "Ink, Coverage\nPlum, 80%", json!({"columnSeparator": "comma"})).unwrap(); + assert_eq!(cells(&mut s, &r), (2, vec!["Ink".into(), "Coverage".into(), "Plum".into(), "80%".into()])); + check(&s); + // The lesson's list: one paragraph per cell; with two columns, two cells a row. + let list = "Name:\nAddress:\nCity:\nState:"; + let r = convert(&mut s, list, json!({"columnSeparator": "paragraph", "rowSeparator": "paragraph", "columns": 2})).unwrap(); + assert_eq!(r["rows"], 2); + assert_eq!(cells(&mut s, &r).0, 2); + let r = convert(&mut s, list, json!({"columnSeparator": "tab"})).unwrap(); + assert_eq!((r["rows"].as_u64(), cells(&mut s, &r).0), (Some(4), 1), "a column of paragraphs"); + // Any characters separate: here " | " between columns and ";" between rows. + let r = convert(&mut s, "a | b;c | d", json!({"columnSeparator": " | ", "rowSeparator": ";"})).unwrap(); + assert_eq!(cells(&mut s, &r), (2, vec!["a".into(), "b".into(), "c".into(), "d".into()])); + // A table style is applied to the new table. + s.execute("style.table.create", &json!({"name": "Grid"})).unwrap(); + let r = convert(&mut s, "x\ty", json!({"tableStyle": "Grid"})).unwrap(); + assert_eq!(s.execute("table.get", &json!({"table": r["table"]})).unwrap()["style"], "Grid"); + for bad in [json!({"columnSeparator": ""}), json!({"rowSeparator": 3}), json!({"columns": 0}), json!({"tableStyle": "Missing"})] { + assert!(convert(&mut s, "p\tq", bad.clone()).is_err(), "{bad}"); + } + check(&s); + // Back to text with a comma between columns and a tab between rows. + let r = convert(&mut s, "Plum\t80%\nSunset\t45%", json!({})).unwrap(); + s.execute("table.convertToText", &json!({"table": r["table"], "columnSeparator": "comma", "rowSeparator": "tab"})).unwrap(); + check(&s); + assert_eq!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text, "Plum,80%\tSunset,45%\nAfter"); +} + #[test] fn caret_placement_in_cells_and_cell_formatting() { let (mut s, sid, fid) = session_with_frame(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index de490a157..ee462ea48 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -34,6 +34,8 @@ impl Dialog { "goToPage" => json!({"page": "1"}), "qrCode" => json!({"type": "url", "content": "https://", "color": "[Black]"}), "insertTable" => json!({"bodyRows": 4, "columns": 4, "headerRows": 0, "footerRows": 0}), + "convertTextToTable" => json!({"columnSeparator": "tab", "rowSeparator": "paragraph", "columns": 1}), + "convertTableToText" => json!({"columnSeparator": "tab", "rowSeparator": "paragraph"}), "insertXref" => json!({"linkTo": "paragraph", "style": "", "target": "", "format": ""}), "findChange" => json!({"find": "", "change": "", "grep": false, "caseSensitive": false, "wholeWord": false, "scope": "document"}), "documentSetup" => json!({}), @@ -137,6 +139,75 @@ fn text_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, w: f32) { } } +/// Separators the Convert dialogs offer by name; anything else typed in the field is the +/// separator's own characters. +const SEPARATORS: [(&str, &str); 3] = [("tab", "Tab"), ("comma", "Comma"), ("paragraph", "Paragraph")]; + +/// A separator field, as InDesign's: type any characters, or pick Tab, Comma or Paragraph from +/// its menu. Stored as `tab`/`comma`/`paragraph` or the typed text. +fn separator_field(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, key: &str) { + let cur = d.s(key); + let name = |v: &str| SEPARATORS.iter().find(|s| s.0 == v).map(|s| crate::i18n::tr(lang, s.1).to_string()); + let mut shown = name(&cur).unwrap_or_else(|| cur.clone()); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + if ui.add(egui::TextEdit::singleline(&mut shown).id_salt(("sep", key)).desired_width(150.0)).changed() { + let v = SEPARATORS.iter().find(|s| crate::i18n::tr(lang, s.1) == shown).map_or(shown.clone(), |s| s.0.to_string()); + d.fields.insert(key.into(), json!(v)); + } + ui.menu_button("▾", |ui| { + for (v, label) in SEPARATORS { + if ui.selectable_label(cur == v, crate::i18n::tr(lang, label)).clicked() { + d.fields.insert(key.into(), json!(v)); + ui.close(); + } + } + }); + }); +} + +/// Table › Convert Text to Table and Convert Table to Text, laid out as InDesign's: Column and +/// Row Separator; converting text to a table also has Number of Columns (only when both +/// separators are the same: the cells then fill rows of that many) and Table Style. +fn convert_table_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let lang = &app.ui.language; + let to_table = d.id == "convertTextToTable"; + let label = |ui: &mut egui::Ui, text: &str, on: bool| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_enabled(on, egui::Label::new(crate::rtl::widget(ui, format!("{}:", crate::i18n::tr(lang, text))))); + }); + }; + egui::Grid::new(("convert_table", to_table)).num_columns(2).min_col_width(120.0).spacing([8.0, 6.0]).show(ui, |ui| { + label(ui, "Column Separator", true); + separator_field(lang, ui, d, "columnSeparator"); + ui.end_row(); + label(ui, "Row Separator", true); + separator_field(lang, ui, d, "rowSeparator"); + ui.end_row(); + if !to_table { + return; + } + let same = d.s("columnSeparator") == d.s("rowSeparator"); + label(ui, "Number of Columns", same); + let field = crate::widgets::NumField::number("columns", d.n("columns"), "", 0).width(70.0).range(1.0, 10_000.0).spinner().enabled(same); + if let Some(v) = field.show(ui) { + d.fields.insert("columns".into(), json!(v.round())); + } + ui.end_row(); + label(ui, "Table Style", true); + let names: Vec<String> = app.session.active().map(|st| st.doc.styles.table.iter().map(|s| s.name.clone()).collect()).unwrap_or_default(); + let cur = Some(d.s("tableStyle")).filter(|s| !s.is_empty()).unwrap_or_else(|| designcraft_doc::BASIC_TABLE.to_string()); + egui::ComboBox::from_id_salt("convert_table_style").selected_text(&cur).width(170.0).show_ui(ui, |ui| { + for n in &names { + if ui.selectable_label(*n == cur, n).clicked() { + d.fields.insert("tableStyle".into(), json!(n)); + } + } + }); + ui.end_row(); + }); +} + /// Table › Insert Table, laid out as InDesign's: Table Dimensions (body rows and columns, then /// header and footer rows, each a number with a spinner, labels on the right edge of their /// column) and the Table Style to apply. @@ -2088,6 +2159,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "frameSize" => crate::i18n::tr(&app.ui.language, "Rectangle"), "goToPage" => crate::i18n::tr(&app.ui.language, "Go to Page"), "insertTable" => crate::i18n::tr(&app.ui.language, "Insert Table…").trim_end_matches('…').trim_end(), + "convertTextToTable" => crate::i18n::tr(&app.ui.language, "Convert Text to Table"), + "convertTableToText" => crate::i18n::tr(&app.ui.language, "Convert Table to Text"), "cellOptions" => crate::i18n::tr(&app.ui.language, "Cell Options"), "createGuides" => crate::i18n::tr(&app.ui.language, "Create Guides"), "marginsAndColumns" => crate::i18n::tr(&app.ui.language, "Margins and Columns"), @@ -2150,7 +2223,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let available = (ctx.content_rect().size() - frame_margin - egui::vec2(16.0, 16.0)).max(egui::Vec2::splat(1.0)); let preferred_width: f32 = match d.id.as_str() { "alert" => 440.0, - "closeDocument" | "updateLink" | "deletePages" | "newWorkspace" | "pagesPanelOptions" | "insertTable" => 380.0, + "closeDocument" | "updateLink" | "deletePages" | "newWorkspace" | "pagesPanelOptions" | "insertTable" | "convertTableToText" => 380.0, + "convertTextToTable" => 420.0, "newDocument" => 960.0, "numberingSection" => 560.0, crate::effects_dialog::ID => 720.0, @@ -2817,6 +2891,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } "ruby" => style_cjk::ruby_dialog(app, ui, &mut d), "insertTable" => insert_table_dialog(app, ui, &mut d), + "convertTextToTable" | "convertTableToText" => convert_table_dialog(app, ui, &mut d), "cellOptions" => cell_options(app, ui, &mut d), "createGuides" => create_guides_dialog(app, ui, &mut d), "marginsAndColumns" => margins_and_columns_dialog(app, ui, &mut d), @@ -3128,6 +3203,18 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> { None => Err(format!("no page \"{typed}\"")), } } + "convertTextToTable" => { + let same = d.s("columnSeparator") == d.s("rowSeparator"); + let style = Some(d.s("tableStyle")).filter(|s| !s.is_empty() && s != designcraft_doc::BASIC_TABLE); + app.run( + "table.convertFromText", + json!({"columnSeparator": d.s("columnSeparator"), "rowSeparator": d.s("rowSeparator"), + "columns": if same { json!(d.n("columns").unwrap_or(1.0).max(1.0) as u64) } else { Value::Null }, "tableStyle": style}), + ) + } + "convertTableToText" => { + app.run("table.convertToText", json!({"columnSeparator": d.s("columnSeparator"), "rowSeparator": d.s("rowSeparator")})) + } "insertTable" => app .run( "table.insert", @@ -9667,6 +9754,38 @@ mod tests { } } + /// Convert Text to Table: available with text selected (not with a caret alone); its + /// separators and Number of Columns shape the table. Convert Table to Text takes it back with + /// the separators chosen there. + #[test] + fn convert_dialogs_use_their_separators() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let text = "Name:\nAddress:\nCity:\nState:"; + let sid = app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text", "text": text})).unwrap()["story"].clone(); + app.run("text.select", json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + assert!(!crate::menus::menu_enabled(&app, "app.convertTextToTableDialog"), "a caret alone"); + assert!(!crate::menus::menu_enabled(&app, "app.convertTableToTextDialog"), "no table"); + app.run("text.select", json!({"story": sid, "anchor": 0, "focus": text.len()})).unwrap(); + assert!(crate::menus::menu_enabled(&app, "app.convertTextToTableDialog")); + app.run("app.convertTextToTableDialog", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("convertTextToTable")); + for (field, value) in [("columnSeparator", json!("paragraph")), ("rowSeparator", json!("paragraph")), ("columns", json!(2))] { + ctl(&mut app, "ui.dialog.set", json!({"field": field, "value": value})); + } + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + let t = app.run("table.get", json!({})).unwrap(); + let len = |k: &str| t[k].as_array().map_or(0, Vec::len); + assert_eq!((len("rows"), len("columns")), (2, 2), "{t}"); + assert!(crate::menus::menu_enabled(&app, "app.convertTableToTextDialog")); + app.run("app.convertTableToTextDialog", json!({})).unwrap(); + ctl(&mut app, "ui.dialog.set", json!({"field": "columnSeparator", "value": ", "})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + let story = app.session.active().unwrap().doc.story(designcraft_doc::StoryId(sid.as_u64().unwrap())).unwrap().text.clone(); + // (The empty paragraph after a table that ends its story stays after the text.) + assert_eq!(story.trim_end(), "Name:, Address:\nCity:, State:"); + } + #[test] fn character_style_options_set_exactly_the_edited_attributes() { let mut app = styled_app(); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 6057abc97..8fab7218a 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1000,6 +1000,19 @@ const TABLE: &[(&str, [&str; 5])] = &[ ], ), ("Load Styles", ["Formate laden", "Charger les styles", "Cargar estilos", "スタイルを読み込み", "加载样式"]), + ("Tab", ["Tabulator", "Tabulation", "Tabulación", "タブ", "制表符"]), + ( + "Convert Text to Table…", + ["Text in Tabelle umwandeln …", "Convertir le texte en tableau…", "Convertir texto en tabla…", "テキストを表に変換…", "将文本转换为表格…"], + ), + ( + "Convert Table to Text…", + ["Tabelle in Text umwandeln …", "Convertir le tableau en texte…", "Convertir tabla en texto…", "表をテキストに変換…", "将表格转换为文本…"], + ), + ("Column Separator", ["Spaltentrennzeichen", "Séparateur de colonnes", "Separador de columnas", "列分解文字", "列分隔符"]), + ("Row Separator", ["Zeilentrennzeichen", "Séparateur de rangs", "Separador de filas", "行分解文字", "行分隔符"]), + ("Number of Columns", ["Spaltenanzahl", "Nombre de colonnes", "Número de columnas", "列数", "列数"]), + ("Comma", ["Komma", "Virgule", "Coma", "カンマ", "逗号"]), ( "Differs from this document", ["Weicht von diesem Dokument ab", "Diffère de ce document", "Difiere de este documento", "このドキュメントと異なります", "与此文档不同"], diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 31f7fb70d..fc4b05688 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Convert Text to Table…", "تحويل النص إلى جدول…"), + ("Convert Table to Text…", "تحويل الجدول إلى نص…"), + ("Column Separator", "فاصل الأعمدة"), + ("Row Separator", "فاصل الصفوف"), + ("Number of Columns", "عدد الأعمدة"), + ("Comma", "فاصلة"), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), ("Fixed Number", "عدد ثابت"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 4d41c30dc..f9066bee1 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Convert Text to Table…", "Převést text na tabulku…"), + ("Convert Table to Text…", "Převést tabulku na text…"), + ("Column Separator", "Oddělovač sloupců"), + ("Row Separator", "Oddělovač řádků"), + ("Number of Columns", "Počet sloupců"), + ("Comma", "Čárka"), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 4f4177cbd..0200516a9 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -31,6 +31,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Ausschneiden"), ("Copy", "Kopieren"), ("Paste", "Einfügen"), + ("Convert Text to Table…", "Text in Tabelle umwandeln …"), + ("Convert Table to Text…", "Tabelle in Text umwandeln …"), + ("Column Separator", "Spaltentrennzeichen"), + ("Row Separator", "Zeilentrennzeichen"), + ("Number of Columns", "Spaltenanzahl"), + ("Comma", "Komma"), ("Inches Decimal", "Zoll dezimal"), ("Back", "Zurück"), ("Warning", "Warnung"), diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 681230d84..b2d9f6dd4 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -351,7 +351,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Subscript:", "Indice :"), ("Superscript:", "Exposant :"), ("Swap Text Fill and Stroke", "Permuter le fond et le contour du texte"), - ("Tab", "Tabulation"), ("Table of Contents", "Table des matières"), ("Table/Convert Cell Type", "Tableau/Convertir le type de cellule"), ("Table/Delete", "Tableau/Supprimer"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 755272fe5..8471dbd5a 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Tab", "Tabulazione"), + ("Convert Text to Table…", "Converti testo in tabella…"), + ("Convert Table to Text…", "Converti tabella in testo…"), + ("Column Separator", "Separatore colonna"), + ("Row Separator", "Separatore riga"), + ("Number of Columns", "Numero di colonne"), + ("Comma", "Virgola"), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index e23ef15c7..3e8499fa7 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Convert Text to Table…", "テキストを表に変換…"), + ("Convert Table to Text…", "表をテキストに変換…"), + ("Column Separator", "列分解文字"), + ("Row Separator", "行分解文字"), + ("Number of Columns", "列数"), + ("Comma", "カンマ"), ("Paste Vector Art", "ベクトルアートをペースト"), ("Baseline Options", "ベースラインオプション"), ("Column Rules", "段落境界線"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 0300b62d7..0734bbc3e 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Convert Text to Table…", "ტექსტის ცხრილად გარდაქმნა…"), + ("Convert Table to Text…", "ცხრილის ტექსტად გარდაქმნა…"), + ("Column Separator", "სვეტების გამყოფი"), + ("Row Separator", "სტრიქონების გამყოფი"), + ("Number of Columns", "სვეტების რაოდენობა"), + ("Comma", "მძიმე"), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 7d1d12c67..03a6e5aa8 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Convert Text to Table…", "Zamień tekst na tabelę…"), + ("Convert Table to Text…", "Zamień tabelę na tekst…"), + ("Column Separator", "Separator kolumn"), + ("Row Separator", "Separator wierszy"), + ("Number of Columns", "Liczba kolumn"), + ("Comma", "Przecinek"), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index f552e9a63..1f7159e1c 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Tab", "Tabulação"), + ("Convert Text to Table…", "Converter texto em tabela…"), + ("Convert Table to Text…", "Converter tabela em texto…"), + ("Column Separator", "Separador de coluna"), + ("Row Separator", "Separador de linha"), + ("Number of Columns", "Número de colunas"), + ("Comma", "Vírgula"), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), ("Fixed Number", "Número fixo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 3f6ef1be0..a2bb57854 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Convert Text to Table…", "Преобразовать текст в таблицу…"), + ("Convert Table to Text…", "Преобразовать таблицу в текст…"), + ("Column Separator", "Разделитель столбцов"), + ("Row Separator", "Разделитель строк"), + ("Number of Columns", "Число столбцов"), + ("Comma", "Запятая"), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 9c7cc855a..2dce9d48b 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Tab", "Табуляція"), + ("Convert Text to Table…", "Перетворити текст на таблицю…"), + ("Convert Table to Text…", "Перетворити таблицю на текст…"), + ("Column Separator", "Роздільник стовпців"), + ("Row Separator", "Роздільник рядків"), + ("Number of Columns", "Кількість стовпців"), + ("Comma", "Кома"), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index f6c8bd02a..d50603c4c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -93,6 +93,13 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ), ("app.findChange", "Find/Change…", Some("Cmd+F"), "{}"), ("app.insertTableDialog", "Insert Table…", Some("Cmd+Alt+Shift+T"), "{} — Insert Table dialog (body/header/footer rows, columns, table style)"), + ( + "app.convertTextToTableDialog", + "Convert Text to Table…", + None, + "{} — Convert Text to Table dialog (column and row separators, number of columns, table style)", + ), + ("app.convertTableToTextDialog", "Convert Table to Text…", None, "{} — Convert Table to Text dialog (column and row separators)"), ( "app.cellOptionsDialog", "Strokes and Fills…", @@ -717,8 +724,8 @@ pub const MENUS: &[(&str, &[&str])] = &[ "Table", &[ "ui:app.insertTableDialog", - "cmd:table.convertFromText", - "cmd:table.convertToText", + "ui:app.convertTextToTableDialog", + "ui:app.convertTableToTextDialog", "-", ">Cell Options", "ui:app.cellOptionsDialog", @@ -1254,6 +1261,20 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value app.ui.dialog = Some(crate::dialogs::Dialog::new("findChange", json!({}))); Ok(Value::Null) } + "app.convertTextToTableDialog" => { + if !menu_enabled(app, id) { + return Some(Err("select the text to convert to a table".into())); + } + app.ui.dialog = Some(crate::dialogs::Dialog::new("convertTextToTable", p.clone())); + Ok(Value::Null) + } + "app.convertTableToTextDialog" => { + if !menu_enabled(app, id) { + return Some(Err("place the insertion point in a table to convert it to text".into())); + } + app.ui.dialog = Some(crate::dialogs::Dialog::new("convertTableToText", p.clone())); + Ok(Value::Null) + } "app.insertTableDialog" => { if app.session.active().is_none_or(|d| d.selection.text.is_none()) { return Some(Err("place the insertion point in a text frame to create a table".into())); @@ -2465,6 +2486,15 @@ pub fn menu_enabled(app: &DesignApp, id: &str) -> bool { if id == "guide.selectAll" && (!app.ui.guides || app.ui.guides_locked) { return false; } + // As InDesign's: Convert Text to Table wants selected text outside a table, Convert Table to + // Text a table. + if id == "app.convertTextToTableDialog" { + let selected = app.session.active().and_then(|d| d.selection.text).is_some_and(|t| t.cell.is_none() && !t.is_caret()); + return selected && enabled(app, "table.convertFromText"); + } + if id == "app.convertTableToTextDialog" { + return enabled(app, "table.convertToText"); + } if ui_label(id).is_some() { return app.session.active().is_some() || id.starts_with("app.") || id.starts_with("window.") || id.starts_with("help."); } @@ -4959,8 +4989,8 @@ mod tests { .collect(); let expected = [ "Insert Table…", - "Convert Text to Table", - "Convert Table to Text", + "Convert Text to Table…", + "Convert Table to Text…", "-", "Cell Options >", "-", diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 77bd6d505..e273fe286 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -273,7 +273,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type; Option/Alt+Left/Right kerns at the caret or tracks a selection by the Kerning/Tracking increment, ×5 with Cmd/Ctrl; words move with Cmd+Left/Right on macOS and Ctrl+Left/Right elsewhere), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. - PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions. - IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories; attributes a file leaves unset take InDesign's defaults (Keep Lines Together at start/end of paragraph, 12 pt corners, paragraph rules in Text Color, auto-sizing from the centre, numbered lists restarting in each story, alternating table fills Black 20 % / None; a tint of −1 is the colour's own; frames take the fill, stroke, corners and Text Frame Options they don't write from their object style chain, then [None]) — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`). -- Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu (in InDesign's order), Table panel, Insert Table dialog (with Table Style), caret/typing/Tab navigation and cell selection in cells; Type tool drags of row and column borders (Shift keeps the table size, or scales every row/column on the outer edge; `table.resizeBorder`); `table.*` commands. +- Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu (in InDesign's order), Table panel, Insert Table dialog (with Table Style), Convert Text to Table and Convert Table to Text dialogs (any separators, Number of Columns, Table Style), caret/typing/Tab navigation and cell selection in cells; Type tool drags of row and column borders (Shift keeps the table size, or scales every row/column on the outer edge; `table.resizeBorder`); `table.*` commands. - Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline. - EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`). - Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid, with a previewing dialog), snippets, object styles, Numbering & Section Options. From cb988aef83f52963fe2de505fff334734c511799 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Fri, 23 Oct 2026 21:31:06 +0200 Subject: [PATCH 146/431] Show Hidden Characters marks table cells, as InDesign Every text cell now shows its own hidden characters: spaces, tabs, paragraph returns and an end-of-story # after its text (an empty cell shows just the #). The table paragraph's return sits after the table's bottom right corner. Cells showed no marks at all, so an empty table looked like plain lines. The table marks come from table_hidden_marks (frame space), drawn with the story's own marks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 65 +++++++++++++++++++++++++++++++----- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d3ef17e47..520b7bf52 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -349,6 +349,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. - **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. +- **2026-10-23** · Show Hidden Characters marks table cells as InDesign does: every cell shows its own spaces, tabs and paragraph returns and an end-of-story # after its text (an empty cell shows just the #), and the table paragraph's ¶ sits after the table's bottom right corner. Cells showed no marks. No readiness percentage change. - **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. - **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 318f82388..388244b78 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2744,6 +2744,8 @@ fn ime_output(app: &mut DesignApp, ctx: &egui::Context, xf: &Xf) { } /// View → Show Hidden Characters: ¶ paragraph ends, » tabs, · spaces, ¬ forced line breaks, # end of story. +/// As InDesign, every table cell shows its own marks (an empty cell its #), and a table's +/// paragraph return sits after the table's bottom right corner. fn draw_hidden_characters(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout) { for story in doc.stories.values() { let cs = app.session.cache.get(doc, story.id, None); @@ -2751,19 +2753,39 @@ fn draw_hidden_characters(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { continue }; let m = a * doc.text_xf(it); let col = layer_color(doc, it); - for l in &ft.lines { - let size = (l.ascent * 0.75 * xf.zoom).clamp(6.0, 40.0) as f32; - let font = egui::FontId::proportional(size); - for (x, mark, centered) in hidden_marks(&story.text, l) { - let p = xf.to_screen(m * Point::new(x, l.baseline)); - let align = if centered { egui::Align2::CENTER_BOTTOM } else { egui::Align2::LEFT_BOTTOM }; - painter.text(p, align, mark, font.clone(), col); - } + let lines = ft + .lines + .iter() + .flat_map(|l| hidden_marks(&story.text, l).into_iter().map(move |(x, mark, c)| (Point::new(x, l.baseline), mark, c, l.ascent))); + for (p, mark, centered, ascent) in lines.chain(ft.tables.iter().flat_map(table_hidden_marks)) { + let size = (ascent * 0.75 * xf.zoom).clamp(6.0, 40.0) as f32; + let align = if centered { egui::Align2::CENTER_BOTTOM } else { egui::Align2::LEFT_BOTTOM }; + painter.text(xf.to_screen(m * p), align, mark, egui::FontId::proportional(size), col); } } } } +/// A table fragment's hidden-character marks in frame space (position at the baseline, mark, +/// centered, the line's ascent): each text cell's own marks, its # where its text ends, and the +/// table paragraph's ¶ after the last row's bottom right corner. +fn table_hidden_marks(t: &compose::TableFrag) -> Vec<(Point, &'static str, bool, f64)> { + let mut out = Vec::new(); + for c in t.cells.iter().filter(|c| c.graphic.is_none()) { + for l in c.text.frames.first().map_or(&[][..], |f| &f.lines) { + out.extend( + hidden_marks(&c.source, l) + .into_iter() + .map(|(x, mark, centered)| (c.origin + designcraft_geom::Vec2::new(x, l.baseline), mark, centered, l.ascent)), + ); + } + } + if t.last { + out.push((Point::new(t.rect.x1 + 1.0, t.rect.y1), "¶", false, 12.0)); + } + out +} + /// The hidden-character marks on line `l` of a story with text `text`: frame-space x, mark, and /// whether the mark is centered on x (spaces) or starts at it. fn hidden_marks(text: &str, l: &compose::Line) -> Vec<(f64, &'static str, bool)> { @@ -5668,6 +5690,33 @@ mod tests { 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]; + let marks = table_hidden_marks(frag); + 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: {marks:?}"); + // 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), "¶")); + } } #[cfg(test)] From f5a67336ad4fb06a5ff20e997763984bbc1fa9ec Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Fri, 23 Oct 2026 21:59:37 +0200 Subject: [PATCH 147/431] Esc on a cell selection starting in a merged cell selects that cell's text selection.cells may start at a covered grid position; the toggle now goes to the merged cell (its owner at the top left), whose story holds the text. Found by CodeRabbit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/table.rs | 8 +++++++- crates/engine/src/tests_table.rs | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 5c662834c..f07294329 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -1555,7 +1555,13 @@ fn toggle_selection(s: &mut Session) -> Result<Value> { let c = t.cell.ok_or_else(|| bad(ID, "not in a cell"))?; let cells = match st.selection.cells { Some(cs) => { - let cell = CellAddr { table: cs.table, row: cs.range.r0, col: cs.range.c0 }; + // A merged cell's text belongs to the cell at its top left. + let (row, col) = st + .doc + .story(cs.story) + .and_then(|x| x.tables.get(&cs.table)) + .map_or((cs.range.r0, cs.range.c0), |tb| tb.owner(cs.range.r0, cs.range.c0)); + let cell = CellAddr { table: cs.table, row, col }; let len = st.doc.text_story(cs.story, Some(cell)).map_or(0, |x| x.len()); st.selection = Selection::text(TextSel { story: cs.story, anchor: 0, focus: len, cell: Some(cell), ..t }); false diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index 3933bd0d0..0c7b3a50a 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -148,6 +148,12 @@ fn escape_toggles_text_and_cell_selection() { assert_eq!((t.cell, t.range()), (Some(cell), 0..5), "the cell's text is selected"); s.execute("table.prevCell", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().selection.text.unwrap().cell, Some(designcraft_doc::CellAddr { col: 0, ..cell })); + // A selection that starts inside a merged cell goes back to the merged cell's text. + s.execute("table.select", &json!({"rows": [0, 1], "cols": [0, 0]})).unwrap(); + s.execute("table.merge", &json!({})).unwrap(); + s.execute("table.select", &json!({"rows": [1, 1], "cols": [0, 1]})).unwrap(); + s.execute("table.toggleSelection", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().selection.text.unwrap().cell, Some(designcraft_doc::CellAddr { row: 0, col: 0, ..cell })); // Outside a table there is nothing to toggle. s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); assert!(s.execute("table.toggleSelection", &json!({})).is_err()); From dd2d7e6949e371b3bed6246db56b1b714de7d50c Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Fri, 23 Oct 2026 22:04:42 +0200 Subject: [PATCH 148/431] Insert Table: the style in the same step, and the engine's limits in the dialog - table.insert takes tableStyle and applies it in its own edit, so one Insert Table is one undo, and a style that is gone (or not a name) changes nothing. The dialog passes it instead of running style.table.apply afterwards. - The dialog's fields stop at table.insert's limits (500 body rows, 200 columns, 100 header or footer rows; now public constants), so the table made is the one shown. Found by CodeRabbit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/table.rs | 36 +++++++++++++++++++++++---- crates/engine/src/tests_table.rs | 20 +++++++++++++++ crates/ui-egui/src/dialogs.rs | 42 +++++++++++++++++++------------- 3 files changed, 76 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 09f433723..be6dab3bb 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -90,7 +90,7 @@ fn table_specs() -> Vec<CommandSpec> { [], // InDesign's Cmd+Alt+Shift+T opens the Insert Table dialog (`app.insertTableDialog`). None, - "{rows?: body rows (4), cols? | columns? (4), headerRows? (0), footerRows? (0), width?} — at the text insertion point", + "{rows?: body rows (4, at most 500), cols? | columns? (4, at most 200), headerRows? (0), footerRows? (0, each at most 100), tableStyle?: name, width?} — at the text insertion point, styled in the same step", has_text, insert ), @@ -564,15 +564,38 @@ fn para_direction(d: &Document, story: StoryId, offset: usize) -> designcraft_do .map_or(designcraft_doc::TextDirection::LeftToRight, |pf| d.styles.resolve_para(pf).0.direction) } +/// The most body rows, columns, and header or footer rows `table.insert` makes (the Insert Table +/// dialog's limits too). +pub const MAX_BODY_ROWS: u64 = 500; +pub const MAX_COLUMNS: u64 = 200; +pub const MAX_HEADER_FOOTER_ROWS: u64 = 100; + +/// A command's `tableStyle`: none for an absent one or [Basic Table] (a new table's look), else the +/// resolved style and the cell styles it names. Anything but an existing style's name is an error. +fn table_style_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<(designcraft_doc::TableStyle, Vec<designcraft_doc::CellStyle>)>> { + let name = match p.get("tableStyle") { + None | Some(Value::Null) => return Ok(None), + Some(Value::String(n)) if n == designcraft_doc::BASIC_TABLE => return Ok(None), + Some(Value::String(n)) => n, + Some(_) => return Err(bad(cmd, "`tableStyle` must be a table style's name")), + }; + let styles = &s.doc()?.doc.styles; + if !styles.table.iter().any(|x| x.name == *name) { + return Err(bad(cmd, format!("no table style `{name}`"))); + } + Ok(Some((styles.resolve_table_style(name), styles.cell.clone()))) +} + fn insert(s: &mut Session, p: &Value) -> Result<Value> { let t = s.doc()?.selection.text.ok_or_else(|| bad("table.insert", "no insertion point"))?; if t.cell.is_some() { return Err(bad("table.insert", "tables can't be nested in cells")); } - let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(4).clamp(1, 500) as usize; - let cols = p.get("cols").or_else(|| p.get("columns")).and_then(Value::as_u64).unwrap_or(4).clamp(1, 200) as usize; - let header = p.get("headerRows").and_then(Value::as_u64).unwrap_or(0).min(100) as usize; - let footer = p.get("footerRows").and_then(Value::as_u64).unwrap_or(0).min(100) as usize; + let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(4).clamp(1, MAX_BODY_ROWS) as usize; + let cols = p.get("cols").or_else(|| p.get("columns")).and_then(Value::as_u64).unwrap_or(4).clamp(1, MAX_COLUMNS) as usize; + let header = p.get("headerRows").and_then(Value::as_u64).unwrap_or(0).min(MAX_HEADER_FOOTER_ROWS) as usize; + let footer = p.get("footerRows").and_then(Value::as_u64).unwrap_or(0).min(MAX_HEADER_FOOTER_ROWS) as usize; + let style = table_style_param(s, p, "table.insert")?; let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t)); s.edit(|d, sel| { let id = d.alloc(); @@ -586,6 +609,9 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> { c.text.paras[0] = designcraft_doc::ParaFormat { table: None, ..pf.clone() }; c.text.chars[0].format = cf.clone(); } + if let Some((ts, cells)) = &style { + ts.apply_to(&mut table, cells); + } let r = t.range(); st.delete(r.clone()); st.insert_table(r.start, table); diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index 59d2f267b..a2a9cd824 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -426,6 +426,26 @@ fn rows_columns_merge_and_options() { assert_eq!(st.doc.story(sid).unwrap().text, "Intro"); } +/// `table.insert` styles the table in the same step; a style that isn't there, or isn't a name, +/// leaves the story alone. Its dimensions stop at the documented limits. +#[test] +fn insert_table_with_a_style_is_one_step() { + let (mut s, sid, _) = session_with_frame(); + s.execute("style.table.create", &json!({"name": "Grid"})).unwrap(); + for bad in [json!("Missing"), json!(7)] { + assert!(s.execute("table.insert", &json!({"tableStyle": bad})).is_err(), "{bad}"); + } + assert!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().tables.is_empty()); + let r = s.execute("table.insert", &json!({"rows": 900, "cols": 300, "headerRows": 150, "tableStyle": "Grid"})).unwrap(); + let t = s.execute("table.get", &json!({"table": r["table"]})).unwrap(); + assert_eq!(t["style"], "Grid"); + let n = |k: &str| t[k].as_array().unwrap().len() as u64; + use crate::cmd::table::{MAX_BODY_ROWS, MAX_COLUMNS, MAX_HEADER_FOOTER_ROWS}; + assert_eq!((n("rows"), n("columns")), (MAX_BODY_ROWS + MAX_HEADER_FOOTER_ROWS, MAX_COLUMNS)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().tables.is_empty(), "one undo"); +} + #[test] fn convert_text_to_table_and_back() { let (mut s, sid, _) = session_with_frame(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index de490a157..b23287579 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -143,11 +143,13 @@ fn text_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, w: f32) { fn insert_table_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let lang = &app.ui.language; crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "Table Dimensions")).font(semibold(12.0))); + use designcraft_engine::cmd::table::{MAX_BODY_ROWS, MAX_COLUMNS, MAX_HEADER_FOOTER_ROWS}; + // `table.insert`'s limits, so the table made is the one the fields show. let rows = [ - ("bodyRows", crate::i18n::tr(lang, "Body Rows"), 1.0), - ("columns", crate::i18n::tr_context(lang, "Columns", "table"), 1.0), - ("headerRows", crate::i18n::tr(lang, "Header Rows"), 0.0), - ("footerRows", crate::i18n::tr(lang, "Footer Rows"), 0.0), + ("bodyRows", crate::i18n::tr(lang, "Body Rows"), 1.0, MAX_BODY_ROWS), + ("columns", crate::i18n::tr_context(lang, "Columns", "table"), 1.0, MAX_COLUMNS), + ("headerRows", crate::i18n::tr(lang, "Header Rows"), 0.0, MAX_HEADER_FOOTER_ROWS), + ("footerRows", crate::i18n::tr(lang, "Footer Rows"), 0.0, MAX_HEADER_FOOTER_ROWS), ]; // Body rows and columns, then (after a gap) header and footer rows; one label width for both. for (g, pair) in rows.chunks(2).enumerate() { @@ -155,9 +157,9 @@ fn insert_table_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.add_space(8.0); } egui::Grid::new(("ins_table", g)).num_columns(2).min_col_width(100.0).spacing([8.0, 6.0]).show(ui, |ui| { - for (key, label, min) in pair { + for (key, label, min, max) in pair { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| crate::rtl::label(ui, format!("{label}:"))); - let field = crate::widgets::NumField::number(key, d.n(key), "", 0).width(70.0).range(*min, 10_000.0).spinner(); + let field = crate::widgets::NumField::number(key, d.n(key), "", 0).width(70.0).range(*min, *max as f64).spinner(); if let Some(v) = field.show(ui) { d.fields.insert((*key).into(), json!(v.round())); } @@ -3128,20 +3130,16 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> { None => Err(format!("no page \"{typed}\"")), } } - "insertTable" => app - .run( + "insertTable" => { + // The table and its style in one step; a style that is gone leaves the document alone. + let style = Some(d.s("tableStyle")).filter(|s| !s.is_empty()); + app.run( "table.insert", json!({"rows": d.n("bodyRows").unwrap_or(4.0).max(1.0) as u64, "cols": d.n("columns").unwrap_or(4.0).max(1.0) as u64, - "headerRows": d.n("headerRows").unwrap_or(0.0).max(0.0) as u64, "footerRows": d.n("footerRows").unwrap_or(0.0).max(0.0) as u64}), + "headerRows": d.n("headerRows").unwrap_or(0.0).max(0.0) as u64, "footerRows": d.n("footerRows").unwrap_or(0.0).max(0.0) as u64, + "tableStyle": style}), ) - .and_then(|r| { - // A table style other than [Basic Table] (which a new table has) is applied after. - let style = d.s("tableStyle"); - if style.is_empty() || style == designcraft_doc::BASIC_TABLE { - return Ok(r); - } - app.run("style.table.apply", json!({"name": style, "story": r["story"], "table": r["table"]})).map(|_| r) - }), + } "numberingSection" => { // On an error the dialog stays open to be corrected. let r = crate::section_options::confirm(app, &d); @@ -9664,7 +9662,17 @@ mod tests { let len = |k: &str| t[k].as_array().map_or(0, Vec::len); assert_eq!((len("rows"), len("columns"), t["headerRows"].as_u64()), (6, 3, Some(1)), "{t}"); assert_eq!(t["style"].as_str().unwrap_or(""), expected, "{t}"); + // One Insert Table, one undo. + app.run("edit.undo", json!({})).unwrap(); + assert!(app.run("table.get", json!({})).is_err(), "the table is gone"); } + // A style that no longer exists changes nothing. + let tables = |app: &DesignApp| app.session.active().unwrap().doc.stories.values().map(|s| s.tables.len()).sum::<usize>(); + app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text"})).unwrap(); + app.run("app.insertTableDialog", json!({})).unwrap(); + ctl(&mut app, "ui.dialog.set", json!({"field": "tableStyle", "value": "Gone"})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false); + assert_eq!(tables(&app), 0); } #[test] From eaef6e94f59e116b66fadc3498d00c64cafd9d62 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Fri, 23 Oct 2026 22:11:30 +0200 Subject: [PATCH 149/431] Format the conversion test Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/tests_table.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index 844d47298..8e9735337 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -497,7 +497,13 @@ fn convert_text_to_table_with_indesigns_separators() { s.execute("style.table.create", &json!({"name": "Grid"})).unwrap(); let r = convert(&mut s, "x\ty", json!({"tableStyle": "Grid"})).unwrap(); assert_eq!(s.execute("table.get", &json!({"table": r["table"]})).unwrap()["style"], "Grid"); - for bad in [json!({"columnSeparator": ""}), json!({"rowSeparator": 3}), json!({"columns": 0}), json!({"tableStyle": "Missing"}), json!({"tableStyle": 123})] { + for bad in [ + json!({"columnSeparator": ""}), + json!({"rowSeparator": 3}), + json!({"columns": 0}), + json!({"tableStyle": "Missing"}), + json!({"tableStyle": 123}), + ] { assert!(convert(&mut s, "p\tq", bad.clone()).is_err(), "{bad}"); } check(&s); From e1aa6b7f15964835cd3900e1f669d546f75c1b6c Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Fri, 23 Oct 2026 22:15:39 +0200 Subject: [PATCH 150/431] Hidden characters: one table mark, nested tables marked too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A table's glyph-less line drew its own end mark at the table's bottom right, sized to the table's height, under the new one: table lines now have no line marks (the drawn ¶ is the table paragraph's). - Marks come from frame_hidden_marks, which visits a cell's own tables, so nested tables' cells are marked too. Found by CodeRabbit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/canvas.rs | 51 ++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 388244b78..ba4ccfe15 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2753,11 +2753,7 @@ fn draw_hidden_characters(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { continue }; let m = a * doc.text_xf(it); let col = layer_color(doc, it); - let lines = ft - .lines - .iter() - .flat_map(|l| hidden_marks(&story.text, l).into_iter().map(move |(x, mark, c)| (Point::new(x, l.baseline), mark, c, l.ascent))); - for (p, mark, centered, ascent) in lines.chain(ft.tables.iter().flat_map(table_hidden_marks)) { + for (p, mark, centered, ascent) in frame_hidden_marks(&story.text, ft) { let size = (ascent * 0.75 * xf.zoom).clamp(6.0, 40.0) as f32; let align = if centered { egui::Align2::CENTER_BOTTOM } else { egui::Align2::LEFT_BOTTOM }; painter.text(xf.to_screen(m * p), align, mark, egui::FontId::proportional(size), col); @@ -2766,22 +2762,28 @@ fn draw_hidden_characters(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc } } -/// A table fragment's hidden-character marks in frame space (position at the baseline, mark, -/// centered, the line's ascent): each text cell's own marks, its # where its text ends, and the -/// table paragraph's ¶ after the last row's bottom right corner. -fn table_hidden_marks(t: &compose::TableFrag) -> Vec<(Point, &'static str, bool, f64)> { - let mut out = Vec::new(); - for c in t.cells.iter().filter(|c| c.graphic.is_none()) { - for l in c.text.frames.first().map_or(&[][..], |f| &f.lines) { - out.extend( - hidden_marks(&c.source, l) - .into_iter() - .map(|(x, mark, centered)| (c.origin + designcraft_geom::Vec2::new(x, l.baseline), mark, centered, l.ascent)), - ); +/// A frame's hidden-character marks in frame space (position at the baseline, mark, centered, +/// 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)> { + let table_lines: Vec<usize> = ft.tables.iter().map(|t| t.line).collect(); + let mut out: Vec<_> = ft + .lines + .iter() + .enumerate() + .filter(|(i, _)| !table_lines.contains(i)) + .flat_map(|(_, l)| hidden_marks(text, l).into_iter().map(move |(x, mark, c)| (Point::new(x, l.baseline), mark, c, l.ascent))) + .collect(); + for t in &ft.tables { + for c in t.cells.iter().filter(|c| c.graphic.is_none()) { + if let Some(cf) = c.text.frames.first() { + out.extend(frame_hidden_marks(&c.source, cf).into_iter().map(|(p, mark, centered, a)| (p + c.origin.to_vec2(), mark, centered, a))); + } + } + if t.last { + out.push((Point::new(t.rect.x1 + 1.0, t.rect.y1), "¶", false, 12.0)); } - } - if t.last { - out.push((Point::new(t.rect.x1 + 1.0, t.rect.y1), "¶", false, 12.0)); } out } @@ -5703,10 +5705,15 @@ mod tests { 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]; - let marks = table_hidden_marks(frag); + // 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: {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(); From 3d652b3e3e0cadd001cac65f7fb270e04b3ec846 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Fri, 23 Oct 2026 22:26:47 +0200 Subject: [PATCH 151/431] Move Anchors: a points value that is not a list is an error path.moveAnchors treated a non-list points as absent and fell back to the id/anchors form, so a request with a malformed points and an id moved that path. Found by CodeRabbit on #1657. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/object.rs | 8 +++++++- crates/engine/src/tests.rs | 4 ++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index e155f84eb..b0117eca7 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -3361,7 +3361,13 @@ fn path_close(s: &mut Session, p: &Value) -> Result<Value> { fn path_move_anchors(s: &mut Session, p: &Value) -> Result<Value> { // The anchors to move, by path: `points` (several paths) or `id` with `anchors`. let mut targets: Vec<(ItemId, Vec<(usize, usize)>)> = Vec::new(); - if let Some(points) = p.get("points").and_then(Value::as_array) { + // A `points` that isn't a list is an error, never the `id` form (which could move another path). + let points = match p.get("points") { + None | Some(Value::Null) => None, + Some(Value::Array(a)) => Some(a), + Some(_) => return Err(bad("path.moveAnchors", "`points` must be a list of [id, subpath, index]")), + }; + if let Some(points) = points { for v in points { let n = |i: usize| v.get(i).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok()); let (Some(id), Some(si), Some(ai)) = (v.get(0).and_then(Value::as_u64), n(1), n(2)) else { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index baeb1b089..a2903fc6a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1705,6 +1705,10 @@ fn selected_points_follow_their_path() { let (_, si, ai) = st.selection.anchors[0]; let it = st.doc.item(id(l)).unwrap(); assert_eq!((it.xf * it.path.subpaths[si].anchors[ai].p).round(), designcraft_geom::Point::new(200.0, 100.0)); + // `points` that isn't a list is refused, even with an `id` beside it. + for bad in [json!("x"), json!({"0": 1}), json!(5)] { + assert!(s.execute("path.moveAnchors", &json!({"points": bad, "id": l, "anchors": [[0, 0]], "dx": 5})).is_err(), "points {bad}"); + } for bad in [json!(3), json!(-1), json!("0")] { assert!(s.execute("path.appendAnchor", &json!({"id": l, "anchor": {"p": [0, 0]}, "subpath": bad})).is_err(), "subpath {bad}"); } From 5c3d25a092554fbb2b8289b1f996c664c39c4f41 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sat, 24 Oct 2026 11:06:27 +0200 Subject: [PATCH 152/431] Tables: typing with one cell selected replaces its text A cell selected with Esc (or a drag inside it) kept the caret at the start of its text, so typing went in front. With one cell (or one merged cell) selected, text.insert now selects the cell's whole text first, as InDesign replaces the contents. Found by CodeRabbit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/table.rs | 16 ++++++++++++++++ crates/engine/src/cmd/text.rs | 2 ++ crates/engine/src/tests_table.rs | 9 +++++++++ 3 files changed, 27 insertions(+) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 99931deeb..8a3c75102 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -1558,6 +1558,22 @@ pub(crate) fn step_cell(s: &mut Session, forward: bool) -> Result<Value> { Ok(json!({"row": row, "col": col})) } +/// With one cell selected (a merged one counts as one), select its whole text instead, so what is +/// typed next replaces it, as InDesign does. Other selections are left as they are. +pub(crate) fn one_selected_cell_to_text(s: &mut Session) -> Result<()> { + let st = s.doc_mut()?; + let (Some(cs), Some(t)) = (st.selection.cells, st.selection.text) else { return Ok(()) }; + let Some(tb) = st.doc.story(cs.story).and_then(|x| x.tables.get(&cs.table)) else { return Ok(()) }; + let (row, col) = tb.owner(cs.range.r0, cs.range.c0); + if tb.owner(cs.range.r1, cs.range.c1) != (row, col) { + return Ok(()); + } + let cell = CellAddr { table: cs.table, row, col }; + let len = st.doc.text_story(cs.story, Some(cell)).map_or(0, |x| x.len()); + st.selection = Selection::text(TextSel { story: cs.story, anchor: 0, focus: len, cell: Some(cell), ..t }); + Ok(()) +} + /// Esc in a table: text in a cell becomes that cell's selection, and selected cells the text of /// the first one (InDesign toggles between the two). fn toggle_selection(s: &mut Session) -> Result<Value> { diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index dbfbcfe15..305830a59 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -663,6 +663,8 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> { if text == "\t" && s.doc()?.selection.text.is_some_and(|t| t.cell.is_some()) { return super::table::step_cell(s, true); } + // Typing with one cell selected replaces its text, as InDesign. + super::table::one_selected_cell_to_text(s)?; let raw = p.get("raw").and_then(Value::as_bool).unwrap_or(false); let text = if s.prefs.typographers_quotes && !raw { smart_quotes(s, &text) } else { text }; let tracking = s.doc()?.doc.settings.track_changes; diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index 1fb1b6cdc..2557b961a 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -148,6 +148,15 @@ fn escape_toggles_text_and_cell_selection() { assert_eq!((t.cell, t.range()), (Some(cell), 0..5), "the cell's text is selected"); s.execute("table.prevCell", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().selection.text.unwrap().cell, Some(designcraft_doc::CellAddr { col: 0, ..cell })); + // With the cell selected, typing replaces its text and leaves the caret after it. + s.execute("table.nextCell", &json!({})).unwrap(); + s.execute("table.toggleSelection", &json!({})).unwrap(); + s.execute("text.insert", &json!({"text": "Fax"})).unwrap(); + let t = s.execute("table.get", &json!({"table": tid})).unwrap(); + assert_eq!(t["cells"][1]["text"], "Fax"); + let sel = s.doc().unwrap().selection.clone(); + assert!(sel.cells.is_none()); + assert_eq!(sel.text.unwrap().range(), 3..3); // A selection that starts inside a merged cell goes back to the merged cell's text. s.execute("table.select", &json!({"rows": [0, 1], "cols": [0, 0]})).unwrap(); s.execute("table.merge", &json!({})).unwrap(); From 394f4152fa3521a0694b1e1968f1bab9fdfad8ab Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sat, 24 Oct 2026 11:13:04 +0200 Subject: [PATCH 153/431] Insert Table: the dialog stays open when the table can't be made As the other dialogs do (Cell Options, Text Frame Options): on an error the message goes to the status bar and the dialog comes back with what was typed. Found by CodeRabbit on #1679. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/dialogs.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2f8e33bc7..26be062f5 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3135,14 +3135,20 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> { } } "insertTable" => { - // The table and its style in one step; a style that is gone leaves the document alone. + // The table and its style in one step; a style that is gone leaves the document alone, + // and the dialog stays open to be corrected. let style = Some(d.s("tableStyle")).filter(|s| !s.is_empty()); - app.run( + let r = app.run( "table.insert", json!({"rows": d.n("bodyRows").unwrap_or(4.0).max(1.0) as u64, "cols": d.n("columns").unwrap_or(4.0).max(1.0) as u64, "headerRows": d.n("headerRows").unwrap_or(0.0).max(0.0) as u64, "footerRows": d.n("footerRows").unwrap_or(0.0).max(0.0) as u64, "tableStyle": style}), - ) + ); + if let Err(e) = &r { + app.status(e.clone()); + app.ui.dialog = Some(d); + } + r } "numberingSection" => { // On an error the dialog stays open to be corrected. @@ -9747,6 +9753,8 @@ mod tests { ctl(&mut app, "ui.dialog.set", json!({"field": "tableStyle", "value": "Gone"})); assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false); assert_eq!(tables(&app), 0); + let d = app.ui.dialog.as_ref().expect("the dialog stays open to be corrected"); + assert_eq!((d.id.as_str(), d.s("tableStyle")), ("insertTable", "Gone".to_string())); } #[test] From ed63b8f4ab47d12238bc2dca49436f957fac3a38 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sat, 24 Oct 2026 11:17:28 +0200 Subject: [PATCH 154/431] Convert dialogs stay open when the conversion fails An emptied separator field reached table.convertFromText / convertToText, which refuse it; the dialog closed and lost what was typed. On an error the message goes to the status bar and the dialog comes back, as the other dialogs do (Insert Table included, from #1676). Found by CodeRabbit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/dialogs.rs | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0a2962991..b2e3bd714 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3210,17 +3210,24 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> { None => Err(format!("no page \"{typed}\"")), } } - "convertTextToTable" => { - let same = d.s("columnSeparator") == d.s("rowSeparator"); - let style = Some(d.s("tableStyle")).filter(|s| !s.is_empty() && s != designcraft_doc::BASIC_TABLE); - app.run( - "table.convertFromText", - json!({"columnSeparator": d.s("columnSeparator"), "rowSeparator": d.s("rowSeparator"), - "columns": if same { json!(d.n("columns").unwrap_or(1.0).max(1.0) as u64) } else { Value::Null }, "tableStyle": style}), - ) - } - "convertTableToText" => { - app.run("table.convertToText", json!({"columnSeparator": d.s("columnSeparator"), "rowSeparator": d.s("rowSeparator")})) + "convertTextToTable" | "convertTableToText" => { + let r = if d.id == "convertTextToTable" { + let same = d.s("columnSeparator") == d.s("rowSeparator"); + let style = Some(d.s("tableStyle")).filter(|s| !s.is_empty() && s != designcraft_doc::BASIC_TABLE); + app.run( + "table.convertFromText", + json!({"columnSeparator": d.s("columnSeparator"), "rowSeparator": d.s("rowSeparator"), + "columns": if same { json!(d.n("columns").unwrap_or(1.0).max(1.0) as u64) } else { Value::Null }, "tableStyle": style}), + ) + } else { + app.run("table.convertToText", json!({"columnSeparator": d.s("columnSeparator"), "rowSeparator": d.s("rowSeparator")})) + }; + // On an error (an emptied separator field) the dialog stays open to be corrected. + if let Err(e) = &r { + app.status(e.clone()); + app.ui.dialog = Some(d); + } + r } "insertTable" => { // The table and its style in one step; a style that is gone leaves the document alone, @@ -9861,6 +9868,10 @@ mod tests { assert!(crate::menus::menu_enabled(&app, "app.convertTextToTableDialog")); app.run("app.convertTextToTableDialog", json!({})).unwrap(); assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("convertTextToTable")); + // An emptied separator: the command refuses it and the dialog stays open, as typed. + ctl(&mut app, "ui.dialog.set", json!({"field": "rowSeparator", "value": ""})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false); + assert_eq!(app.ui.dialog.as_ref().map(|d| (d.id.as_str(), d.s("rowSeparator"))), Some(("convertTextToTable", String::new()))); for (field, value) in [("columnSeparator", json!("paragraph")), ("rowSeparator", json!("paragraph")), ("columns", json!(2))] { ctl(&mut app, "ui.dialog.set", json!({"field": field, "value": value})); } From 89f44620d5837bf3612b7d93c9c464fc8b3e672d Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sat, 24 Oct 2026 12:08:55 +0200 Subject: [PATCH 155/431] Insert Pages: an after past the last page appends again, as main pins; before stays strict Main's insert_pages_bounds.rs and a_page_count_insert_pages_cannot_mean_is_refused pin that an `after` past the last page appends. This branch had made it an error; `before` (new here) still has to name a page, and both together are refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/layout.rs | 14 ++++----- crates/engine/tests/it/insert_pages_bounds.rs | 30 +++++++++++-------- 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index b88419538..0bbc5eab7 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -161,7 +161,7 @@ pub fn specs() -> Vec<CommandSpec> { "Insert Pages…", ["Layout", "Pages"], Some("Cmd+Shift+P"), - "{count?: 1 (1 to 9999), after?: 0-based page index (default: the last page), before?: 0-based page index (instead of `after`; 0: at the start), parent?: id|\"A-Parent\"|\"A\"|null}", + "{count?: 1 (1 to 9999), after?: 0-based page index (default: the last page; past it: at the end), before?: 0-based page index (instead of `after`; 0: at the start), parent?: id|\"A-Parent\"|\"A\"|null}", has_doc, |s, p| { const ID: &str = "layout.pages.insert"; @@ -181,15 +181,16 @@ pub fn specs() -> Vec<CommandSpec> { if p.get("after").is_some() && p.get("before").is_some() { return Err(bad("layout.pages.insert", "give `after` or `before`, not both")); } - // A page of the document: one past the last isn't, and would insert at the end. + // `before` names a page of the document; an `after` past the last page appends + // (`insert_pages_bounds.rs` pins that). let index = |k: &str| match p.get(k) { None => Ok(None), Some(v) => v .as_u64() - .and_then(|v| usize::try_from(v).ok()) - .filter(|&i| i < n) - .map(Some) - .ok_or_else(|| bad("layout.pages.insert", format!("`{k}` must be the index of one of the {n} pages, not {v}"))), + .map(|v| usize::try_from(v).unwrap_or(usize::MAX)) + .filter(|&i| k == "after" || i < n) + .map(|i| Some(i.min(n.saturating_sub(1)))) + .ok_or_else(|| bad(ID, format!("`{k}` must be the index of one of the {n} pages, not {v}"))), }; // The page the new ones follow; none: they go first. let after = match (index("after")?, index("before")?) { @@ -2220,7 +2221,6 @@ mod parent_margin_tests { json!({"before": null}), json!({"after": 1.5}), json!({"before": 6}), - json!({"after": 6}), json!({"before": 99}), ] { assert!(s.execute("layout.pages.insert", &bad).is_err(), "{bad}"); diff --git a/crates/engine/tests/it/insert_pages_bounds.rs b/crates/engine/tests/it/insert_pages_bounds.rs index 340e2fc81..5f8856246 100644 --- a/crates/engine/tests/it/insert_pages_bounds.rs +++ b/crates/engine/tests/it/insert_pages_bounds.rs @@ -1,21 +1,27 @@ -//! `layout.pages.insert` refuses an out-of-range `after` or `before` (they must name a page) -//! instead of overflowing, and changes nothing. +//! `layout.pages.insert` clamps an out-of-range `after` to the end instead of overflowing; a +//! `before` must name a page, and one that doesn't is refused without a change. use designcraft_engine::Session; use serde_json::json; #[test] -fn insert_after_u64_max_is_refused_without_overflow() { +fn insert_after_u64_max_appends_at_the_end() { let mut s = Session::new(); s.execute("file.new", &json!({"pages":2})).unwrap(); let before: Vec<_> = s.doc().unwrap().doc.pages().map(|(_, _, p)| p.id).collect(); - let undo = s.doc().unwrap().history.undo.len(); - for p in [json!({"after": u64::MAX, "count": 2}), json!({"before": u64::MAX, "count": 2})] { - assert!(s.execute("layout.pages.insert", &p).is_err(), "{p}"); - } - let after: Vec<_> = s.doc().unwrap().doc.pages().map(|(_, _, p)| p.id).collect(); - assert_eq!(after, before, "no pages added"); - assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step"); - // The last page is still a valid place: the new pages go at the end. - let out = s.execute("layout.pages.insert", &json!({"after": 1, "count": 2})).unwrap(); + let out = s.execute("layout.pages.insert", &json!({"after": u64::MAX, "count": 2})).unwrap(); assert_eq!(out["total"], 4); + let after: Vec<_> = s.doc().unwrap().doc.pages().map(|(_, _, p)| p.id).collect(); + assert_eq!(after.len(), 4); + assert_eq!(after[..2], before[..], "the original pages must stay first; new pages go at the end"); + assert!(after[2..].iter().all(|id| !before.contains(id))); +} + +#[test] +fn insert_before_u64_max_is_refused_without_overflow() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages":2})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + assert!(s.execute("layout.pages.insert", &json!({"before": u64::MAX, "count": 2})).is_err()); + assert_eq!(s.doc().unwrap().doc.page_count(), 2, "no pages added"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step"); } From 4b281bd20bbb80da313098f0fa094ff533088676 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sat, 24 Oct 2026 12:38:20 +0200 Subject: [PATCH 156/431] Format the merge of main Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/tools/src/select.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 9f2c9492d..3f36fea59 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -293,7 +293,17 @@ pub struct SelectionTool { impl SelectionTool { pub fn new(direct: bool) -> Self { - Self { direct, drag: Drag::None, hover_handle: None, guides: vec![], shift_release: None, key_release: None, anchor_release: None, loaded: None, live: None } + Self { + direct, + drag: Drag::None, + hover_handle: None, + guides: vec![], + shift_release: None, + key_release: None, + anchor_release: None, + loaded: None, + live: None, + } } /// [`bracket_at`] for this tool. With Direct Selection an anchor under the pointer wins: the From a645127ce991773037b30db62affa1b6b7417b67 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sat, 24 Oct 2026 13:03:00 +0200 Subject: [PATCH 157/431] i18n: keys main added to fr.rs are in the shared table here: keep one The catalogue test refuses a key in both fr.rs and the shared table (CI). The shared row keeps the French; it matches, or takes fr.rs's text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/i18n/fr.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 681230d84..adf8898d7 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -105,7 +105,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Em Dash", "Tiret cadratin"), ("Em Space", "Espace cadratin"), ("Email Address:", "Adresse e-mail :"), - ("Email:", "E-mail :"), ("En Dash", "Tiret demi-cadratin"), ("En Space", "Espace demi-cadratin"), ( @@ -254,7 +253,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("PDF import cancelled: the originating document has closed", "Importation PDF annulée : le document d'origine a été fermé"), ("PRs", "PR"), ("Package", "Assemblage"), - ("Packaged {count} files into {dir}", "{count} fichiers assemblés dans {dir}"), ("Page Break", "Saut de page"), ("Page Options", "Options de page"), ("Page Tool", "Outil Page"), @@ -265,7 +263,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Parent Options…", "Options de gabarit…"), ("Pen Tool", "Outil Plume"), ("Pencil Tool", "Outil Crayon"), - ("Phone:", "Téléphone :"), ("Picas", "Picas"), ("Pin Bar Position", "Épingler la position de la barre"), ("Place with Import Options", "Importer avec les options d'importation"), From 4dbc9674fadc76dbeca858ad99bf7a813b69dc77 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sat, 24 Oct 2026 13:04:38 +0200 Subject: [PATCH 158/431] i18n: keys main added to fr.rs are in the shared table here: keep one The catalogue test refuses a key in both fr.rs and the shared table (CI). The shared row keeps the French; it matches, or takes fr.rs's text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/i18n/fr.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 681230d84..adf8898d7 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -105,7 +105,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Em Dash", "Tiret cadratin"), ("Em Space", "Espace cadratin"), ("Email Address:", "Adresse e-mail :"), - ("Email:", "E-mail :"), ("En Dash", "Tiret demi-cadratin"), ("En Space", "Espace demi-cadratin"), ( @@ -254,7 +253,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("PDF import cancelled: the originating document has closed", "Importation PDF annulée : le document d'origine a été fermé"), ("PRs", "PR"), ("Package", "Assemblage"), - ("Packaged {count} files into {dir}", "{count} fichiers assemblés dans {dir}"), ("Page Break", "Saut de page"), ("Page Options", "Options de page"), ("Page Tool", "Outil Page"), @@ -265,7 +263,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Parent Options…", "Options de gabarit…"), ("Pen Tool", "Outil Plume"), ("Pencil Tool", "Outil Crayon"), - ("Phone:", "Téléphone :"), ("Picas", "Picas"), ("Pin Bar Position", "Épingler la position de la barre"), ("Place with Import Options", "Importer avec les options d'importation"), From 5fe0fe6ff6e4b8d0905da3d2fa7b1e657c2b5bb7 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sat, 24 Oct 2026 13:07:49 +0200 Subject: [PATCH 159/431] Insert Table: a null table style is no style Dialog::s turned JSON null into the string "null", which table.insert refused as an unknown style. The field is read as text, and null or empty means [Basic Table]. Found by CodeRabbit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/dialogs.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 26be062f5..0d201619d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -137,6 +137,11 @@ fn text_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, w: f32) { } } +/// The dialog's chosen table style: none when unset, null or empty (a new table's [Basic Table]). +fn table_style_field(d: &Dialog) -> Option<String> { + d.fields.get("tableStyle").and_then(Value::as_str).filter(|s| !s.is_empty()).map(str::to_string) +} + /// Table › Insert Table, laid out as InDesign's: Table Dimensions (body rows and columns, then /// header and footer rows, each a number with a spinner, labels on the right edge of their /// column) and the Table Style to apply. @@ -169,7 +174,7 @@ fn insert_table_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } ui.add_space(8.0); let names: Vec<String> = app.session.active().map(|st| st.doc.styles.table.iter().map(|s| s.name.clone()).collect()).unwrap_or_default(); - let cur = Some(d.s("tableStyle")).filter(|s| !s.is_empty()).unwrap_or_else(|| designcraft_doc::BASIC_TABLE.to_string()); + let cur = table_style_field(d).unwrap_or_else(|| designcraft_doc::BASIC_TABLE.to_string()); ui.horizontal(|ui| { crate::rtl::label(ui, format!("{}:", crate::i18n::tr(lang, "Table Style"))); egui::ComboBox::from_id_salt("ins_table_style").selected_text(&cur).width(160.0).show_ui(ui, |ui| { @@ -3137,7 +3142,7 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> { "insertTable" => { // The table and its style in one step; a style that is gone leaves the document alone, // and the dialog stays open to be corrected. - let style = Some(d.s("tableStyle")).filter(|s| !s.is_empty()); + let style = table_style_field(&d); let r = app.run( "table.insert", json!({"rows": d.n("bodyRows").unwrap_or(4.0).max(1.0) as u64, "cols": d.n("columns").unwrap_or(4.0).max(1.0) as u64, @@ -9746,6 +9751,13 @@ mod tests { app.run("edit.undo", json!({})).unwrap(); assert!(app.run("table.get", json!({})).is_err(), "the table is gone"); } + // A null style is no style: the table is made, unstyled. + app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text"})).unwrap(); + app.run("app.insertTableDialog", json!({})).unwrap(); + ctl(&mut app, "ui.dialog.set", json!({"field": "tableStyle", "value": null})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + assert_eq!(app.run("table.get", json!({})).unwrap()["style"].as_str().unwrap_or(""), ""); + app.run("edit.undo", json!({})).unwrap(); // A style that no longer exists changes nothing. let tables = |app: &DesignApp| app.session.active().unwrap().doc.stories.values().map(|s| s.tables.len()).sum::<usize>(); app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text"})).unwrap(); From f756c9174766999031426f72c8acbd536e0a9dc8 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sat, 24 Oct 2026 13:36:59 +0200 Subject: [PATCH 160/431] Package: missing styles count, same document, Enter in a field, instructions first - A style the font lacks counts as a missing font in Summary and Fonts, as file.package counts it (font_status). - The dialog remembers its document; Package... and Continue refuse when another document became active meanwhile, so the package is the one summarised. - Enter in Address or Instructions makes a new line: the dialog footer no longer takes it for Continue (dialogs::MULTILINE_FOCUS). - Instructions.txt starts with the printing instructions, then the report, as the dialog promises. Found by CodeRabbit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/package.rs | 7 +- crates/ui-egui/src/dialogs.rs | 8 ++- crates/ui-egui/src/package_dialog.rs | 98 +++++++++++++++++++++++++--- 3 files changed, 100 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index d0ed58b03..de3846114 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -441,11 +441,13 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> { root.write(&path, &bytes)?; files.push(path); } - // The report: fonts (missing first), links, preflight, the user's instructions. - let mut r = format!("Package report: {display_title}\n\n"); + // The printing instructions first, as the form gave them; then the report: fonts (missing + // first), links, preflight. + let mut r = String::new(); if let Some(t) = str_param(p, "instructions").filter(|t| !t.trim().is_empty()) { r += &format!("Instructions\n{t}\n\n"); } + r += &format!("Package report: {display_title}\n\n"); r += "Fonts\n"; r += &font_lines.concat(); if !fallback_lines.is_empty() { @@ -629,6 +631,7 @@ mod tests { 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.contains("Preflight:"), "{report}"); // The packaged document links to its Links folder. let doc_file = files.iter().find(|f| f.ends_with(".designcraft")).unwrap(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index bc7d28262..e5f608972 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -137,6 +137,10 @@ fn text_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, w: f32) { } } +/// The temporary-data key a dialog sets, each frame, while one of its multi-line fields has focus: +/// Enter then makes a new line there, and the footer doesn't take it for OK. +pub(crate) const MULTILINE_FOCUS: &str = "dialog_multiline_focus"; + /// Extract once when opened. Cancel is read-only; confirmation adds exactly these colours. pub(crate) fn open_color_theme(app: &mut DesignApp, params: Value) { match app.run("color.extractTheme", params) { @@ -2850,6 +2854,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { app.ui.style_preview = d.b("preview"); 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::<bool>(egui::Id::new(MULTILINE_FOCUS))).unwrap_or(false); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui .add( @@ -2857,7 +2863,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { .fill(crate::theme::Tokens::get(ui.ctx()).accent_strong), ) .clicked() - || (!popup_open && ui.input(|i| i.key_pressed(egui::Key::Enter))) + || (!popup_open && !multiline && ui.input(|i| i.key_pressed(egui::Key::Enter))) { result = Some(true); } diff --git a/crates/ui-egui/src/package_dialog.rs b/crates/ui-egui/src/package_dialog.rs index cf42af11d..e064fd1f4 100644 --- a/crates/ui-egui/src/package_dialog.rs +++ b/crates/ui-egui/src/package_dialog.rs @@ -45,13 +45,7 @@ pub fn open(app: &mut DesignApp) -> Result<(), String> { .unwrap_or_default() .iter() .map(|f| { - let status = if f["missing"].as_bool().unwrap_or(false) { - "missing" - } else if f["styleMissing"].as_bool().unwrap_or(false) || f["matchStatus"] == "styleSubstitute" { - "substituted" - } else { - "ok" - }; + let status = font_status(f); let name = format!("{} {}", f["family"].as_str().unwrap_or(""), f["style"].as_str().unwrap_or("")).trim().to_string(); json!({"name": name, "source": f["source"].as_str().unwrap_or(""), "status": status}) }) @@ -81,13 +75,35 @@ pub fn open(app: &mut DesignApp) -> Result<(), String> { .collect() }) .unwrap_or_default(); + let doc = app.session.active().map(|d| d.uid); app.ui.dialog = Some(Dialog::new( ID, - json!({"page": "summary", "fonts": fonts, "links": links, "spot": spot, "instructions": false, "idml": true, "pdf": false, "problemsOnly": false}), + json!({"page": "summary", "doc": doc, "fonts": fonts, "links": links, "spot": spot, "instructions": false, "idml": true, "pdf": false, "problemsOnly": false}), )); Ok(()) } +/// A `font.list` entry's status here: a missing font, or a style its family lacks, is missing +/// (as `file.package` counts them); a style it stands in for is substituted. +fn font_status(f: &Value) -> &'static str { + if f["missing"].as_bool().unwrap_or(false) || f["styleMissing"].as_bool().unwrap_or(false) { + "missing" + } else if f["matchStatus"] == "styleSubstitute" { + "substituted" + } else { + "ok" + } +} + +/// The document the dialog was opened on is still the active one (automation can switch +/// documents while it is open, and the package must be of the one summarised). +fn same_document(app: &DesignApp, d: &Dialog) -> Result<(), String> { + match app.session.active() { + Some(doc) if d.fields.get("doc").and_then(Value::as_u64) == Some(doc.uid) => Ok(()), + _ => Err("the active document changed after the Package dialog opened".into()), + } +} + fn rows(d: &Dialog, key: &str) -> Vec<Value> { d.fields.get(key).and_then(Value::as_array).cloned().unwrap_or_default() } @@ -268,9 +284,10 @@ pub fn show(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { /// Package…: the Printing Instructions first when asked for, else the folder. pub fn confirm(app: &mut DesignApp, d: &Dialog) -> Result<Value, String> { + same_document(app, d)?; let (idml, pdf) = (d.b("idml"), d.b("pdf")); if d.b("instructions") { - app.ui.dialog = Some(Dialog::new(INSTRUCTIONS, json!({"idml": idml, "pdf": pdf}))); + app.ui.dialog = Some(Dialog::new(INSTRUCTIONS, json!({"idml": idml, "pdf": pdf, "doc": d.fields.get("doc")}))); return Ok(Value::Null); } package_to_folder(app, idml, pdf, None) @@ -284,9 +301,14 @@ pub fn show_instructions(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { 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) }; - if ui.add(edit.desired_width(320.0)).changed() { + let resp = ui.add(edit.desired_width(320.0)); + if resp.changed() { d.fields.insert(key.into(), json!(text)); } + // 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.end_row(); } }); @@ -318,6 +340,7 @@ fn instructions_text(d: &Dialog) -> String { /// Continue: the folder, with the instructions. pub fn confirm_instructions(app: &mut DesignApp, d: &Dialog) -> Result<Value, String> { + same_document(app, d)?; let text = instructions_text(d); package_to_folder(app, d.b("idml"), d.b("pdf"), Some(text).filter(|t| !t.is_empty())) } @@ -481,4 +504,59 @@ mod tests { assert!(app.ui.dialog.is_none()); std::fs::remove_dir_all(dir).unwrap(); } + + /// A style the font's family lacks counts as missing, as `file.package` counts it. + #[test] + fn a_missing_font_style_is_a_missing_font() { + assert_eq!(font_status(&json!({"missing": true})), "missing"); + assert_eq!(font_status(&json!({"missing": false, "styleMissing": true})), "missing"); + assert_eq!(font_status(&json!({"missing": false, "matchStatus": "styleSubstitute"})), "substituted"); + assert_eq!(font_status(&json!({"missing": false})), "ok"); + } + + /// The package is of the document the dialog summarised: when another document became active + /// meanwhile, Package… and Continue refuse, and nothing is written. + #[test] + fn package_refuses_when_the_active_document_changed() { + let dir = temp_dir("switched"); + let mut app = app_with_missing_link(&dir); + let out = dir.join("Packed Folder"); + let picked = out.to_string_lossy().to_string(); + app.services.pick_save = Some(Box::new(move |_| Some(picked.clone()))); + open(&mut app).unwrap(); + let d = app.ui.dialog.take().unwrap(); + app.run("file.new", json!({"title": "Other"})).unwrap(); + assert!(confirm(&mut app, &d).is_err()); + let ins = Dialog::new(INSTRUCTIONS, json!({"idml": true, "pdf": false, "doc": d.fields["doc"]})); + assert!(confirm_instructions(&mut app, &ins).is_err()); + assert!(!out.exists(), "nothing packaged"); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// Enter in Address or Instructions makes a new line there; in a one-line field it continues. + #[test] + fn enter_in_a_multiline_instruction_field_is_a_new_line() { + let dir = temp_dir("enter"); + let mut app = app_with_missing_link(&dir); + let doc = app.session.active().unwrap().uid; + app.ui.dialog = Some(Dialog::new(INSTRUCTIONS, json!({"idml": true, "pdf": false, "doc": doc}))); + let mut h = harness(app); + h.run_steps(3); + let field = |h: &Harness<'static, DesignApp>, n: usize| { + h.query_all_by(|x| x.role() == egui::accesskit::Role::TextInput || x.role() == egui::accesskit::Role::MultilineTextInput) + .nth(n) + .unwrap() + .rect() + .center() + }; + // Address is the third field. + let address = field(&h, 2); + h.event(egui::Event::PointerButton { pos: address, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.event(egui::Event::PointerButton { pos: address, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.id.as_str()), Some(INSTRUCTIONS), "still open"); + std::fs::remove_dir_all(dir).unwrap(); + } } From c6a0e1e8137e2c6d5561526a5bd031cc5b17d147 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sat, 24 Oct 2026 13:50:38 +0200 Subject: [PATCH 161/431] Change Format: empty matches get the paragraph style; style helpers bounded - A GREP match that is empty (^) was counted but got no paragraph style; the paragraph style now goes to the paragraphs of every match, empty ones too. Character formatting still needs characters. - apply_char_style and apply_para_style listed every paragraph of the story for each match; Story::paras_touching gives the ones a range reaches, from the range itself. Found by CodeRabbit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/doc/src/story.rs | 39 ++++++++++++++++++++++++++++++++++ crates/engine/src/cmd/find.rs | 36 ++++++++++++++++++++++++++++--- crates/engine/src/cmd/style.rs | 4 ++-- 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/crates/doc/src/story.rs b/crates/doc/src/story.rs index e3a0e39ee..c21d54c4b 100644 --- a/crates/doc/src/story.rs +++ b/crates/doc/src/story.rs @@ -244,6 +244,24 @@ impl Story { } /// 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<usize>) -> Vec<(usize, std::ops::Range<usize>)> { + let start = floor_char_boundary(&self.text, r.start.min(self.text.len())); + let mut pi = self.para_at(start); + let mut a = self.text[..start].rfind('\n').map_or(0, |i| i + 1); + let mut out = Vec::new(); + loop { + let b = self.text[a..].find('\n').map_or(self.text.len(), |i| a + i); + out.push((pi, a..b)); + // The next paragraph starts after this one's return: is it still inside `r`? + if b >= self.text.len() || b + 1 >= r.end { + return out; + } + (a, pi) = (b + 1, pi + 1); + } + } + pub fn para_at(&self, pos: usize) -> usize { self.text[..floor_char_boundary(&self.text, pos)].bytes().filter(|b| *b == b'\n').count() } @@ -596,6 +614,27 @@ mod tests { Story::with_text(StoryId(1), t, ParaFormat::default()) } + /// `paras_touching` gives the paragraphs a range reaches into (returns included), as listing + /// every paragraph would, for every range of a story with empty and multi-byte paragraphs. + #[test] + fn paras_touching_matches_the_full_listing() { + let s = st("ab + +çé +last"); + let all = s.para_ranges(); + for a in 0..=s.len() { + for b in a..=s.len() { + if !s.text.is_char_boundary(a) || !s.text.is_char_boundary(b) { + continue; + } + let want: Vec<(usize, std::ops::Range<usize>)> = + all.iter().cloned().enumerate().filter(|(i, p)| *i == s.para_at(a) || (a < b && p.start < b && a <= p.end)).collect(); + assert_eq!(s.paras_touching(a..b), want, "{a}..{b}"); + } + } + } + #[test] fn paragraphs_track_newlines() { let mut s = st("one\ntwo"); diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index b87a7b226..9e6f984b4 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -372,10 +372,12 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> { st.replace(range.clone(), new); range.start..range.start + new.len() }; + // A paragraph style goes to the match's paragraphs, an empty match's (`^`) too; + // character formatting needs characters. + if let Some(n) = ¶_style { + super::style::apply_para_style(st, &styles, n, &changed, false); + } if !changed.is_empty() { - if let Some(n) = ¶_style { - super::style::apply_para_style(st, &styles, n, &changed, false); - } if let Some(n) = &char_style { super::style::apply_char_style(st, &styles, n, &changed); } @@ -485,6 +487,34 @@ mod tests { assert!(s.doc().unwrap().selection.text.is_some()); } + /// A GREP match that is empty (`^`, the start of each paragraph) gets the paragraph style: + /// only character formatting needs characters. + #[test] + fn change_format_styles_paragraphs_of_empty_matches() { + 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": "One +Two +Three"}), + ) + .unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + s.execute("style.paragraph.create", &json!({"name": "Method"})).unwrap(); + let r = s.execute("find.change", &json!({"find": "^", "change": "", "grep": true, "paraStyle": "Method"})).unwrap(); + assert_eq!(r["count"], 3); + let story = s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert!(story.paras.iter().all(|p| p.style == "Method"), "{:?}", story.paras.iter().map(|p| &p.style).collect::<Vec<_>>()); + assert_eq!( + story.text, + "One +Two +Three" + ); + } + #[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 diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 9ae967613..27414306e 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -790,7 +790,7 @@ pub(super) fn apply_para_style(st: &mut designcraft_doc::Story, styles: &Styles, f.chars.retain_differences(&style_chars); } }); - for (pi, range) in st.para_ranges().into_iter().enumerate().take(last.saturating_add(1)).skip(first) { + for (pi, range) in st.paras_touching(r.clone()).into_iter().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()); @@ -827,7 +827,7 @@ fn apply_char(s: &mut Session, p: &Value) -> Result<Value> { /// Character style `name` applied to `r` in `st`: the text's overrides that the style (over its /// paragraph) now gives go. pub(super) fn apply_char_style(st: &mut designcraft_doc::Story, styles: &Styles, name: &str, r: &std::ops::Range<usize>) { - for (pi, range) in st.para_ranges().into_iter().enumerate() { + for (pi, range) in st.paras_touching(r.clone()) { let range = range.start.max(r.start)..range.end.saturating_add(1).min(st.len()).min(r.end); if range.is_empty() { continue; From 7907cde2709469ff37b025c4c7c1bb1e9f0e3938 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sat, 24 Oct 2026 14:01:28 +0200 Subject: [PATCH 162/431] Swatches: a row's accessible name says what its marks show Screen readers got only the swatch name; the painted marks (colour type and mode, can't be edited) were not in it. The row is now named "Cyan, Process, CMYK" or "[Black], Process, CMYK, Cannot be edited" (translated; one new string). Tests find rows by name with names_swatch. Found by CodeRabbit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/dialogs/options.rs | 3 +- crates/ui-egui/src/i18n.rs | 1 + crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + crates/ui-egui/src/panels/swatches.rs | 62 ++++++++++++++++++++++++--- 13 files changed, 68 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/dialogs/options.rs b/crates/ui-egui/src/dialogs/options.rs index d8aba2bea..df7dc4cf0 100644 --- a/crates/ui-egui/src/dialogs/options.rs +++ b/crates/ui-egui/src/dialogs/options.rs @@ -342,7 +342,8 @@ mod tests { } fn double_click(h: &mut Harness<'static, DesignApp>, label: &str) { - let at = h.get_by_label(label).rect().center(); + // A swatch row's accessible name is its name, then what its marks show. + let at = h.get_by(|n| n.label().is_some_and(|l| crate::panels::swatches::names_swatch(&l, label))).rect().center(); h.hover_at(at); // Two clicks without the pointer leaving (`drop_at` would end the double click). for pressed in [true, false, true, false] { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index bdca83589..158262da4 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1010,6 +1010,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ], ), ("Load Styles", ["Formate laden", "Charger les styles", "Cargar estilos", "スタイルを読み込み", "加载样式"]), + ("Cannot be edited", ["Nicht bearbeitbar", "Non modifiable", "No editable", "編集不可", "不可编辑"]), ( "Differs from this document", ["Weicht von diesem Dokument ab", "Diffère de ce document", "Difiere de este documento", "このドキュメントと異なります", "与此文档不同"], diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 31f7fb70d..fa50476a3 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", "لصق"), + ("Cannot be edited", "غير قابل للتحرير"), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), ("Fixed Number", "عدد ثابت"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 4d41c30dc..e28fd4f27 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Cannot be edited", "Nelze upravit"), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 367ca52f8..63f7b6172 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Ausschneiden"), ("Copy", "Kopieren"), ("Paste", "Einfügen"), + ("Cannot be edited", "Nicht bearbeitbar"), ("Inches Decimal", "Zoll dezimal"), ("Back", "Zurück"), ("Warning", "Warnung"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 755272fe5..7de7b7ee4 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"), + ("Cannot be edited", "Non modificabile"), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 37b14ac5c..601801055 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", "ペースト"), + ("Cannot be edited", "編集不可"), ("Paste Vector Art", "ベクトルアートをペースト"), ("Baseline Options", "ベースラインオプション"), ("Column Rules", "段落境界線"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 0300b62d7..1806a43e0 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Cannot be edited", "რედაქტირება შეუძლებელია"), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 7d1d12c67..7c131b088 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Cannot be edited", "Nie można edytować"), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index f552e9a63..f9ff794e9 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"), + ("Cannot be edited", "Não editável"), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), ("Fixed Number", "Número fixo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 3f6ef1be0..16c4aba10 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Cannot be edited", "Нельзя изменить"), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 9c7cc855a..c9f2b1abb 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Cannot be edited", "Не можна змінити"), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 71b7f0ed1..fc87a4d6a 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -281,6 +281,34 @@ fn row_marks(sw: &designcraft_color::swatch::Swatch) -> RowMarks { } } +/// A row's accessible name: the swatch's name, then what its marks show (colour type, colour +/// mode, that it can't be edited). [None] and [Registration] say their mode by name. +fn row_label(lang: &str, name: &str, marks: RowMarks) -> String { + let tr = |s: &str| crate::i18n::tr(lang, s).to_string(); + let mut parts = vec![name.to_string()]; + match marks.kind { + Some(ColorType::Process) => parts.push(tr("Process")), + Some(ColorType::Spot) => parts.push(tr("Spot")), + None => {} + } + match marks.mode { + Some(RowMode::Cmyk) => parts.push(tr("CMYK")), + Some(RowMode::Rgb) => parts.push(tr("RGB")), + Some(RowMode::Gray) => parts.push(tr("Gray")), + Some(RowMode::None | RowMode::Registration) | None => {} + } + if marks.not_editable { + parts.push(tr("Cannot be edited")); + } + parts.join(", ") +} + +/// Whether accessible name `label` is swatch `name`'s row (its name, then its marks). +#[cfg(test)] +pub(crate) fn names_swatch(label: &str, name: &str) -> bool { + label.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with(", ")) +} + /// A pencil with a stroke through it: the swatch can't be edited. Drawn in code. fn paint_not_editable(p: &egui::Painter, r: egui::Rect, ink: egui::Color32) { // The pencil: its tip at the lower left, its body up to the right. @@ -379,7 +407,10 @@ fn swatch_row( let t = Tokens::get(ui.ctx()); // Rows can be dragged onto the Gradient panel's ramp. let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click_and_drag()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &sw.name)); + // What the row's marks show, for screen readers too: "Cyan, Process, CMYK". + let marks = row_marks(sw); + let spoken = row_label(&app.ui.language, &sw.name, marks); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &spoken)); resp.dnd_set_drag_payload(SwatchDrag(sw.name.clone())); if resp.dragged() && let Some(p) = ui.ctx().pointer_interact_pos() @@ -401,7 +432,6 @@ fn swatch_row( crate::widgets::paint_chip(ui.painter(), chip, c, g); ui.painter().text(row.min + vec2(28.0 + indent, 11.0), egui::Align2::LEFT_CENTER, &sw.name, egui::FontId::proportional(12.5), t.text); // InDesign's columns on the right: not editable, colour type, colour mode. - let marks = row_marks(sw); let col = |k: f32| egui::Rect::from_min_size(egui::pos2(row.max.x - 19.0 - 18.0 * k, row.min.y + 4.5), vec2(13.0, 13.0)); if marks.not_editable { paint_not_editable(ui.painter(), col(2.0), t.text_dim); @@ -1205,7 +1235,7 @@ mod gradient_panel_tests { // The thumbnail sits left of the Type row. let ty = h.get_by_label("Type:").rect(); let from = egui::pos2(ty.min.x - 21.0, ty.min.y + 12.0); - let to = h.get_by_label(label).rect().center(); + let to = h.get_by(|n| n.label().is_some_and(|l| crate::panels::swatches::names_swatch(&l, label))).rect().center(); h.event(Event::PointerMoved(from)); h.step(); h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); @@ -1317,14 +1347,14 @@ mod swatches_header_tests { let mut h = harness(app); h.run_steps(3); // Fill in front: a swatch is the fill. - h.get_by_label("C=0 M=100 Y=0 K=0").click(); + h.get_by(|n| n.label().is_some_and(|l| crate::panels::swatches::names_swatch(&l, "C=0 M=100 Y=0 K=0"))).click(); h.run_steps(2); assert_eq!(items(h.state())[0].fill.swatch, "C=0 M=100 Y=0 K=0"); // The proxy brings the stroke to the front; the next swatch is the stroke. h.get_by_label("Fill").click(); h.run_steps(2); assert!(h.state().ui.stroke_front); - h.get_by_label("C=100 M=0 Y=0 K=0").click(); + h.get_by(|n| n.label().is_some_and(|l| crate::panels::swatches::names_swatch(&l, "C=100 M=0 Y=0 K=0"))).click(); h.run_steps(2); let it = &items(h.state())[0]; assert_eq!((it.fill.swatch.as_str(), it.stroke.swatch.as_str()), ("C=0 M=100 Y=0 K=0", "C=100 M=0 Y=0 K=0")); @@ -1336,7 +1366,7 @@ mod swatches_header_tests { h.run_steps(2); assert!(!h.state().ui.formatting_affects_text); // Delete Swatch removes the swatch last clicked. - h.get_by_label("Brand").click(); + h.get_by(|n| n.label().is_some_and(|l| crate::panels::swatches::names_swatch(&l, "Brand"))).click(); h.run_steps(2); h.get_by_label("Delete Swatch").click(); h.run_steps(2); @@ -1375,7 +1405,25 @@ mod row_mark_tests { use designcraft_color::swatch::{Swatch, default_swatches}; use designcraft_color::{Color, ColorType, SwatchValue}; - use super::{RowMarks, RowMode, row_marks}; + use super::{RowMarks, RowMode, row_label, row_marks}; + + /// A row's accessible name says what its marks show. + #[test] + fn swatch_rows_say_their_marks() { + let all = default_swatches(); + let label = |name: &str| row_label("", name, row_marks(all.iter().find(|s| s.name == name).unwrap())); + assert_eq!(label("[Black]"), "[Black], Process, CMYK, Cannot be edited"); + assert_eq!(label("[None]"), "[None], Cannot be edited"); + assert_eq!(label("[Paper]"), "[Paper]"); + let cyan = all.iter().find(|s| matches!(&s.value, SwatchValue::Color { .. }) && !s.locked).unwrap(); + let spot = Swatch { + name: "Pantone".into(), + value: SwatchValue::Color { color: Color::Rgb { r: 1.0, g: 0.0, b: 0.0 }, color_type: ColorType::Spot }, + locked: false, + ..cyan.clone() + }; + assert_eq!(row_label("it", &spot.name, row_marks(&spot)), "Pantone, Tinta piatta, RGB"); + } /// The Swatches panel's right-hand columns, as InDesign's: [None], [Registration] and [Black] /// can't be edited, [Paper] can; colours show their type and mode. From 7a50e480819fc0bdd3b400d0a393064d9ba1b48f Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sat, 24 Oct 2026 14:35:32 +0200 Subject: [PATCH 163/431] Text wrap: an all-null offset array keeps a custom boundary {"offset": [null, null, null, null]} parsed as an offset edit, so the custom wrap boundary (from IDML) was dropped although no side changed. An array with no number in it is no offset edit. Found by CodeRabbit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/object.rs | 3 ++- crates/engine/src/tests.rs | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index b47eb3a2d..44e8dce96 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -2109,9 +2109,10 @@ fn text_wrap(s: &mut Session, p: &Value) -> Result<Value> { }; // Per side [top, left, bottom, right]; a null side keeps each object's own offset there, so // an edit of one side keeps the others of every object. + // An array with no number in it changes no offset (and so keeps a custom boundary). let off: Option<[Option<f64>; 4]> = match p.get("offset") { Some(Value::Number(n)) => Some([n.as_f64(); 4]), - Some(v) => serde_json::from_value(v.clone()).ok(), + Some(v) => serde_json::from_value::<[Option<f64>; 4]>(v.clone()).ok().filter(|o| o.iter().any(Option::is_some)), None => None, }; let invert = p.get("invert").and_then(Value::as_bool); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b1792485d..243e73759 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -868,6 +868,9 @@ fn custom_wrap_path_follows_resize_and_yields_to_offsets() { s.execute("transform.resize", &json!({"from": [b.x0, b.y0, b.x1, b.y1], "to": to})).unwrap(); let w = wrap_path(&s).unwrap().bounds().unwrap(); assert!((w.width() - 80.0).abs() < 1e-6 && (w.height() - 100.0).abs() < 1e-6, "{w:?}"); + // Offsets that are all null change nothing, so the boundary stays. + s.execute("object.textWrap", &json!({"mode": null, "offset": [null, null, null, null]})).unwrap(); + assert!(wrap_path(&s).is_some(), "an all-null offset keeps the custom boundary"); // An offset rebuilds the wrap from the frame. s.execute("object.textWrap", &json!({"offset": 6})).unwrap(); assert_eq!(wrap_path(&s), None); From ebed87e1922d1d76a90a4a3a81f66b5fb11c67b8 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sat, 24 Oct 2026 14:59:52 +0200 Subject: [PATCH 164/431] Checkbox: one widget_info call The merge of main kept both the branch's and main's accessibility call, so a click emitted two Clicked events. Main's (with the enabled state) stays. Found by CodeRabbit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/widgets.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 1f97b86a8..c147c6376 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -621,8 +621,6 @@ pub fn checkbox(ui: &mut Ui, on: &mut bool, label: &str) -> Response { // Named and ticked or not for screen readers (and tests). let checked = *on; resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), checked, label)); - let checked = *on; - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, checked, label)); resp } From b30f4edb5b93f25c264eab49dfa44e3615e1aec6 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sun, 25 Oct 2026 17:11:29 +0100 Subject: [PATCH 165/431] Convert Text to Table: refuse text too wide or too big for a table A row wider than MAX_COLUMNS, or more cells than the largest table table.insert makes (MAX_BODY_ROWS x MAX_COLUMNS), is an error before the table is built; the text stays as it was. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/table.rs | 11 +++++++++++ crates/engine/src/tests_table.rs | 9 +++++++++ 2 files changed, 20 insertions(+) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 827384713..58a31e863 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -569,6 +569,9 @@ fn para_direction(d: &Document, story: StoryId, offset: usize) -> designcraft_do pub const MAX_BODY_ROWS: u64 = 500; pub const MAX_COLUMNS: u64 = 200; pub const MAX_HEADER_FOOTER_ROWS: u64 = 100; +/// The most cells `table.convertFromText` makes: as many as the largest table `table.insert` +/// makes, however the rows and columns divide them (a long list can make a long table). +pub const MAX_CONVERTED_CELLS: u64 = MAX_BODY_ROWS * MAX_COLUMNS; /// A command's `tableStyle`: none for an absent one or [Basic Table] (a new table's look), else the /// resolved style and the cell styles it names. Anything but an existing style's name is an error. @@ -673,6 +676,14 @@ fn convert_from_text(s: &mut Session, p: &Value) -> Result<Value> { } else { text.split(row_sep.as_str()).map(|row| row.split(col_sep.as_str()).map(|c| c.trim().to_string()).collect()).collect() }; + // The table is as wide as its widest row; refuse what no table here could hold. + let cols = data.iter().map(Vec::len).max().unwrap_or(1) as u64; + if cols > MAX_COLUMNS { + return Err(bad(ID, format!("the text makes {cols} columns; a table has at most {MAX_COLUMNS}"))); + } + if cols * data.len() as u64 > MAX_CONVERTED_CELLS { + return Err(bad(ID, format!("the text makes more than {MAX_CONVERTED_CELLS} cells"))); + } let pf = st.paras[pa].clone(); let cf = st.format_after(a).clone(); let mut table = Table::from_strings(id, &data, width, &pf, &cf); diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index cb2519e1e..072e91f9a 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -512,6 +512,15 @@ fn convert_text_to_table_with_indesigns_separators() { ] { assert!(convert(&mut s, "p\tq", bad.clone()).is_err(), "{bad}"); } + // Text that would make too wide or too big a table is refused, and stays as it was. + use crate::cmd::table::{MAX_COLUMNS, MAX_CONVERTED_CELLS}; + let wide = vec!["x"; MAX_COLUMNS as usize + 1].join("\t"); + assert!(convert(&mut s, &wide, json!({})).is_err(), "a row wider than a table can be"); + let long = vec!["a\tb"; MAX_CONVERTED_CELLS as usize / 2 + 1].join("\n"); + assert!(convert(&mut s, &long, json!({})).is_err(), "more cells than a table can have"); + assert_eq!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text, format!("{long}\nAfter")); + let at_most = vec!["x"; MAX_COLUMNS as usize].join("\t"); + assert!(convert(&mut s, &at_most, json!({})).is_ok(), "a row as wide as a table can be"); check(&s); // Back to text with a comma between columns and a tab between rows. let r = convert(&mut s, "Plum\t80%\nSunset\t45%", json!({})).unwrap(); From c27ef351e1d5585645954b0ad07090cba13eee97 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sun, 25 Oct 2026 17:12:01 +0100 Subject: [PATCH 166/431] Insert Table: a tableStyle that isn't a name goes to table.insert as it is So the command refuses it (and the dialog stays open) instead of the table being made unstyled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/dialogs.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7489398d2..93fa0a44c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -142,6 +142,15 @@ fn table_style_field(d: &Dialog) -> Option<String> { d.fields.get("tableStyle").and_then(Value::as_str).filter(|s| !s.is_empty()).map(str::to_string) } +/// The dialog's `tableStyle` as the table commands take it: null for none, the style's name, or +/// a value that is neither (set through `ui.dialog.set`) as it is, for the command to refuse. +fn table_style_value(d: &Dialog) -> Value { + match d.fields.get("tableStyle") { + None | Some(Value::String(_)) => table_style_field(d).map_or(Value::Null, Value::String), + Some(v) => v.clone(), + } +} + /// Table › Insert Table, laid out as InDesign's: Table Dimensions (body rows and columns, then /// header and footer rows, each a number with a spinner, labels on the right edge of their /// column) and the Table Style to apply. @@ -3162,7 +3171,7 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> { "insertTable" => { // The table and its style in one step; a style that is gone leaves the document alone, // and the dialog stays open to be corrected. - let style = table_style_field(&d); + let style = table_style_value(&d); let r = app.run( "table.insert", json!({"rows": d.n("bodyRows").unwrap_or(4.0).max(1.0) as u64, "cols": d.n("columns").unwrap_or(4.0).max(1.0) as u64, @@ -10072,6 +10081,12 @@ mod tests { assert_eq!(tables(&app), 0); let d = app.ui.dialog.as_ref().expect("the dialog stays open to be corrected"); assert_eq!((d.id.as_str(), d.s("tableStyle")), ("insertTable", "Gone".to_string())); + // So does a style that isn't a name: the command refuses it, rather than making the table + // unstyled. + ctl(&mut app, "ui.dialog.set", json!({"field": "tableStyle", "value": 42})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false); + assert_eq!(tables(&app), 0); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("insertTable"), "the dialog stays open"); } #[test] From 7ffb4874daeb3579b3224b11230908bff4e34244 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sun, 25 Oct 2026 17:12:01 +0100 Subject: [PATCH 167/431] Insert Table: a tableStyle that isn't a name goes to table.insert as it is So the command refuses it (and the dialog stays open) instead of the table being made unstyled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/dialogs.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ee1d6f2b4..50b403358 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -231,6 +231,15 @@ fn table_style_field(d: &Dialog) -> Option<String> { d.fields.get("tableStyle").and_then(Value::as_str).filter(|s| !s.is_empty()).map(str::to_string) } +/// The dialog's `tableStyle` as the table commands take it: null for none, the style's name, or +/// a value that is neither (set through `ui.dialog.set`) as it is, for the command to refuse. +fn table_style_value(d: &Dialog) -> Value { + match d.fields.get("tableStyle") { + None | Some(Value::String(_)) => table_style_field(d).map_or(Value::Null, Value::String), + Some(v) => v.clone(), + } +} + /// Table › Insert Table, laid out as InDesign's: Table Dimensions (body rows and columns, then /// header and footer rows, each a number with a spinner, labels on the right edge of their /// column) and the Table Style to apply. @@ -3402,7 +3411,7 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> { "insertTable" => { // The table and its style in one step; a style that is gone leaves the document alone, // and the dialog stays open to be corrected. - let style = table_style_field(&d); + let style = table_style_value(&d); let r = app.run( "table.insert", json!({"rows": d.n("bodyRows").unwrap_or(4.0).max(1.0) as u64, "cols": d.n("columns").unwrap_or(4.0).max(1.0) as u64, @@ -10567,6 +10576,12 @@ mod tests { assert_eq!(tables(&app), 0); let d = app.ui.dialog.as_ref().expect("the dialog stays open to be corrected"); assert_eq!((d.id.as_str(), d.s("tableStyle")), ("insertTable", "Gone".to_string())); + // So does a style that isn't a name: the command refuses it, rather than making the table + // unstyled. + ctl(&mut app, "ui.dialog.set", json!({"field": "tableStyle", "value": 42})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false); + assert_eq!(tables(&app), 0); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("insertTable"), "the dialog stays open"); } /// Convert Text to Table: available with text selected (not with a caret alone); its From fd02125c68bb3b19b13e3f5fc06a8b4ac63d0f86 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sun, 25 Oct 2026 17:12:20 +0100 Subject: [PATCH 168/431] Convert Text to Table: a tableStyle that isn't a name goes to the command as it is As Insert Table's: the command refuses it, and the dialog stays open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/dialogs.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 50b403358..46915dce9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3392,7 +3392,7 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> { "convertTextToTable" | "convertTableToText" => { let r = if d.id == "convertTextToTable" { let same = same_separators(&d); - let style = table_style_field(&d).filter(|s| s != designcraft_doc::BASIC_TABLE); + let style = table_style_value(&d); app.run( "table.convertFromText", json!({"columnSeparator": d.s("columnSeparator"), "rowSeparator": d.s("rowSeparator"), From 2e44b1909603823e34387c478689df2bea176fa2 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sun, 25 Oct 2026 17:13:33 +0100 Subject: [PATCH 169/431] Table keys and cell commands: footnote text isn't a table cell The caret in a footnote has a CellAddr too (FOOTNOTE_TABLE). Shift+Tab and Esc there now act as outside a table, and table.nextCell, table.prevCell and table.toggleSelection refuse it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/table.rs | 6 ++++-- crates/engine/src/tests_table.rs | 15 +++++++++++++++ crates/tools/src/tests.rs | 11 +++++++---- crates/tools/src/text.rs | 3 ++- 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 55e10bd58..4dffe2a93 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -438,7 +438,9 @@ fn in_table_or_ids(s: &Session) -> std::result::Result<(), String> { fn in_cell(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; - if s.active().is_some_and(|d| d.selection.text.is_some_and(|t| t.cell.is_some())) { Ok(()) } else { Err("not in a table cell".into()) } + // Footnote text is addressed as a cell too, but isn't in a table. + let in_table = |c: CellAddr| c.footnote_id().is_none(); + if s.active().is_some_and(|d| d.selection.text.is_some_and(|t| t.cell.is_some_and(in_table))) { Ok(()) } else { Err("not in a table cell".into()) } } /// The table a command acts on and its target cell range. @@ -1594,7 +1596,7 @@ fn toggle_selection(s: &mut Session) -> Result<Value> { const ID: &str = "table.toggleSelection"; let st = s.doc_mut()?; let t = st.selection.text.ok_or_else(|| bad(ID, "no caret"))?; - let c = t.cell.ok_or_else(|| bad(ID, "not in a cell"))?; + let c = t.cell.filter(|c| c.footnote_id().is_none()).ok_or_else(|| bad(ID, "not in a cell"))?; let cells = match st.selection.cells { Some(cs) => { // A merged cell's text belongs to the cell at its top left. diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index 2557b961a..a3dfd30a3 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -127,6 +127,21 @@ fn table_edit_after_anchor_removal_floors_parent_caret() { } } +/// Footnote text is addressed as a cell, but the table commands don't take it for one. +#[test] +fn the_cell_commands_refuse_footnote_text() { + let (mut s, sid, _) = session_with_frame(); + s.execute("story.setText", &json!({"story": sid, "text": "Hello"})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 5, "focus": 5})).unwrap(); + s.execute("footnote.insert", &json!({"text": "Note"})).unwrap(); + let before = s.doc().unwrap().selection.clone(); + assert!(before.text.and_then(|t| t.cell).and_then(|c| c.footnote_id()).is_some(), "the caret is in the footnote"); + for cmd in ["table.toggleSelection", "table.prevCell", "table.nextCell"] { + assert!(s.execute(cmd, &json!({})).is_err(), "{cmd}"); + } + assert_eq!(s.doc().unwrap().selection, before); +} + /// Esc in a table toggles between the text and the cell (InDesign's Esc): the caret in a cell /// selects that cell, and selected cells select the first one's text; Shift+Tab goes back a cell. #[test] diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index dce3629b6..14dd65a0d 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -1469,10 +1469,13 @@ fn type_tool_keys_in_a_table_cell() { assert_eq!(t.key(&cx, ToolKey::Tab, shift), exec("table.prevCell", serde_json::json!({}))); assert_eq!(t.key(&cx, ToolKey::Tab, Mods::default()), exec("text.insert", serde_json::json!({"text": "\t"}))); assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), exec("table.toggleSelection", serde_json::json!({}))); - let s = Selection::text(designcraft_doc::TextSel::caret(sid, 0)); - let cx = ctx(&d, &s, &c, &l); - assert_eq!(t.key(&cx, ToolKey::Tab, shift), exec("text.insert", serde_json::json!({"text": "\t"}))); - assert!(matches!(t.key(&cx, ToolKey::Escape, Mods::default()).first(), Some(Action::SwitchTool(_)))); + // Out of a table, and in footnote text (addressed as a cell too). + for cell in [None, Some(designcraft_doc::CellAddr::footnote(1))] { + let s = Selection::text(designcraft_doc::TextSel { cell, ..designcraft_doc::TextSel::caret(sid, 0) }); + let cx = ctx(&d, &s, &c, &l); + assert_eq!(t.key(&cx, ToolKey::Tab, shift), exec("text.insert", serde_json::json!({"text": "\t"})), "{cell:?}"); + assert!(matches!(t.key(&cx, ToolKey::Escape, Mods::default()).first(), Some(Action::SwitchTool(_))), "{cell:?}"); + } } #[test] diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index d934ad0f2..a9b2069bd 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -364,7 +364,8 @@ impl Tool for TypeTool { let steps = if mods.cmd || mods.ctrl { 5.0 } else { 1.0 }; vec![Action::Exec("type.kernStep".into(), json!({"steps": sign * steps}))] }; - let in_cell = cx.selection.text.is_some_and(|t| t.cell.is_some()); + // A table cell's text, not a footnote's (which is addressed as a cell too). + let in_cell = cx.selection.text.is_some_and(|t| t.cell.is_some_and(|c| c.footnote_id().is_none())); match key { ToolKey::Left if mods.alt && !mods.shift => kern(-1.0), ToolKey::Right if mods.alt && !mods.shift => kern(1.0), From 595ec3568a33bdbbeee4b998f69cf22b06fab5a7 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sun, 25 Oct 2026 17:13:49 +0100 Subject: [PATCH 170/431] Paragraph border overprint: its doc comment names its own IDML property Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/doc/src/attrs.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index 8662e5fdb..aea93d713 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -1317,7 +1317,7 @@ attr_set! { border_weights: [f64; 4] = [1.0; 4], border_color: String = "[Black]".into(), border_tint: f32 = 1.0, - /// Overprint the border's stroke (stored and kept through IDML, as `shading_overprint`). + /// Overprint the border's stroke (stored and kept through IDML, as `ParagraphBorderOverprint`). border_overprint: bool = false, border_offsets: [f64; 4] = [0.0; 4], /// The border's stroke type (a document stroke style by name, or a built-in type), cap and From 61dbd814db3a1c387a3541c2ed80d32acc8e7790 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sun, 25 Oct 2026 17:14:24 +0100 Subject: [PATCH 171/431] Status bar: the page box's accessible name is in the interface language Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/chrome.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index ab96d1983..a52b164e7 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -1151,8 +1151,9 @@ pub fn status_bar(app: &mut DesignApp, ui: &mut egui::Ui) { t.text, ); 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, "Page"); + let mut info = egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, label); info.current_text_value = Some(name.clone()); info }); @@ -1717,6 +1718,10 @@ mod tests { 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. From 2de763bb522ce9e2b589a34c29571ffc5c52da57 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sun, 25 Oct 2026 17:14:51 +0100 Subject: [PATCH 172/431] Color Picker eyedropper: let go over the picker, it takes nothing The picker has no backdrop, so a page can lie under it; color_at only checks the canvas. The drop now samples only when the picker's layer isn't the topmost one there, and the test drops onto its colour field. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/dialogs.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0f95512a0..ad4de5b9c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -7115,8 +7115,10 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { if resp.dragged() { ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); } + // Let go over the picker itself it takes nothing, though a page may lie under it. if resp.drag_stopped() && let Some(pos) = ui.input(|i| i.pointer.latest_pos()) + && ui.ctx().layer_id_at(pos) != Some(ui.layer_id()) && let Some(c) = crate::canvas::color_at(app, ui.ctx(), pos) { p = Picked::from_rgb(c.map(|v| f32::from(v) / 255.0), p.hsb[0]); @@ -10523,6 +10525,18 @@ mod tests { 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] From 14d68ee0b1221190252868a82aa18a86fa1a83da Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sun, 25 Oct 2026 17:15:42 +0100 Subject: [PATCH 173/431] Swatches: a rename in place ends with its document The panel keeps the uid of the document it last showed; another one (switched to, or moved into its place by a close) drops the rename and the pending rename click, so a same-named swatch there isn't renamed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/panels/swatches.rs | 36 +++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 14d187655..15b2f5873 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -11,6 +11,16 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); + // 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::<u64>(egui::Id::new(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); + }); + } let target = super::color_target(app); header(app, ui, &doc, target.as_ref()); // The row of the colour in front of the proxy is highlighted. @@ -159,6 +169,8 @@ const RENAMING: &str = "swatches_renaming"; /// Temp-data key of a click on the picked swatch's name: (swatch, time). Unless it turns into a /// double-click (Swatch Options), it starts renaming. const RENAME_CLICK: &str = "swatches_rename_click"; +/// Temp-data key of the document (its uid) the panel last showed: a rename belongs to it. +const RENAME_DOC: &str = "swatches_rename_doc"; /// The Swatches panel's top row, as InDesign's: a Fill/Stroke proxy (click: the other in front), /// Formatting Affects Container / Text, and the tint of the colour in front. `target` is @@ -1295,6 +1307,30 @@ mod swatches_header_tests { /// clicking it, then clicking its name again: the name becomes a field, and Enter renames the /// swatch (and what uses it). A double-click still opens Swatch Options, Escape keeps the name, /// and [Black] can't be renamed. + /// A rename in place belongs to its document: with another one active, a swatch of the same + /// name there gets no field, and nothing is renamed. + #[test] + fn a_rename_in_place_ends_with_its_document() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + 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 fields = h.query_all_by_role(egui::accesskit::Role::TextInput).count(); + let renaming = egui::Id::new(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"); + // Another document with a swatch of the same name. + h.state_mut().run("file.new", json!({})).unwrap(); + h.state_mut().run("swatch.create", color).unwrap(); + h.run_steps(3); + assert!(h.ctx.data(|d| d.get_temp::<(String, String)>(renaming)).is_none(), "the rename is dropped"); + let doc = &h.state().session.active().unwrap().doc; + assert!(doc.swatch(&name).is_some() && doc.swatch("Typed").is_none()); + } + #[test] fn a_second_click_on_the_picked_swatch_renames_it_in_place() { use egui::accesskit::Role; From 5ab5f6aae6c84a56ee543ae4fcf2292193d97b71 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sun, 25 Oct 2026 17:21:05 +0100 Subject: [PATCH 174/431] Corner Options: only the corners edited change; an unknown shape is an error The dialog shows the first selected object's corners and sent all four, so other objects lost corners nobody touched. object.cornerOptions now takes null for a corner (each object keeps its own), and the dialog sends only the corners edited: changed from the first object's, or set by a linked edit (all four). A shape the dialog doesn't offer is an error that keeps it open, instead of another shape taking its place. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/object.rs | 26 +++++++--- crates/ui-egui/src/dialogs.rs | 88 ++++++++++++++++++++++++++++++--- 2 files changed, 101 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 87f601604..37c9e13f4 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -607,7 +607,7 @@ pub fn specs() -> Vec<CommandSpec> { "Corner Options…", ["Object"], None, - "{shape: none|rounded|inverseRounded|inset|bevel|fancy, size, corners?: [{shape, size}] (4 entries in path order: top-left, top-right, bottom-right, bottom-left; replaces shape and size), ids?}", + "{shape: none|rounded|inverseRounded|inset|bevel|fancy, size, corners?: [{shape, size} | null] (4 entries in path order: top-left, top-right, bottom-right, bottom-left; replaces shape and size; null keeps that corner of each object), ids?}", has_selection, corner_options ), @@ -2152,19 +2152,31 @@ fn corner_options(s: &mut Session, p: &Value) -> Result<Value> { use designcraft_geom::corners::{Corner, CornerOptions, CornerShape}; let opts = match p.get("corners") { Some(v) => { - let corners: [Corner; 4] = - serde_json::from_value(v.clone()).map_err(|e| bad("object.cornerOptions", format!("corners: four {{shape, size}} entries ({e})")))?; - if corners.iter().any(|c| !c.size.is_finite() || c.size < 0.0) { + let corners: [Option<Corner>; 4] = serde_json::from_value(v.clone()) + .map_err(|e| bad("object.cornerOptions", format!("corners: four {{shape, size}} or null entries ({e})")))?; + if corners.iter().flatten().any(|c| !c.size.is_finite() || c.size < 0.0) { return Err(bad("object.cornerOptions", "corner sizes must be finite and not negative")); } - CornerOptions { corners } + corners } None => { let shape = p.get("shape").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or(CornerShape::Rounded); - CornerOptions::uniform(shape, f64_or(p, "size", 12.0)) + CornerOptions::uniform(shape, f64_or(p, "size", 12.0)).corners.map(Some) } }; - set_flag(s, p, move |i| i.corners = opts, false) + // A null corner keeps each object's own. + set_flag( + s, + p, + move |i| { + for (c, new) in i.corners.corners.iter_mut().zip(opts) { + if let Some(new) = new { + *c = new; + } + } + }, + false, + ) } /// `object.matchAttributes`: the selected objects (or `ids`) take `from`'s appearance. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index a5e594fac..3d7ab9315 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -6807,15 +6807,34 @@ fn seed_corner_options(app: &DesignApp, d: &mut Dialog) { } let first = corners[0]; d.fields.entry("link").or_insert(json!(corners.iter().all(|c| *c == first))); + d.fields.insert("_seed".into(), json!(corner_fields(d))); d.fields.insert("_seeded".into(), json!(true)); } +/// Each corner's size and shape fields, in `CORNERS` order. +fn corner_fields(d: &Dialog) -> Vec<(String, String)> { + CORNERS.iter().map(|(k, _)| (d.s(&format!("size{k}")), d.s(&format!("shape{k}")))).collect() +} + +/// The corners edited since the dialog opened: changed from the first object's, or set by a +/// linked edit (all four, even one that was already the value). +fn edited_corners(d: &Dialog) -> [bool; 4] { + let touched: [bool; 4] = d.fields.get("_touched").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + let seed: Vec<(String, String)> = d.fields.get("_seed").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + let now = corner_fields(d); + std::array::from_fn(|i| touched[i] || seed.get(i) != now.get(i)) +} + /// Linked corners: a corner changed since the dialog last looked (size or shape, typed or set /// over the control channel) is all four. fn link_corners(d: &mut Dialog) { - let now: Vec<(String, String)> = CORNERS.iter().map(|(k, _)| (d.s(&format!("size{k}")), d.s(&format!("shape{k}")))).collect(); + let now = corner_fields(d); let seen: Vec<(String, String)> = d.fields.get("_corners").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_else(|| now.clone()); let mut now = now; + let mut touched: [bool; 4] = d.fields.get("_touched").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + for (t, (a, b)) in touched.iter_mut().zip(now.iter().zip(&seen)) { + *t |= a != b; + } if d.b("link") && let Some(i) = now.iter().zip(&seen).position(|(a, b)| a != b) && let Some(changed) = now.get(i).cloned() @@ -6825,20 +6844,28 @@ fn link_corners(d: &mut Dialog) { d.fields.insert(format!("shape{k}"), json!(changed.1)); } now = vec![changed; CORNERS.len()]; + touched = [true; 4]; } d.fields.insert("_corners".into(), json!(now)); + d.fields.insert("_touched".into(), json!(touched)); } -/// `object.cornerOptions`' params from the dialog. A size that does not parse is an error to show. +/// `object.cornerOptions`' params from the dialog: the corners edited, the others null (each +/// selected object keeps its own). A size that does not parse, or a shape the dialog doesn't +/// offer, is an error to show. fn corner_options_params(d: &Dialog) -> Result<Value, String> { use designcraft_geom::corners::CornerShape; + let edited = edited_corners(d); let mut corners = Vec::with_capacity(CORNERS.len()); - for (k, _) in &CORNERS { + for ((k, _), edited) in CORNERS.iter().zip(edited) { let key = format!("size{k}"); let size = d.m(&key).filter(|v| v.is_finite() && *v >= 0.0).ok_or_else(|| format!("Enter a valid corner size, e.g. 12 or 3 mm: {}", d.s(&key)))?; - let shape: CornerShape = d.fields.get(&format!("shape{k}")).and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); - corners.push(json!({"shape": shape, "size": size})); + let shape: CornerShape = match d.fields.get(&format!("shape{k}")) { + None => CornerShape::default(), + Some(v) => serde_json::from_value(v.clone()).map_err(|_| format!("Choose a corner shape: {v} isn't one"))?, + }; + corners.push(if edited { json!({"shape": shape, "size": size}) } else { Value::Null }); } Ok(json!({"corners": corners})) } @@ -7427,14 +7454,63 @@ mod tests { 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. + // 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 step_and_repeat_dialog_previews_and_makes_the_copies_as_one_undo_step() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From 7ce4552e4f0fb4d86b5e0f2c2d72a1394ca718b6 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sun, 25 Oct 2026 17:22:05 +0100 Subject: [PATCH 175/431] Table of Contents: Enter on the options toggle only toggles egui activates a focused button with Enter, and the footer, drawn after it, took the same Enter for OK: the dialog closed and made the table of contents. The toggle now consumes that Enter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/dialogs.rs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b18676ebc..564f7fe06 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -6560,7 +6560,13 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.add_space(8.0); ui.horizontal(|ui| { crate::rtl::label(ui, egui::RichText::new(tr("Styles in Table of Contents")).font(semibold(12.0))); - if ui.button(tr(if expanded { "Fewer Options" } else { "More Options" })).clicked() { + let toggle = ui.button(tr(if expanded { "Fewer Options" } else { "More Options" })); + // Enter on the focused toggle toggles it (egui activates a focused button with Enter): + // the footer drawn after it mustn't take that Enter for OK too. + if toggle.has_focus() { + ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); + } + if toggle.clicked() { expanded = !expanded; } }); @@ -8399,6 +8405,27 @@ mod tests { 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 toc_dialog_adds_a_style_and_ok_makes_the_table_of_contents() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From 976f0d63ba609f33c6e1392d12bb427bbbb8751a Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sun, 25 Oct 2026 17:22:39 +0100 Subject: [PATCH 176/431] New Section: the dialog's title in every interface language Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/i18n.rs | 1 + crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + 11 files changed, 11 insertions(+) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index e74cc0f73..b408c586a 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1075,6 +1075,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ], ), ("Load Styles", ["Formate laden", "Charger les styles", "Cargar estilos", "スタイルを読み込み", "加载样式"]), + ("New Section", ["Neuer Abschnitt", "Nouvelle section", "Nueva sección", "新規セクション", "新建章节"]), ("Restart Numbering", ["Nummerierung neu beginnen", "Recommencer la numérotation", "Reiniciar numeración", "番号の振り直し", "重新开始编号"]), ("Continue Numbering", ["Nummerierung fortführen", "Poursuivre la numérotation", "Continuar numeración", "番号の継続", "继续编号"]), ("Keep Options…", ["Umbruchoptionen …", "Options d'enchaînement…", "Opciones de separación…", "段落分離禁止オプション…", "保持选项…"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 49fd3046e..bf6a50fa6 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", "قسم جديد"), ("Restart Numbering", "إعادة الترقيم"), ("Continue Numbering", "متابعة الترقيم"), ("Keep Options…", "خيارات الإبقاء…"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 6f47fdaff..a6442ab94 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("New Section", "Nový oddíl"), ("Restart Numbering", "Začít číslování znovu"), ("Continue Numbering", "Pokračovat v číslování"), ("Keep Options…", "Volby zachování…"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 97b48316b..53dd46e7a 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Ausschneiden"), ("Copy", "Kopieren"), ("Paste", "Einfügen"), + ("New Section", "Neuer Abschnitt"), ("Restart Numbering", "Nummerierung neu beginnen"), ("Continue Numbering", "Nummerierung fortführen"), ("Keep Options…", "Umbruchoptionen …"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 4a3656699..3e806899b 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"), ("Restart Numbering", "Riavvia numerazione"), ("Continue Numbering", "Continua numerazione"), ("Keep Options…", "Opzioni di separazione…"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 6180858af..302aa8b11 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", "新規セクション"), ("Restart Numbering", "番号の振り直し"), ("Continue Numbering", "番号の継続"), ("Keep Options…", "段落分離禁止オプション…"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 4767b6c30..6bd45a2db 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("New Section", "ახალი სექცია"), ("Restart Numbering", "ნუმერაციის თავიდან დაწყება"), ("Continue Numbering", "ნუმერაციის გაგრძელება"), ("Keep Options…", "შენარჩუნების პარამეტრები…"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index bf0c7600b..d6a5f296c 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("New Section", "Nowa sekcja"), ("Restart Numbering", "Zacznij numerowanie od nowa"), ("Continue Numbering", "Kontynuuj numerowanie"), ("Keep Options…", "Opcje utrzymywania…"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 59c4f2e23..133ca9f98 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"), ("Restart Numbering", "Reiniciar numeração"), ("Continue Numbering", "Continuar numeração"), ("Keep Options…", "Opções de manutenção…"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 412faea7e..abd94d8c1 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("New Section", "Новый раздел"), ("Restart Numbering", "Начать нумерацию заново"), ("Continue Numbering", "Продолжить нумерацию"), ("Keep Options…", "Параметры удержания…"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 62c594fd1..77b456a1a 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("New Section", "Новий розділ"), ("Restart Numbering", "Почати нумерацію знову"), ("Continue Numbering", "Продовжити нумерацію"), ("Keep Options…", "Параметри утримання…"), From 520fd13b0b6a2e71de7fc9a9bab871975fe35552 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sun, 25 Oct 2026 17:45:09 +0100 Subject: [PATCH 177/431] Effects dialog: drop a line left over from the merge Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/effects_dialog.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/ui-egui/src/effects_dialog.rs b/crates/ui-egui/src/effects_dialog.rs index 3f85c0252..9a4d45489 100644 --- a/crates/ui-egui/src/effects_dialog.rs +++ b/crates/ui-egui/src/effects_dialog.rs @@ -313,7 +313,6 @@ fn dial_angle(c: egui::Pos2, p: egui::Pos2) -> Option<f64> { let d = p - c; // Straight left is 180° (not −180°, which a negative zero would give). (d.length() > 2.0).then(|| f64::from((-d.y).atan2(d.x)).to_degrees().round()).map(|a| if a <= -180.0 { 180.0 } else { a }) - r } fn flag(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str], label: &str) { From 57e3a9a73b557d3fd8b1c725b61449d7497ea57f Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sun, 25 Oct 2026 17:46:52 +0100 Subject: [PATCH 178/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/table.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index edc0b8963..cbf4d2fde 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -440,7 +440,11 @@ fn in_cell(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; // Footnote text is addressed as a cell too, but isn't in a table. let in_table = |c: CellAddr| c.footnote_id().is_none(); - if s.active().is_some_and(|d| d.selection.text.is_some_and(|t| t.cell.is_some_and(in_table))) { Ok(()) } else { Err("not in a table cell".into()) } + if s.active().is_some_and(|d| d.selection.text.is_some_and(|t| t.cell.is_some_and(in_table))) { + Ok(()) + } else { + Err("not in a table cell".into()) + } } /// The table a command acts on and its target cell range. From 38d71ac91bca1a939f4f75665801c144789be2d8 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sun, 25 Oct 2026 19:18:22 +0100 Subject: [PATCH 179/431] Color Picker: on egui 0.37, with its fields named main moved to egui 0.37 (#1810) and named the old picker's fields. The picker's own layout here takes the new API (Role for WidgetType, scope_id, Id::unique, TextEdit::align) and names its value fields by their captions and the hex field "Hex". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/dialogs.rs | 48 +++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 38c85fa61..c3c99ec44 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -7233,11 +7233,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); @@ -7256,9 +7256,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); @@ -7285,7 +7285,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 { @@ -7293,7 +7293,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() }; if pill(ui, 0, &tr("OK"), true) || ui.input(|i| i.key_pressed(egui::Key::Enter)) { @@ -7340,12 +7340,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; } @@ -7355,7 +7355,7 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { 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) { + if let Some(v) = picker_field(ui, d, fr, key, label.trim_end_matches(':'), value, range) { if matches!(key, "r" | "g" | "bl") { d.fields.insert("model".into(), json!("rgb")); } @@ -7395,11 +7395,12 @@ 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))) + .accessible_name("Hex".to_owned()) .changed() && (h.len() == 6 || (h.len() == 7 && h.starts_with('#'))) && let Some(c) = designcraft_color::Color::from_hex(&h) @@ -11398,21 +11399,24 @@ mod picker_layout { 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<f32> { - let id = egui::Id::new(("color_picker_field", key)); +/// A value field, named `name` (its caption): 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, name: &str, value: f32, range: (f32, f32)) -> Option<f32> { + 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}"); 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 resp = ui + .put( + r, + egui::TextEdit::singleline(&mut text) + .id(id) + .font(egui::FontId::proportional(11.5)) + .align(egui::Align2::LEFT_CENTER) + .margin(egui::Margin::symmetric(4, 2)), + ) + .accessible_name(name.to_owned()); let mut out = None; if resp.changed() && let Ok(v) = text.trim().parse::<f32>() From b85d119ceb6dc53a492802f6ab8259f8e42fdedd Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sun, 25 Oct 2026 19:43:30 +0100 Subject: [PATCH 180/431] Color Picker: typed C/M/Y/K are kept even when the colour shown doesn't change At 100% K a device CMYK conversion is black whatever C, M and Y are, so the picked RGB stayed the same and the `p != before` guard dropped the typed percentages. They are stored (and the colour marked edited) whenever a C/M/Y/K field was typed in; the rest still only when the colour changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/dialogs.rs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c3c99ec44..2404a7032 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -7416,9 +7416,12 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ); if p != before { store_picked(d, &p); - if let Some(c) = exact_cmyk { - d.fields.insert("cmyk".into(), json!(c)); - } + } + // Typed C/M/Y/K are kept as typed, even when the colour shown stays the same (at 100% K, a + // device conversion is black whatever C, M and Y are). + if let Some(c) = exact_cmyk { + d.fields.insert("cmyk".into(), json!(c)); + d.fields.insert("edited".into(), json!(true)); } } @@ -11113,6 +11116,19 @@ mod tests { panic!("a CMYK swatch") }; assert_eq!([c, m, y, k].map(|v| (v * 100.0).round()), [0.0, 50.0, 100.0, 0.0]); + // 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["rgb"].clone(); + h.state_mut().app.ui.dialog.as_mut().unwrap().fields.insert("cmyk".into(), json!([0.0, 0.0, 0.0, 100.0])); + h.run_steps(2); + 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["rgb"]); + assert!(d.b("edited")); + 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); // Its own OK, at the top right, applies it. h.get_by_label("OK").click(); h.run_steps(3); From 8509c39102ae782438cadfa9805584ab4f1467e5 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sun, 25 Oct 2026 20:04:44 +0100 Subject: [PATCH 181/431] Color Picker test: the typed-CMYK case on its own Typing in C switches the picker to CMYK, which the swatch test's OK at the end then applied. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/dialogs.rs | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2404a7032..9fe6a049c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -11058,6 +11058,25 @@ mod tests { /// 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")); + } + + #[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()); @@ -11116,19 +11135,6 @@ mod tests { panic!("a CMYK swatch") }; assert_eq!([c, m, y, k].map(|v| (v * 100.0).round()), [0.0, 50.0, 100.0, 0.0]); - // 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["rgb"].clone(); - h.state_mut().app.ui.dialog.as_mut().unwrap().fields.insert("cmyk".into(), json!([0.0, 0.0, 0.0, 100.0])); - h.run_steps(2); - 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["rgb"]); - assert!(d.b("edited")); - 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); // Its own OK, at the top right, applies it. h.get_by_label("OK").click(); h.run_steps(3); 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 182/431] 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 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- 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<String, String if let Some(sz) = c.size { let _ = write!(rule, "font-size: {:.3}em; ", sz / base); } + attrs_decor_css(c, &mut rule); let (tag, class) = export_mapping(st, &cs.name, true, &cc); if let Some(selector) = class_selector(tag, &class) { let _ = writeln!(css, "{selector} {{ {rule}}}"); @@ -158,6 +159,29 @@ pub(crate) fn stylesheet_with(doc: &Document, generics: &BTreeMap<String, String css } +/// Tracking, capitalization and line decorations of a style or override, as CSS declarations. +fn attrs_decor_css(c: &CharAttrs, out: &mut String) { + if let Some(t) = c.tracking + && t != 0.0 + { + let _ = write!(out, "letter-spacing: {:.3}em; ", t / 1000.0); + } + match c.capitalization { + Some(designcraft_doc::Capitalization::AllCaps) => 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 5eddc821ecffc265b8c66e1ed1458d5413c71c5b Mon Sep 17 00:00:00 2001 From: VoiceLessQ <t12kaem@gmail.com> Date: Wed, 28 Oct 2026 22:50:39 -0200 Subject: [PATCH 183/431] 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<image::RgbaImage> { 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<u8> { + use std::io::Write; + let chunk = |out: &mut Vec<u8>, 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 d5f07d30eb9c67671ee037c869009a6e5ce7f618 Mon Sep 17 00:00:00 2001 From: VoiceLessQ <t12kaem@gmail.com> Date: Wed, 28 Oct 2026 23:28:11 -0200 Subject: [PATCH 184/431] 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<image::RgbaImage> { 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> { 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<Image> { + 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<u8>, Vec<u8>) { (b, px) } +/// A black `side`² 1-bit grey PNG: well under 1 MB, it decodes to `side`² bytes. +fn blank_png(side: u32) -> Vec<u8> { + use std::io::Write; + let chunk = |out: &mut Vec<u8>, 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<u8>, 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<u8>)> { use hayro_syntax::object::Name; From 033fc2e17e5410921aba283ab6966eef2aa703a0 Mon Sep 17 00:00:00 2001 From: VoiceLessQ <t12kaem@gmail.com> Date: Thu, 29 Oct 2026 00:12:33 -0200 Subject: [PATCH 185/431] 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> { 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<usize> = 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<Image> { 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<u8>, Vec<u8>) { } /// A black `side`² 1-bit grey PNG: well under 1 MB, it decodes to `side`² bytes. -fn blank_png(side: u32) -> Vec<u8> { +pub(crate) fn blank_png(side: u32) -> Vec<u8> { use std::io::Write; let chunk = |out: &mut Vec<u8>, 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 fd7d8dfb77c29b4203503196a287130c1550aa4c Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 12:08:10 +0100 Subject: [PATCH 186/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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<Value> = 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<Value> = 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 12:13:28 +0100 Subject: [PATCH 187/431] Eyedropper test: the ink limit is the Separations view's on main, not the canvas texture's Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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<usize> { 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 12:19:19 +0100 Subject: [PATCH 188/431] Table keys: the selection helpers by their paths on main's split modules Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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<Value> { +pub(super) fn toggle_selection(s: &mut Session) -> Result<Value> { const ID: &str = "table.toggleSelection"; let st = s.doc_mut()?; let t = st.selection.text.ok_or_else(|| bad(ID, "no caret"))?; 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<Value> { 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 12:19:27 +0100 Subject: [PATCH 189/431] Hidden characters: the test reaches frame_hidden_marks in its new module Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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<usize> = 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 12:38:46 +0100 Subject: [PATCH 190/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 12:43:14 +0100 Subject: [PATCH 191/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 3de3efccafdf84725588e4bec78a08f20e6eb9ed Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 12:50:00 +0100 Subject: [PATCH 192/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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::<bool>(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::<bool>(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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 12:50:08 +0100 Subject: [PATCH 193/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 299a65c89042e8f0f14fc963c3b3be4984da9827 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 13:16:00 +0100 Subject: [PATCH 194/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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<String> { From 4a71ee5d83f5a733dec7a36446bc0a93baa5ef15 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 13:22:55 +0100 Subject: [PATCH 195/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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::<Vec<_>>() - }; - 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::<Vec<_>>() + }; + 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 5fc74966297d2bbd579c845d598c5bef0137ddbb Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 13:43:42 +0100 Subject: [PATCH 196/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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<Value> = 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<Value> = 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 13:51:04 +0100 Subject: [PATCH 197/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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::<Vec<_>>() - }; - 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::<Vec<_>>() + }; + 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 13:55:33 +0100 Subject: [PATCH 198/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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<usize> { } #[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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 14:00:45 +0100 Subject: [PATCH 199/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 14:06:07 +0100 Subject: [PATCH 200/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 14:08:04 +0100 Subject: [PATCH 201/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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<egui::Rect> = 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<egui::Rect> = + 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 14:12:43 +0100 Subject: [PATCH 202/431] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 14:15:30 +0100 Subject: [PATCH 203/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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::<bool>(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::<bool>(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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 14:32:08 +0100 Subject: [PATCH 204/431] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 14:35:23 +0100 Subject: [PATCH 205/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 14:35:28 +0100 Subject: [PATCH 206/431] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 14:47:56 +0100 Subject: [PATCH 207/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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::<Vec<_>>(), ["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::<Vec<_>>(), ["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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 14:50:52 +0100 Subject: [PATCH 208/431] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 14:53:46 +0100 Subject: [PATCH 209/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 15:01:01 +0100 Subject: [PATCH 210/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 15:08:12 +0100 Subject: [PATCH 211/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 15:14:53 +0100 Subject: [PATCH 212/431] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 3959cafd12e5cd965a73367f06c23b615faaa25b Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 15:26:16 +0100 Subject: [PATCH 213/431] Tests: the branch's dialog tests beside main's dialog test helpers (dialogs/tests.rs) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 15:26:17 +0100 Subject: [PATCH 214/431] Tests: the branch's dialog tests beside main's dialog test helpers (dialogs/tests.rs) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 15:26:17 +0100 Subject: [PATCH 215/431] Tests: the branch's dialog tests beside main's dialog test helpers (dialogs/tests.rs) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 15:26:17 +0100 Subject: [PATCH 216/431] Tests: the branch's dialog tests beside main's dialog test helpers (dialogs/tests.rs) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 15:26:18 +0100 Subject: [PATCH 217/431] Tests: the branch's dialog tests beside main's dialog test helpers (dialogs/tests.rs) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 15:26:18 +0100 Subject: [PATCH 218/431] Tests: the branch's dialog tests beside main's dialog test helpers (dialogs/tests.rs) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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::<Vec<_>>() - }; - 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::<Vec<_>>() + }; + 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 15:26:19 +0100 Subject: [PATCH 219/431] Tests: the branch's dialog tests beside main's dialog test helpers (dialogs/tests.rs) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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<egui::Rect> = 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<egui::Rect> = 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 15:26:19 +0100 Subject: [PATCH 220/431] Tests: the branch's dialog tests beside main's dialog test helpers (dialogs/tests.rs) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 15:26:52 +0100 Subject: [PATCH 221/431] Parent page box: json! where main's split canvas view has no import Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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<PageBox> { 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 15:27:13 +0100 Subject: [PATCH 222/431] 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 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 15:27:38 +0100 Subject: [PATCH 223/431] Anchor points: the panels module's helpers from main's split Properties module Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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<Value, String> { - 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 15:29:22 +0100 Subject: [PATCH 224/431] Loaded cursor thumbnail: its drawing after main's canvas pass, one loaded field per snap context Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 15:30:31 +0100 Subject: [PATCH 225/431] TOC import: the listed style reference shared from main's split import styles module Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 15:30:59 +0100 Subject: [PATCH 226/431] Hidden characters test: in canvas/tests.rs, out of the canvas paint function Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 15:31:57 +0100 Subject: [PATCH 227/431] Georgian: main's rows for the Object menu's point words, once each Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 15:34:27 +0100 Subject: [PATCH 228/431] 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 <noreply@anthropic.com> --- 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<Value> { 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 16:26:34 +0100 Subject: [PATCH 229/431] 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 <noreply@anthropic.com> --- 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<Value> { 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 9fe094575a0a9a64e75e5885bb08ad3968361a7b Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 17:04:00 +0100 Subject: [PATCH 230/431] egui 0.37: Id::unique for the wrap offsets link state Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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<u64> = 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 17:31:39 +0100 Subject: [PATCH 231/431] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 17:49:56 +0100 Subject: [PATCH 232/431] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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::<bool>(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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 17:53:41 +0100 Subject: [PATCH 233/431] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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::<bool>(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::<bool>(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::<bool>(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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 17:57:34 +0100 Subject: [PATCH 234/431] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 18:01:19 +0100 Subject: [PATCH 235/431] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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::<bool>(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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 18:06:18 +0100 Subject: [PATCH 236/431] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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::<bool>(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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 18:27:22 +0100 Subject: [PATCH 237/431] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 18:27:45 +0100 Subject: [PATCH 238/431] Corner Options: the corner shape button re-exported from main's split Properties module Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 18:28:05 +0100 Subject: [PATCH 239/431] Paragraph border corners: the corner shape painter re-exported from main's split Properties module Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 18:42:51 +0100 Subject: [PATCH 240/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 18:46:31 +0100 Subject: [PATCH 241/431] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 18:46:46 +0100 Subject: [PATCH 242/431] Parent page box: re-exported from main's split canvas view module Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 18:47:05 +0100 Subject: [PATCH 243/431] Eyedropper: the canvas colour sampler re-exported from main's split canvas render module Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 18:49:03 +0100 Subject: [PATCH 244/431] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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::<bool>(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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 18:51:14 +0100 Subject: [PATCH 245/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 19:00:00 +0100 Subject: [PATCH 246/431] Anchor point sections: the Properties helpers they use from main's split modules Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 19:07:36 +0100 Subject: [PATCH 247/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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<Guid snap: app.ui.snap_view(), unit: app.ui.units, stroke_in_front: false, - loaded: None, + loaded: None, }; let s = designcraft_tools::snap::snap( &cx, diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index de8ae32c9..51d5d32d3 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -3,56 +3,56 @@ use egui_kittest::{Harness, kittest::Queryable}; use super::*; - /// 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:?}"); - } +/// 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 19:10:39 +0100 Subject: [PATCH 248/431] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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<egui::TextureHandle> { 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::<egui::TextureHandle>(key)) { return Some(t); } From f5647c5e081c01f70283afc927dd15d66fd07622 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 19:11:29 +0100 Subject: [PATCH 249/431] Grid snap test: the tool context's loaded field in main's moved canvas test Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 19:39:04 +0100 Subject: [PATCH 250/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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<String> = 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<String> = 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 19:43:55 +0100 Subject: [PATCH 251/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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<String> = 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<String> = 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 19:44:05 +0100 Subject: [PATCH 252/431] Tests: the branch's dialog tests beside main's dialog test helpers (dialogs/tests.rs) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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::<usize>(); - 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::<usize>(); + 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 19:44:07 +0100 Subject: [PATCH 253/431] Tests: the branch's dialog tests beside main's dialog test helpers (dialogs/tests.rs) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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<egui::Rect> = 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<egui::Rect> = 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<egui::Rect> = 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<egui::Rect> = 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 19:44:08 +0100 Subject: [PATCH 254/431] Tests: the branch's dialog tests beside main's dialog test helpers (dialogs/tests.rs) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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::<bool>(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::<bool>(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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 19:44:18 +0100 Subject: [PATCH 255/431] Tests: the branch's dialog tests beside main's dialog test helpers (dialogs/tests.rs) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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::<bool>(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::<bool>(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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 19:44:18 +0100 Subject: [PATCH 256/431] Tests: the branch's dialog tests beside main's dialog test helpers (dialogs/tests.rs) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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::<bool>(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::<bool>(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::<bool>(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::<bool>(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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 19:44:19 +0100 Subject: [PATCH 257/431] Tests: the branch's dialog tests beside main's dialog test helpers (dialogs/tests.rs) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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::<bool>(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::<bool>(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::<bool>(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::<bool>(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::<bool>(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::<bool>(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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 19:44:20 +0100 Subject: [PATCH 258/431] Tests: the branch's dialog tests beside main's dialog test helpers (dialogs/tests.rs) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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<egui::Pos2>| { - 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<egui::Pos2>| { + 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 19:44:29 +0100 Subject: [PATCH 259/431] Tests: the branch's dialog tests beside main's dialog test helpers (dialogs/tests.rs) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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::<bool>(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::<bool>(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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 19:44:30 +0100 Subject: [PATCH 260/431] Tests: the branch's dialog tests beside main's dialog test helpers (dialogs/tests.rs) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 19:51:12 +0100 Subject: [PATCH 261/431] Tests: the branch's table tests beside main's moved test helpers and imports Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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::<usize>(); - 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::<usize>(); + 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 19:52:38 +0100 Subject: [PATCH 262/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 19:57:07 +0100 Subject: [PATCH 263/431] IDML export: paragraph overprint attributes through main's attribute macros Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 19:57:14 +0100 Subject: [PATCH 264/431] IDML export: the border gap overprint through main's attribute macro too Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 19:57:22 +0100 Subject: [PATCH 265/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 20:01:16 +0100 Subject: [PATCH 266/431] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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::<bool>(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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 20:03:23 +0100 Subject: [PATCH 267/431] Georgian: main's row for Single Column, once Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 20:31:27 +0100 Subject: [PATCH 268/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 20:50:36 +0100 Subject: [PATCH 269/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 20:56:41 +0100 Subject: [PATCH 270/431] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 21:00:41 +0100 Subject: [PATCH 271/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 21:30:57 +0100 Subject: [PATCH 272/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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<usize> { (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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 21:44:05 +0100 Subject: [PATCH 273/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 21:44:21 +0100 Subject: [PATCH 274/431] 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 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 21:44:29 +0100 Subject: [PATCH 275/431] 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 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 22:02:59 +0100 Subject: [PATCH 276/431] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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::<bool>(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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 22:07:17 +0100 Subject: [PATCH 277/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 22:10:30 +0100 Subject: [PATCH 278/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 22:10:49 +0100 Subject: [PATCH 279/431] Paragraph style apply: the story's paragraph ranges, as main lists them Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 22:10:58 +0100 Subject: [PATCH 280/431] Story: para_at's doc comment back on para_at Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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<usize>) -> Vec<(usize, std::ops::Range<usize>)> { @@ -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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 22:12:48 +0100 Subject: [PATCH 281/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 22:13:13 +0100 Subject: [PATCH 282/431] 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 <noreply@anthropic.com> --- 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<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<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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 22:13:20 +0100 Subject: [PATCH 283/431] Find/Change: the branch's find tests beside main's scope helpers (find/tests.rs) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 22:24:22 +0100 Subject: [PATCH 284/431] Georgian: main's row for Gap Tint:, once Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 22:25:00 +0100 Subject: [PATCH 285/431] Georgian: main's rows where the branch added the same keys again Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 22:25:01 +0100 Subject: [PATCH 286/431] Georgian: main's rows where the branch added the same keys again Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 22:39:54 +0100 Subject: [PATCH 287/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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<usize> { (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<f64> = 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<f64> = 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 22:42:42 +0100 Subject: [PATCH 288/431] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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<f32> { - 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 22:43:09 +0100 Subject: [PATCH 289/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 22:43:22 +0100 Subject: [PATCH 290/431] Eyedropper: the canvas render module's imports for the colour sampler; screen_options keeps its doc comment Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 22:51:14 +0100 Subject: [PATCH 291/431] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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::<bool>(egui::Id::new(MULTILINE_FOCUS))).unwrap_or(false); + let multiline = ui.ctx().data_mut(|m| m.remove_temp::<bool>(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::<bool>(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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 22:55:15 +0100 Subject: [PATCH 292/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 22:59:47 +0100 Subject: [PATCH 293/431] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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::<bool>(egui::Id::new(MULTILINE_FOCUS))).unwrap_or(false); + let multiline = ui.ctx().data_mut(|m| m.remove_temp::<bool>(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::<bool>(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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 23:05:27 +0100 Subject: [PATCH 294/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 23:05:46 +0100 Subject: [PATCH 295/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 23:06:21 +0100 Subject: [PATCH 296/431] Paragraph styles: create_para whole again, with the style name parameter helper before it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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<Value> { - 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<Option<String>> { match p.get(key) { @@ -414,7 +412,7 @@ fn style_name_param(cmd: &str, p: &Value, key: &str) -> Result<Option<String>> { } } -fn create_para(s: &mut Session, p: &Value) -> Result<Value> { +pub(super) fn create_para(s: &mut Session, p: &Value) -> Result<Value> { 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 23:19:46 +0100 Subject: [PATCH 297/431] TOC toggle test: the app's fonts installed first, as main's dialog tests do Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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::<bool>(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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 23:26:17 +0100 Subject: [PATCH 298/431] Printing instructions: each field named by its label, for screen readers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 23:26:19 +0100 Subject: [PATCH 299/431] Printing instructions: each field named by its label, for screen readers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 23:26:34 +0100 Subject: [PATCH 300/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 23:36:32 +0100 Subject: [PATCH 301/431] Find/Change: Find what, Change to and Search named by their captions, for screen readers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 23:36:33 +0100 Subject: [PATCH 302/431] Find/Change: Find what, Change to and Search named by their captions, for screen readers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 23:36:33 +0100 Subject: [PATCH 303/431] Find/Change: Find what, Change to and Search named by their captions, for screen readers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 23:43:20 +0100 Subject: [PATCH 304/431] 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 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 23:46:49 +0100 Subject: [PATCH 305/431] 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 <noreply@anthropic.com> --- 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::<u64>(egui::Id::new(RENAME_DOC))) != Some(uid) { + if ui.data(|d| d.get_temp::<u64>(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::<String>(egui::Id::new(PICKED)).is_none_or(|p| p == name) { - d.insert_temp(egui::Id::new(PICKED), to.to_string()); + if d.get_temp::<String>(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::<String>(egui::Id::new(PICKED))).is_some_and(|p| p == sw.name); + let picked = ui.data(|d| d.get_temp::<String>(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::<String>(egui::Id::new(PICKED))).is_none_or(|p| p != name) { + if ui.data(|d| d.get_temp::<String>(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::<bool>(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::<bool>(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::<String>(egui::Id::new(super::PICKED))); + let picked = |h: &Harness<'_, DesignApp>| h.ctx.data(|d| d.get_temp::<String>(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 515daea39311b71eee08a6c553da7dbd6992232a Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 23:54:42 +0100 Subject: [PATCH 306/431] 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 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Thu, 29 Oct 2026 23:56:54 +0100 Subject: [PATCH 307/431] Preferences: the Tools Panel dropdown named by its label, for screen readers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 00:00:22 +0100 Subject: [PATCH 308/431] Paragraph Style Options: Next Style named by its label, for screen readers; craft_ui dropdowns Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 00:00:52 +0100 Subject: [PATCH 309/431] Dropdowns: craft_ui::ComboBox, as main's clippy configuration asks Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 00:00:55 +0100 Subject: [PATCH 310/431] Dropdowns: craft_ui::ComboBox, as main's clippy configuration asks Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 00:00:56 +0100 Subject: [PATCH 311/431] Dropdowns: craft_ui::ComboBox, as main's clippy configuration asks Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 00:00:58 +0100 Subject: [PATCH 312/431] Dropdowns: craft_ui::ComboBox, as main's clippy configuration asks Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 00:01:06 +0100 Subject: [PATCH 313/431] Dropdowns: craft_ui::ComboBox, as main's clippy configuration asks Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 00:01:08 +0100 Subject: [PATCH 314/431] Dropdowns: craft_ui::ComboBox, as main's clippy configuration asks Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 00:01:11 +0100 Subject: [PATCH 315/431] Dropdowns: craft_ui::ComboBox, as main's clippy configuration asks Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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<String> = 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 <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 00:01:12 +0100 Subject: [PATCH 316/431] Dropdowns: craft_ui::ComboBox, as main's clippy configuration asks Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 00:04:27 +0100 Subject: [PATCH 317/431] Package: the PDF Preset dropdown named by its label, for screen readers; craft_ui's dropdown Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 00:19:51 +0100 Subject: [PATCH 318/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 00:20:02 +0100 Subject: [PATCH 319/431] Page box test: the menu's entries found among the names the window shows elsewhere too Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 be81d5db0e458998e18eeb64a635b41a4471f0a2 Mon Sep 17 00:00:00 2001 From: VoiceLessQ <t12kaem@gmail.com> Date: Fri, 30 Oct 2026 00:40:00 -0200 Subject: [PATCH 320/431] 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<CmykRaster> { 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<u8> { + 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<u8> { + 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<Tone> { }, // 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 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 321/431] fix: combine inline decoration overrides with the character style in EPUB export Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- 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<String, String if let Some(sz) = c.size { let _ = write!(rule, "font-size: {:.3}em; ", sz / base); } - attrs_decor_css(c, &mut rule); + attrs_decor_css(&st.char_style_attrs(&cs.name), &mut rule); let (tag, class) = export_mapping(st, &cs.name, true, &cc); if let Some(selector) = class_selector(tag, &class) { let _ = writeln!(css, "{selector} {{ {rule}}}"); @@ -161,20 +145,23 @@ pub(crate) fn stylesheet_with(doc: &Document, generics: &BTreeMap<String, String /// Tracking, capitalization and line decorations of a style or override, as CSS declarations. fn attrs_decor_css(c: &CharAttrs, out: &mut String) { - if let Some(t) = c.tracking - && t != 0.0 - { - let _ = write!(out, "letter-spacing: {:.3}em; ", t / 1000.0); + decor_css(c.tracking.unwrap_or(0.0), c.capitalization.unwrap_or_default(), c.underline == Some(true), c.strikethrough == Some(true), out); +} + +/// Tracking, capitalization and line decorations as CSS declarations; defaults write nothing. +fn decor_css(tracking: f64, caps: designcraft_doc::Capitalization, underline: bool, strikethrough: bool, out: &mut String) { + if tracking != 0.0 { + let _ = write!(out, "letter-spacing: {:.3}em; ", tracking / 1000.0); } - match c.capitalization { - Some(designcraft_doc::Capitalization::AllCaps) => 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 7366589464ddf31e8c398af60966c3c8fdd28bea Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 11:41:28 +0100 Subject: [PATCH 322/431] Anchor point tests: transform_anchors from main's split Properties object module Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 11:46:38 +0100 Subject: [PATCH 323/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 11:46:43 +0100 Subject: [PATCH 324/431] 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 <noreply@anthropic.com> --- .../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 <tareq.halaby@gmail.com> Date: Fri, 30 Oct 2026 12:00:47 +0100 Subject: [PATCH 325/431] 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::<bool>(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::<bool>(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 d4b7e6659242b77150ad9596cbf494095b715fd8 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 12:33:53 +0100 Subject: [PATCH 326/431] Table convert dialogs: menu_enabled from main's split menu state module Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <tareq.halaby@gmail.com> Date: Fri, 30 Oct 2026 12:43:59 +0100 Subject: [PATCH 327/431] 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::<bool>(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 <tareq.halaby@gmail.com> Date: Fri, 30 Oct 2026 13:02:19 +0100 Subject: [PATCH 328/431] 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::<bool>(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::<bool>(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::<bool>(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::<bool>(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 <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 13:05:20 +0100 Subject: [PATCH 329/431] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 13:07:37 +0100 Subject: [PATCH 330/431] 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 <noreply@anthropic.com> --- 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<El>, 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 <tareq.halaby@gmail.com> Date: Fri, 30 Oct 2026 13:21:01 +0100 Subject: [PATCH 331/431] 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 <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 13:21:04 +0100 Subject: [PATCH 332/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <tareq.halaby@gmail.com> Date: Fri, 30 Oct 2026 13:56:25 +0100 Subject: [PATCH 333/431] 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 <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 14:24:57 +0100 Subject: [PATCH 334/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <tareq.halaby@gmail.com> Date: Fri, 30 Oct 2026 14:31:53 +0100 Subject: [PATCH 335/431] 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 <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 14:36:10 +0100 Subject: [PATCH 336/431] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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::<bool>(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::<bool>(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 <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 14:40:58 +0100 Subject: [PATCH 337/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 14:45:50 +0100 Subject: [PATCH 338/431] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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::<bool>(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::<bool>(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::<bool>(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 <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 14:46:10 +0100 Subject: [PATCH 339/431] Find/Change: the Search label's name apart from the branch's scope_name closure Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 15:11:34 +0100 Subject: [PATCH 340/431] Paragraph Style Options: Next Style named by its label, for screen readers; craft_ui dropdown Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 15:33:46 +0100 Subject: [PATCH 341/431] Eyedropper: the renderer's size as one (width, height) argument, as main's render takes it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 15:35:16 +0100 Subject: [PATCH 342/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 15:42:16 +0100 Subject: [PATCH 343/431] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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::<bool>(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 <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 15:50:46 +0100 Subject: [PATCH 344/431] Table of Contents: every field named by its label, for screen readers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 15:56:16 +0100 Subject: [PATCH 345/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 16:23:18 +0100 Subject: [PATCH 346/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 16:32:41 +0100 Subject: [PATCH 347/431] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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::<bool>(key)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(key, true)); 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 348/431] 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<Item>)>| { + 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::<Vec<_>>() + }; + 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 349/431] 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<Pos2> = - (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<Pos2> { + 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<String> { let st = app.session.active()?; @@ -368,3 +382,38 @@ pub fn selected_text(app: &DesignApp) -> Option<String> { } 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 350/431] 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<Submenu>, ) { - 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 <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 16:48:55 +0100 Subject: [PATCH 351/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 fc46bf20b399696f69dc80f7e1a550d8134139b8 Mon Sep 17 00:00:00 2001 From: texjer <texjer@gmail.com> Date: Fri, 30 Oct 2026 11:50:41 -0400 Subject: [PATCH 352/431] 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 @@ <key>CFBundleTypeExtensions</key> <array><string>idml</string></array> </dict> + <!-- InDesign documents and templates, converted to IDML on open (file.open); by extension + for the same reason as above. --> + <dict> + <key>CFBundleTypeName</key> + <string>InDesign Document</string> + <key>CFBundleTypeRole</key> + <string>Viewer</string> + <key>LSHandlerRank</key> + <string>Alternate</string> + <key>CFBundleTypeExtensions</key> + <array><string>indd</string><string>indt</string></array> + </dict> </array> <!-- DesignCraft's own format: a zip package, declared as a document rather than an archive so From 2505d8972ccd7c1a2581df291c9a3b24d334af4b Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 17:05:43 +0100 Subject: [PATCH 353/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/dialogs.rs | 65 ++++++++++++++++++++--------------- 1 file changed, 37 insertions(+), 28 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 773aab2c7..52f42965e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -6861,20 +6861,23 @@ fn toc_params(app: &DesignApp, d: &Dialog) -> Value { 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| { - if ui.selectable_label(cur.is_empty(), auto).clicked() { - cur.clear(); - changed = true; - } - for n in styles.iter().filter(|n| *n != auto) { - if ui.selectable_label(cur == n, n.as_str()).clicked() { - *cur = n.clone(); + craft_ui::ComboBox::from_id_salt(("toc_style", salt)) + .selected_text(shown) + .width(200.0) + .show_ui(ui, |ui| { + if ui.selectable_label(cur.is_empty(), auto).clicked() { + cur.clear(); changed = true; } - } - }) - .response - .labelled_by(name); + for n in styles.iter().filter(|n| *n != auto) { + if ui.selectable_label(cur == n, n.as_str()).clicked() { + *cur = n.clone(); + changed = true; + } + } + }) + .response + .labelled_by(name); changed } @@ -6969,15 +6972,18 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { 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| { - for (v, l) in choices { - if ui.selectable_label(r.page_number == v, tr(l)).clicked() { - r.page_number = v.into(); + craft_ui::ComboBox::from_id_salt("toc_page_number") + .selected_text(tr(cur)) + .width(200.0) + .show_ui(ui, |ui| { + for (v, l) in choices { + if ui.selectable_label(r.page_number == v, tr(l)).clicked() { + r.page_number = v.into(); + } } - } - }) - .response - .labelled_by(page_number); + }) + .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(); @@ -6990,15 +6996,18 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.checkbox(&mut r.sort, tr("Sort Entries in Alphabetical Order")); ui.end_row(); 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; + 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); + }) + .response + .labelled_by(level); ui.end_row(); }); } From 8e514a4a074db044e0f8c6db368bda861bb74fbf Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 17:15:02 +0100 Subject: [PATCH 354/431] Color Picker: its number fields and hex code named for screen readers, as on color-picker-layout Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/dialogs.rs | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2a7567400..fc39a67c0 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -7385,7 +7385,7 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { 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) { + if let Some(v) = picker_field(ui, d, fr, key, label.trim_end_matches(':'), value, range) { if matches!(key, "r" | "g" | "bl") { d.fields.insert("model".into(), json!("rgb")); } @@ -7430,6 +7430,7 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { 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).align(egui::Align2::LEFT_CENTER).margin(egui::Margin::symmetric(4, 2))) + .accessible_name("Hex".to_owned()) .changed() && (h.len() == 6 || (h.len() == 7 && h.starts_with('#'))) && let Some(c) = designcraft_color::Color::from_hex(&h) @@ -7852,20 +7853,22 @@ 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<f32> { +fn picker_field(ui: &mut egui::Ui, d: &mut Dialog, r: egui::Rect, key: &str, name: &str, value: f32, range: (f32, f32)) -> Option<f32> { 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}"); 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)) - .align(egui::Align2::LEFT_CENTER) - .margin(egui::Margin::symmetric(4, 2)), - ); + let resp = ui + .put( + r, + egui::TextEdit::singleline(&mut text) + .id(id) + .font(egui::FontId::proportional(11.5)) + .align(egui::Align2::LEFT_CENTER) + .margin(egui::Margin::symmetric(4, 2)), + ) + .accessible_name(name.to_owned()); let mut out = None; if resp.changed() && let Ok(v) = text.trim().parse::<f32>() From 69e8ab0d3afa464a87454915a81cfd8ac0de6af4 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 18:51:44 +0100 Subject: [PATCH 355/431] Searched UI commands test: a text selected context, where Convert Text to Table runs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/menus/tests.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 69e72e47e..45bf6a794 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -1953,7 +1953,7 @@ fn searched_ui_commands_run_without_parameters_and_keep_preferences() { let mut scratch = designcraft_engine::Session::new(); scratch.execute("file.new", &json!({})).unwrap(); let pdf = scratch.execute("file.exportPdf", &json!({})).unwrap()["base64"].clone(); - let contexts = ["nothing selected", "text insertion point", "text frame selected", "table cell", "placed PDF selected"]; + let contexts = ["nothing selected", "text insertion point", "text frame selected", "table cell", "placed PDF selected", "text selected"]; // A two-page document, a saved workspace and non-default preferences, a host with window // controls, and file dialogs that are dismissed. let setup = |context: usize| { @@ -1987,6 +1987,11 @@ fn searched_ui_commands_run_without_parameters_and_keep_preferences() { app.run("file.place", json!({"base64": pdf, "name": "a.pdf", "frame": f})).unwrap(); app.run("selection.set", json!({"ids": [f]})).unwrap(); } + // Convert Text to Table needs text selected. + 5 => { + let f = app.run("frame.create", json!({"rect": [72, 72, 400, 400], "content": "text", "text": "One Two"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 7})).unwrap(); + } _ => {} } app.ui.dialog = None; From a9b6bc5753c649e38a6c60290dbbaf81236ded9c Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 20:32:37 +0100 Subject: [PATCH 356/431] Eyedropper test: the drop on a point of the frame outside the picker The picker sits over the page's middle on main's dialog layout, and a drop on the picker takes nothing, as it should. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/dialogs/tests.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index a35377864..72826aa80 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3525,13 +3525,18 @@ fn the_color_pickers_eyedropper_takes_a_colour_off_the_page() { app, ); h.run_steps(6); - // Where the frame shows on screen. + // Where the frame shows on screen, outside the picker (a drop on the picker takes nothing). 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 xf = crate::canvas::Xf::new(app.canvas_rect.unwrap(), &v); + [(60.0, 660.0), (60.0, 80.0), (300.0, 600.0), (540.0, 80.0)] + .into_iter() + .map(|(x, y)| xf.to_screen(layout.slots[0].xf * designcraft_geom::Point::new(x, y))) + .find(|p| h.ctx.layer_id_at(*p).is_none_or(|l| l.order != egui::Order::Foreground)) + .expect("a point of the frame outside the picker") }; let from = h.get_by_label("Eyedropper: drag it onto the page").rect().center(); h.hover_at(from); From 5819e3aea8b298ac25185e8ff6b14f78b893197f Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 20:34:34 +0100 Subject: [PATCH 357/431] Eyedropper test: outside the picker is outside the dialog's own layer Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- 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 72826aa80..4a97d6770 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3535,7 +3535,7 @@ fn the_color_pickers_eyedropper_takes_a_colour_off_the_page() { [(60.0, 660.0), (60.0, 80.0), (300.0, 600.0), (540.0, 80.0)] .into_iter() .map(|(x, y)| xf.to_screen(layout.slots[0].xf * designcraft_geom::Point::new(x, y))) - .find(|p| h.ctx.layer_id_at(*p).is_none_or(|l| l.order != egui::Order::Foreground)) + .find(|p| h.ctx.layer_id_at(*p).is_none_or(|l| l.id != egui::Id::unique("dialog"))) .expect("a point of the frame outside the picker") }; let from = h.get_by_label("Eyedropper: drag it onto the page").rect().center(); From aef687906f52393963f3973ccab9c70cdbde7446 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 20:38:24 +0100 Subject: [PATCH 358/431] Eyedropper test: any point of the frame the picker leaves uncovered Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/dialogs/tests.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 4a97d6770..a07d46c17 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3532,8 +3532,9 @@ fn the_color_pickers_eyedropper_takes_a_colour_off_the_page() { let layout = designcraft_tools::CanvasLayout::new(doc, false); let v = *app.view().unwrap(); let xf = crate::canvas::Xf::new(app.canvas_rect.unwrap(), &v); - [(60.0, 660.0), (60.0, 80.0), (300.0, 600.0), (540.0, 80.0)] - .into_iter() + // Any point of the frame (36–560 × 36–700 pt) the picker leaves uncovered. + (0..27) + .flat_map(|i| (0..34).map(move |j| (46.0 + 19.0 * f64::from(i), 46.0 + 19.0 * f64::from(j)))) .map(|(x, y)| xf.to_screen(layout.slots[0].xf * designcraft_geom::Point::new(x, y))) .find(|p| h.ctx.layer_id_at(*p).is_none_or(|l| l.id != egui::Id::unique("dialog"))) .expect("a point of the frame outside the picker") From d31a838aab0dbf7089f1a8a620d9a2ac7a8b973f Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 20:41:11 +0100 Subject: [PATCH 359/431] Eyedropper test: back to dropping on the frame's middle The picker's modal backdrop covers the screen on main, so no point is outside its host layer; the drop check compares with the picker's own layer, which the middle of the frame isn't on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/dialogs/tests.rs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index a07d46c17..a35377864 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3525,19 +3525,13 @@ fn the_color_pickers_eyedropper_takes_a_colour_off_the_page() { app, ); h.run_steps(6); - // Where the frame shows on screen, outside the picker (a drop on the picker takes nothing). + // 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(); - let xf = crate::canvas::Xf::new(app.canvas_rect.unwrap(), &v); - // Any point of the frame (36–560 × 36–700 pt) the picker leaves uncovered. - (0..27) - .flat_map(|i| (0..34).map(move |j| (46.0 + 19.0 * f64::from(i), 46.0 + 19.0 * f64::from(j)))) - .map(|(x, y)| xf.to_screen(layout.slots[0].xf * designcraft_geom::Point::new(x, y))) - .find(|p| h.ctx.layer_id_at(*p).is_none_or(|l| l.id != egui::Id::unique("dialog"))) - .expect("a point of the frame outside the picker") + 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); From 9e51746b63eedba1d7c49f354362c0a44ec0fa63 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 20:44:50 +0100 Subject: [PATCH 360/431] Color Picker eyedropper: a drop over the picker is one inside where it draws On main the dialog is a modal whose transparent backdrop shares the picker's layer and covers the screen, so comparing layers took every drop for one on the picker and the eyedropper took nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/dialogs.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index fc39a67c0..15ff35e34 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -7451,10 +7451,11 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { if resp.dragged() { ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); } - // Let go over the picker itself it takes nothing, though a page may lie under it. + // Let go over the picker itself it takes nothing, though a page may lie under it. (Its modal + // backdrop shares the picker's layer and covers the screen: the picker is where it draws.) if resp.drag_stopped() && let Some(pos) = ui.input(|i| i.pointer.latest_pos()) - && ui.ctx().layer_id_at(pos) != Some(ui.layer_id()) + && !ui.clip_rect().contains(pos) && let Some(c) = crate::canvas::color_at(app, ui.ctx(), pos) { p = Picked::from_rgb(c.map(|v| f32::from(v) / 255.0), p.hsb[0]); From a562053b7e9e2c24ec2feba3685ee6cc7cf63179 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 30 Oct 2026 19:46:02 +0000 Subject: [PATCH 361/431] Integration: #1406 preview_dropdown 4-argument form --- crates/ui-egui/src/panels/properties/object.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/properties/object.rs b/crates/ui-egui/src/panels/properties/object.rs index 61bc3c38d..869a741ab 100644 --- a/crates/ui-egui/src/panels/properties/object.rs +++ b/crates/ui-egui/src/panels/properties/object.rs @@ -312,7 +312,8 @@ fn paint_corner_shape(p: &egui::Painter, r: Rect, shape: CornerShape, c: egui::C /// A corner shape picker's face: the shape's glyph and the dropdown arrow (the menu is the caller's). pub(crate) fn corner_shape_button(ui: &mut Ui, shape: CornerShape, width: f32) -> egui::Response { - preview_dropdown(ui, width, |p, r| paint_corner_shape(p, r, shape)) + // The caller names the control (widget_info) once it knows the corner. + preview_dropdown(ui, width, "", |p, r| paint_corner_shape(p, r, shape, egui::Color32::BLACK)) } pub(crate) fn corner_type_dropdown(app: &mut DesignApp, ui: &mut Ui, shape: CornerShape, size: f64, width: f32) { From aabf0fa80521f3c026f77ddbd9bf083ba7d67b9b Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 30 Oct 2026 19:47:45 +0000 Subject: [PATCH 362/431] Integration: #1431/#1504 spectrum_pick takes the language (merged helper lost its app) --- crates/ui-egui/src/panels/swatches.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 236bd9388..58cc8d85c 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -781,7 +781,7 @@ fn gradient_stop(app: &mut DesignApp, ui: &mut egui::Ui, g: &designcraft_color:: ui.label(egui::RichText::new(format!("L {:.0} a {:.0} b {:.0}", lab.l, lab.a, lab.b)).color(t.text_dim)); } } - if let Some([r, gr, b]) = spectrum_pick(ui) { + if let Some([r, gr, b]) = spectrum_pick(ui, &app.ui.language) { new = Some(Color::rgb(r, gr, b)); } if let Some(c) = new { @@ -793,16 +793,16 @@ fn gradient_stop(app: &mut DesignApp, ui: &mut egui::Ui, g: &designcraft_color:: /// The spectrum ramp: hue across, light to dark down; click to apply. fn spectrum(app: &mut DesignApp, ui: &mut egui::Ui, target: &str) { - if let Some([r, g, b]) = spectrum_pick(ui) { + if let Some([r, g, b]) = spectrum_pick(ui, &app.ui.language) { let text = super::colors_affect_text(app); let _ = app.run("object.color", json!({"color": {"model": "rgb", "r": r, "g": g, "b": b}, "target": target, "text": text})); } } /// The spectrum ramp; a click on it gives that colour (RGB, 0–1, in 1/255 steps). -fn spectrum_pick(ui: &mut egui::Ui) -> Option<[f32; 3]> { +fn spectrum_pick(ui: &mut egui::Ui, language: &str) -> Option<[f32; 3]> { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 36.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, crate::i18n::tr(&app.ui.language, "Color spectrum"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, crate::i18n::tr(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]] From 6e9ef6248c7ced843ef036ed7bcf688427fc98f3 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 30 Oct 2026 19:48:22 +0000 Subject: [PATCH 363/431] Integration: cargo fmt --- 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 ecb9e664a..7243bad44 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -30,9 +30,9 @@ pub use flex::flex_panel; 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}; #[cfg(test)] use object::{align_to_icon, preview_row}; -use object::{align_row, appearance_section, guide_section, quick_actions_object, transform_section, wrap_section}; 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}; From 4bc752dc66df9ecb88729e85e55740e999fe5c69 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 20:49:12 +0100 Subject: [PATCH 364/431] Color Picker: the eyedropper in its own function Keeps color_picker within clippy's cognitive complexity limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/dialogs.rs | 48 ++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 15ff35e34..06466d4e6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -7217,6 +7217,32 @@ fn nums<const N: usize>(d: &Dialog, k: &str) -> Option<[f64; N]> { Some(out) } +/// The Color Picker's eyedropper at `at`, as InDesign's: dragged onto the page, it takes the colour +/// shown under the pointer when it is let go. The colour taken, if any. +fn eyedropper(app: &mut DesignApp, ui: &mut egui::Ui, at: egui::Pos2, tip: String, t: &crate::theme::Tokens) -> Option<[u8; 3]> { + let er = egui::Rect::from_center_size(at, egui::vec2(21.0, 21.0)); + 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); + } + crate::icons::paint(ui.painter(), egui::Rect::from_center_size(er.center(), egui::vec2(15.0, 15.0)), "tool-eyedropper", t.icon); + if resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + // Let go over the picker itself it takes nothing, though a page may lie under it. (Its modal + // backdrop shares the picker's layer and covers the screen: the picker is where it draws.) + let mut taken = None; + if resp.drag_stopped() + && let Some(pos) = ui.input(|i| i.pointer.latest_pos()) + && !ui.clip_rect().contains(pos) + { + taken = crate::canvas::color_at(app, ui.ctx(), pos); + } + resp.on_hover_text(tip); + taken +} + /// 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 @@ -7438,29 +7464,9 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { p = Picked::from_rgb(c.to_rgb_uncalibrated(), p.hsb[0]); } d.fields.insert("hexEdit".into(), json!(h)); - // The eyedropper, as InDesign's: dragged onto the page, it takes the colour shown under the - // 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::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); - } - crate::icons::paint(ui.painter(), egui::Rect::from_center_size(er.center(), egui::vec2(15.0, 15.0)), "tool-eyedropper", t.icon); - if resp.dragged() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } - // Let go over the picker itself it takes nothing, though a page may lie under it. (Its modal - // backdrop shares the picker's layer and covers the screen: the picker is where it draws.) - if resp.drag_stopped() - && let Some(pos) = ui.input(|i| i.pointer.latest_pos()) - && !ui.clip_rect().contains(pos) - && let Some(c) = crate::canvas::color_at(app, ui.ctx(), pos) - { + if let Some(c) = eyedropper(app, ui, egui::pos2(hr.right() + 16.0, cy), tr("Eyedropper: drag it onto the page"), &t) { p = Picked::from_rgb(c.map(|v| f32::from(v) / 255.0), p.hsb[0]); } - resp.on_hover_text(tip); if p != before { store_picked(d, &p); if let Some(c) = exact_cmyk { From 60e3068bbfcf7d3386aea1fb88ade9dde087c433 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 30 Oct 2026 20:51:17 +0000 Subject: [PATCH 365/431] Integration: close the TOC bookmarks test where track a's tests were appended --- crates/ui-egui/src/dialogs/tests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 7cf010c83..8a80198ea 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -4173,6 +4173,8 @@ fn toc_bookmarks_checkbox_sets_and_reopens_the_persisted_option() { assert!(app.session.doc().unwrap().doc.bookmarks.is_empty()); open_toc(&mut app); assert!(!app.ui.dialog.as_ref().unwrap().b("createBookmarks")); +} + #[test] fn corner_options_dialog_sets_each_corner_with_preview_and_one_undo_step() { use designcraft_geom::corners::CornerShape; From 6a1d554d4477dc43088b8cd508e54763c729f563 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 30 Oct 2026 21:22:34 +0000 Subject: [PATCH 366/431] Integration: #1554 prefs.set rejects unknown keys inside defaultUnits --- crates/engine/src/cmd/prefs.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/engine/src/cmd/prefs.rs b/crates/engine/src/cmd/prefs.rs index a6b53d605..f51ce3c70 100644 --- a/crates/engine/src/cmd/prefs.rs +++ b/crates/engine/src/cmd/prefs.rs @@ -102,6 +102,9 @@ fn merge(cmd: &str, mut cur: Value, p: &Value, allowed: Option<&[&str]>) -> Resu match (c.get_mut(k), v) { (Some(Value::Object(old)), Value::Object(v)) => { for (k2, v2) in v { + if !old.is_empty() && !old.contains_key(k2) { + return Err(bad(cmd, format!("unknown preference {k}.{k2}"))); + } old.insert(k2.clone(), v2.clone()); } } @@ -129,6 +132,7 @@ mod tests { s.execute("prefs.set", &json!({"showFontNamesInEnglish": true})).unwrap(); assert!(s.prefs.show_font_names_in_english); assert!(s.execute("prefs.set", &json!({"nope": 1})).is_err()); + assert!(s.execute("prefs.set", &json!({"defaultUnits": {"horizontalUnit": "inches"}})).is_err()); s.execute("file.new", &json!({})).unwrap(); let r = s .execute("document.preferences", &json!({"horizontalUnits": "millimeters", "keyboardIncrement": 2.5, "grid": {"subdivisions": 4}})) From 2bf89ce3708ee315393a04f5086b50412d4f611a Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 30 Oct 2026 21:23:07 +0000 Subject: [PATCH 367/431] Integration: #1394 turning Auto-Fit off alone keeps the picture where it is --- crates/engine/src/cmd/object.rs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index e85f20f33..6bf4a02cc 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -2968,7 +2968,9 @@ fn fitting_options(s: &mut Session, p: &Value) -> Result<Value> { } else if fitting.is_some() && g.auto_fit != designcraft_doc::Fitting::None { g.auto_fit = mode; } - if let Some(xf) = g.fitted(r, mode) { + // Turning Auto-Fit off alone leaves the picture where the user put it. + let turn_off_only = auto == Some(false) && fitting.is_none() && align.is_none() && crop.is_none(); + if !turn_off_only && let Some(xf) = g.fitted(r, mode) { g.xf = xf; } n += 1; @@ -4412,6 +4414,28 @@ mod fitting_tests { assert_eq!(g.auto_fit, designcraft_doc::Fitting::FillProportionally); } + /// Turning Auto-Fit off by itself keeps a moved picture where it is. + #[test] + fn auto_fit_off_alone_keeps_the_picture_position() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].as_u64().unwrap(); + let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png(); + s.execute("file.place", &json!({"base64": crate::cmd::base64_encode(&png), "name": "p.png", "frame": f})).unwrap(); + s.execute("object.fittingOptions", &json!({"ids": [f], "autoFit": true, "fitting": "fitProportionally"})).unwrap(); + let xf = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().graphic().unwrap().xf; + s.edit(|d, _| { + if let Some(Content::Graphic(g)) = d.item_mut(ItemId(f)).map(|it| &mut it.content) { + g.xf = designcraft_geom::Affine::translate((7.0, 9.0)) * g.xf; + } + Ok(Value::Null) + }) + .unwrap(); + let moved = xf(&s); + s.execute("object.fittingOptions", &json!({"ids": [f], "autoFit": false})).unwrap(); + assert_eq!(xf(&s), moved); + } + /// Clear Frame Fitting Options puts auto-fit, crop and the reference point back to their /// defaults and leaves the picture where it is. #[test] From bc1be1e64667c3690698f1fffcd9cd0fa0f1bf8b Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 30 Oct 2026 21:23:46 +0000 Subject: [PATCH 368/431] Integration: #1504 a Color panel edit of a gradient stop drops the stop's swatch --- crates/ui-egui/src/panels/swatches.rs | 29 +++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 58cc8d85c..c934d6b19 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -786,7 +786,7 @@ fn gradient_stop(app: &mut DesignApp, ui: &mut egui::Ui, g: &designcraft_color:: } if let Some(c) = new { let mut stops = stops_json(g); - set_stop(&mut stops, i, "color", color_json(&c)); + set_stop_color(&mut stops, i, color_json(&c)); let _ = app.run("object.gradient", json!({"stops": stops, "target": target})); } } @@ -843,6 +843,19 @@ fn color_json(c: &designcraft_color::Color) -> serde_json::Value { } } +#[cfg(test)] +mod stop_color_tests { + use super::*; + + #[test] + fn a_new_stop_colour_drops_the_stops_swatch() { + let mut stops = vec![json!({"location": 0, "color": "#000000", "swatch": "Gold"})]; + set_stop_color(&mut stops, 0, json!("#ff0000")); + assert_eq!(stops[0]["color"], "#ff0000"); + assert!(stops[0].get("swatch").is_none()); + } +} + #[cfg(test)] mod color_panel_defaults_tests { use super::*; @@ -1050,6 +1063,14 @@ fn live_drag(d: Option<RampDrag>, n: usize) -> Option<RampDrag> { } /// Sets `key` of stop `i` in `object.gradient` stops; nothing if there's no such stop. +/// A new colour replaces the stop's swatch (`object.gradient` gives `swatch` precedence). +fn set_stop_color(stops: &mut [serde_json::Value], i: usize, color: serde_json::Value) { + if let Some(o) = stops.get_mut(i).and_then(serde_json::Value::as_object_mut) { + o.remove("swatch"); + } + set_stop(stops, i, "color", color); +} + fn set_stop(stops: &mut [serde_json::Value], i: usize, key: &str, v: serde_json::Value) { if let Some(o) = stops.get_mut(i).and_then(serde_json::Value::as_object_mut) { o.insert(key.to_string(), v); @@ -1239,11 +1260,7 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let mut rgb = [(r * 255.0) as u8, (gg * 255.0) as u8, (bb * 255.0) as u8]; if egui::color_picker::color_edit_button_srgb(ui, &mut rgb).changed() { let mut stops = stops_json(&g); - // A picked color replaces the stop's swatch. - if let Some(o) = stops.get_mut(i).and_then(serde_json::Value::as_object_mut) { - o.remove("swatch"); - } - set_stop(&mut stops, i, "color", json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]))); + set_stop_color(&mut stops, i, json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]))); send = Some(json!({"stops": stops})); } }); From 09fbe280ffa0958f897f1ec0baa3458147a86ff5 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 30 Oct 2026 21:24:19 +0000 Subject: [PATCH 369/431] Integration: #1125 Direct Selection on one of several selected frames selects just that frame --- crates/tools/src/select.rs | 7 +++++-- crates/tools/src/tests.rs | 13 +++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 43f36b703..16ccbad0c 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -789,8 +789,11 @@ 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.selection.items.len() > 1 || cx.doc.item(id).is_some_and(holds_content))) + { + // Direct Selection on a selected frame selects its content (a graphic or pasted-in items), + // and on one of several selected objects just that object. 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 diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 94e858092..03a19c73a 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -2069,3 +2069,16 @@ fn loaded_text_cursor_threads_into_a_frame_inside_a_group() { assert_eq!(cx2.hit_text_frame(q).map(|(_, i)| i), None, "no text frame in that group"); assert_eq!(t2.cursor(&cx2, q, Mods::default()), Cursor::NotAllowed); } + +#[test] +fn direct_selection_click_on_one_of_several_selected_frames_selects_only_it() { + let mut d = Document::new(&NewDocument::default()); + let a = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0)); + let b = add_rect(&mut d, Rect::new(300.0, 100.0, 400.0, 200.0)); + let (s, c, l) = (Selection::items(vec![a, b]), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let p = spread_pt(&l, 150.0, 150.0); + let mut t = create("directSelection"); + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y)); + assert_eq!(acts, vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [a.0], "content": true}))]); +} From 55fb94242e7b28c14da0705b1ea67f4633253ee1 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 30 Oct 2026 21:24:27 +0000 Subject: [PATCH 370/431] Integration: #902 Direct Selection anchor pull selects the frame, not its content --- crates/tools/src/select.rs | 2 +- crates/tools/src/tests.rs | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 16ccbad0c..7f4c75780 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -714,7 +714,7 @@ impl SelectionTool { { 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())]; + return vec![Action::Exec("selection.set".into(), json!({"ids": [id], "content": false})), 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). diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 03a19c73a..c216277aa 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -2082,3 +2082,18 @@ fn direct_selection_click_on_one_of_several_selected_frames_selects_only_it() { let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y)); assert_eq!(acts, vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [a.0], "content": true}))]); } + +#[test] +fn pulling_a_corner_of_an_unselected_frame_selects_the_frame_not_its_content() { + let mut d = Document::new(&NewDocument::default()); + let a = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0)); + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let p = spread_pt(&l, 200.0, 100.0); + let mut t = create("directSelection"); + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y)); + assert_eq!( + acts, + vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [a.0], "content": false})), Action::Begin("Move Anchor".into())] + ); +} From 978effab49d06d80510521353974c1081c185637 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 30 Oct 2026 21:24:27 +0000 Subject: [PATCH 371/431] Integration: #902 Direct Selection anchor drags send a spread-space delta --- crates/tools/src/select.rs | 4 +++- crates/tools/src/tests.rs | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 7f4c75780..d7b8ed48e 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -1095,7 +1095,9 @@ impl SelectionTool { } self.guides = hit.guides; } - let mut params = json!({"id": id, "anchors": [[si, ai]], "dx": dx, "dy": dy}); + // path.moveAnchors takes a spread-space delta. + let delta = 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": delta.x, "dy": delta.y}); if let Some(h) = handle { params["handle"] = json!(h); } diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index c216277aa..3b8ea0f75 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -2097,3 +2097,22 @@ fn pulling_a_corner_of_an_unselected_frame_selects_the_frame_not_its_content() { vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [a.0], "content": false})), Action::Begin("Move Anchor".into())] ); } + +#[test] +fn anchor_drag_on_a_rotated_spread_sends_a_spread_space_delta() { + let mut d = Document::new(&NewDocument::default()); + let a = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0)); + Arc::make_mut(&mut d.spreads[0]).pages[0].view_rotation = 1; + let (s, c, l) = (Selection::items(vec![a]), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let p = l.to_canvas(SpreadRef::Doc(0), Point::new(200.0, 100.0)); + let mut t = create("directSelection"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y)); + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, p.x + 50.0, p.y)); + let [Action::Preview(cmd, params)] = acts.as_slice() else { panic!("{acts:?}") }; + assert_eq!(cmd, "path.moveAnchors"); + let want = l.delta_to_spread(SpreadRef::Doc(0), Vec2::new(50.0, 0.0)); + assert!(want.y.abs() > 1.0, "the view rotation turns a canvas x move into a spread y move: {want:?}"); + let (dx, dy) = (params["dx"].as_f64().unwrap(), params["dy"].as_f64().unwrap()); + assert!((dx - want.x).abs() < 1e-6 && (dy - want.y).abs() < 1e-6, "{params} want {want:?}"); +} From 376cf3325a5f4fb6dfbd36ea4b55ab0d03bc5c6f Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 30 Oct 2026 21:24:39 +0000 Subject: [PATCH 372/431] Integration: #1505 leaving parent pages for a page fits its spread before centring the page --- crates/ui-egui/src/canvas/tests.rs | 14 ++++++++++++++ crates/ui-egui/src/canvas/view.rs | 8 +++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index f36269479..7d89a24a8 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2869,3 +2869,17 @@ fn table_cells_show_their_hidden_characters() { let last = marks.last().unwrap(); assert_eq!((last.0, last.1), (designcraft_geom::Point::new(frag.rect.x1 + 1.0, frag.rect.y1), "¶")); } + +#[test] +fn leaving_parent_editing_for_the_left_page_of_a_spread_keeps_that_page_selected() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 4, "facingPages": true})).unwrap(); + let mut h = harness(app); + h.state_mut().run("layout.parents.edit", json!({"on": true, "parent": "A-Parent"})).unwrap(); + h.run_steps(2); + let b = page_box(h.state()).unwrap(); + // Page 2 is the left page of the second spread. + page_box_go(h.state_mut(), &b, 1); + h.run_steps(3); + assert_eq!(current_page(h.state()), Some(1)); +} diff --git a/crates/ui-egui/src/canvas/view.rs b/crates/ui-egui/src/canvas/view.rs index b679923d2..2ba0876a1 100644 --- a/crates/ui-egui/src/canvas/view.rs +++ b/crates/ui-egui/src/canvas/view.rs @@ -188,9 +188,11 @@ 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", 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; + // Fit the page's spread first, then centre the page: a later refit would centre the + // spread, and the page box would then show its other page. + let spread = app.session.active().and_then(|st| st.doc.page_loc(i)).map(|(si, _)| SpreadRef::Doc(si)); + if let Some(r) = spread { + show_spread(app, r); } } go_to_page(app, i); From e05c1c5360c46cdf9901366e94b754f36c23c024 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 30 Oct 2026 21:24:39 +0000 Subject: [PATCH 373/431] Integration: #1425 the cursor readout has its own dark text colour on the light grey box --- crates/ui-egui/src/canvas/tool_overlays.rs | 4 +-- crates/ui-egui/src/theme.rs | 30 +++++++++++++++++----- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/canvas/tool_overlays.rs b/crates/ui-egui/src/canvas/tool_overlays.rs index 6fb8a0719..e6beb1e65 100644 --- a/crates/ui-egui/src/canvas/tool_overlays.rs +++ b/crates/ui-egui/src/canvas/tool_overlays.rs @@ -31,10 +31,10 @@ pub(super) fn draw_tool_overlays(app: &mut DesignApp, painter: &egui::Painter, x } Overlay::Measure { p, text } => { let s = xf.to_screen(p) + vec2(14.0, 14.0); - let g = painter.layout_no_wrap(text, egui::FontId::proportional(11.0), tok.measure_text); + let g = painter.layout_no_wrap(text, egui::FontId::proportional(11.0), tok.readout_text); let r = Rect::from_min_size(s, g.size() + vec2(10.0, 6.0)); painter.rect_filled(r, 3.0, tok.readout_bg); - painter.galley(r.min + vec2(5.0, 3.0), g, tok.measure_text); + painter.galley(r.min + vec2(5.0, 3.0), g, tok.readout_text); } Overlay::Line { a, b, color, dashed: d } => { let st = Stroke::new(1.0, c32(color)); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index bdc6dae98..8efa2e423 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -84,8 +84,9 @@ pub struct Tokens { pub measure_bg: Color32, pub measure_text: Color32, /// The readout beside the cursor while drawing, moving or measuring ("W: 3.13 cm"): InDesign's - /// light grey box with white text, in every brightness but High Contrast. + /// light grey box, with dark text in every brightness but High Contrast. pub readout_bg: Color32, + pub readout_text: Color32, /// Live-corner widget and corner diamonds. pub live_corner: Color32, /// The Windows caption Close button when hovered, and its glyph. @@ -134,6 +135,7 @@ impl Tokens { measure_bg: Color32::from_rgba_unmultiplied(70, 70, 70, 230), measure_text: Color32::WHITE, readout_bg: hex(0xa8a8a8), + readout_text: hex(0x1e1e1e), live_corner: hex(0xffe500), caption_close: hex(0xc42b1c), caption_close_text: Color32::WHITE, @@ -261,6 +263,7 @@ impl Tokens { tab_strip: hex(0x000000), // White on InDesign's grey is too faint here: black, as the rest of the chrome. readout_bg: hex(0x000000), + readout_text: Color32::WHITE, ..dark }, } @@ -864,16 +867,31 @@ mod russian_font_tests { mod readout_tests { use super::{Brightness, Tokens}; + fn luminance(c: egui::Color32) -> f32 { + let lin = |v: u8| { + let v = v as f32 / 255.0; + if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * lin(c.r()) + 0.7152 * lin(c.g()) + 0.0722 * lin(c.b()) + } + + fn contrast(a: egui::Color32, b: egui::Color32) -> f32 { + let (x, y) = (luminance(a), luminance(b)); + (x.max(y) + 0.05) / (x.min(y) + 0.05) + } + /// The readout beside the cursor ("W: 3.13 cm" while drawing a frame) is InDesign's light - /// grey box with white text in every brightness, as measured from the tutorials' frames; - /// High Contrast keeps it black. + /// grey box in every brightness, with text that stays readable on it; High Contrast keeps + /// it black. #[test] - fn the_cursor_readout_is_indesigns_light_grey() { + fn the_cursor_readout_is_indesigns_light_grey_with_readable_text() { for b in [Brightness::Dark, Brightness::MediumDark, Brightness::MediumLight, Brightness::Light] { let t = Tokens::for_brightness(b); assert_eq!(t.readout_bg, egui::Color32::from_rgb(0xa8, 0xa8, 0xa8), "{b:?}"); - assert_eq!(t.measure_text, egui::Color32::WHITE, "{b:?}"); + assert!(contrast(t.readout_text, t.readout_bg) >= 4.5, "{b:?}"); } - assert_eq!(Tokens::for_brightness(Brightness::HighContrast).readout_bg, egui::Color32::BLACK); + let hc = Tokens::for_brightness(Brightness::HighContrast); + assert_eq!(hc.readout_bg, egui::Color32::BLACK); + assert!(contrast(hc.readout_text, hc.readout_bg) >= 4.5); } } From ff5d4e1f8e624a5063a78db9b3bd5143fa0ed125 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 30 Oct 2026 21:24:47 +0000 Subject: [PATCH 374/431] Integration: #1509 spelling suggestions include the common words --- crates/engine/src/cmd/spelling.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/spelling.rs b/crates/engine/src/cmd/spelling.rs index c799f9b1f..5e7a425ea 100644 --- a/crates/engine/src/cmd/spelling.rs +++ b/crates/engine/src/cmd/spelling.rs @@ -206,7 +206,7 @@ pub fn suggest(dict: &Dictionary, w: &str) -> Vec<String> { let mut out: Vec<String> = Vec::new(); let mut push = |c: Vec<char>| { let c: String = c.into_iter().collect(); - if c != w && !out.contains(&c) && dict.get(&c).is_some() { + if c != w && !out.contains(&c) && (dict.get(&c).is_some() || COMMON.binary_search(&c.as_str()).is_ok()) { out.push(c); } }; @@ -316,6 +316,7 @@ mod tests { } assert!(!known(d, &[], "typograpy")); assert!(suggest(d, "typograpy").contains(&"typography".to_string())); + assert!(suggest(d, "emial").contains(&"email".to_string()), "words in COMMON are suggested too"); assert!(known(d, &["designcraft".into()], "designcraft")); } From 336eb0593c67052bff67fba2f48fe0464ed17c02 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 30 Oct 2026 21:25:12 +0000 Subject: [PATCH 375/431] Integration: #1394/#1413 ROADMAP: one Status heading, progress log newest first --- ROADMAP.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 471e16039..52bfb0f3b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -46,7 +46,6 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope default layer queries find a placed PDF anywhere in mixed selections. Regression tests cover IDML export after rejection and both selection orders. -## Status (2026-10-01) ## Status (2026-10-21) **Working today** @@ -469,8 +468,6 @@ Newest first. - **2026-10-21** · Preferences › Interface follows InDesign's: Appearance has a Color Theme swatch per interface theme (a click shows it at once, Cancel goes back) and Panels sets the Tools panel to a single or double column; UI Size moves to a UI Scaling section of its own. Interface held only UI Size, and the theme could be changed only from the Window menu. Found in the Learn Skills Daily course, chapter 26. No readiness percentage change. - **2026-10-21** · Preferences › File Handling shows Number of Recent Items to Display as a whole number ("20"; it read "20.0"). Found in the Learn Skills Daily course, chapter 26. No readiness percentage change. - **2026-10-21** · Insert Pages… opens InDesign's dialog: Pages, Insert (After Page, Before Page, At Start of Document, At End of Document) with the page by name, and Parent ([None] or a parent), set to the page in view and its parent. It was a form asking for a page index and a parent id, and pages could not go before the first page; `layout.pages.insert` takes `before` (0: at the start). Found in the Learn Skills Daily course, chapter 25. No readiness percentage change. -- **2026-10-18** · Table of Contents offers More Options / Fewer Options, preserving page-number, separator, sorting and level settings while their controls are hidden. No readiness percentage change. -- **2026-10-18** · Table of Contents reserves the title field's width so Contents is readable next to its style picker; the grid previously squeezed it to a few characters. No readiness percentage change. - **2026-10-21** · Spelling (Check Spelling and Dynamic Spelling) no longer flags everyday words the bundled hyphenation word list lacks: "we", "us", "if", titles such as "Mr", and words such as email, online, website, database, desktop and download, with their plurals and other forms. Contractions typed with a typographer's apostrophe (don’t, we’re), which Use Typographer's Quotes produces by default, count like their straight-apostrophe forms. Dynamic Spelling used to underline most words of an ordinary sentence. Found in the Learn Skills Daily course, chapter 26. No readiness percentage change. - **2026-10-21** · While a parent is shown, the status bar's page box and Properties › Page name it ("A-Parent") instead of page 1, as InDesign's; their lists have the parents after the pages, and choosing one shows that parent (a page leaves the parents). Found in the Learn Skills Daily course, chapter 25. No readiness percentage change. - **2026-10-21** · Effects dialog: the Angle field of the shadows, Bevel and Emboss, Satin and Gradient Feather has InDesign's dial beside it, its radius pointing where the light comes from; a click or drag on it sets the angle. Found in the Learn Skills Daily course, chapter 14. No readiness percentage change. @@ -484,26 +481,25 @@ Newest first. - **2026-10-21** · Properties › Align: the Align To button shows what the objects align to, one glyph each for the selection, the key object, the margins, the page and the spread, as InDesign's. Found in the Learn Skills Daily course, chapter 12. No readiness percentage change. - **2026-10-20** · Object › Corner Options opens a Corner Options dialog laid out as InDesign's: each corner's size and shape, the link button that makes all four the same, and Preview. The Corner link in Properties › Appearance opens it too (it opened a Rectangle size dialog). No readiness percentage change. - **2026-10-20** · The Object menu is in InDesign's order and groups; Paths has Join, Open Path and Close Path (Convert Shape lists only shapes, as InDesign's does) and a Convert Point submenu (Plain, Corner, Smooth, Symmetrical) is added. No readiness percentage change. -- **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-20** · Layout › Create Guides puts its guides on both pages of the spread in view, as InDesign does; "This page" in Apply to narrows them to one page. No readiness percentage change. - **2026-10-21** · Object › Fitting › Clear Frame Fitting Options (`object.clearFittingOptions`): the frames' auto-fit, crop amounts and reference point go back to their defaults, and the picture stays where it is. The Fitting submenu is grouped as InDesign's. Found in the Learn Skills Daily course, chapter 11. No readiness percentage change. - **2026-10-21** · Swatches panel rows show InDesign's columns, drawn in code: a crossed pencil on the swatches that can't be edited ([None], [Registration], [Black]), the colour type (process or spot) and the colour mode (CMYK, RGB, Gray, or [None]'s and [Registration]'s own glyphs), in place of a lock and "CMYK" text. Tint reads 100 with nothing selected. No readiness percentage change. - **2026-10-21** · With a gradient fill or stroke, the Color panel shows the stop selected in the Gradient panel (the stop before a selected midpoint) with its values, and its C/M/Y/K or R/G/B sliders and spectrum colour that stop, keeping the others, as in InDesign. It showed "[None] — choose a colour on the ramp", and a spectrum click replaced the whole gradient with a flat colour. Found in the Learn Skills Daily course, chapter 24. No readiness percentage change. - **2026-10-20** · A new document's Swatches panel lists InDesign's defaults: [None], [Registration], [Paper], [Black] and six process colours (C=100, M=100, Y=100, C=15 M=100 Y=100, C=75 M=5 Y=100, C=100 M=90 Y=10); the extra M=100 Y=100, C=100 Y=100 and C=100 M=100 swatches are gone. No readiness percentage change. - **2026-10-21** · The readout beside the cursor while drawing or measuring ("W: 3.13 cm") is InDesign's light grey box with white text, measured from the tutorials' frames; High Contrast keeps it black. It was a dark translucent box. No readiness percentage change. -- **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-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-20** · Drawing a frame or shape shows its width and height in the document's ruler units, to two decimals and on two lines ("W: 6.08 cm", "H: 6.08 cm"), as InDesign does; it was always whole points. No readiness percentage change. - **2026-10-20** · Type tool: a drag that starts in an empty frame draws a new text frame over it, as in InDesign; a click still turns the frame into a text frame. No readiness percentage change. - **2026-10-20** · Measures in centimetres show four decimals, as InDesign's fields do (a 1p gutter reads 0.4233 cm, not 0.423 cm). No readiness percentage change. -- **2026-10-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-21** · Properties › Character and Paragraph: ••• (More Options) expands the section in place, as InDesign's: Character adds vertical and horizontal scale, baseline shift, skew, language and OpenType, and All Caps, Small Caps, Superscript, Subscript, Underline and Strikethrough; Paragraph adds the left, right, first line and last line indents, space before and after, the drop cap's lines and characters, Align to Baseline Grid and Hyphenate. It used to open the Character or Paragraph panel; the language field moved under ••• as in InDesign. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change. - **2026-10-21** · The text Control panel has InDesign's two modes: A (Character Formatting Controls: font and style, size and leading, All Caps/Superscript/Underline and Small Caps/Subscript/Strikethrough, kerning and tracking, vertical and horizontal scale, baseline shift and skew, character style and language) and ¶ (Paragraph Formatting Controls: the nine alignments, left/first line and right/last line indents, space before and after, the drop cap's lines and characters, paragraph style, Hyphenate and Align to Baseline Grid). It used to show a fixed mix of both. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change. - **2026-10-20** · With the Type tool, the Control panel's font size, leading and tracking fields have a spinner and a list of common values, as in InDesign (auto leading in parentheses). No readiness percentage change. - **2026-10-21** · The ¶ (Paragraph Formatting Controls) mode of the text Control panel has InDesign's Bulleted List and Numbered List buttons (with the default hanging indent) and Shading and Border: a checkbox, a button opening Paragraph Borders and Shading on that tab, and the colour. Found in the Learn Skills Daily course, chapter 21. No readiness percentage change. - **2026-10-21** · The Bulleted and Numbered List buttons (Properties, and Type › Bulleted & Numbered Lists › Apply Bullets / Apply Numbers) work as InDesign's: a paragraph with no indents gets the default hanging indent (0.25 in, −0.25 in), so the text lines up after the bullet instead of at the next default tab stop, and pressing the button again removes the list and that indent (`list.bullets`, `list.numbers`). Found in the Learn Skills Daily course, chapter 21. No readiness percentage change. +- **2026-10-18** · Table of Contents offers More Options / Fewer Options, preserving page-number, separator, sorting and level settings while their controls are hidden. No readiness percentage change. +- **2026-10-18** · Table of Contents reserves the title field's width so Contents is readable next to its style picker; the grid previously squeezed it to a few characters. No readiness percentage change. - **2026-10-17** · Inline and anchored objects paint under their frame's text, on screen and in PDF, as InDesign draws them: a picture whose frame reaches into the next line (a negative Y offset) no longer hides the top of that line. No readiness percentage change. - **2026-10-17** · Running headers: *first/last on page* read a page in text order (story order, then column by column), not by height — a multi-column page's right header showed the lowest entry of column 1 instead of the page's last entry. No readiness percentage change. +- **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-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. @@ -516,6 +512,7 @@ Newest first. - **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change. - **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change. - **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `<Image FillColor>` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change. +- **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-15** · Color Theme: the toolbar tool previews up to five colours from a placed raster and adds them as one undoable swatch group. Local deterministic extraction; transparent padding is ignored. No readiness percentage change. - **2026-10-15** · Applying text styles removes matching local formatting so capture sources inherit later edits; real overrides and named character styles remain. @@ -575,6 +572,8 @@ Newest first. - **2026-10-15** · Flex Layout (InDesign 2026): Object › Flex Layout (Add to, Convert to, Remove) and a Flex Layout panel laid out as InDesign's, also in Properties. A container lays out its objects by direction, wrap, justification, the alignment grid, Fixed/Auto width and height, padding and spacing, after every edit; Add detects aligned objects (direction, Auto size, Space Between at their spacing); resizing a container reflows its objects instead of scaling them; dragging an object in one reorders it. Montilla Design's tutorial replays step for step. No readiness percentage change. - **2026-10-15** · IDML: the CJK leading model InDesign writes on [No paragraph style] of every document (Aki Below) only applies when the document uses a Japanese composer, as in InDesign; a Western document's paragraphs after a size change no longer sit lower than in InDesign (Montilla Design's Flex Layout file: its captions came out overset). No readiness percentage change. - **2026-10-15** · IDML: linked images in a subfolder beside the package load when the package is opened on another machine (a link to `/images/logo.png` finds `images/logo.png` next to the .idml); they showed as missing. No readiness percentage change. +- **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-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-14** · Overset text is marked on every frame that ends an overset story, selected or not and inside groups too (the red mark at the out port, with frame edges shown); a selected group shows the in/out ports of the text frames inside it. InDesign shows it only on the selected frame, which hides overset labels in grouped objects until Preflight. No readiness percentage change. - **2026-10-14** · Load Swatches adds the colors of an `.ase` file that repeat a name or have none: a repeated name gets a number (`Red 2`), and a color without a name is named by its value (`C=0 M=0 Y=0 K=55`). Before, a repeated name kept one of its colors, and only the first unnamed color was added, with an empty name. No readiness percentage change. - **2026-10-14** · Objects snap to the bleed guide's four sides while moving, resizing or drawing; snapping and the canvas share one bleed rectangle (`Spread::bleed_rect`). No readiness percentage change. From 14eb9f0be6a7311d6da9a663346a9fb959afad1e Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 30 Oct 2026 21:25:23 +0000 Subject: [PATCH 376/431] Integration: #1285/#1286 ui-parity: TOC dialog notes in the body, revision-history row, header --- docs/ui-parity.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b52c7e15a..199bfd45c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Story panel; text Control panel Character/Paragraph modes; Properties ••• expands Character and Paragraph; live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Table of Contents dialog: title field keeps its width, More Options toggles without losing hidden settings; Story panel; text Control panel Character/Paragraph modes; Properties ••• expands Character and Paragraph; live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS) Tools, handles, snapping, nudging, modifiers, shortcuts, panels, menus, context menus and feel, against InDesign 2026. The look was measured from the running app @@ -68,11 +68,14 @@ focused text field, and does not interrupt an existing tool drag. Synthetic canv cover the cursors, click anchors, scrub/release ownership, restoration, typing guards and undo history (DC-1). -## Remaining effort - The Table of Contents title field reserves its edit width inside the title/style row. The default Contents title is fully visible instead of being clipped to its first few characters. +The Table of Contents dialog can collapse to its entry-style controls or expand to show page +number, separator, sorting and level controls. Toggling visibility preserves their settings. + +## Remaining effort + Applying a paragraph or character style now removes local values equal to the applied style, so text used to capture a style follows later edits to that style. Different local formatting and named character styles stay intact. Paragraph-style application treats selection endpoints @@ -125,12 +128,10 @@ covers checking the sampled and redefined settings in the styles tutorial (2:45 | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Dialogs: Table of Contents title field keeps its edit width; More Options toggles without losing hidden settings | | 2026-10-24 | minor | Panels row: Story panel (Optical Margin Alignment and its size) | | 2026-10-21 | minor | Chrome row: the text Control panel's A/¶ modes; Properties row: ••• expands Character and Paragraph in place | | 2026-10-11 | minor | Live corners row to ✓: widget, diamonds, drag, Shift-drag, Alt-click shape cycling (#186) | | 2026-10-11 | minor | Added held Cmd+Space / Cmd+Alt+Space Zoom navigation and canvas input regression tests | | 2026-10-10 | minor | Threading row corrected to ✗ after checking the code (no out-port interaction, no thread command) | | 2026-10-10 | major | Created: measured menu coverage, 27-row interaction checklist from the code and issues | - -The Table of Contents dialog can collapse to its entry-style controls or expand to show page -number, separator, sorting and level controls. Toggling visibility preserves their settings. From 0ca30e1408f54aeaa27aa9549fac2228ec4c5e82 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 30 Oct 2026 21:29:34 +0000 Subject: [PATCH 377/431] Integration: #1496 Find/Change: a null attribute with an empty Change formats the found text instead of deleting it --- crates/engine/src/cmd/find.rs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index e356bcac8..473b2d622 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -355,6 +355,9 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> { let first_only = bool_or(p, "first", false); let attrs = p.get("attrs").cloned(); let over = char_overrides(attrs.as_ref())?; + // A null attribute clears that override on the found text. + let cleared: Vec<String> = + attrs.as_ref().and_then(Value::as_object).map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()).unwrap_or_default(); super::text::validate_char_fill(&s.doc()?.doc, &over, "find.change")?; let char_style = str_param(p, "charStyle").map(str::to_string); let para_style = str_param(p, "paraStyle").map(str::to_string); @@ -366,7 +369,7 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> { return Err(bad("find.change", format!("no paragraph style `{n}`"))); } // InDesign: with a Change Format and nothing in Change to, the found text is only formatted. - let format_only = str_param(p, "change").unwrap_or("").is_empty() && (char_style.is_some() || para_style.is_some() || !over.is_empty()); + let format_only = str_param(p, "change").unwrap_or("").is_empty() && (char_style.is_some() || para_style.is_some() || !over.is_empty() || !cleared.is_empty()); let targets = scope(s, p)?.targets; s.edit(|d, sel| { let styles = d.styles.clone(); @@ -401,8 +404,13 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> { if let Some(n) = &char_style { super::style::apply_char_style(st, &styles, n, &changed); } - if !over.is_empty() { - st.format_chars(changed, |f| f.over.merge(&over)); + if !over.is_empty() || !cleared.is_empty() { + st.format_chars(changed, |f| { + f.over.merge(&over); + for k in &cleared { + let _ = f.over.set_json(k, &Value::Null); + } + }); } } count += 1; @@ -582,6 +590,12 @@ Three" 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); + // A null attribute is a Change Format too: the found text stays and loses that override. + let before = s.doc().unwrap().doc.story(sid).unwrap().clone(); + s.execute("find.change", &json!({"find": "cook", "change": "", "attrs": {"underline": null}})).unwrap(); + let story = s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert_eq!(story.text, before.text, "the words stay"); + assert_eq!(story.format_after(25).over.underline, None); // 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")); From 2ecb85ff97928902a92dca25d62f975a5d0f9d0b Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 30 Oct 2026 21:29:34 +0000 Subject: [PATCH 378/431] Integration: #1674 text.move leaves the selected cells, so typing inserts at the caret --- crates/engine/src/cmd/text/caret.rs | 2 ++ crates/engine/src/tests/table.rs | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/crates/engine/src/cmd/text/caret.rs b/crates/engine/src/cmd/text/caret.rs index 670de4049..0621e30de 100644 --- a/crates/engine/src/cmd/text/caret.rs +++ b/crates/engine/src/cmd/text/caret.rs @@ -380,6 +380,8 @@ pub(super) fn move_caret(s: &mut Session, p: &Value) -> Result<Value> { s.typing_format = None; let stm = s.doc_mut()?; stm.selection.text = Some(if extend { TextSel { focus: new, ..t } } else { TextSel { anchor: new, focus: new, ..t } }); + // Moving the caret leaves the selected cells, so typing inserts at the caret. + stm.selection.cells = None; stm.bump_revision(); Ok(json!({"pos": new})) } diff --git a/crates/engine/src/tests/table.rs b/crates/engine/src/tests/table.rs index 45e4b8db8..770581fa6 100644 --- a/crates/engine/src/tests/table.rs +++ b/crates/engine/src/tests/table.rs @@ -176,6 +176,12 @@ fn escape_toggles_text_and_cell_selection() { let sel = s.doc().unwrap().selection.clone(); assert!(sel.cells.is_none()); assert_eq!(sel.text.unwrap().range(), 3..3); + // Moving the caret leaves the cell selection: typing inserts at the caret, not over the cell. + s.execute("table.toggleSelection", &json!({})).unwrap(); + s.execute("text.move", &json!({"dir": "right"})).unwrap(); + assert!(s.doc().unwrap().selection.cells.is_none()); + s.execute("text.insert", &json!({"text": "!"})).unwrap(); + assert_eq!(s.execute("table.get", &json!({"table": tid})).unwrap()["cells"][1]["text"], "F!ax"); // A selection that starts inside a merged cell goes back to the merged cell's text. s.execute("table.select", &json!({"rows": [0, 1], "cols": [0, 0]})).unwrap(); s.execute("table.merge", &json!({})).unwrap(); From 7bad69c14a77f0b8e37de04d0617a04ebe034c68 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 30 Oct 2026 21:33:58 +0000 Subject: [PATCH 379/431] Integration: cargo fmt --- crates/engine/src/cmd/find.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index 473b2d622..aa6475f26 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -356,8 +356,11 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> { let attrs = p.get("attrs").cloned(); let over = char_overrides(attrs.as_ref())?; // A null attribute clears that override on the found text. - let cleared: Vec<String> = - attrs.as_ref().and_then(Value::as_object).map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()).unwrap_or_default(); + let cleared: Vec<String> = attrs + .as_ref() + .and_then(Value::as_object) + .map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()) + .unwrap_or_default(); super::text::validate_char_fill(&s.doc()?.doc, &over, "find.change")?; let char_style = str_param(p, "charStyle").map(str::to_string); let para_style = str_param(p, "paraStyle").map(str::to_string); @@ -369,7 +372,8 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> { return Err(bad("find.change", format!("no paragraph style `{n}`"))); } // InDesign: with a Change Format and nothing in Change to, the found text is only formatted. - let format_only = str_param(p, "change").unwrap_or("").is_empty() && (char_style.is_some() || para_style.is_some() || !over.is_empty() || !cleared.is_empty()); + let format_only = + str_param(p, "change").unwrap_or("").is_empty() && (char_style.is_some() || para_style.is_some() || !over.is_empty() || !cleared.is_empty()); let targets = scope(s, p)?.targets; s.edit(|d, sel| { let styles = d.styles.clone(); From 8c89bf5f1a837abd36dbf578a31ca882e73e1d66 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Fri, 30 Oct 2026 22:40:44 +0100 Subject: [PATCH 380/431] rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/render/src/fx.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index cedb75d66..af3c0227c 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -230,7 +230,19 @@ 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)), crate::blend_mode(s.mode).mix, painted); + self.inner_edge( + ctx, + f, + reach, + bp, + xf, + off, + sigma, + choke, + color_of(&c, s.opacity.clamp(0.0, 1.0)), + crate::blend_mode(s.mode).mix, + painted, + ); } // Inner glow: from the edges inwards (or out from the centre), screened over the object. if e.inner_glow.on From 3fec4d32d834193712e2406c108cd01bb6277132 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:52:52 +0100 Subject: [PATCH 381/431] Skip quoted attribute values when finding the end of an xref tag Found while testing crates/doc/src/xref.rs:85-91 (expand) Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- crates/doc/src/xref.rs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/xref.rs b/crates/doc/src/xref.rs index 96e584862..d3456a291 100644 --- a/crates/doc/src/xref.rs +++ b/crates/doc/src/xref.rs @@ -84,7 +84,7 @@ pub fn expand(definition: &str, v: &XrefValues) -> String { let mut rest = definition; while let Some(i) = rest.find('<') { out.push_str(&rest[..i]); - let Some(j) = rest[i..].find('>') else { + let Some(j) = tag_end(&rest[i..]) else { out.push_str(&rest[i..]); return out; }; @@ -120,6 +120,20 @@ pub fn expand(definition: &str, v: &XrefValues) -> String { typographic_quotes(&out) } +/// Byte index of the `>` that ends the tag starting at `s`, skipping quoted attribute values. +/// An unterminated quote falls back to the first `>`, so only that malformed tag is affected. +fn tag_end(s: &str) -> Option<usize> { + let mut in_quote = false; + for (k, c) in s.char_indices() { + match c { + '"' => in_quote = !in_quote, + '>' if !in_quote => return Some(k), + _ => {} + } + } + s.find('>') +} + /// Straight double quotes in format text become typographer's quotes. fn typographic_quotes(s: &str) -> String { let mut out = String::with_capacity(s.len()); @@ -388,6 +402,19 @@ pub fn clean(s: &str) -> String { mod tests { use super::*; + #[test] + fn gt_inside_quoted_attribute_does_not_end_tag() { + let v = XrefValues { para: "Intro > the start".into(), ..Default::default() }; + assert_eq!(expand("<partialPara delim=\">\" includeDelim=\"false\" />|", &v), "Intro |"); + } + + #[test] + fn unterminated_quote_only_affects_its_own_tag() { + let v = XrefValues { para: "Intro > the start".into(), ..Default::default() }; + let out = expand("<x a=\"b> <fullPara />", &v); + assert!(out.contains("Intro > the start"), "{out}"); + } + #[test] fn formats_expand() { let v = From 6561878d9f82a0fcf8413c637ae5820bb905927d Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 30 Oct 2026 23:15:09 +0000 Subject: [PATCH 382/431] Find/Change: Find Format (PR #1497's own change on main's Find/Change) Applied as the diff of #1496 (already in main) to #1497's head; the old copy of #1496 (story.rs paras_touching, paragraph.rs ranges) is not taken. crates/engine/src/cmd/find.rs: main's tests kept, paragraph style outside the non-empty check as in main crates/ui-egui/src/i18n/*.rs: union of keys docs/target-app-parity.md: main's text plus the Find Format wording --- ROADMAP.md | 1 + crates/engine/src/cmd/find.rs | 237 +++++++++++++++++++++++++--- crates/engine/src/cmd/find/tests.rs | 115 ++++++++++++++ crates/ui-egui/src/dialogs.rs | 93 +++++++---- crates/ui-egui/src/dialogs/tests.rs | 70 +++++++- crates/ui-egui/src/i18n/ar.rs | 7 + crates/ui-egui/src/i18n/cs.rs | 7 + crates/ui-egui/src/i18n/it.rs | 7 + crates/ui-egui/src/i18n/ka.rs | 7 + crates/ui-egui/src/i18n/pl.rs | 7 + crates/ui-egui/src/i18n/pt_br.rs | 7 + crates/ui-egui/src/i18n/ru.rs | 7 + crates/ui-egui/src/i18n/table.rs | 25 +++ crates/ui-egui/src/i18n/uk.rs | 7 + docs/target-app-parity.md | 2 +- 15 files changed, 545 insertions(+), 54 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 52bfb0f3b..ed98bb2f1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -464,6 +464,7 @@ Newest first. - **2026-10-21** · Paragraph rules have InDesign's Type (solid, thick/thin stripes, dashed, dotted, straight hash, wavy, or a stroke style of the document), Gap Color and Gap Tint, and Keep In Frame (rule above); the Paragraph Rules page is laid out as InDesign's (Weight and Type, Color and Tint, Gap Color and Gap Tint, Width and Offset, Left and Right Indent). IDML reads and writes them (`RuleAboveType`, `RuleBelowGapColor`, `KeepRuleAboveInFrame`…). Found in the Learn Skills Daily course, chapter 20. No readiness percentage change. - **2026-10-21** · New Character Style with text selected starts from what the text has of its own over its paragraph style, as InDesign's ("[None] + Bold + color"), and Apply Style to Selection gives the text the new style, with no override left. A new style's dialog is titled New Paragraph Style / New Character Style. `type.selectionAttrs` reports the text's own character attributes (`ownChars`). Found in the Learn Skills Daily course, chapter 22. No readiness percentage change. - **2026-10-21** · New Paragraph Style with text selected starts from it, as InDesign's (Based On [No Paragraph Style], every setting of the text that differs) and Apply Style to Selection gives the paragraph the new style; General has Next Style and Reset To Base; Basic Character Formats is in two columns (Size | Leading, Kerning | Tracking, Case | Position). `style.paragraph.edit` honours `nextStyle` (it was ignored) and takes `resetToBase`. Found in the Learn Skills Daily course, chapter 20. No readiness percentage change. +- **2026-10-21** · Find/Change has InDesign's Find Format: Specify Attributes opens Find Format Settings (the same Style Options and character sections as Change Format Settings), and only text with the format is found. With nothing in Find what, each stretch of text with the format is found, so Change All can restyle every word in a character style. `find.find`, `find.next` and `find.change` take `findFormat {charStyle?, paraStyle?, chars?}`. Found in the Learn Skills Daily course, chapter 22. No readiness percentage change. - **2026-10-21** · Find/Change has InDesign's Change Format. Specify Attributes opens Change Format Settings: Style Options (the character and paragraph style to apply, [Any Style] for none), then Basic and Advanced Character Formats, Character Color, OpenType Features, Underline and Strikethrough Options. The box shows the format ("Character Style: eggs"), and Change All gives it to the changed text. With a Change Format and nothing in Change to, the found text stays and is only formatted, as InDesign's (an ⓘ beside Change to says so). `find.change` takes `charStyle` and `paraStyle`. Found in the Learn Skills Daily course, chapter 22. No readiness percentage change. - **2026-10-21** · Preferences › Interface follows InDesign's: Appearance has a Color Theme swatch per interface theme (a click shows it at once, Cancel goes back) and Panels sets the Tools panel to a single or double column; UI Size moves to a UI Scaling section of its own. Interface held only UI Size, and the theme could be changed only from the Window menu. Found in the Learn Skills Daily course, chapter 26. No readiness percentage change. - **2026-10-21** · Preferences › File Handling shows Number of Recent Items to Display as a whole number ("20"; it read "20.0"). Found in the Learn Skills Daily course, chapter 26. No readiness percentage change. diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index aa6475f26..6c0be2cd9 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -1,12 +1,12 @@ //! Edit → Find/Change: plain text and GREP (regular expressions) with InDesign's metacharacters, -//! optional formatting in the change, scoped to the document, a story or the selection. +//! a Find Format and a Change Format, scoped to the document, a story or the selection. use std::ops::Range; use std::sync::{Arc, Weak}; use designcraft_compose::grep::{Grep, GrepOptions, translate_replacement}; use designcraft_doc::story::floor_char_boundary; -use designcraft_doc::{CellAddr, Document, Selection, StoryId, TextSel}; +use designcraft_doc::{CellAddr, Document, Selection, Story, StoryId, Styles, TextSel}; use serde_json::{Value, json}; use super::{CommandSpec, bad, bool_or, cmd, has_doc, str_param}; @@ -61,18 +61,18 @@ pub fn specs() -> Vec<CommandSpec> { } ), cmd!(query "find.find", "Find", ["Edit", "Find/Change"], None, - "{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, change?|first?|attrs?|charStyle?|paraStyle?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text, cell?: {table, row, col}}] (document scope also searches table cells; start/end then index the cell's text)", + "{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), change?|first?|attrs?|charStyle?|paraStyle?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text, cell?: {table, row, col}}] (document scope also searches table cells; start/end then index the cell's text)", has_doc, find), cmd!( "find.change", "Change All", ["Edit", "Find/Change"], None, - "{find, change, grep?, caseSensitive?, wholeWord?, scope?, story?, first?: bool (change only the first match), attrs?: {character attributes applied to changed text}, charStyle?: a character style applied to it, paraStyle?: a paragraph style applied to its paragraphs} → {count} — attrs, charStyle and paraStyle are the Change Format: with one and an empty change, the found text stays and is only formatted", + "{find, change, grep?, caseSensitive?, wholeWord?, scope?, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), first?: bool (change only the first match), attrs?: {character attributes applied to changed text}, charStyle?: a character style applied to it, paraStyle?: a paragraph style applied to its paragraphs} → {count} — attrs, charStyle and paraStyle are the Change Format: with one and an empty change, the found text stays and is only formatted; with no find, change is literal text", has_doc, change ), - cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, caseSensitive?, wholeWord?, scope?: document|story|selection, story?, change?|first?|attrs?|charStyle?|paraStyle?: ignored (find.change takes them; one request serves all three Find/Change commands)} — Selection starts at its original beginning; later calls advance within the scope", has_doc, find_next), + cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, caseSensitive?, wholeWord?, scope?: document|story|selection, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), change?|first?|attrs?|charStyle?|paraStyle?: ignored (find.change takes them; one request serves all three Find/Change commands)} — Selection starts at its original beginning; later calls advance within the scope", has_doc, find_next), ] } @@ -108,6 +108,199 @@ fn build(p: &Value) -> Result<Grep> { re.map_err(|e| bad("find", e)) } +/// Find/Change's Find Format: the text a search finds must have it all through. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct FindFormat { + char_style: Option<String>, + para_style: Option<String>, + /// Resolved character attributes, as `type.selectionAttrs` reports them. + chars: serde_json::Map<String, Value>, +} + +impl FindFormat { + fn from_params(p: &Value) -> Result<Option<Self>> { + let Some(f) = p.get("findFormat").filter(|f| !f.is_null()) else { return Ok(None) }; + let o = f.as_object().ok_or_else(|| bad("find", "`findFormat` is an object"))?; + // A style is named; a null asks for none. Any other value is refused rather than + // searched without. + let name = |k: &str| match o.get(k) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(n)) => Ok(Some(n.clone())), + Some(v) => Err(bad("find", format!("findFormat: `{k}` is a style name, not {v}"))), + }; + let chars = match o.get("chars") { + None | Some(Value::Null) => None, + Some(Value::Object(m)) => Some(m), + Some(v) => return Err(bad("find", format!("findFormat: `chars` is an object of character attributes, not {v}"))), + }; + // Only attributes text has, kept as the document writes them: `font_style` and + // `fontStyle`, or 12 and 12.0, then find the same text. A null asks for nothing. + let mut known = designcraft_doc::CharAttrs::default(); + for (k, v) in chars.into_iter().flatten() { + known.set_json(k, v).map_err(|e| bad("find", format!("findFormat: {e}")))?; + } + let chars = match serde_json::to_value(&known) { + Ok(Value::Object(m)) => m, + _ => serde_json::Map::new(), + }; + let f = Self { char_style: name("charStyle")?, para_style: name("paraStyle")?, chars }; + Ok((f != Self::default()).then_some(f)) + } + + /// Styles the document doesn't have are refused, as `find.change` refuses them. + fn check(&self, styles: &Styles) -> Result<()> { + if let Some(n) = self.char_style.as_ref().filter(|n| styles.char_style(n).is_none()) { + return Err(bad("find", format!("no character style `{n}`"))); + } + if let Some(n) = self.para_style.as_ref().filter(|n| styles.para(n).is_none()) { + return Err(bad("find", format!("no paragraph style `{n}`"))); + } + Ok(()) + } + + /// The text in `r` of `story` that has the format, relative to `r.start`: the longest + /// stretches, each paragraph's apart (a paragraph's `\n` isn't in one). + fn spans(&self, styles: &Styles, story: &Story, r: &Range<usize>) -> Vec<Range<usize>> { + let mut out: Vec<Range<usize>> = Vec::new(); + // Paragraphs and runs both go in text order: the runs before a paragraph are passed once. + let runs: Vec<_> = story.runs().collect(); + let mut first = 0; + for (pi, pr) in story.para_ranges().into_iter().enumerate() { + let (a, b) = (pr.start.max(r.start), pr.end.min(r.end)); + let Some(pf) = story.paras.get(pi) else { continue }; + if a >= b || self.para_style.as_ref().is_some_and(|n| *n != pf.style) { + continue; + } + while runs.get(first).is_some_and(|(run, _)| run.end <= a) { + first += 1; + } + let base = (!self.chars.is_empty()).then(|| styles.resolve_para(pf).1); + for (run, cf) in runs[first..].iter().take_while(|(run, _)| run.start < b) { + let (x, y) = (run.start.max(a), run.end.min(b)); + if x >= y || self.char_style.as_ref().is_some_and(|n| *n != cf.style) { + continue; + } + if let Some(base) = &base { + let cp = serde_json::to_value(styles.resolve_char(base, cf)).unwrap_or_default(); + if !self.chars.iter().all(|(k, v)| cp.get(k).is_some_and(|c| same_value(c, v))) { + continue; + } + } + match out.last_mut() { + Some(last) if last.end == x - r.start => last.end = y - r.start, + _ => out.push(x - r.start..y - r.start), + } + } + } + out + } +} + +/// Attribute values alike: numbers to within rounding (inside objects and lists too), the rest +/// exactly. +fn same_value(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::Number(_), Value::Number(_)) => match (a.as_f64(), b.as_f64()) { + (Some(x), Some(y)) => (x - y).abs() < 1e-6, + _ => a == b, + }, + (Value::Array(x), Value::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(a, b)| same_value(a, b)), + (Value::Object(x), Value::Object(y)) => x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| same_value(v, w))), + _ => a == b, + } +} + +/// Whether match `m` lies in one of `spans`. +fn within(spans: &[Range<usize>], m: &Range<usize>) -> bool { + spans.iter().any(|s| s.start <= m.start && m.end <= s.end) +} + +/// What a search finds: the pattern's matches (with a Find Format, those whose text has it all +/// through), or, with no pattern, the stretches of text with the Find Format. +struct Search { + re: Option<Grep>, + format: Option<FindFormat>, +} + +/// The search of a Find/Change request: an empty `find` needs a Find Format. +fn search(p: &Value) -> Result<Search> { + let format = FindFormat::from_params(p)?; + if format.is_some() && str_param(p, "find") == Some("") { + return Ok(Search { re: None, format }); + } + Ok(Search { re: Some(build(p)?), format }) +} + +impl Search { + fn check(&self, styles: &Styles) -> Result<()> { + self.format.as_ref().map_or(Ok(()), |f| f.check(styles)) + } + + /// Where the Find Format holds in target `r` of story `sid` (of its table cell `cell`, if any; + /// `None`: no Find Format). + fn spans(&self, d: &Document, sid: StoryId, cell: Option<CellAddr>, r: &Range<usize>) -> Option<Vec<Range<usize>>> { + let f = self.format.as_ref()?; + Some(d.text_story(sid, cell).map(|story| f.spans(&d.styles, story, r)).unwrap_or_default()) + } + + /// Up to `limit` matches in `text`, target `r` of story `sid` (offsets in `text`). + fn find_all(&self, d: &Document, sid: StoryId, cell: Option<CellAddr>, r: &Range<usize>, text: &str, limit: usize) -> Result<Vec<Range<usize>>> { + let spans = self.spans(d, sid, cell, r); + let Some(re) = &self.re else { return Ok(spans.unwrap_or_default().into_iter().take(limit).collect()) }; + let Some(spans) = spans else { return re.find_all(text, limit).map_err(|e| bad("find", e)) }; + Ok(re.find_all(text, usize::MAX).map_err(|e| bad("find", e))?.into_iter().filter(|m| within(&spans, m)).take(limit).collect()) + } + + /// The first match in `text` (target `r` of story `sid`) at or after byte `from`. + fn find_at(&self, d: &Document, sid: StoryId, cell: Option<CellAddr>, r: &Range<usize>, text: &str, from: usize) -> Result<Option<Range<usize>>> { + let spans = self.spans(d, sid, cell, r); + let Some(re) = &self.re else { + // From inside a stretch, the rest of it. + return Ok(spans.unwrap_or_default().into_iter().find(|s| s.end > from).map(|s| s.start.max(from)..s.end)); + }; + let mut at = from; + loop { + let Some(m) = re.find_at(text, at).map_err(|e| bad("find.next", e))? else { return Ok(None) }; + if spans.as_ref().is_none_or(|s| within(s, &m)) { + return Ok(Some(m)); + } + // On past it: its end, or a character on from an empty match. + at = if m.is_empty() { text.get(m.end..).and_then(|t| t.chars().next()).map_or(text.len() + 1, |c| m.end + c.len_utf8()) } else { m.end }; + if at > text.len() { + return Ok(None); + } + } + } + + /// The edits Change All makes in `text` (target `r` of story `sid`): `rep` expanded for each + /// GREP match, else `rep` itself. + #[allow(clippy::too_many_arguments)] + fn replacements( + &self, + d: &Document, + sid: StoryId, + cell: Option<CellAddr>, + r: &Range<usize>, + text: &str, + rep: &str, + grep: bool, + first_only: bool, + ) -> Result<Vec<(Range<usize>, String)>> { + let limit = if first_only { 1 } else { usize::MAX }; + match (&self.re, self.spans(d, sid, cell, r)) { + (Some(re), None) if grep => re.replacements(text, rep, first_only).map_err(|e| bad("find.change", e)), + (Some(re), Some(spans)) if grep => Ok(re + .replacements(text, rep, false) + .map_err(|e| bad("find.change", e))? + .into_iter() + .filter(|(m, _)| within(&spans, m)) + .take(limit) + .collect()), + _ => Ok(self.find_all(d, sid, cell, r, text, limit)?.into_iter().map(|m| (m, rep.to_string())).collect()), + } + } +} + #[derive(Clone, Debug, PartialEq, Eq)] enum Domain { Document, @@ -140,6 +333,7 @@ struct Matcher { grep: bool, case_sensitive: bool, whole_word: bool, + format: Option<FindFormat>, } impl Matcher { @@ -149,6 +343,7 @@ impl Matcher { grep: bool_or(p, "grep", false), case_sensitive: bool_or(p, "caseSensitive", false), whole_word: bool_or(p, "wholeWord", false), + format: FindFormat::from_params(p).ok().flatten(), } } } @@ -299,13 +494,14 @@ fn scope(s: &Session, p: &Value) -> Result<FindContext> { const MAX_MATCHES: usize = 10_000; fn find(s: &mut Session, p: &Value) -> Result<Value> { - let re = build(p)?; + let re = search(p)?; + re.check(&s.doc()?.doc.styles)?; let context = scope(s, p)?; let st = s.doc()?; let mut out = Vec::new(); for (sid, cell, r) in &context.targets { let text = target_text(&st.doc, *sid, *cell, r)?; - for m in re.find_all(text, MAX_MATCHES.saturating_sub(out.len())).map_err(|e| bad("find", e))? { + for m in re.find_all(&st.doc, *sid, *cell, r, text, MAX_MATCHES.saturating_sub(out.len()))? { let mut hit = json!({"story": sid.0, "start": r.start + m.start, "end": r.start + m.end, "text": text.get(m)}); if let Some(c) = cell { hit["cell"] = json!({"table": c.table, "row": c.row, "col": c.col}); @@ -349,9 +545,11 @@ fn char_overrides(attrs: Option<&Value>) -> Result<designcraft_doc::CharAttrs> { } fn change(s: &mut Session, p: &Value) -> Result<Value> { - let re = build(p)?; - let grep = bool_or(p, "grep", false); - let rep = replacement(p); + let re = search(p)?; + re.check(&s.doc()?.doc.styles)?; + // With no pattern (a Find Format alone), the change is literal text. + let grep = bool_or(p, "grep", false) && re.re.is_some(); + let rep = if re.re.is_some() { replacement(p) } else { metachars(str_param(p, "change").unwrap_or("")) }; let first_only = bool_or(p, "first", false); let attrs = p.get("attrs").cloned(); let over = char_overrides(attrs.as_ref())?; @@ -382,15 +580,11 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> { let Some(story) = d.text_story(sid, cell) else { continue }; // Collect matches first (positions refer to the original text), apply back to front. let text = story.text.get(r.clone()).ok_or_else(|| bad("find", "invalid selection bounds or UTF-8 boundary"))?; - let edits: Vec<(std::ops::Range<usize>, String)> = if grep { - re.replacements(text, &rep, first_only) - } else { - re.find_all(text, if first_only { 1 } else { usize::MAX }).map(|ms| ms.into_iter().map(|m| (m, rep.clone())).collect()) - } - .map_err(|e| bad("find.change", e))? - .into_iter() - .map(|(m, out)| (r.start + m.start..r.start + m.end, out)) - .collect(); + let edits: Vec<(std::ops::Range<usize>, String)> = re + .replacements(d, sid, cell, &r, text, &rep, grep, first_only)? + .into_iter() + .map(|(m, out)| (r.start + m.start..r.start + m.end, out)) + .collect(); let Some(st) = d.text_story_mut(sid, cell) else { continue }; for (range, new) in edits.iter().rev() { let changed = if format_only { @@ -432,7 +626,8 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> { } fn find_next(s: &mut Session, p: &Value) -> Result<Value> { - let re = build(p)?; + let re = search(p)?; + re.check(&s.doc()?.doc.styles)?; let mut context = scope(s, p)?; let st = s.doc()?; let mut hit = None; @@ -442,7 +637,7 @@ fn find_next(s: &mut Session, p: &Value) -> Result<Value> { if text.get(from..).is_none() { return Err(bad("find", "invalid UTF-8 search cursor")); } - if let Some(m) = re.find_at(text, from).map_err(|e| bad("find.next", e))? { + if let Some(m) = re.find_at(&st.doc, sid, cell, &r, text, from)? { let (a, b) = (r.start + m.start, r.start + m.end); // Match find_iter/captures_iter: suppress an empty match adjacent to the // previous match's end, but keep adjacent nonempty matches. diff --git a/crates/engine/src/cmd/find/tests.rs b/crates/engine/src/cmd/find/tests.rs index 5914c2e51..7de29f8ea 100644 --- a/crates/engine/src/cmd/find/tests.rs +++ b/crates/engine/src/cmd/find/tests.rs @@ -747,3 +747,118 @@ 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()); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 253deebd3..642f0d1cd 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2644,9 +2644,21 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }); ui.label(d.s("status")); } else { - let formatted = d.fields.get(CHANGE_FORMAT).and_then(Value::as_object).is_some_and(|f| !f.is_empty()); + let (find_formatted, formatted) = (format_set(&d, FIND_FORMAT), format_set(&d, CHANGE_FORMAT)); 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:")); + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Find what:")); + // InDesign's ⓘ: a Find Format narrows what is found. + if find_formatted { + info_icon( + ui, + crate::i18n::tr( + &app.ui.language, + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + ), + ); + } + }); text_field_named(ui, &mut d, "find", 260.0, crate::i18n::tr(&app.ui.language, "Find what:")); ui.end_row(); ui.horizontal(|ui| { @@ -2685,7 +2697,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { check(ui, &mut d, "caseSensitive", crate::i18n::tr(&app.ui.language, "Case sensitive")); check(ui, &mut d, "wholeWord", crate::i18n::tr(&app.ui.language, "Whole word")); }); - change_format_box(&app.ui.language, ui, &mut d); + format_box(&app.ui.language, ui, &mut d, true); + format_box(&app.ui.language, ui, &mut d, false); ui.horizontal(|ui| { let params0 = find_change_params(&d); let params = || params0.clone(); @@ -3278,7 +3291,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { crate::new_document::preview(app, &mut d, ctx); } if d.id == "findChange" && result.is_none() && d.fields.get(FORMAT_EDIT).is_some_and(Value::is_object) { - change_format_settings(app, ctx, &mut d); + format_settings(app, ctx, &mut d); } app.ui.dialog = Some(d); match result { @@ -3446,6 +3459,10 @@ fn find_change_params(d: &Dialog) -> Value { if let Some(story) = d.fields.get("story") { p["story"] = story.clone(); } + // Find Format: only text with it is found (with nothing in Find what, all text with it). + if let Some(f) = d.fields.get(FIND_FORMAT).and_then(Value::as_object).filter(|f| !f.is_empty()) { + p["findFormat"] = Value::Object(f.clone()); + } // Change Format: find.change applies it to the changed text (the other commands ignore it). if let Some(f) = d.fields.get(CHANGE_FORMAT) { for key in ["charStyle", "paraStyle"] { @@ -3460,14 +3477,21 @@ fn find_change_params(d: &Dialog) -> Value { p } -/// Find/Change's Change Format ({charStyle?, paraStyle?, chars?}), as Change Format Settings set -/// it; absent when there is none. +/// Find/Change's Find Format and Change Format ({charStyle?, paraStyle?, chars?}), as their +/// settings set them; absent when there is none. +const FIND_FORMAT: &str = "findFormat"; const CHANGE_FORMAT: &str = "changeFormat"; -/// The fields of Change Format Settings while it is open (its section, `charStyle`, `paraStyle` -/// and `c.<attr>`), until OK makes them the Change Format. +/// The fields of Find or Change Format Settings while it is open (`for`: find|change, its +/// section, `charStyle`, `paraStyle` and `c.<attr>`), until OK makes them the format. const FORMAT_EDIT: &str = "formatEdit"; -/// The sections of Change Format Settings: Style Options, then those of Character Style Options. +/// Whether the dialog has a Find (`FIND_FORMAT`) or Change Format. +fn format_set(d: &Dialog, key: &str) -> bool { + d.fields.get(key).and_then(Value::as_object).is_some_and(|f| !f.is_empty()) +} + +/// The sections of Find and Change Format Settings: Style Options, then those of Character Style +/// Options. const FORMAT_SECTIONS: &[(&str, &str)] = &[ ("style", "Style Options"), ("chars", "Basic Character Formats"), @@ -3486,8 +3510,8 @@ fn info_icon(ui: &mut egui::Ui, tip: &str) { resp.on_hover_text(tip); } -/// What the Change Format box shows: "Character Style: eggs + …". -fn change_format_summary(lang: &str, format: Option<&Value>) -> String { +/// What a Find or Change Format box shows: "Character Style: eggs + …". +fn format_summary(lang: &str, format: Option<&Value>) -> String { let Some(f) = format else { return String::new() }; let mut parts = Vec::new(); for (key, label) in [("charStyle", "Character Style:"), ("paraStyle", "Paragraph Style:")] { @@ -3501,23 +3525,30 @@ fn change_format_summary(lang: &str, format: Option<&Value>) -> String { parts.join(" + ") } -/// Find/Change's Change Format, as InDesign's: a box with what Change All also gives the changed -/// text, a button that opens Change Format Settings and one that clears it. -fn change_format_box(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) { +/// Find/Change's Find Format (`find`) or Change Format, as InDesign's: a box with what the found +/// text must have or what Change All also gives the changed text, a button that opens its +/// settings and one that clears it. +fn format_box(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, find: bool) { + let (key, label, specify) = if find { + (FIND_FORMAT, "Find Format:", "Specify attributes to find") + } else { + (CHANGE_FORMAT, "Change Format:", "Specify attributes to change") + }; ui.add_space(6.0); - crate::rtl::label(ui, crate::i18n::tr(lang, "Change Format:")); + crate::rtl::label(ui, crate::i18n::tr(lang, label)); ui.horizontal_top(|ui| { let t = crate::theme::Tokens::get(ui.ctx()); egui::Frame::new().fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(6.0).show(ui, |ui| { ui.set_width(340.0); ui.set_min_height(40.0); - let summary = change_format_summary(lang, d.fields.get(CHANGE_FORMAT)); + let summary = format_summary(lang, d.fields.get(key)); ui.add(egui::Label::new(egui::RichText::new(summary).color(t.text)).wrap()); }); ui.vertical(|ui| { - if crate::icons::button(ui, "find-format", 22.0, false, crate::i18n::tr(lang, "Specify attributes to change")).clicked() { - let f = d.fields.get(CHANGE_FORMAT).cloned().unwrap_or_default(); + if crate::icons::button(ui, "find-format", 22.0, false, crate::i18n::tr(lang, specify)).clicked() { + let f = d.fields.get(key).cloned().unwrap_or_default(); let mut edit = Map::new(); + edit.insert("for".into(), json!(if find { "find" } else { "change" })); edit.insert("section".into(), json!("style")); for key in ["charStyle", "paraStyle"] { if let Some(v) = f.get(key) { @@ -3530,16 +3561,17 @@ fn change_format_box(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) { d.fields.insert(FORMAT_EDIT.into(), Value::Object(edit)); } if crate::icons::button(ui, "trash", 22.0, false, crate::i18n::tr(lang, "Clear specified attributes")).clicked() { - d.fields.remove(CHANGE_FORMAT); + d.fields.remove(key); } }); }); } -/// Find/Change › Change Format Settings, over Find/Change as InDesign's: Style Options (the -/// character and paragraph style to apply, [Any Style] for none), then the character sections of -/// Character Style Options with every field blank (left as it is). OK makes it the Change Format. -fn change_format_settings(app: &mut DesignApp, ctx: &egui::Context, d: &mut Dialog) { +/// Find/Change › Find or Change Format Settings, over Find/Change as InDesign's: Style Options +/// (the character and paragraph style the found text has or the changed text gets, [Any Style] +/// for none), then the character sections of Character Style Options with every field blank (any +/// value, or left as it is). OK makes it the Find or Change Format. +fn format_settings(app: &mut DesignApp, ctx: &egui::Context, d: &mut Dialog) { let lang = app.ui.language.clone(); let Some(doc) = app.session.active().map(|st| st.doc.clone()) else { d.fields.remove(FORMAT_EDIT); @@ -3549,12 +3581,14 @@ fn change_format_settings(app: &mut DesignApp, ctx: &egui::Context, d: &mut Dial Dialog { id: "changeFormat".into(), fields: d.fields.get(FORMAT_EDIT).and_then(Value::as_object).cloned().unwrap_or_default(), unit: d.unit }; let base = json!({}); let cf = CharFields { base: &base, sparse: true }; + let find = fd.s("for") == "find"; let popup_open = egui::Popup::is_any_open(ctx); let mut result = None; - let host = egui::Id::unique("change_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(); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Change Format Settings")).font(semibold(16.0))); + let title = if find { "Find Format Settings" } else { "Change Format Settings" }; + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, title)).font(semibold(16.0))); let margin = egui::Frame::popup(ui.style()).inner_margin; let title_row = egui::Rect::from_min_max( egui::pos2(ui.max_rect().left() - margin.leftf(), title_top - margin.topf()), @@ -3614,10 +3648,11 @@ fn change_format_settings(app: &mut DesignApp, ctx: &egui::Context, d: &mut Dial if !chars.is_empty() { f.insert("chars".into(), Value::Object(chars)); } + let key = if find { FIND_FORMAT } else { CHANGE_FORMAT }; if f.is_empty() { - d.fields.remove(CHANGE_FORMAT); + d.fields.remove(key); } else { - d.fields.insert(CHANGE_FORMAT.into(), Value::Object(f)); + d.fields.insert(key.into(), Value::Object(f)); } } Some(false) => { @@ -3629,8 +3664,8 @@ fn change_format_settings(app: &mut DesignApp, ctx: &egui::Context, d: &mut Dial } } -/// Change Format Settings › Style Options: the character and paragraph style the changed text -/// gets ([Any Style]: as it is). +/// Find or Change Format Settings › Style Options: the character and paragraph style the found +/// text has or the changed text gets ([Any Style]: any, or as it is). fn format_style_options(lang: &str, ui: &mut egui::Ui, fd: &mut Dialog, styles: &designcraft_doc::Styles) { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "Style Options")).font(semibold(13.0))); ui.add_space(6.0); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 8a80198ea..d3f99b14e 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3884,8 +3884,8 @@ fn find_change_gives_the_found_text_a_change_format() { [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(); + // 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()); @@ -3899,8 +3899,72 @@ fn a_change_format_goes_with_change_all() { 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_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::unique("find_format_test_fonts"); + if ui.data(|d| d.get_temp::<bool>(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); } /// The course's chapter 26: Preferences › Interface as InDesign's. A Color Theme swatch shows diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index a2d19e1cd..79f28509f 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -39,6 +39,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[Same style]", "[النمط نفسه]"), ("Reset To Base", "إعادة التعيين إلى الأساس"), ("Apply Style to Selection", "تطبيق النمط على التحديد"), + ("Find Format:", "البحث عن تنسيق:"), + ("Find Format Settings", "إعدادات البحث عن تنسيق"), + ("Specify attributes to find", "تحديد السمات المراد البحث عنها"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "مع تنسيق للبحث، يُعثر فقط على النص الذي يحمله؛ وإذا كان حقل «البحث عن» فارغًا، فعلى كل نص يحمله", + ), ("Change Format:", "تغيير التنسيق:"), ("Change Format Settings", "إعدادات تغيير التنسيق"), ("Style Options", "خيارات النمط"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index fdb75d7b4..e9023b369 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -41,6 +41,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[Same style]", "[Stejný styl]"), ("Reset To Base", "Obnovit podle základu"), ("Apply Style to Selection", "Použít styl na výběr"), + ("Find Format:", "Hledat formát:"), + ("Find Format Settings", "Nastavení hledání formátu"), + ("Specify attributes to find", "Určit atributy k hledání"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "S formátem hledání se najde jen text, který ho má; s prázdným polem „Najít“ veškerý text, který ho má", + ), ("Change Format:", "Změnit formát:"), ("Change Format Settings", "Nastavení změny formátu"), ("Style Options", "Volby stylu"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index d71f2870d..180524863 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -37,6 +37,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[Same style]", "[Stesso stile]"), ("Reset To Base", "Ripristina base"), ("Apply Style to Selection", "Applica stile alla selezione"), + ("Find Format:", "Trova formato:"), + ("Find Format Settings", "Impostazioni di ricerca formato"), + ("Specify attributes to find", "Specifica gli attributi da trovare"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "Con un formato di ricerca si trova solo il testo che lo ha; con «Trova» vuoto, tutto il testo che lo ha", + ), ("Change Format:", "Modifica formato:"), ("Change Format Settings", "Impostazioni di modifica formato"), ("Style Options", "Opzioni stile"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 5a68e37b0..2344a666c 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -40,6 +40,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[Same style]", "[იგივე სტილი]"), ("Reset To Base", "საბაზოზე დაბრუნება"), ("Apply Style to Selection", "სტილის გამოყენება მონიშნულზე"), + ("Find Format:", "ფორმატის ძიება:"), + ("Find Format Settings", "ფორმატის ძიების პარამეტრები"), + ("Specify attributes to find", "საძიებო ატრიბუტების მითითება"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "ძიების ფორმატით მხოლოდ ის ტექსტი მოიძებნება, რომელსაც ის აქვს; ცარიელი „ძიება“ ველით — ყველა ასეთი ტექსტი", + ), ("Change Format:", "ფორმატის შეცვლა:"), ("Change Format Settings", "ფორმატის შეცვლის პარამეტრები"), ("Style Options", "სტილის პარამეტრები"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index b536be8c7..252f82f74 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -39,6 +39,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[Same style]", "[Ten sam styl]"), ("Reset To Base", "Przywróć bazowy"), ("Apply Style to Selection", "Zastosuj styl do zaznaczenia"), + ("Find Format:", "Znajdź format:"), + ("Find Format Settings", "Ustawienia wyszukiwania formatu"), + ("Specify attributes to find", "Określ atrybuty do znalezienia"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "Przy formacie wyszukiwania znajdowany jest tylko tekst, który go ma; przy pustym polu „Znajdź” — cały taki tekst", + ), ("Change Format:", "Zmień format:"), ("Change Format Settings", "Ustawienia zmiany formatu"), ("Style Options", "Opcje stylu"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 505b840ea..ccd3117e1 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -40,6 +40,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[Same style]", "[Mesmo estilo]"), ("Reset To Base", "Redefinir para base"), ("Apply Style to Selection", "Aplicar estilo à seleção"), + ("Find Format:", "Localizar formato:"), + ("Find Format Settings", "Configurações de localização de formato"), + ("Specify attributes to find", "Especificar atributos a localizar"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "Com um formato de localização, só o texto que o tem é encontrado; com “Localizar” vazio, todo o texto que o tem", + ), ("Change Format:", "Alterar formato:"), ("Change Format Settings", "Configurações de alteração de formato"), ("Style Options", "Opções de estilo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 6e8e49df2..a3271593e 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -43,6 +43,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[Same style]", "[Тот же стиль]"), ("Reset To Base", "Сбросить до основы"), ("Apply Style to Selection", "Применить стиль к выделенному"), + ("Find Format:", "Найти формат:"), + ("Find Format Settings", "Параметры поиска формата"), + ("Specify attributes to find", "Задать атрибуты для поиска"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "С форматом поиска находится только текст с этим форматом; если поле «Найти» пусто — весь такой текст", + ), ("Change Format:", "Заменить формат:"), ("Change Format Settings", "Параметры замены формата"), ("Style Options", "Параметры стиля"), diff --git a/crates/ui-egui/src/i18n/table.rs b/crates/ui-egui/src/i18n/table.rs index 93e83a382..ffc78fdfc 100644 --- a/crates/ui-egui/src/i18n/table.rs +++ b/crates/ui-egui/src/i18n/table.rs @@ -988,6 +988,31 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ("Change All", ["Change All", "Tout remplacer", "Change All", "Change All", "全部更改"]), ("Change to:", ["Change to:", "Remplacer par :", "Change to:", "Change to:", "更改为:"]), ("Change Format:", ["Format ersetzen:", "Remplacer le format :", "Cambiar formato:", "置換形式 :", "更改格式:"]), + ("Find Format:", ["Format suchen:", "Rechercher le format :", "Buscar formato:", "検索形式 :", "查找格式:"]), + ( + "Find Format Settings", + ["Einstellungen für Format suchen", "Paramètres de recherche de format", "Ajustes de búsqueda de formato", "検索形式の設定", "查找格式设置"], + ), + ( + "Specify attributes to find", + [ + "Zu suchende Attribute festlegen", + "Indiquer les attributs à rechercher", + "Especificar atributos que buscar", + "検索する属性を指定", + "指定要查找的属性", + ], + ), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + [ + "Mit einem Suchformat wird nur Text gefunden, der es hat; bei leerem Feld „Suchen nach“ jeder Text, der es hat", + "Avec un format de recherche, seul le texte qui l’a est trouvé ; si « Rechercher » est vide, tout le texte qui l’a", + "Con un formato de búsqueda solo se encuentra el texto que lo tiene; con «Buscar» vacío, todo el texto que lo tiene", + "検索形式があるときは、その書式のテキストだけが見つかります。検索文字列が空のときは、その書式のテキストすべてが見つかります", + "设置了查找格式时,只查找具有该格式的文本;“查找内容”为空时,查找所有具有该格式的文本", + ], + ), ( "Change Format Settings", [ diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 858e0912e..87b9ec847 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -38,6 +38,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[Same style]", "[Той самий стиль]"), ("Reset To Base", "Скинути до основи"), ("Apply Style to Selection", "Застосувати стиль до виділеного"), + ("Find Format:", "Знайти формат:"), + ("Find Format Settings", "Параметри пошуку формату"), + ("Specify attributes to find", "Задати атрибути для пошуку"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "З форматом пошуку знаходиться лише текст із цим форматом; якщо поле «Знайти» порожнє — увесь такий текст", + ), ("Change Format:", "Замінити формат:"), ("Change Format Settings", "Параметри заміни формату"), ("Style Options", "Параметри стилю"), diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 228dc5231..7e65a1833 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -276,7 +276,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - FID2: IDML underline/strikethrough Text Color resets survive style inheritance and roundtrip. Raster and PDF paint underlines behind glyphs, retain strikethrough above, and center rules on their offsets; synthetic overlap tests cover both backends. - Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text. - UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts and the Fill/Stroke proxy (D, X, Shift+X, / as `color.*` commands), document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), document grid (View ▸ Grids & Guides, ⌘', on the snap positions, behind or in front of items), Lock Guides (⌥⌘;) and Lock Column Guides (View ▸ Grids & Guides; Lock Column Guides is saved with the document and in IDML), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches (InDesign's header: Fill/Stroke proxy, Formatting Affects Container/Text, Tint, which reads 100 with nothing selected; a swatch goes to the fill or stroke in front; rows show InDesign's type columns: the crossed pencil on swatches that can't be edited, process or spot, and the colour mode; New Color Group / New Swatch / Delete Swatch row), Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. -- Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows, expandable groups and drag-and-drop reorder of layers and objects (the selection square moves the selection to another layer). +- Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP, with Find Format: text that has a character or paragraph style or character attributes), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows, expandable groups and drag-and-drop reorder of layers and objects (the selection square moves the selection to another layer). - Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type; Option/Alt+Left/Right kerns at the caret or tracks a selection by the Kerning/Tracking increment, ×5 with Cmd/Ctrl; words move with Cmd+Left/Right on macOS and Ctrl+Left/Right elsewhere), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. - PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions. - IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories; attributes a file leaves unset take InDesign's defaults (Keep Lines Together at start/end of paragraph, 12 pt corners, paragraph rules in Text Color, auto-sizing from the centre, numbered lists restarting in each story, alternating table fills Black 20 % / None; a tint of −1 is the colour's own; frames take the fill, stroke, corners and Text Frame Options they don't write from their object style chain, then [None]) — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`). From b9f884f002c7268c82ff05b34ccf6e653c336254 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 30 Oct 2026 23:15:51 +0000 Subject: [PATCH 383/431] Find/Change: Change and Change/Find (PR #1498's own change on main's Find/Change) Applied as the diff of #1497 to #1498's head; the older copy of #1496 is not taken. crates/engine/src/cmd/find.rs: PR's found-text Change path; format_only keeps main's cleared-attributes case crates/ui-egui/src/dialogs/tests.rs: main's tests kept, the PR's Change test added crates/ui-egui/src/i18n/*.rs: union of keys docs/target-app-parity.md: main's text plus the Change wording --- ROADMAP.md | 1 + crates/compose/src/grep.rs | 10 +++ crates/engine/src/cmd/find.rs | 132 +++++++++++++++++++++++++--- crates/engine/src/cmd/find/tests.rs | 91 +++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 49 +++++++++-- crates/ui-egui/src/dialogs/tests.rs | 67 ++++++++++++-- crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/fr.rs | 1 - crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/table.rs | 2 + crates/ui-egui/src/i18n/uk.rs | 1 + docs/target-app-parity.md | 2 +- 17 files changed, 334 insertions(+), 29 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ed98bb2f1..4c925cd23 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -464,6 +464,7 @@ Newest first. - **2026-10-21** · Paragraph rules have InDesign's Type (solid, thick/thin stripes, dashed, dotted, straight hash, wavy, or a stroke style of the document), Gap Color and Gap Tint, and Keep In Frame (rule above); the Paragraph Rules page is laid out as InDesign's (Weight and Type, Color and Tint, Gap Color and Gap Tint, Width and Offset, Left and Right Indent). IDML reads and writes them (`RuleAboveType`, `RuleBelowGapColor`, `KeepRuleAboveInFrame`…). Found in the Learn Skills Daily course, chapter 20. No readiness percentage change. - **2026-10-21** · New Character Style with text selected starts from what the text has of its own over its paragraph style, as InDesign's ("[None] + Bold + color"), and Apply Style to Selection gives the text the new style, with no override left. A new style's dialog is titled New Paragraph Style / New Character Style. `type.selectionAttrs` reports the text's own character attributes (`ownChars`). Found in the Learn Skills Daily course, chapter 22. No readiness percentage change. - **2026-10-21** · New Paragraph Style with text selected starts from it, as InDesign's (Based On [No Paragraph Style], every setting of the text that differs) and Apply Style to Selection gives the paragraph the new style; General has Next Style and Reset To Base; Basic Character Formats is in two columns (Size | Leading, Kerning | Tracking, Case | Position). `style.paragraph.edit` honours `nextStyle` (it was ignored) and takes `resetToBase`. Found in the Learn Skills Daily course, chapter 20. No readiness percentage change. +- **2026-10-21** · Find/Change has InDesign's Change and Change/Find: Change changes the text Find Next found and keeps it selected, Change/Find also finds the next match, and the search keeps its domain (a selection or story) across them. The Search list reads Document, Story and Selection. `find.change` takes `found`. Found in the Learn Skills Daily course, chapter 22. No readiness percentage change. - **2026-10-21** · Find/Change has InDesign's Find Format: Specify Attributes opens Find Format Settings (the same Style Options and character sections as Change Format Settings), and only text with the format is found. With nothing in Find what, each stretch of text with the format is found, so Change All can restyle every word in a character style. `find.find`, `find.next` and `find.change` take `findFormat {charStyle?, paraStyle?, chars?}`. Found in the Learn Skills Daily course, chapter 22. No readiness percentage change. - **2026-10-21** · Find/Change has InDesign's Change Format. Specify Attributes opens Change Format Settings: Style Options (the character and paragraph style to apply, [Any Style] for none), then Basic and Advanced Character Formats, Character Color, OpenType Features, Underline and Strikethrough Options. The box shows the format ("Character Style: eggs"), and Change All gives it to the changed text. With a Change Format and nothing in Change to, the found text stays and is only formatted, as InDesign's (an ⓘ beside Change to says so). `find.change` takes `charStyle` and `paraStyle`. Found in the Learn Skills Daily course, chapter 22. No readiness percentage change. - **2026-10-21** · Preferences › Interface follows InDesign's: Appearance has a Color Theme swatch per interface theme (a click shows it at once, Cancel goes back) and Panels sets the Tools panel to a single or double column; UI Size moves to a UI Scaling section of its own. Interface held only UI Size, and the theme could be changed only from the Window menu. Found in the Learn Skills Daily course, chapter 26. No readiness percentage change. diff --git a/crates/compose/src/grep.rs b/crates/compose/src/grep.rs index 6f9e8f9e3..61546d162 100644 --- a/crates/compose/src/grep.rs +++ b/crates/compose/src/grep.rs @@ -385,6 +385,16 @@ impl Grep { self.re.find_iter(text).take(limit).map(|m| m.map(|m| m.range()).map_err(run_err)).collect() } + /// The first match starting at or after byte `start`, with its replacement: `template` + /// expanded as [`Self::replacements`] does. + pub fn replacement_at(&self, text: &str, start: usize, template: &str) -> Result<Option<(Range<usize>, String)>, GrepError> { + if start > text.len() || !text.is_char_boundary(start) { + return Ok(None); + } + let Some(caps) = self.re.captures_from_pos(text, start).map_err(run_err)? else { return Ok(None) }; + Ok(caps.get(0).map(|m| (m.range(), fancy_regex::Expander::default().expansion(template, &caps)))) + } + /// Non-empty matches in `text`, at most `limit` of them: an empty match styles nothing, so it /// doesn't count toward a GREP style's cap. pub fn find_nonempty(&self, text: &str, limit: usize) -> Result<Vec<Range<usize>>, GrepError> { diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index 6c0be2cd9..4ac02d928 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -61,18 +61,18 @@ pub fn specs() -> Vec<CommandSpec> { } ), cmd!(query "find.find", "Find", ["Edit", "Find/Change"], None, - "{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), change?|first?|attrs?|charStyle?|paraStyle?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text, cell?: {table, row, col}}] (document scope also searches table cells; start/end then index the cell's text)", + "{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), change?|first?|found?|attrs?|charStyle?|paraStyle?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text, cell?: {table, row, col}}] (document scope also searches table cells; start/end then index the cell's text)", has_doc, find), cmd!( "find.change", "Change All", ["Edit", "Find/Change"], None, - "{find, change, grep?, caseSensitive?, wholeWord?, scope?, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), first?: bool (change only the first match), attrs?: {character attributes applied to changed text}, charStyle?: a character style applied to it, paraStyle?: a paragraph style applied to its paragraphs} → {count} — attrs, charStyle and paraStyle are the Change Format: with one and an empty change, the found text stays and is only formatted; with no find, change is literal text", + "{find, change, grep?, caseSensitive?, wholeWord?, scope?, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), first?: bool (change only the first match), found?: bool (Change: only the text Find Next found, while it is selected and still a match; it stays selected, and Find Next goes on after it in the same domain), attrs?: {character attributes applied to changed text}, charStyle?: a character style applied to it, paraStyle?: a paragraph style applied to its paragraphs} → {count} — attrs, charStyle and paraStyle are the Change Format: with one and an empty change, the found text stays and is only formatted; with no find, change is literal text", has_doc, change ), - cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, caseSensitive?, wholeWord?, scope?: document|story|selection, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), change?|first?|attrs?|charStyle?|paraStyle?: ignored (find.change takes them; one request serves all three Find/Change commands)} — Selection starts at its original beginning; later calls advance within the scope", has_doc, find_next), + cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, caseSensitive?, wholeWord?, scope?: document|story|selection, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), change?|first?|found?|attrs?|charStyle?|paraStyle?: ignored (find.change takes them; one request serves all three Find/Change commands)} — Selection starts at its original beginning; later calls advance within the scope", has_doc, find_next), ] } @@ -272,6 +272,34 @@ impl Search { } } + /// The edit Change makes when the selection `range` (offsets in `text`, target `r` of story + /// `sid`) is what Find Next found there, so the two commands agree on what a match is. With + /// no pattern: a selection inside one stretch with the Find Format (Find Next selects the rest + /// of a stretch from inside it). With a pattern: the match it makes from the selection's start + /// must be the selection, overlapping matches Find Next can select included. + #[allow(clippy::too_many_arguments)] + fn found_edit( + &self, + d: &Document, + sid: StoryId, + cell: Option<CellAddr>, + r: &Range<usize>, + text: &str, + range: &Range<usize>, + rep: &str, + grep: bool, + ) -> Result<Option<(Range<usize>, String)>> { + let spans = self.spans(d, sid, cell, r); + let Some(re) = &self.re else { + let inside = !range.is_empty() && spans.unwrap_or_default().iter().any(|s| s.start <= range.start && range.end <= s.end); + return Ok(inside.then(|| (range.clone(), rep.to_string()))); + }; + let found = + if grep { re.replacement_at(text, range.start, rep) } else { re.find_at(text, range.start).map(|m| m.map(|m| (m, rep.to_string()))) }; + let found = found.map_err(|e| bad("find.change", e))?; + Ok(found.filter(|(m, _)| m == range && spans.as_ref().is_none_or(|s| within(s, m)))) + } + /// The edits Change All makes in `text` (target `r` of story `sid`): `rep` expanded for each /// GREP match, else `rep` itself. #[allow(clippy::too_many_arguments)] @@ -378,6 +406,29 @@ impl FindContext { self.selection = st.selection.clone(); } + /// After Change put `new_len` bytes in place of `old` in story `sid`: the domain keeps the + /// changed text and the search goes on after it. + fn after_change(&mut self, st: &DocState, sid: StoryId, cell: Option<CellAddr>, old: &Range<usize>, new_len: usize) { + let moved = |x: usize| if x >= old.end { x - old.len() + new_len } else { x }; + for (tid, tcell, r) in &mut self.targets { + if *tid == sid && *tcell == cell { + *r = moved(r.start)..moved(r.end); + } + } + self.offset = old.start + new_len; + self.last_match_end = Some(self.offset); + let at = self.offset; + if let Some(i) = self + .order + .iter() + .position(|i| self.targets.get(*i).is_some_and(|(tid, tcell, r)| *tid == sid && *tcell == cell && r.start <= at && at <= r.end)) + { + self.target = i; + } + self.document = Arc::downgrade(&st.doc); + self.acknowledge(st); + } + fn restart(&mut self, st: &DocState) -> Result<()> { self.target = 0; self.last_match_end = None; @@ -570,21 +621,58 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> { return Err(bad("find.change", format!("no paragraph style `{n}`"))); } // InDesign: with a Change Format and nothing in Change to, the found text is only formatted. - let format_only = - str_param(p, "change").unwrap_or("").is_empty() && (char_style.is_some() || para_style.is_some() || !over.is_empty() || !cleared.is_empty()); - let targets = scope(s, p)?.targets; - s.edit(|d, sel| { + let format_only = str_param(p, "change").unwrap_or("").is_empty() && (char_style.is_some() || para_style.is_some() || !over.is_empty() || !cleared.is_empty()); + let context = scope(s, p)?; + let targets = context.targets.clone(); + // Change: only the text Find Next found, the selection, while it is still a match (as Find + // Next defines one), with its replacement. + let (found, found_out) = if bool_or(p, "found", false) { + let st = s.doc()?; + let selected = st.selection.text.filter(|t| st.selection.cells.is_none() && !t.is_caret()).map(|t| (t.story, t.cell, t.range())); + let hit = selected.and_then(|(sid, cell, range)| { + targets.iter().find_map(|(tid, tcell, r)| { + if *tid != sid || *tcell != cell || range.start < r.start || r.end < range.end { + return None; + } + let text = target_text(&st.doc, *tid, cell, r).ok()?; + let local = range.start - r.start..range.end - r.start; + let (m, out) = re.found_edit(&st.doc, *tid, cell, r, text, &local, &rep, grep).ok()??; + Some(((sid, cell, r.start + m.start..r.start + m.end), out)) + }) + }); + match hit { + Some((hit, out)) => (Some(hit), Some(out)), + None => return Ok(json!({"count": 0})), + } + } else { + (None, None) + }; + let (count, changed) = s.edit(|d, sel| { let styles = d.styles.clone(); let mut count = 0usize; + let mut changed = None; for (sid, cell, r) in targets { + if found.as_ref().is_some_and(|(at, in_cell, _)| *at != sid || *in_cell != cell) { + continue; + } let Some(story) = d.text_story(sid, cell) else { continue }; // Collect matches first (positions refer to the original text), apply back to front. let text = story.text.get(r.clone()).ok_or_else(|| bad("find", "invalid selection bounds or UTF-8 boundary"))?; - let edits: Vec<(std::ops::Range<usize>, String)> = re - .replacements(d, sid, cell, &r, text, &rep, grep, first_only)? - .into_iter() - .map(|(m, out)| (r.start + m.start..r.start + m.end, out)) - .collect(); + let edits: Vec<(std::ops::Range<usize>, String)> = match (&found, &found_out) { + // Change: the found text alone, in the target it lies in. + (Some((_, _, range)), Some(out)) => { + if r.start <= range.start && range.end <= r.end { + vec![(range.clone(), out.clone())] + } else { + Vec::new() + } + } + _ => re + .replacements(d, sid, cell, &r, text, &rep, grep, first_only)? + .into_iter() + .map(|(m, out)| (r.start + m.start..r.start + m.end, out)) + .collect(), + }; let Some(st) = d.text_story_mut(sid, cell) else { continue }; for (range, new) in edits.iter().rev() { let changed = if format_only { @@ -616,13 +704,29 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> { if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell == cell) { repair_selection(t, st); } + // Change leaves the changed text selected. + if let Some((_, _, range)) = &found + && let Some((_, new)) = edits.first() + && let Some(t) = sel.text.as_mut() + { + let len = if format_only { range.len() } else { new.len() }; + (t.anchor, t.focus) = (range.start, range.start + len); + changed = Some(len); + } // `first` is the first match of the whole search, not of each story. if first_only && count > 0 { break; } } - Ok(json!({"count": count})) - }) + Ok((count, changed)) + })?; + // Change: Find Next goes on after the changed text, in the search's domain. + if let (Some((sid, cell, old)), Some(len)) = (&found, changed) { + let mut context = context; + context.after_change(s.doc()?, *sid, *cell, old, len); + s.find_context = Some(context); + } + Ok(json!({"count": count})) } fn find_next(s: &mut Session, p: &Value) -> Result<Value> { diff --git a/crates/engine/src/cmd/find/tests.rs b/crates/engine/src/cmd/find/tests.rs index 7de29f8ea..65dad887b 100644 --- a/crates/engine/src/cmd/find/tests.rs +++ b/crates/engine/src/cmd/find/tests.rs @@ -621,6 +621,27 @@ 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}"); +} + fn child_scope_session(note: bool, child: &str) -> (Session, designcraft_doc::StoryId, designcraft_doc::TextSel) { let (mut s, sid) = scope_session("red"); scope_select(&mut s, sid, 3, 3); @@ -862,3 +883,73 @@ fn a_find_format_finds_only_text_with_it() { } 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<a>" } else { "ab" }); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 642f0d1cd..bd99d9222 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2676,13 +2676,21 @@ 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 { + "story" => "Story", + "selection" => "Selection", + _ => "Document", + }; craft_ui::ComboBox::from_id_salt("fcscope") - .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &cur))) + .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, scope_name(&cur)))) .show_ui(ui, |ui| { for v in ["document", "story", "selection"] { - if ui.selectable_label(cur == v, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, v))).clicked() + if ui + .selectable_label(cur == v, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, scope_name(v)))) + .clicked() && d.set_field("scope", json!(v)) { app.session.reset_find_scope(); @@ -2690,7 +2698,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| { @@ -2702,11 +2710,27 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { ui.horizontal(|ui| { let params0 = find_change_params(&d); let params = || params0.clone(); + // Change and Change/Find act on the text Find Next found. + let mut found = params0.clone(); + found["found"] = json!(true); + let selected = app.session.active().and_then(|st| st.selection.text).is_some_and(|t| !t.is_caret()); + let next_status = |app: &mut DesignApp, r: Result<Value, String>| match r { + Ok(Value::Null) => crate::i18n::tr(&app.ui.language, "No matches").to_string(), + Ok(_) => crate::i18n::tr(&app.ui.language, "Found").to_string(), + Err(e) => e, + }; if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Find Next"))).clicked() { let r = app.run("find.next", params()); - let msg = match r { - Ok(Value::Null) => crate::i18n::tr(&app.ui.language, "No matches").to_string(), - Ok(_) => crate::i18n::tr(&app.ui.language, "Found").to_string(), + let msg = next_status(app, r); + d.fields.insert("status".into(), json!(msg)); + } + if ui + .add_enabled(selected, egui::Button::new(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Change")))) + .clicked() + { + let msg = match app.run("find.change", found.clone()) { + Ok(v) => crate::i18n::tr(&app.ui.language, "{count} replacement(s) made") + .replace("{count}", &v["count"].to_string()), Err(e) => e, }; d.fields.insert("status".into(), json!(msg)); @@ -2719,6 +2743,17 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }; d.fields.insert("status".into(), json!(msg)); } + if ui + .add_enabled( + selected, + egui::Button::new(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Change/Find"))), + ) + .clicked() + { + let r = app.run("find.change", found.clone()).and_then(|_| app.run("find.next", params())); + let msg = next_status(app, r); + d.fields.insert("status".into(), json!(msg)); + } if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Count"))).clicked() { let msg = match app.run("find.find", params()) { Ok(v) => crate::i18n::tr(&app.ui.language, "{count} match(es)") diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index d3f99b14e..e3e271a7e 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -1647,6 +1647,15 @@ 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", + } +} + 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:?}"); @@ -1730,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) } @@ -1806,8 +1815,8 @@ fn find_scope_dialog_explicit_story_ignored_scope_preserves_grep_cursor() { let cur = app.ui.dialog.as_ref().unwrap().s("scope"); let next = if cur == "selection" { "document" } else { "selection" }; if physical { - scope_click(&mut app, &ctx, screen, &cur); - scope_click(&mut app, &ctx, screen, next); + scope_click(&mut app, &ctx, screen, scope_label(&cur)); + scope_click(&mut app, &ctx, screen, scope_label(next)); } else { scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "scope", "value": next})); } @@ -1824,8 +1833,8 @@ fn find_scope_dialog_explicit_story_ignored_scope_preserves_grep_cursor() { let cur = app.ui.dialog.as_ref().unwrap().s("scope"); let next = if cur == "selection" { "document" } else { "selection" }; if physical { - scope_click(&mut app, &ctx, screen, &cur); - scope_click(&mut app, &ctx, screen, next); + scope_click(&mut app, &ctx, screen, scope_label(&cur)); + scope_click(&mut app, &ctx, screen, scope_label(next)); } else { scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "scope", "value": next})); } @@ -4400,3 +4409,49 @@ fn on_a_page_without_a_section_the_window_is_new_section() { visible_label(&labels, screen, "Numbering & Section Options"); assert!(!labels.iter().any(|(t, _, _)| t == "New Section")); } + +/// 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::unique("change_find_test_fonts"); + if ui.data(|d| d.get_temp::<bool>(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")); +} diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 79f28509f..b507d292c 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -39,6 +39,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[Same style]", "[النمط نفسه]"), ("Reset To Base", "إعادة التعيين إلى الأساس"), ("Apply Style to Selection", "تطبيق النمط على التحديد"), + ("Change/Find", "تغيير/بحث"), ("Find Format:", "البحث عن تنسيق:"), ("Find Format Settings", "إعدادات البحث عن تنسيق"), ("Specify attributes to find", "تحديد السمات المراد البحث عنها"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e9023b369..c2c977876 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -41,6 +41,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[Same style]", "[Stejný styl]"), ("Reset To Base", "Obnovit podle základu"), ("Apply Style to Selection", "Použít styl na výběr"), + ("Change/Find", "Změnit/Najít"), ("Find Format:", "Hledat formát:"), ("Find Format Settings", "Nastavení hledání formátu"), ("Specify attributes to find", "Určit atributy k hledání"), diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 2dd2e1bfc..993d2722c 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -319,7 +319,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Select Sentence", "Sélectionner la phrase"), ("Select Story", "Sélectionner l'article"), ("Select Text Unit", "Sélectionner une unité de texte"), - ("Selection", "Sélection"), ("Selection Tool", "Outil Sélection"), ("Sent {count} page(s) to {printer}", "{count} page(s) envoyée(s) à {printer}"), ("Sentence case", "Majuscule en début de phrase"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 180524863..2976a4df3 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -37,6 +37,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[Same style]", "[Stesso stile]"), ("Reset To Base", "Ripristina base"), ("Apply Style to Selection", "Applica stile alla selezione"), + ("Change/Find", "Sostituisci/Trova"), ("Find Format:", "Trova formato:"), ("Find Format Settings", "Impostazioni di ricerca formato"), ("Specify attributes to find", "Specifica gli attributi da trovare"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 2344a666c..a22fe780f 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -40,6 +40,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[Same style]", "[იგივე სტილი]"), ("Reset To Base", "საბაზოზე დაბრუნება"), ("Apply Style to Selection", "სტილის გამოყენება მონიშნულზე"), + ("Change/Find", "ჩანაცვლება/ძიება"), ("Find Format:", "ფორმატის ძიება:"), ("Find Format Settings", "ფორმატის ძიების პარამეტრები"), ("Specify attributes to find", "საძიებო ატრიბუტების მითითება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 252f82f74..de5360fbd 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -39,6 +39,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[Same style]", "[Ten sam styl]"), ("Reset To Base", "Przywróć bazowy"), ("Apply Style to Selection", "Zastosuj styl do zaznaczenia"), + ("Change/Find", "Zmień/Znajdź"), ("Find Format:", "Znajdź format:"), ("Find Format Settings", "Ustawienia wyszukiwania formatu"), ("Specify attributes to find", "Określ atrybuty do znalezienia"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index ccd3117e1..ac0c1dd43 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -40,6 +40,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[Same style]", "[Mesmo estilo]"), ("Reset To Base", "Redefinir para base"), ("Apply Style to Selection", "Aplicar estilo à seleção"), + ("Change/Find", "Alterar/Localizar"), ("Find Format:", "Localizar formato:"), ("Find Format Settings", "Configurações de localização de formato"), ("Specify attributes to find", "Especificar atributos a localizar"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index a3271593e..c87d33f25 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -43,6 +43,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[Same style]", "[Тот же стиль]"), ("Reset To Base", "Сбросить до основы"), ("Apply Style to Selection", "Применить стиль к выделенному"), + ("Change/Find", "Заменить/Найти"), ("Find Format:", "Найти формат:"), ("Find Format Settings", "Параметры поиска формата"), ("Specify attributes to find", "Задать атрибуты для поиска"), diff --git a/crates/ui-egui/src/i18n/table.rs b/crates/ui-egui/src/i18n/table.rs index ffc78fdfc..c879a2424 100644 --- a/crates/ui-egui/src/i18n/table.rs +++ b/crates/ui-egui/src/i18n/table.rs @@ -988,6 +988,7 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ("Change All", ["Change All", "Tout remplacer", "Change All", "Change All", "全部更改"]), ("Change to:", ["Change to:", "Remplacer par :", "Change to:", "Change to:", "更改为:"]), ("Change Format:", ["Format ersetzen:", "Remplacer le format :", "Cambiar formato:", "置換形式 :", "更改格式:"]), + ("Change/Find", ["Ersetzen/Suchen", "Remplacer/Rechercher", "Cambiar/Buscar", "置換して検索", "更改/查找"]), ("Find Format:", ["Format suchen:", "Rechercher le format :", "Buscar formato:", "検索形式 :", "查找格式:"]), ( "Find Format Settings", @@ -1227,6 +1228,7 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ("Load Styles", ["Formate laden", "Charger les styles", "Cargar estilos", "スタイルを読み込み", "加载样式"]), ("New Section", ["Neuer Abschnitt", "Nouvelle section", "Nueva sección", "新規セクション", "新建章节"]), ("Cannot be edited", ["Nicht bearbeitbar", "Non modifiable", "No editable", "編集不可", "不可编辑"]), + ("Selection", ["Auswahl", "Sélection", "Selección", "選択範囲", "选区"]), ("Restart Numbering", ["Nummerierung neu beginnen", "Recommencer la numérotation", "Reiniciar numeración", "番号の振り直し", "重新开始编号"]), ("Continue Numbering", ["Nummerierung fortführen", "Poursuivre la numérotation", "Continuar numeración", "番号の継続", "继续编号"]), ("Keep Options…", ["Umbruchoptionen …", "Options d'enchaînement…", "Opciones de separación…", "段落分離禁止オプション…", "保持选项…"]), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 87b9ec847..8706ec6be 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -38,6 +38,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("[Same style]", "[Той самий стиль]"), ("Reset To Base", "Скинути до основи"), ("Apply Style to Selection", "Застосувати стиль до виділеного"), + ("Change/Find", "Замінити/Знайти"), ("Find Format:", "Знайти формат:"), ("Find Format Settings", "Параметри пошуку формату"), ("Specify attributes to find", "Задати атрибути для пошуку"), diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7e65a1833..3506695d4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -276,7 +276,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - FID2: IDML underline/strikethrough Text Color resets survive style inheritance and roundtrip. Raster and PDF paint underlines behind glyphs, retain strikethrough above, and center rules on their offsets; synthetic overlap tests cover both backends. - Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text. - UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts and the Fill/Stroke proxy (D, X, Shift+X, / as `color.*` commands), document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), document grid (View ▸ Grids & Guides, ⌘', on the snap positions, behind or in front of items), Lock Guides (⌥⌘;) and Lock Column Guides (View ▸ Grids & Guides; Lock Column Guides is saved with the document and in IDML), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches (InDesign's header: Fill/Stroke proxy, Formatting Affects Container/Text, Tint, which reads 100 with nothing selected; a swatch goes to the fill or stroke in front; rows show InDesign's type columns: the crossed pencil on swatches that can't be edited, process or spot, and the colour mode; New Color Group / New Swatch / Delete Swatch row), Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. -- Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP, with Find Format: text that has a character or paragraph style or character attributes), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows, expandable groups and drag-and-drop reorder of layers and objects (the selection square moves the selection to another layer). +- Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP, with Find Format: text that has a character or paragraph style or character attributes; Change and Change/Find on the text Find Next found), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows, expandable groups and drag-and-drop reorder of layers and objects (the selection square moves the selection to another layer). - Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type; Option/Alt+Left/Right kerns at the caret or tracks a selection by the Kerning/Tracking increment, ×5 with Cmd/Ctrl; words move with Cmd+Left/Right on macOS and Ctrl+Left/Right elsewhere), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. - PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions. - IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories; attributes a file leaves unset take InDesign's defaults (Keep Lines Together at start/end of paragraph, 12 pt corners, paragraph rules in Text Color, auto-sizing from the centre, numbered lists restarting in each story, alternating table fills Black 20 % / None; a tint of −1 is the colour's own; frames take the fill, stroke, corners and Text Frame Options they don't write from their object style chain, then [None]) — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`). From 5390d73ca01274af55850fc692e9195214400675 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 30 Oct 2026 23:16:06 +0000 Subject: [PATCH 384/431] Integration: #1416 drop duplicated roadmap line --- ROADMAP.md | 1 - 1 file changed, 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 23e8ebe4e..bcae28601 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -491,7 +491,6 @@ Newest first. - **2026-10-20** · Drawing a frame or shape shows its width and height in the document's ruler units, to two decimals and on two lines ("W: 6.08 cm", "H: 6.08 cm"), as InDesign does; it was always whole points. No readiness percentage change. - **2026-10-20** · Type tool: a drag that starts in an empty frame draws a new text frame over it, as in InDesign; a click still turns the frame into a text frame. No readiness percentage change. - **2026-10-20** · Measures in centimetres show four decimals, as InDesign's fields do (a 1p gutter reads 0.4233 cm, not 0.423 cm). No readiness percentage change. -- **2026-10-14** · The 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-21** · Properties for points selected with Direct Selection, as InDesign's: the title is "Anchor Point(s)", and the panel shows Transform, Appearance, Convert Points (Plain, Corner, Smooth, Symmetrical) and the Delete Point(s) quick action (`path.deletePoints`, one undo step). Found in Zero2Graphic's *How to Page Layout design in Adobe InDesign 2022 CC*. No readiness percentage change. - **2026-10-20** · Direct Selection selects anchor points: a marquee selects the points inside it on every path it reaches, a click selects one point and Shift+click adds or removes one, selected points show solid, and dragging one (Shift: at 45° steps) or pressing the arrow keys moves them all in one step (`selection.set {anchors}`, `path.moveAnchors {points}`). No readiness percentage change. - **2026-10-21** · Properties › Character and Paragraph: ••• (More Options) expands the section in place, as InDesign's: Character adds vertical and horizontal scale, baseline shift, skew, language and OpenType, and All Caps, Small Caps, Superscript, Subscript, Underline and Strikethrough; Paragraph adds the left, right, first line and last line indents, space before and after, the drop cap's lines and characters, Align to Baseline Grid and Hyphenate. It used to open the Character or Paragraph panel; the language field moved under ••• as in InDesign. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change. From cc80be16ab5b555a966ca8a81b25be17fd15af4f Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 30 Oct 2026 23:16:19 +0000 Subject: [PATCH 385/431] Integration: #1461 spacing-limit test checks every paragraph gap against the small limit --- crates/compose/src/tests.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 458a9b2ea..6127ef232 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -7503,7 +7503,15 @@ Three"; // No limit: equal steps. A big limit: all of it between paragraphs (here every line is one). assert!((gaps(&none).0 - gaps(&none).1).abs() < 1e-6, "{none:?}"); assert!((gaps(&big).0 - gaps(&big).1).abs() < 1e-6, "{big:?}"); - // A small limit: each paragraph gap gets at most 20 pt more than the even share's base. + // A small limit: every paragraph gap takes the whole 20 pt, and the rest spreads over lines, + // so each gap is wider than the top-aligned one by at least 20 pt and narrower than with a big limit. + let top = { + let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 400.0), ParaAttrs::default()); + all_lines(&compose_story(&d, sid, &ComposeOptions::default())).iter().map(|l| l.baseline).collect::<Vec<_>>() + }; + let (natural, small_gaps, big_gaps) = (gaps(&top), gaps(&small), gaps(&big)); + assert!(small_gaps.0 >= natural.0 + 20.0 - 1e-6 && small_gaps.1 >= natural.1 + 20.0 - 1e-6, "{top:?} {small:?}"); + assert!(small_gaps.0 <= big_gaps.0 + 1e-6, "{small:?} {big:?}"); assert!((none[2] - big[2]).abs() < 1e-6 && (none[2] - small[2]).abs() < 1e-6, "the last line stays at the bottom"); // A paragraph of two lines keeps its leading when the limit absorbs the space. let (mut d, sid, fid) = doc_with( From ae085777166f971d70cb3f81e073e74b9fb58a39 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 30 Oct 2026 23:18:14 +0000 Subject: [PATCH 386/431] Integration: #1430 fix the properties imports after the merge --- crates/ui-egui/src/panels/properties.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index ccc8598ab..6dcc0a967 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -34,7 +34,6 @@ use object::{align_row, appearance_section, guide_section, quick_actions_object, #[cfg(test)] use object::{align_to_icon, preview_row}; 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}; use object_panels::{anchor_point_sections, pathfinder_row}; pub(crate) use text::{LANGUAGES, kerning_control, language_menu}; From b9f59f62e34fb1f0200c746b89c01e6633007e6d Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 30 Oct 2026 23:18:50 +0000 Subject: [PATCH 387/431] Integration: #1461 close the table entry the key union cut open --- crates/ui-egui/src/i18n/table.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/ui-egui/src/i18n/table.rs b/crates/ui-egui/src/i18n/table.rs index b23d7105b..38bd38036 100644 --- a/crates/ui-egui/src/i18n/table.rs +++ b/crates/ui-egui/src/i18n/table.rs @@ -24,6 +24,8 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ "Borrar opciones de encaje de marco", "フレーム調整オプションを消去", "清除框架适合选项", + ], + ), ("Maximum:", ["Maximum:", "Maximum :", "Máximo:", "最大 :", "最大值:"]), ( "Paragraph Spacing Limit:", From f3549fb2d82614e9c3415a38cc6e76ba0e2e3fde Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 30 Oct 2026 23:24:16 +0000 Subject: [PATCH 388/431] Integration: #892 spread label test comment --- crates/ui-egui/src/panels/pages.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 559d11963..5488996ee 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -821,7 +821,7 @@ mod spread_label_tests { #[test] fn spreads_are_labelled_as_indesign_labels_them() { // Chris Nielsen's "Numbering Page Sections": sections starting at 1 on pages 3 and 7, - // the second in letters, label their spreads "2, 1" and "6, A". + // the second in letters, label their spreads "2, 1" and "4, A". let mut app = app(); assert_eq!(labels(&app), ["1", "2–3", "4–5", "6–7", "8–9"]); app.run("layout.section", json!({"page": 3, "startNumber": 1})).unwrap(); From 1fe4bcf11fc7c36cb6fd9b95efed0210f599f6b9 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 30 Oct 2026 23:24:41 +0000 Subject: [PATCH 389/431] Integration: #1080 theme tokens The swatch outline reads Tokens::field_border. The field cross, the slider markers and the OK pill's white text stay fixed colours: they sit on arbitrary colours or on the accent fill, as in the other dialogs' OK buttons. --- crates/ui-egui/src/dialogs.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f95da5e52..6b8a5a32d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -8317,7 +8317,8 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { 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. + // The marker: a cross with an open centre, white over black. Fixed colours: it sits on the + // colour field, whatever the theme. 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); @@ -8328,7 +8329,7 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { 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. + // The slider, with a marker on each side (white with a dark edge, readable on any colour). let sr = put(at::slider()); 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)); @@ -8355,7 +8356,7 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { 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); + ui.painter().rect_stroke(pr, 0.0, egui::Stroke::new(1.0, t.field_border), 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)); From 99b2b6a08809e6c57309f6e59cc2284d778429f3 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 30 Oct 2026 23:25:18 +0000 Subject: [PATCH 390/431] Integration: #1080 Enter/Esc The colour picker draws its own buttons, so the generic footer's Enter/Esc handling does not run for it. It now consumes the keys it handles and lets a button click win over a key in the same frame. --- crates/ui-egui/src/dialogs.rs | 16 +++++++++++----- crates/ui-egui/src/dialogs/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6b8a5a32d..c9e8683ac 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -8371,11 +8371,17 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::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")); + // The picker draws its own buttons, so the generic footer's Enter/Esc handling is skipped. + // The keys are consumed here so nothing else reacts to them as well, and a click on a pill + // wins over a key pressed in the same frame, so the dialog closes once. + let ok = pill(ui, 0, &tr("OK"), true); + let cancel = pill(ui, 1, &tr("Cancel"), false); + let enter = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); + let esc = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); + if ok || cancel { + d.fields.insert("_close".into(), json!(if cancel { "cancel" } else { "ok" })); + } else if enter || esc { + d.fields.insert("_close".into(), json!(if enter { "ok" } else { "cancel" })); } // The C/M/Y/K the fields show: the percentages typed or given, else the colour converted. let cmyk = nums::<4>(d, "cmyk").map(|v| v.map(|x| x as f32)).unwrap_or_else(|| { diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index fb4c6ea44..284d6bbc6 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -576,6 +576,26 @@ fn fill(app: &DesignApp, id: u64) -> designcraft_color::Color { d.resolve_color(&d.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, 1.0).unwrap() } +fn key_press(key: egui::Key) -> egui::Event { + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE } +} + +#[test] +fn the_color_picker_enter_confirms_and_escape_cancels_even_in_a_field() { + for (key, applied) in [(egui::Key::Enter, true), (egui::Key::Escape, false)] { + let (mut app, ctx, screen, id) = picker(json!({"target": "fill", "hex": "#ffffff"})); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "ffffff").center()); + dialog_frame(&mut app, &ctx, screen, vec![select_all_key()]); + type_slowly(&mut app, &ctx, screen, "#f59019"); + dialog_frame(&mut app, &ctx, screen, vec![key_press(key)]); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert!(app.ui.dialog.is_none(), "{key:?} closes the picker"); + let hex = fill(&app, id).to_hex(); + assert_eq!(hex == "#f59019", applied, "{key:?}: {hex}"); + } +} + #[test] fn the_hex_field_keeps_typed_text_and_commits_a_valid_code() { let (mut app, ctx, screen, _) = picker(json!({"target": "fill", "hex": "#ffffff"})); From 4f8cc475c50edd247d5a524b82cbcfb134adf8e9 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 30 Oct 2026 23:25:19 +0000 Subject: [PATCH 391/431] Integration: #1676 flat Convert Cell items --- crates/engine/src/cmd/table/registry.rs | 4 ++-- crates/ui-egui/src/menus/tables.rs | 2 -- crates/ui-egui/src/menus/tests.rs | 3 ++- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/table/registry.rs b/crates/engine/src/cmd/table/registry.rs index c6b0dda17..0374b71df 100644 --- a/crates/engine/src/cmd/table/registry.rs +++ b/crates/engine/src/cmd/table/registry.rs @@ -208,7 +208,7 @@ fn table_specs() -> Vec<CommandSpec> { cmd!( "table.placeGraphic", "Convert Cell to Graphic Cell", - ["Table", "Convert Cell Type"], + ["Table"], None, "{path? | base64?, name?: with base64, fit?: proportional|fill (default proportional), story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — an image in the target cell (its text is kept but hidden)", in_table, @@ -217,7 +217,7 @@ fn table_specs() -> Vec<CommandSpec> { cmd!( "table.textCell", "Convert Cell to Text Cell", - ["Table", "Convert Cell Type"], + ["Table"], None, "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — drop the target cells' graphics", in_table, diff --git a/crates/ui-egui/src/menus/tables.rs b/crates/ui-egui/src/menus/tables.rs index b361927cd..d654a02c7 100644 --- a/crates/ui-egui/src/menus/tables.rs +++ b/crates/ui-egui/src/menus/tables.rs @@ -453,10 +453,8 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:table.unmerge", "cmd:table.splitHorizontally", "cmd:table.splitVertically", - ">Convert Cell Type", "ui:app.graphicCell|Convert Cell to Graphic Cell…", "cmd:table.textCell", - "<", ">Convert Rows", "cmd:table.convertRowsToHeader", "cmd:table.convertRowsToBody", diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index abf3552d4..d7b6fc0ed 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -2233,7 +2233,8 @@ fn the_table_menu_follows_indesigns_order() { "Unmerge Cells", "Split Cell Horizontally", "Split Cell Vertically", - "Convert Cell Type >", + "Convert Cell to Graphic Cell…", + "Convert Cell to Text Cell", "Convert Rows >", "-", "Distribute Rows Evenly", From 79d09395f558a359bdcbf8a4f17505bb12d812ed Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 30 Oct 2026 23:25:49 +0000 Subject: [PATCH 392/431] Integration: #1503 eyedropper The merge of PR #1503 took main's and #1080's merged picker as is; this applies the eyedropper's own delta on top of upr/1080 (canvas::color_at, drag-from-picker in dialogs.rs, tests, picker strings). Conflicts: dialogs.rs kept the single-fire Enter/Esc handling with the eyedropper's trimmed OK key; the i18n tables keep both sides' entries. --- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/canvas/render.rs | 43 ++++++++++- crates/ui-egui/src/dialogs.rs | 52 +++++++++---- crates/ui-egui/src/dialogs/tests.rs | 116 ++++++++++++++++++++++++---- crates/ui-egui/src/i18n/ar.rs | 5 ++ crates/ui-egui/src/i18n/cs.rs | 5 ++ crates/ui-egui/src/i18n/it.rs | 5 ++ crates/ui-egui/src/i18n/ka.rs | 5 ++ crates/ui-egui/src/i18n/pl.rs | 5 ++ crates/ui-egui/src/i18n/pt_br.rs | 5 ++ crates/ui-egui/src/i18n/ru.rs | 5 ++ crates/ui-egui/src/i18n/table.rs | 10 +++ crates/ui-egui/src/i18n/uk.rs | 5 ++ 13 files changed, 230 insertions(+), 33 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 96afd1784..933d71a19 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; 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/canvas/render.rs b/crates/ui-egui/src/canvas/render.rs index 642694b8a..07983944e 100644 --- a/crates/ui-egui/src/canvas/render.rs +++ b/crates/ui-egui/src/canvas/render.rs @@ -1,9 +1,40 @@ -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]) } +/// 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]> { + let rect = app.canvas_rect.filter(|r| r.contains(pos))?; + let v = *app.view()?; + let xf = Xf::new(rect, &v); + let at = xf.to_canvas(pos); + let st = app.session.active()?; + let doc = st.doc.clone(); + let layout = CanvasLayout::new(&doc, st.editing_parents); + let on_page = layout + .slots + .iter() + .any(|slot| doc.spread(slot.spread).is_some_and(|sp| sp.pages.iter().any(|page| slot.xf.transform_rect_bbox(page.bounds()).contains(at)))); + if !on_page { + return None; + } + let placed: Vec<designcraft_render::Placed> = layout.slots.iter().map(|s| designcraft_render::Placed { spread: s.spread, xf: s.xf }).collect(); + 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); + // 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()?; + // Premultiplied over the paper. + let paper = doc.resolve_color(designcraft_color::swatch::PAPER, 1.0).unwrap_or(designcraft_color::Color::WHITE).to_rgba8(1.0); + let over = |c: u8, p: u8| (u32::from(c) + u32::from(p) * (255 - u32::from(a)) / 255).min(255) as u8; + 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 { @@ -268,10 +299,16 @@ fn render_worker_spawn(ctx: &egui::Context) -> Option<crate::render_worker::Work crate::render_worker::Worker::spawn(ctx.clone()) } -fn upload(app: &mut DesignApp, ctx: &egui::Context, mut img: designcraft_render::Rendered) { +/// What the canvas does to a render before showing it: Proof Colors. The texture and the +/// eyedropper's sample both go through it. +fn screen_post(app: &DesignApp, img: &mut designcraft_render::Rendered) { if app.ui.proof_colors { - designcraft_render::proof_view(&mut img, &app.ui.proof_setup); + designcraft_render::proof_view(img, &app.ui.proof_setup); } +} + +fn upload(app: &mut DesignApp, ctx: &egui::Context, mut img: designcraft_render::Rendered) { + screen_post(app, &mut img); let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); let ci = crate::widgets::fit_texture(ci, ctx.input(|i| i.max_texture_side)); match &mut app.canvas.texture { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c9e8683ac..55d88dced 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -8262,6 +8262,32 @@ fn nums<const N: usize>(d: &Dialog, k: &str) -> Option<[f64; N]> { Some(out) } +/// The Color Picker's eyedropper at `at`, as InDesign's: dragged onto the page, it takes the colour +/// shown under the pointer when it is let go. The colour taken, if any. +fn eyedropper(app: &mut DesignApp, ui: &mut egui::Ui, at: egui::Pos2, tip: String, t: &crate::theme::Tokens) -> Option<[u8; 3]> { + let er = egui::Rect::from_center_size(at, egui::vec2(21.0, 21.0)); + 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); + } + crate::icons::paint(ui.painter(), egui::Rect::from_center_size(er.center(), egui::vec2(15.0, 15.0)), "tool-eyedropper", t.icon); + if resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + // Let go over the picker itself it takes nothing, though a page may lie under it. (Its modal + // backdrop shares the picker's layer and covers the screen: the picker is where it draws.) + let mut taken = None; + if resp.drag_stopped() + && let Some(pos) = ui.input(|i| i.pointer.latest_pos()) + && !ui.clip_rect().contains(pos) + { + taken = crate::canvas::color_at(app, ui.ctx(), pos); + } + resp.on_hover_text(tip); + taken +} + /// 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 @@ -8374,7 +8400,8 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { // The picker draws its own buttons, so the generic footer's Enter/Esc handling is skipped. // The keys are consumed here so nothing else reacts to them as well, and a click on a pill // wins over a key pressed in the same frame, so the dialog closes once. - let ok = pill(ui, 0, &tr("OK"), true); + // The footer's padded OK key, which every catalog has. + let ok = pill(ui, 0, tr(" OK ").trim(), true); let cancel = pill(ui, 1, &tr("Cancel"), false); let enter = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); let esc = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); @@ -8472,7 +8499,7 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { if comp_now != comp { d.fields.insert("component".into(), json!(comp_now.key())); } - // The hex code, and the eyedropper's mark beside it. + // The hex code, and the eyedropper 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)); @@ -8489,20 +8516,14 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { 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 let Some(c) = eyedropper(app, ui, egui::pos2(hr.right() + 16.0, cy), tr("Eyedropper: drag it onto the page"), &t) { + p = Picked::from_rgb(c.map(|v| f32::from(v) / 255.0), p.hsb[0]); + } if p != before { store_picked(d, &p); - } - // Typed C/M/Y/K are kept as typed, even when the colour shown stays the same (at 100% K, a - // device conversion is black whatever C, M and Y are). - if let Some(c) = exact_cmyk { - d.fields.insert("cmyk".into(), json!(c)); - d.fields.insert("edited".into(), json!(true)); + if let Some(c) = exact_cmyk { + d.fields.insert("cmyk".into(), json!(c)); + } } } @@ -8890,8 +8911,7 @@ mod picker_layout { pub const RIGHT: f32 = 457.0; } -/// A value field, named `name` (its caption): the text typed stays while it has the focus; a -/// number that parses applies. +/// 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, name: &str, value: f32, range: (f32, f32)) -> Option<f32> { let id = egui::Id::unique(("color_picker_field", key)); let editing = ui.memory(|m| m.has_focus(id)); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 284d6bbc6..89e4830b5 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3487,24 +3487,114 @@ fn guide_counts(app: &DesignApp) -> Vec<usize> { /// 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_color_picker_keeps_a_typed_cmyk_value_the_colour_shown_does_not_change_with() { - use egui_kittest::kittest::Queryable; +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.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(); + 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); 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")); + 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<f64> = 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] diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index a2d19e1cd..e1d1caee8 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -66,6 +66,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New Section", "قسم جديد"), ("Clear Frame Fitting Options", "مسح خيارات ملاءمة الإطار"), ("Cannot be edited", "غير قابل للتحرير"), + ("RGB Color Space View", "عرض فضاء ألوان RGB"), + ("Lab Color Space View", "عرض فضاء ألوان Lab"), + ("Add CMYK Swatch", "إضافة عينة لون CMYK"), + ("Added swatch", "تمت إضافة عينة اللون"), + ("Eyedropper: drag it onto the page", "القطارة: اسحبها إلى الصفحة"), ("Restart Numbering", "إعادة الترقيم"), ("Continue Numbering", "متابعة الترقيم"), ("Keep Options…", "خيارات الإبقاء…"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index fdb75d7b4..a4dcc6855 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -71,6 +71,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New Section", "Nový oddíl"), ("Clear Frame Fitting Options", "Vymazat volby přizpůsobení rámečku"), ("Cannot be edited", "Nelze upravit"), + ("RGB Color Space View", "Zobrazení barevného prostoru RGB"), + ("Lab Color Space View", "Zobrazení barevného prostoru Lab"), + ("Add CMYK Swatch", "Přidat vzorek CMYK"), + ("Added swatch", "Vzorek přidán"), + ("Eyedropper: drag it onto the page", "Kapátko: přetáhněte ho na stránku"), ("Restart Numbering", "Začít číslování znovu"), ("Continue Numbering", "Pokračovat v číslování"), ("Keep Options…", "Volby zachování…"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index d71f2870d..9fa5f185f 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -65,6 +65,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New Section", "Nuova sezione"), ("Clear Frame Fitting Options", "Cancella opzioni di adattamento cornice"), ("Cannot be edited", "Non modificabile"), + ("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"), + ("Eyedropper: drag it onto the page", "Contagocce: trascinalo sulla pagina"), ("Restart Numbering", "Riavvia numerazione"), ("Continue Numbering", "Continua numerazione"), ("Keep Options…", "Opzioni di separazione…"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 5a68e37b0..1407e4b05 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -65,6 +65,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New Section", "ახალი სექცია"), ("Clear Frame Fitting Options", "ჩარჩოს მორგების პარამეტრების გასუფთავება"), ("Cannot be edited", "რედაქტირება შეუძლებელია"), + ("RGB Color Space View", "RGB ფერთა სივრცის ხედი"), + ("Lab Color Space View", "Lab ფერთა სივრცის ხედი"), + ("Add CMYK Swatch", "CMYK ფერის ნიმუშის დამატება"), + ("Added swatch", "ფერის ნიმუში დამატებულია"), + ("Eyedropper: drag it onto the page", "პიპეტი: გადაიტანეთ გვერდზე"), ("Restart Numbering", "ნუმერაციის თავიდან დაწყება"), ("Continue Numbering", "ნუმერაციის გაგრძელება"), ("Keep Options…", "შენარჩუნების პარამეტრები…"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index b536be8c7..2cfa47402 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -69,6 +69,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New Section", "Nowa sekcja"), ("Clear Frame Fitting Options", "Wyczyść opcje dopasowania ramki"), ("Cannot be edited", "Nie można edytować"), + ("RGB Color Space View", "Widok przestrzeni barw RGB"), + ("Lab Color Space View", "Widok przestrzeni barw Lab"), + ("Add CMYK Swatch", "Dodaj próbkę CMYK"), + ("Added swatch", "Dodano próbkę"), + ("Eyedropper: drag it onto the page", "Kroplomierz: przeciągnij go na stronę"), ("Restart Numbering", "Zacznij numerowanie od nowa"), ("Continue Numbering", "Kontynuuj numerowanie"), ("Keep Options…", "Opcje utrzymywania…"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 505b840ea..0614aa9b8 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -68,6 +68,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New Section", "Nova seção"), ("Clear Frame Fitting Options", "Limpar opções de ajuste do quadro"), ("Cannot be edited", "Não editável"), + ("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"), + ("Eyedropper: drag it onto the page", "Conta-gotas: arraste-o para a página"), ("Restart Numbering", "Reiniciar numeração"), ("Continue Numbering", "Continuar numeração"), ("Keep Options…", "Opções de manutenção…"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 6e8e49df2..5a096b76b 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -73,6 +73,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New Section", "Новый раздел"), ("Clear Frame Fitting Options", "Очистить параметры подгонки фрейма"), ("Cannot be edited", "Нельзя изменить"), + ("RGB Color Space View", "Просмотр цветового пространства RGB"), + ("Lab Color Space View", "Просмотр цветового пространства Lab"), + ("Add CMYK Swatch", "Добавить образец CMYK"), + ("Added swatch", "Образец добавлен"), + ("Eyedropper: drag it onto the page", "Пипетка: перетащите её на страницу"), ("Restart Numbering", "Начать нумерацию заново"), ("Continue Numbering", "Продолжить нумерацию"), ("Keep Options…", "Параметры удержания…"), diff --git a/crates/ui-egui/src/i18n/table.rs b/crates/ui-egui/src/i18n/table.rs index 384d7eb92..8170e6cb6 100644 --- a/crates/ui-egui/src/i18n/table.rs +++ b/crates/ui-egui/src/i18n/table.rs @@ -1053,6 +1053,16 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ("Class:", ["Class:", "Classe :", "Class:", "Class:", "类别:"]), ("Color Picker", ["Color Picker", "Sélecteur de couleurs", "Color Picker", "Color Picker", "拾色器"]), ("Color spectrum", ["Farbspektrum", "Spectre de couleurs", "Espectro de color", "カラースペクトル", "色谱"]), + ( + "Eyedropper: drag it onto the page", + [ + "Pipette: auf die Seite ziehen", + "Pipette : faites-la glisser sur la page", + "Cuentagotas: arrástrelo a la página", + "スポイト : ページにドラッグ", + "吸管:将其拖到页面上", + ], + ), ("Color Settings", ["Color Settings", "Réglages des couleurs", "Color Settings", "Color Settings", "颜色设置"]), ("Color:", ["Color:", "Couleur :", "Color:", "Color:", "颜色:"]), ("Columns", ["Columns", "Colonnes", "Columns", "Columns", "栏"]), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 858e0912e..b81bce8b4 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -66,6 +66,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New Section", "Новий розділ"), ("Clear Frame Fitting Options", "Очистити параметри припасування фрейму"), ("Cannot be edited", "Не можна змінити"), + ("RGB Color Space View", "Перегляд колірного простору RGB"), + ("Lab Color Space View", "Перегляд колірного простору Lab"), + ("Add CMYK Swatch", "Додати зразок CMYK"), + ("Added swatch", "Зразок додано"), + ("Eyedropper: drag it onto the page", "Піпетка: перетягніть її на сторінку"), ("Restart Numbering", "Почати нумерацію знову"), ("Continue Numbering", "Продовжити нумерацію"), ("Keep Options…", "Параметри утримання…"), From f816c3b673343b1589b14aea4723a2055987ea3b Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 30 Oct 2026 23:26:21 +0000 Subject: [PATCH 393/431] Integration: #1503 keep #1080's tests; roadmap log lines for #1080 and #1503 --- ROADMAP.md | 2 ++ crates/ui-egui/src/dialogs.rs | 12 ++++++++---- crates/ui-egui/src/dialogs/tests.rs | 28 ++++++++++++++++++++++++---- 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 52bfb0f3b..8b49d2dd1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -382,6 +382,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **2026-10-30** · The Color Picker takes an eyedropper: dragged from the picker onto the page, it takes the colour the canvas shows (proofed under Proof Colors). No readiness percentage change. +- **2026-10-30** · The Color Picker is laid out as the reference app's: a field and slider for the component chosen, HSB, Lab, RGB and CMYK values, the old colour, and Add CMYK Swatch; Enter and Esc close it once. No readiness percentage change. - **2026-10-18** · Table of Contents gains Create PDF Bookmarks: included headings become nested page destinations, regenerated in their existing outline position on update without replacing or moving surrounding manual bookmarks. The setting survives native and IDML files. No readiness percentage change. Newest first. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 55d88dced..78777fd45 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -8521,9 +8521,12 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } if p != before { store_picked(d, &p); - if let Some(c) = exact_cmyk { - d.fields.insert("cmyk".into(), json!(c)); - } + } + // Typed C/M/Y/K are kept as typed, even when the colour shown stays the same (at 100% K, a + // device conversion is black whatever C, M and Y are). + if let Some(c) = exact_cmyk { + d.fields.insert("cmyk".into(), json!(c)); + d.fields.insert("edited".into(), json!(true)); } } @@ -8911,7 +8914,8 @@ mod picker_layout { pub const RIGHT: f32 = 457.0; } -/// A value field: the text typed stays while it has the focus; a number that parses applies. +/// A value field, named `name` (its caption): 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, name: &str, value: f32, range: (f32, f32)) -> Option<f32> { let id = egui::Id::unique(("color_picker_field", key)); let editing = ui.memory(|m| m.has_focus(id)); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 89e4830b5..6733e9a79 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3485,10 +3485,6 @@ fn guide_counts(app: &DesignApp) -> Vec<usize> { (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] @@ -3531,6 +3527,8 @@ fn the_eyedropper_samples_what_proof_colors_and_the_ink_limit_show() { assert_ne!(proofed, plain, "the proof changes this green"); } +/// 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. #[test] fn the_color_pickers_eyedropper_takes_a_colour_off_the_page() { use egui_kittest::{Harness, kittest::Queryable}; @@ -3597,6 +3595,28 @@ fn the_color_pickers_eyedropper_takes_a_colour_off_the_page() { assert_eq!(h.state().ui.dialog.as_ref().unwrap().s("hex"), hex, "the picker isn't sampled"); } +/// 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")); +} + #[test] fn the_color_picker_picks_on_its_field_and_slider_and_adds_a_cmyk_swatch() { use egui_kittest::kittest::Queryable; From bbce6f0323a48c5117caddcf83c2907137067484 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 30 Oct 2026 23:27:29 +0000 Subject: [PATCH 394/431] Integration: #1514 theme tokens Status and warning colours come from the theme's warn and error colours instead of hard-coded RGB values. --- crates/ui-egui/src/package_dialog.rs | 34 +++++++++++++++++++--------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/package_dialog.rs b/crates/ui-egui/src/package_dialog.rs index 1919e6d9c..f9ce0752d 100644 --- a/crates/ui-egui/src/package_dialog.rs +++ b/crates/ui-egui/src/package_dialog.rs @@ -144,23 +144,35 @@ fn summary_lines(lang: &str, d: &Dialog) -> Vec<(String, bool)> { ] } -/// The warning sign InDesign shows beside a line with problems, and the colours of statuses. -const WARN: egui::Color32 = egui::Color32::from_rgb(0xe8, 0xb3, 0x2c); -const BAD: egui::Color32 = egui::Color32::from_rgb(0xe5, 0x4b, 0x4b); +/// How a status is shown: coloured with the theme's warning or error colour. +#[derive(Clone, Copy, PartialEq, Debug)] +enum Level { + Warn, + Bad, +} -fn status_text(lang: &str, status: &str) -> (String, Option<egui::Color32>) { +impl Level { + fn color(self, ui: &egui::Ui) -> egui::Color32 { + match self { + Level::Warn => ui.visuals().warn_fg_color, + Level::Bad => ui.visuals().error_fg_color, + } + } +} + +fn status_text(lang: &str, status: &str) -> (String, Option<Level>) { let tr = |s: &str| crate::i18n::tr(lang, s).to_string(); match status { - "missing" => (format!("\u{26A0} {}", tr("Missing")), Some(BAD)), - "modified" => (format!("\u{25B2} {}", tr("Modified")), Some(WARN)), - "substituted" => (tr("Substituted"), Some(WARN)), + "missing" => (format!("\u{26A0} {}", tr("Missing")), Some(Level::Bad)), + "modified" => (format!("\u{25B2} {}", tr("Modified")), Some(Level::Warn)), + "substituted" => (tr("Substituted"), Some(Level::Warn)), "embedded" => (tr("Embedded"), None), _ => (tr("OK"), None), } } -/// A table: a header row and `cells` rows (each with an optional colour for its last cell). -fn table(ui: &mut egui::Ui, id: &str, header: &[String], cells: &[(Vec<String>, Option<egui::Color32>)]) { +/// A table: a header row and `cells` rows (each with an optional level colouring its last cell). +fn table(ui: &mut egui::Ui, id: &str, header: &[String], cells: &[(Vec<String>, Option<Level>)]) { let t = crate::theme::Tokens::get(ui.ctx()); egui::Frame::NONE.stroke(egui::Stroke::new(1.0, t.field_border)).inner_margin(6.0).show(ui, |ui| { egui::ScrollArea::vertical().id_salt(id).max_height(220.0).auto_shrink([false, true]).show(ui, |ui| { @@ -175,7 +187,7 @@ fn table(ui: &mut egui::Ui, id: &str, header: &[String], cells: &[(Vec<String>, if i + 1 == row.len() && let Some(color) = color { - text = text.color(*color); + text = text.color(color.color(ui)); } crate::rtl::label(ui, text); } @@ -258,7 +270,7 @@ pub fn show(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.add_space(6.0); for (line, warn) in summary_lines(&lang, d) { ui.horizontal(|ui| { - let sign = egui::RichText::new(if warn { "\u{26A0}" } else { " " }).color(WARN); + let sign = egui::RichText::new(if warn { "\u{26A0}" } else { " " }).color(Level::Warn.color(ui)); ui.add_sized([14.0, 16.0], egui::Label::new(sign)); crate::rtl::label(ui, line); }); From c77b593f80537fc7b4599d59c7018554ed2a2046 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 30 Oct 2026 23:27:46 +0000 Subject: [PATCH 395/431] Integration: #1514 summary wording The Summary lines use neutral wording of our own (keeping the feature names); i18n keys and the test follow, translations carry over. --- crates/ui-egui/src/i18n/ar.rs | 8 ++++---- crates/ui-egui/src/i18n/cs.rs | 8 ++++---- crates/ui-egui/src/i18n/it.rs | 8 ++++---- crates/ui-egui/src/i18n/ka.rs | 8 ++++---- crates/ui-egui/src/i18n/pl.rs | 8 ++++---- crates/ui-egui/src/i18n/pt_br.rs | 8 ++++---- crates/ui-egui/src/i18n/ru.rs | 8 ++++---- crates/ui-egui/src/i18n/table.rs | 8 ++++---- crates/ui-egui/src/i18n/uk.rs | 8 ++++---- crates/ui-egui/src/package_dialog.rs | 14 +++++++------- 10 files changed, 43 insertions(+), 43 deletions(-) diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b79ea87a1..f823f4c53 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -82,13 +82,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Substituted", "مستبدل"), ("Modified", "معدل"), ("Missing", "مفقود"), - ("Colors and Inks: {process} Process Inks; {spot} Spot Inks", "الألوان والأحبار: {process} أحبار معالجة؛ {spot} أحبار مخصصة"), - ("Images: {embedded} Embedded", "الصور: {embedded} مضمنة"), + ("Colors and Inks: {process} process, {spot} spot", "الألوان والأحبار: {process} أحبار معالجة؛ {spot} أحبار مخصصة"), + ("Embedded images: {embedded}", "الصور: {embedded} مضمنة"), ( - "Links and Images: {found} Links Found; {modified} Modified, {missing} Missing", + "Links and Images: {found} linked, {modified} changed, {missing} not found", "الروابط والصور: {found} روابط؛ {modified} معدلة، {missing} مفقودة", ), - ("Fonts: {used} Fonts Used, {missing} Missing", "الخطوط: {used} مستخدمة، {missing} مفقودة"), + ("Fonts: {used} in use, {missing} unavailable", "الخطوط: {used} مستخدمة، {missing} مفقودة"), ("Instructions:", "التعليمات:"), ("Email:", "البريد الإلكتروني:"), ("Fax:", "الفاكس:"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 41811c755..5feea5db4 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -88,13 +88,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Substituted", "Nahrazeno"), ("Modified", "Změněno"), ("Missing", "Chybí"), - ("Colors and Inks: {process} Process Inks; {spot} Spot Inks", "Barvy a inkousty: procesní {process}; přímé {spot}"), - ("Images: {embedded} Embedded", "Obrazy: vloženo {embedded}"), + ("Colors and Inks: {process} process, {spot} spot", "Barvy a inkousty: procesní {process}; přímé {spot}"), + ("Embedded images: {embedded}", "Obrazy: vloženo {embedded}"), ( - "Links and Images: {found} Links Found; {modified} Modified, {missing} Missing", + "Links and Images: {found} linked, {modified} changed, {missing} not found", "Odkazy a obrazy: nalezeno {found}; změněno {modified}, chybí {missing}", ), - ("Fonts: {used} Fonts Used, {missing} Missing", "Písma: použito {used}, chybí {missing}"), + ("Fonts: {used} in use, {missing} unavailable", "Písma: použito {used}, chybí {missing}"), ("Instructions:", "Pokyny:"), ("Fax:", "Fax:"), ("Address:", "Adresa:"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 8c4f38912..8d97ee17d 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -85,13 +85,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Substituted", "Sostituito"), ("Modified", "Modificato"), ("Missing", "Mancante"), - ("Colors and Inks: {process} Process Inks; {spot} Spot Inks", "Colori e inchiostri: {process} quadricromia; {spot} tinte piatte"), - ("Images: {embedded} Embedded", "Immagini: {embedded} incorporate"), + ("Colors and Inks: {process} process, {spot} spot", "Colori e inchiostri: {process} quadricromia; {spot} tinte piatte"), + ("Embedded images: {embedded}", "Immagini: {embedded} incorporate"), ( - "Links and Images: {found} Links Found; {modified} Modified, {missing} Missing", + "Links and Images: {found} linked, {modified} changed, {missing} not found", "Collegamenti e immagini: {found} trovati; {modified} modificati, {missing} mancanti", ), - ("Fonts: {used} Fonts Used, {missing} Missing", "Font: {used} utilizzati, {missing} mancanti"), + ("Fonts: {used} in use, {missing} unavailable", "Font: {used} utilizzati, {missing} mancanti"), ("Instructions:", "Istruzioni:"), ("Email:", "E-mail:"), ("Fax:", "Fax:"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index bdf324954..4cf1e66ff 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -80,13 +80,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Substituted", "ჩანაცვლებული"), ("Modified", "შეცვლილი"), ("Missing", "აკლია"), - ("Colors and Inks: {process} Process Inks; {spot} Spot Inks", "ფერები და მელნები: {process} პროცესის; {spot} ლაქის"), - ("Images: {embedded} Embedded", "გამოსახულებები: ჩაშენებულია {embedded}"), + ("Colors and Inks: {process} process, {spot} spot", "ფერები და მელნები: {process} პროცესის; {spot} ლაქის"), + ("Embedded images: {embedded}", "გამოსახულებები: ჩაშენებულია {embedded}"), ( - "Links and Images: {found} Links Found; {modified} Modified, {missing} Missing", + "Links and Images: {found} linked, {modified} changed, {missing} not found", "ბმულები და გამოსახულებები: ნაპოვნია {found}; შეცვლილია {modified}, აკლია {missing}", ), - ("Fonts: {used} Fonts Used, {missing} Missing", "შრიფტები: გამოყენებულია {used}, აკლია {missing}"), + ("Fonts: {used} in use, {missing} unavailable", "შრიფტები: გამოყენებულია {used}, აკლია {missing}"), ("Instructions:", "ინსტრუქციები:"), ("Fax:", "ფაქსი:"), ("Address:", "მისამართი:"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 3943daffb..0d2ea135b 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -87,13 +87,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Substituted", "Zastąpiona"), ("Modified", "Zmieniony"), ("Missing", "Brak"), - ("Colors and Inks: {process} Process Inks; {spot} Spot Inks", "Kolory i farby: procesowe {process}; dodatkowe {spot}"), - ("Images: {embedded} Embedded", "Obrazy: osadzone {embedded}"), + ("Colors and Inks: {process} process, {spot} spot", "Kolory i farby: procesowe {process}; dodatkowe {spot}"), + ("Embedded images: {embedded}", "Obrazy: osadzone {embedded}"), ( - "Links and Images: {found} Links Found; {modified} Modified, {missing} Missing", + "Links and Images: {found} linked, {modified} changed, {missing} not found", "Łącza i obrazy: znaleziono {found}; zmienione {modified}, brakujące {missing}", ), - ("Fonts: {used} Fonts Used, {missing} Missing", "Czcionki: użyte {used}, brakujące {missing}"), + ("Fonts: {used} in use, {missing} unavailable", "Czcionki: użyte {used}, brakujące {missing}"), ("Instructions:", "Instrukcje:"), ("Email:", "E-mail:"), ("Fax:", "Faks:"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index f87c8310d..df588c3a7 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -88,13 +88,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Substituted", "Substituída"), ("Modified", "Modificado"), ("Missing", "Ausente"), - ("Colors and Inks: {process} Process Inks; {spot} Spot Inks", "Cores e tintas: {process} de escala; {spot} especiais"), - ("Images: {embedded} Embedded", "Imagens: {embedded} incorporadas"), + ("Colors and Inks: {process} process, {spot} spot", "Cores e tintas: {process} de escala; {spot} especiais"), + ("Embedded images: {embedded}", "Imagens: {embedded} incorporadas"), ( - "Links and Images: {found} Links Found; {modified} Modified, {missing} Missing", + "Links and Images: {found} linked, {modified} changed, {missing} not found", "Vínculos e imagens: {found} encontrados; {modified} modificados, {missing} ausentes", ), - ("Fonts: {used} Fonts Used, {missing} Missing", "Fontes: {used} usadas, {missing} ausentes"), + ("Fonts: {used} in use, {missing} unavailable", "Fontes: {used} usadas, {missing} ausentes"), ("Instructions:", "Instruções:"), ("Email:", "E-mail:"), ("Fax:", "Fax:"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index f6a201665..216e07f14 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -91,13 +91,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Substituted", "Заменен"), ("Modified", "Изменено"), ("Missing", "Отсутствует"), - ("Colors and Inks: {process} Process Inks; {spot} Spot Inks", "Цвета и краски: триадных {process}; плашечных {spot}"), - ("Images: {embedded} Embedded", "Изображения: внедрено {embedded}"), + ("Colors and Inks: {process} process, {spot} spot", "Цвета и краски: триадных {process}; плашечных {spot}"), + ("Embedded images: {embedded}", "Изображения: внедрено {embedded}"), ( - "Links and Images: {found} Links Found; {modified} Modified, {missing} Missing", + "Links and Images: {found} linked, {modified} changed, {missing} not found", "Связи и изображения: найдено {found}; изменено {modified}, отсутствует {missing}", ), - ("Fonts: {used} Fonts Used, {missing} Missing", "Шрифты: используется {used}, отсутствует {missing}"), + ("Fonts: {used} in use, {missing} unavailable", "Шрифты: используется {used}, отсутствует {missing}"), ("Instructions:", "Инструкции:"), ("Email:", "Эл. почта:"), ("Fax:", "Факс:"), diff --git a/crates/ui-egui/src/i18n/table.rs b/crates/ui-egui/src/i18n/table.rs index 8ad790546..ace3a0e68 100644 --- a/crates/ui-egui/src/i18n/table.rs +++ b/crates/ui-egui/src/i18n/table.rs @@ -157,7 +157,7 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ("Email:", ["E-Mail:", "E-mail :", "Correo electrónico:", "メール:", "电子邮件:"]), ("Instructions:", ["Anweisungen:", "Instructions :", "Instrucciones:", "指示:", "说明:"]), ( - "Fonts: {used} Fonts Used, {missing} Missing", + "Fonts: {used} in use, {missing} unavailable", [ "Schriften: {used} verwendet, {missing} fehlen", "Polices : {used} utilisées, {missing} manquantes", @@ -167,7 +167,7 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ], ), ( - "Links and Images: {found} Links Found; {modified} Modified, {missing} Missing", + "Links and Images: {found} linked, {modified} changed, {missing} not found", [ "Verknüpfungen und Bilder: {found} gefunden; {modified} geändert, {missing} fehlen", "Liens et images : {found} liens trouvés ; {modified} modifiés, {missing} manquants", @@ -177,7 +177,7 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ], ), ( - "Images: {embedded} Embedded", + "Embedded images: {embedded}", [ "Bilder: {embedded} eingebettet", "Images : {embedded} incorporées", @@ -187,7 +187,7 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ], ), ( - "Colors and Inks: {process} Process Inks; {spot} Spot Inks", + "Colors and Inks: {process} process, {spot} spot", [ "Farben und Druckfarben: {process} Prozessfarben; {spot} Volltonfarben", "Couleurs et encres : {process} encres quadri ; {spot} encres d’accompagnement", diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index e7c83ed95..8ab4c5c3d 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -86,13 +86,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Substituted", "Замінено"), ("Modified", "Змінено"), ("Missing", "Бракує"), - ("Colors and Inks: {process} Process Inks; {spot} Spot Inks", "Кольори й фарби: тріадних {process}; плашкових {spot}"), - ("Images: {embedded} Embedded", "Зображення: вбудовано {embedded}"), + ("Colors and Inks: {process} process, {spot} spot", "Кольори й фарби: тріадних {process}; плашкових {spot}"), + ("Embedded images: {embedded}", "Зображення: вбудовано {embedded}"), ( - "Links and Images: {found} Links Found; {modified} Modified, {missing} Missing", + "Links and Images: {found} linked, {modified} changed, {missing} not found", "Зв’язки й зображення: знайдено {found}; змінено {modified}, бракує {missing}", ), - ("Fonts: {used} Fonts Used, {missing} Missing", "Шрифти: використано {used}, бракує {missing}"), + ("Fonts: {used} in use, {missing} unavailable", "Шрифти: використано {used}, бракує {missing}"), ("Instructions:", "Інструкції:"), ("Email:", "Ел. пошта:"), ("Fax:", "Факс:"), diff --git a/crates/ui-egui/src/package_dialog.rs b/crates/ui-egui/src/package_dialog.rs index f9ce0752d..5f1e98b1d 100644 --- a/crates/ui-egui/src/package_dialog.rs +++ b/crates/ui-egui/src/package_dialog.rs @@ -122,21 +122,21 @@ fn summary_lines(lang: &str, d: &Dialog) -> Vec<(String, bool)> { let (modified, missing) = (count(&links, &["modified"]), count(&links, &["missing"])); vec![ ( - tr("Fonts: {used} Fonts Used, {missing} Missing") + tr("Fonts: {used} in use, {missing} unavailable") .replace("{used}", &fonts.len().to_string()) .replace("{missing}", &missing_fonts.to_string()), missing_fonts > 0, ), ( - tr("Links and Images: {found} Links Found; {modified} Modified, {missing} Missing") + tr("Links and Images: {found} linked, {modified} changed, {missing} not found") .replace("{found}", &links.len().to_string()) .replace("{modified}", &modified.to_string()) .replace("{missing}", &missing.to_string()), modified + missing > 0, ), - (tr("Images: {embedded} Embedded").replace("{embedded}", &count(&links, &["embedded"]).to_string()), false), + (tr("Embedded images: {embedded}").replace("{embedded}", &count(&links, &["embedded"]).to_string()), false), ( - tr("Colors and Inks: {process} Process Inks; {spot} Spot Inks") + tr("Colors and Inks: {process} process, {spot} spot") .replace("{process}", &PROCESS_INKS.len().to_string()) .replace("{spot}", &rows(d, "spot").len().to_string()), false, @@ -440,10 +440,10 @@ mod tests { let mut h = harness(app); h.run_steps(3); assert!(h.query_by_label("Scope:").is_some()); - assert!(h.query_by_label("Links and Images: 1 Links Found; 0 Modified, 1 Missing").is_some()); - assert!(h.query_by_label("Colors and Inks: 4 Process Inks; 0 Spot Inks").is_some()); + assert!(h.query_by_label("Links and Images: 1 linked, 0 changed, 1 not found").is_some()); + assert!(h.query_by_label("Colors and Inks: 4 process, 0 spot").is_some()); let fonts = h.state().ui.dialog.as_ref().unwrap().fields["fonts"].as_array().unwrap().len(); - assert!(h.query_by_label(&format!("Fonts: {fonts} Fonts Used, 0 Missing")).is_some()); + assert!(h.query_by_label(&format!("Fonts: {fonts} in use, 0 unavailable")).is_some()); // Links and Images: the link, missing; Show Problems Only keeps it. h.get_by_label("Links and Images").click(); h.run_steps(2); From eea99d1ee8cbbeaa29e6addeb2ed1ab08acb3bb7 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 30 Oct 2026 23:28:05 +0000 Subject: [PATCH 396/431] Integration: #1514 Enter stays local Enter in a multi-line field is a new line only in the Printing Instructions dialog; the flag now uses a stable id (Id::unique never matched) and is cleared for every dialog. --- crates/ui-egui/src/dialogs.rs | 32 ++++++++++++++++++++++++---- crates/ui-egui/src/package_dialog.rs | 2 +- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f1a443603..ddab4e5dc 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -200,9 +200,21 @@ fn insert_pages_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.end_row(); }); } -/// The temporary-data key a dialog sets, each frame, while one of its multi-line fields has focus: -/// Enter then makes a new line there, and the footer doesn't take it for OK. -pub(crate) const MULTILINE_FOCUS: &str = "dialog_multiline_focus"; +/// The temporary-data key the Printing Instructions dialog sets, each frame, while one of its +/// multi-line fields has focus: Enter then makes a new line there, and the footer doesn't take it +/// for OK. No other dialog changes how Enter works. +const MULTILINE_FOCUS: &str = "dialog_multiline_focus"; + +pub(crate) fn set_multiline_focus(ctx: &egui::Context) { + ctx.data_mut(|m| m.insert_temp(egui::Id::new(MULTILINE_FOCUS), true)); +} + +/// Whether Enter in dialog `id` goes to a multi-line field this frame. The flag is read (and cleared) +/// for every dialog, but only the Printing Instructions dialog honours it. +fn take_multiline_focus(ctx: &egui::Context, id: &str) -> bool { + let set = ctx.data_mut(|m| m.remove_temp::<bool>(egui::Id::new(MULTILINE_FOCUS))).unwrap_or(false); + set && id == crate::package_dialog::INSTRUCTIONS +} /// Extract once when opened. Cancel is read-only; confirmation adds exactly these colours. pub(crate) fn open_color_theme(app: &mut DesignApp, params: Value) { @@ -241,6 +253,18 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; + /// Enter in a multi-line field makes a new line in the Printing Instructions only; every other + /// dialog keeps Enter as OK, and the flag does not outlive the frame. + #[test] + fn enter_in_a_multiline_field_is_local_to_the_printing_instructions() { + let ctx = egui::Context::default(); + set_multiline_focus(&ctx); + assert!(take_multiline_focus(&ctx, crate::package_dialog::INSTRUCTIONS)); + assert!(!take_multiline_focus(&ctx, crate::package_dialog::INSTRUCTIONS), "cleared once read"); + set_multiline_focus(&ctx); + assert!(!take_multiline_focus(&ctx, "insertPages")); + } + /// A page past the end shows as the last page in the dialog, and Before Page puts the new /// page before that one rather than at the end. #[test] @@ -3277,7 +3301,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::<bool>(egui::Id::unique(MULTILINE_FOCUS))).unwrap_or(false); + let multiline = take_multiline_focus(ui.ctx(), &d.id); 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 5f1e98b1d..04b236d76 100644 --- a/crates/ui-egui/src/package_dialog.rs +++ b/crates/ui-egui/src/package_dialog.rs @@ -321,7 +321,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::unique(crate::dialogs::MULTILINE_FOCUS), true)); + crate::dialogs::set_multiline_focus(ui.ctx()); } ui.end_row(); } From ea5df5980856fdd53ac5a7dbc7a4d9e0afcfdda0 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 30 Oct 2026 23:29:17 +0000 Subject: [PATCH 397/431] Integration: ROADMAP lines for #1249, #1255, #1514, #1515 --- ROADMAP.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 4f950db90..5717dd3da 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -386,6 +386,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-30** · File > Package opens a dialog, as the reference app's: a Summary of fonts, links and images and inks, pages for each, a Printing Instructions step, and a PDF made with the options of a preset (#1514, #1515). +- **2026-10-30** · IDML import reads TOC separators and entry styles written with the `$ID/` prefix or as `Properties` children, and the TOC separator notation (`^t`, `^y`, `^m`, `^>`) is shared by the dialog and IDML, with a special-character menu on the separator field (#1249, #1255). - **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. From 5321e27220d7ad64853c37bb26d79b1edfc605dd Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:32:37 +0100 Subject: [PATCH 398/431] Keep text of nested inline elements in file.importXml Found while testing crates/engine/src/cmd/xml.rs:~215-245 (parse: the Start/Text/End depth handling; text at depth >= 4 matches no arm) Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/xml.rs | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/xml.rs b/crates/engine/src/cmd/xml.rs index f3d55099b..81b57b5b3 100644 --- a/crates/engine/src/cmd/xml.rs +++ b/crates/engine/src/cmd/xml.rs @@ -168,7 +168,7 @@ fn parse(xml: &str) -> std::result::Result<Vec<Element>, String> { _ => String::new(), }; match (depth, child.as_mut(), out.last_mut()) { - (3, Some(c), _) => c.1.push_str(&s), + (3.., Some(c), _) => c.1.push_str(&s), (2, _, Some(el)) => el.text.push_str(&s), _ => {} } @@ -518,6 +518,34 @@ mod tests { assert_eq!(s.doc().unwrap().doc.xml.style_map.len(), 1); } + #[test] + fn import_xml_keeps_text_of_inline_tags_in_mapped_paragraphs() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Head"})).unwrap(); + let a = s + .execute("frame.create", &json!({"rect": [72, 72, 400, 150], "content": "text", "text": "See the glossary entry", "caret": false})) + .unwrap(); + s.execute("text.select", &json!({"story": a["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Head"})).unwrap(); + s.execute("xml.tag", &json!({"tag": "story", "ids": [a["id"]]})).unwrap(); + s.execute("xml.mapStyle", &json!({"style": "Head", "tag": "title"})).unwrap(); + s.execute("text.select", &json!({"story": a["story"], "anchor": 8, "focus": 16})).unwrap(); + s.execute("xml.tagText", &json!({"tag": "term"})).unwrap(); + let x = s.execute("file.exportXml", &json!({})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(x.contains("<title>See the <term>glossary</term> entry"), "{x}"); + s.execute("file.importXml", &json!({"text": x})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.stories[&designcraft_doc::StoryId(a["story"].as_u64().unwrap())].text, "See the glossary entry"); + } + + #[test] + fn parse_keeps_text_nested_below_a_child_element() { + let els = super::parse("See the x y").unwrap(); + assert_eq!(els.len(), 1); + assert_eq!(els[0].children, vec![("note".to_string(), "See the x y".to_string())]); + } + #[test] fn tag_export_and_import_xml() { let mut s = Session::new(); From 664740f0afd7d50fc419bbce41a000686c720bd6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 30 Oct 2026 23:36:13 +0000 Subject: [PATCH 399/431] Integration: drop duplicate #892 progress line --- ROADMAP.md | 1 - 1 file changed, 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 172c7a496..13b10d99b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -494,7 +494,6 @@ Newest first. - **2026-10-20** · Measures in centimetres show four decimals, as InDesign's fields do (a 1p gutter reads 0.4233 cm, not 0.423 cm). No readiness percentage change. - **2026-10-14** · The Pages panel labels spreads as InDesign does ("2–3", "2, 1" across a section start, "8, A"), marks each section's first page with a triangle, and scrolls to the page in view. No readiness percentage change. - **2026-10-18** · Double-clicking a section triangle in the Pages panel opens that page’s numbering options, including from parent-page editing. No readiness percentage change. -- **2026-10-14** · The Pages panel labels spreads as ranges or separate names across a section start, marks each section’s first page with a triangle, and scrolls to the page in view (#892). No readiness percentage change. - **2026-10-21** · Properties › Character and Paragraph: ••• (More Options) expands the section in place, as InDesign's: Character adds vertical and horizontal scale, baseline shift, skew, language and OpenType, and All Caps, Small Caps, Superscript, Subscript, Underline and Strikethrough; Paragraph adds the left, right, first line and last line indents, space before and after, the drop cap's lines and characters, Align to Baseline Grid and Hyphenate. It used to open the Character or Paragraph panel; the language field moved under ••• as in InDesign. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change. - **2026-10-21** · The text Control panel has InDesign's two modes: A (Character Formatting Controls: font and style, size and leading, All Caps/Superscript/Underline and Small Caps/Subscript/Strikethrough, kerning and tracking, vertical and horizontal scale, baseline shift and skew, character style and language) and ¶ (Paragraph Formatting Controls: the nine alignments, left/first line and right/last line indents, space before and after, the drop cap's lines and characters, paragraph style, Hyphenate and Align to Baseline Grid). It used to show a fixed mix of both. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change. - **2026-10-20** · With the Type tool, the Control panel's font size, leading and tracking fields have a spinner and a list of common values, as in InDesign (auto leading in parentheses). No readiness percentage change. From 0e97e66d4552ff1df5e18b7c9a05bb88fa2e0e83 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 30 Oct 2026 23:50:09 +0000 Subject: [PATCH 400/431] Integration: #1503 eyedropper test uses a named swatch; Enter/Esc test reads the fill's swatch --- crates/ui-egui/src/dialogs/tests.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 6733e9a79..bbb3c2e7c 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -591,8 +591,10 @@ fn the_color_picker_enter_confirms_and_escape_cancels_even_in_a_field() { dialog_frame(&mut app, &ctx, screen, vec![key_press(key)]); dialog_frame(&mut app, &ctx, screen, vec![]); assert!(app.ui.dialog.is_none(), "{key:?} closes the picker"); - let hex = fill(&app, id).to_hex(); - assert_eq!(hex == "#f59019", applied, "{key:?}: {hex}"); + let d = &app.session.active().unwrap().doc; + let swatch = d.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch.clone(); + let hex = d.resolve_color(&swatch, 1.0).map(|c| c.to_hex()); + assert_eq!(hex.as_deref() == Some("#f59019"), applied, "{key:?}: {hex:?}"); } } @@ -3535,7 +3537,8 @@ fn the_color_pickers_eyedropper_takes_a_colour_off_the_page() { 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.run("swatch.create", json!({"name": "Eyedrop Red", "color": {"c": 0, "m": 100, "y": 100, "k": 0}})).unwrap(); + app.run("object.fill", json!({"ids": [id], "swatch": "Eyedrop Red"})).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| { @@ -3565,7 +3568,7 @@ fn the_color_pickers_eyedropper_takes_a_colour_off_the_page() { 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 [r, g, b, _] = h.state().session.doc().unwrap().doc.resolve_color("Eyedrop Red", 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]); From 00fd6c4e85c54107f4341232b7fafb1637095df9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 30 Oct 2026 23:51:21 +0000 Subject: [PATCH 401/431] Integration: cargo fmt --- crates/engine/src/cmd/find.rs | 3 ++- crates/ui-egui/src/panels/properties.rs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index 4ac02d928..4456dafa0 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -621,7 +621,8 @@ fn change(s: &mut Session, p: &Value) -> Result { return Err(bad("find.change", format!("no paragraph style `{n}`"))); } // InDesign: with a Change Format and nothing in Change to, the found text is only formatted. - let format_only = str_param(p, "change").unwrap_or("").is_empty() && (char_style.is_some() || para_style.is_some() || !over.is_empty() || !cleared.is_empty()); + let format_only = + str_param(p, "change").unwrap_or("").is_empty() && (char_style.is_some() || para_style.is_some() || !over.is_empty() || !cleared.is_empty()); let context = scope(s, p)?; let targets = context.targets.clone(); // Change: only the text Find Next found, the selection, while it is still a match (as Find diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 1f51fd30c..d946fa190 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -30,9 +30,9 @@ pub use flex::flex_panel; 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}; #[cfg(test)] use object::align_to_icon; +use object::{align_row, appearance_section, guide_section, quick_actions_object, transform_section, wrap_section}; pub(crate) use object::{corner_shape_button, corner_type_dropdown, paint_corner_shape, 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}; From 75797612cd546afdd2c356b7f83442bf5f9b0e27 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 30 Oct 2026 20:12:11 -0400 Subject: [PATCH 402/431] fix(flatpak): allow reading launcher version pins (#2059) * fix(flatpak): allow reading launcher version pins * fix(flatpak): allow reading launcher version pins --- packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml | 1 + packaging/linux/flatpak/ai.storyteller.designcraft.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml index 09ac37d73..a18f2c306 100644 --- a/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml @@ -17,6 +17,7 @@ runtime-version: '25.08' sdk: org.freedesktop.Sdk command: designcraft finish-args: + - --filesystem=xdg-data/ArtCraft/versions:ro # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland - --socket=fallback-x11 diff --git a/packaging/linux/flatpak/ai.storyteller.designcraft.yml b/packaging/linux/flatpak/ai.storyteller.designcraft.yml index bd456579f..fd56910ce 100644 --- a/packaging/linux/flatpak/ai.storyteller.designcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.designcraft.yml @@ -18,6 +18,7 @@ sdk-extensions: - org.freedesktop.Sdk.Extension.rust-stable command: designcraft finish-args: + - --filesystem=xdg-data/ArtCraft/versions:ro # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland - --socket=fallback-x11 From f3e0f614fd8e16ec78a3e8af17ed414eb521ecac Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 00:13:37 +0000 Subject: [PATCH 403/431] Integration: #1416 Clear acts on live points only; reset anchor_release on press --- crates/engine/src/cmd/anchors.rs | 15 +++++++++++++++ crates/engine/src/cmd/edit.rs | 6 ++++++ crates/tools/src/select.rs | 1 + 3 files changed, 22 insertions(+) diff --git a/crates/engine/src/cmd/anchors.rs b/crates/engine/src/cmd/anchors.rs index 9d65098e6..46ec6c9e7 100644 --- a/crates/engine/src/cmd/anchors.rs +++ b/crates/engine/src/cmd/anchors.rs @@ -593,6 +593,21 @@ mod tests { assert_eq!(s.doc().unwrap().doc.item(ItemId(a)).unwrap().path.subpaths[0].anchors.len(), 3, "one point gone, the frame stays"); } + /// Shift-adding an object to a point selection drops the points, so Clear deletes the objects. + #[test] + fn clear_after_adding_an_object_to_a_point_selection_deletes_objects() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [72, 72, 172, 172]})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [200, 72, 300, 172]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"anchors": [[a, 0, 0]]})).unwrap(); + s.execute("selection.set", &json!({"ids": [b], "add": true})).unwrap(); + assert!(s.doc().unwrap().selection.anchors.is_empty()); + s.execute("edit.clear", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.item(ItemId(a)).is_none()); + assert!(s.doc().unwrap().doc.item(ItemId(b)).is_none()); + } + #[test] fn convert_point_kinds() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 34611c9e2..37ab535cb 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -263,6 +263,11 @@ pub fn specs() -> Vec { } let ids: Vec = ids.into_iter().filter(|i| st.doc.item(*i).is_some()).collect(); if add { + // Adding objects (no points) leaves no live points: Delete would otherwise remove + // the stale points instead of the objects. + if anchors.is_empty() { + st.selection.anchors.clear(); + } for i in ids { if !st.selection.items.contains(&i) { st.selection.items.push(i); @@ -296,6 +301,7 @@ pub fn specs() -> Vec { } } else { st.selection.items.push(id); + st.selection.anchors.clear(); } st.bump_revision(); ok() diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 94e8e2eaf..1fb30e6f7 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -792,6 +792,7 @@ impl SelectionTool { self.shift_release = None; self.key_release = None; + self.anchor_release = None; // Type on a path: a bracket moves where the text starts or ends. if !ev.mods.shift && let Some((id, end)) = self.bracket_at(cx, p) From 17895e1239f301ea74bc7f072a9c96bc76a3b7e0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 00:13:38 +0000 Subject: [PATCH 404/431] Integration: #1657 bounding-box comment names the pen tools too --- crates/ui-egui/src/canvas/frames.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas/frames.rs b/crates/ui-egui/src/canvas/frames.rs index 4617b653a..a6a03bf6a 100644 --- a/crates/ui-egui/src/canvas/frames.rs +++ b/crates/ui-egui/src/canvas/frames.rs @@ -171,8 +171,8 @@ pub(super) fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, for poly in path_screen(it, xf, a) { painter.add(egui::Shape::line(poly, Stroke::new(1.0, color))); } - // InDesign draws a 1 pt bounding box around the selection; with the Direct Selection tool, - // only the path and its points. + // InDesign draws a 1 pt bounding box around the selection; with the Direct Selection and + // pen tools, only the path and its points. if sel.items.len() == 1 && sel.text.is_none() && !direct { painter.rect_stroke(xf.rect(a.transform_rect_bbox(it.bounds())), 0.0, Stroke::new(1.0, color), StrokeKind::Middle); } else if sel.key == Some(*id) { From bdcce39e5602b3f095c918397655cd95eb86ee32 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 00:13:39 +0000 Subject: [PATCH 405/431] Integration: #1566 apostrophe elision only in French and Italian --- crates/compose/src/hyphen.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/compose/src/hyphen.rs b/crates/compose/src/hyphen.rs index fc01dd375..174adb575 100644 --- a/crates/compose/src/hyphen.rs +++ b/crates/compose/src/hyphen.rs @@ -140,9 +140,12 @@ fn core_breaks(core: &[char], lang: Lang) -> (Vec, Source) { return (vec![], Source::None); } if let Lang::Patterns(l) = lang { - // An elision (French "l'", Italian "dell'"): the word after the apostrophe hyphenates, - // with no break up to it. if let Some(ap) = core.iter().rposition(|&c| c == '\'') { + // An elision (French "l'", Italian "dell'"): the word after the apostrophe hyphenates, + // with no break up to it. Elsewhere (German "Rock'n'Roll") an apostrophe word gets none. + if !matches!(l, hypher::Lang::French | hypher::Lang::Italian) { + return (vec![], Source::None); + } let (pts, src) = if ap + 1 < n { core_breaks(&core[ap + 1..], lang) } else { (vec![], Source::None) }; return (pts.into_iter().map(|p| p + ap + 1).collect(), src); } @@ -403,6 +406,15 @@ mod tests { assert!(hyphen_points("typography", &lim).is_empty()); } + /// Only French and Italian elide at an apostrophe; other languages leave such words unbroken. + #[test] + fn apostrophe_words_hyphenate_only_in_french_and_italian() { + let de = Lang::for_language("German: 2006 Reform").unwrap(); + assert!(hyphen_points_in("Rock'n'Roll", &Limits::default(), de).is_empty()); + let fr = Lang::for_language("French").unwrap(); + assert!(!hyphen_points_in("l'information", &Limits::default(), fr).is_empty()); + } + #[test] fn german_2006_uses_reformed_patterns() { let de = Lang::for_language("German: 2006 Reform").unwrap(); From 1139839795cd362bae53963c99c22f0d35f70b21 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 00:13:40 +0000 Subject: [PATCH 406/431] Integration: #1514 drop translations of the reworded Package Summary lines (English fallback) --- crates/ui-egui/src/i18n/ar.rs | 7 ------ crates/ui-egui/src/i18n/cs.rs | 7 ------ crates/ui-egui/src/i18n/it.rs | 7 ------ crates/ui-egui/src/i18n/ka.rs | 7 ------ crates/ui-egui/src/i18n/pl.rs | 7 ------ crates/ui-egui/src/i18n/pt_br.rs | 7 ------ crates/ui-egui/src/i18n/ru.rs | 7 ------ crates/ui-egui/src/i18n/table.rs | 40 -------------------------------- crates/ui-egui/src/i18n/uk.rs | 7 ------ 9 files changed, 96 deletions(-) diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 9936b1a9c..0c66b894c 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -112,13 +112,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Substituted", "مستبدل"), ("Modified", "معدل"), ("Missing", "مفقود"), - ("Colors and Inks: {process} process, {spot} spot", "الألوان والأحبار: {process} أحبار معالجة؛ {spot} أحبار مخصصة"), - ("Embedded images: {embedded}", "الصور: {embedded} مضمنة"), - ( - "Links and Images: {found} linked, {modified} changed, {missing} not found", - "الروابط والصور: {found} روابط؛ {modified} معدلة، {missing} مفقودة", - ), - ("Fonts: {used} in use, {missing} unavailable", "الخطوط: {used} مستخدمة، {missing} مفقودة"), ("Instructions:", "التعليمات:"), ("Email:", "البريد الإلكتروني:"), ("Fax:", "الفاكس:"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 38ffdbc4f..b6459bc36 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -118,13 +118,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Substituted", "Nahrazeno"), ("Modified", "Změněno"), ("Missing", "Chybí"), - ("Colors and Inks: {process} process, {spot} spot", "Barvy a inkousty: procesní {process}; přímé {spot}"), - ("Embedded images: {embedded}", "Obrazy: vloženo {embedded}"), - ( - "Links and Images: {found} linked, {modified} changed, {missing} not found", - "Odkazy a obrazy: nalezeno {found}; změněno {modified}, chybí {missing}", - ), - ("Fonts: {used} in use, {missing} unavailable", "Písma: použito {used}, chybí {missing}"), ("Instructions:", "Pokyny:"), ("Fax:", "Fax:"), ("Address:", "Adresa:"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 551b5dc05..87ffdbadb 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -116,13 +116,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Substituted", "Sostituito"), ("Modified", "Modificato"), ("Missing", "Mancante"), - ("Colors and Inks: {process} process, {spot} spot", "Colori e inchiostri: {process} quadricromia; {spot} tinte piatte"), - ("Embedded images: {embedded}", "Immagini: {embedded} incorporate"), - ( - "Links and Images: {found} linked, {modified} changed, {missing} not found", - "Collegamenti e immagini: {found} trovati; {modified} modificati, {missing} mancanti", - ), - ("Fonts: {used} in use, {missing} unavailable", "Font: {used} utilizzati, {missing} mancanti"), ("Instructions:", "Istruzioni:"), ("Email:", "E-mail:"), ("Fax:", "Fax:"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 162b81780..533ec9c57 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -110,15 +110,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Substituted", "ჩანაცვლებული"), ("Modified", "შეცვლილი"), ("Missing", "აკლია"), - ("Colors and Inks: {process} process, {spot} spot", "ფერები და მელნები: {process} პროცესის; {spot} ლაქის"), - ("Embedded images: {embedded}", "გამოსახულებები: ჩაშენებულია {embedded}"), ("Colors and Inks: {process} Process Inks; {spot} Spot Inks", "ფერები და მელნები: {process} პროცესის მელანი; {spot} სპოტ-საღებავი"), ("Images: {embedded} Embedded", "გამოსახულებები: ჩაშენებულია {embedded}"), - ( - "Links and Images: {found} linked, {modified} changed, {missing} not found", - "ბმულები და გამოსახულებები: ნაპოვნია {found}; შეცვლილია {modified}, აკლია {missing}", - ), - ("Fonts: {used} in use, {missing} unavailable", "შრიფტები: გამოყენებულია {used}, აკლია {missing}"), ("Instructions:", "ინსტრუქციები:"), ("Fax:", "ფაქსი:"), ("Address:", "მისამართი:"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 223085ad4..fcdb3718e 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -117,13 +117,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Substituted", "Zastąpiona"), ("Modified", "Zmieniony"), ("Missing", "Brak"), - ("Colors and Inks: {process} process, {spot} spot", "Kolory i farby: procesowe {process}; dodatkowe {spot}"), - ("Embedded images: {embedded}", "Obrazy: osadzone {embedded}"), - ( - "Links and Images: {found} linked, {modified} changed, {missing} not found", - "Łącza i obrazy: znaleziono {found}; zmienione {modified}, brakujące {missing}", - ), - ("Fonts: {used} in use, {missing} unavailable", "Czcionki: użyte {used}, brakujące {missing}"), ("Instructions:", "Instrukcje:"), ("Email:", "E-mail:"), ("Fax:", "Faks:"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 634cafca3..1562ecc6f 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -119,13 +119,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Substituted", "Substituída"), ("Modified", "Modificado"), ("Missing", "Ausente"), - ("Colors and Inks: {process} process, {spot} spot", "Cores e tintas: {process} de escala; {spot} especiais"), - ("Embedded images: {embedded}", "Imagens: {embedded} incorporadas"), - ( - "Links and Images: {found} linked, {modified} changed, {missing} not found", - "Vínculos e imagens: {found} encontrados; {modified} modificados, {missing} ausentes", - ), - ("Fonts: {used} in use, {missing} unavailable", "Fontes: {used} usadas, {missing} ausentes"), ("Instructions:", "Instruções:"), ("Email:", "E-mail:"), ("Fax:", "Fax:"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 9a298286b..8b933fe60 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -121,13 +121,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Substituted", "Заменен"), ("Modified", "Изменено"), ("Missing", "Отсутствует"), - ("Colors and Inks: {process} process, {spot} spot", "Цвета и краски: триадных {process}; плашечных {spot}"), - ("Embedded images: {embedded}", "Изображения: внедрено {embedded}"), - ( - "Links and Images: {found} linked, {modified} changed, {missing} not found", - "Связи и изображения: найдено {found}; изменено {modified}, отсутствует {missing}", - ), - ("Fonts: {used} in use, {missing} unavailable", "Шрифты: используется {used}, отсутствует {missing}"), ("Instructions:", "Инструкции:"), ("Email:", "Эл. почта:"), ("Fax:", "Факс:"), diff --git a/crates/ui-egui/src/i18n/table.rs b/crates/ui-egui/src/i18n/table.rs index 45d444f24..2d4688558 100644 --- a/crates/ui-egui/src/i18n/table.rs +++ b/crates/ui-egui/src/i18n/table.rs @@ -167,46 +167,6 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ("Fax:", ["Fax:", "Fax :", "Fax:", "FAX:", "传真:"]), ("Email:", ["E-Mail:", "E-mail :", "Correo electrónico:", "メール:", "电子邮件:"]), ("Instructions:", ["Anweisungen:", "Instructions :", "Instrucciones:", "指示:", "说明:"]), - ( - "Fonts: {used} in use, {missing} unavailable", - [ - "Schriften: {used} verwendet, {missing} fehlen", - "Polices : {used} utilisées, {missing} manquantes", - "Fuentes: {used} utilizadas, {missing} ausentes", - "フォント : {used} 個使用、{missing} 個見つかりません", - "字体:使用 {used} 种,缺失 {missing} 种", - ], - ), - ( - "Links and Images: {found} linked, {modified} changed, {missing} not found", - [ - "Verknüpfungen und Bilder: {found} gefunden; {modified} geändert, {missing} fehlen", - "Liens et images : {found} liens trouvés ; {modified} modifiés, {missing} manquants", - "Vínculos e imágenes: {found} vínculos encontrados; {modified} modificados, {missing} ausentes", - "リンクと画像 : {found} 個のリンク、{modified} 個変更、{missing} 個見つかりません", - "链接和图像:找到 {found} 个链接;{modified} 个已修改,{missing} 个缺失", - ], - ), - ( - "Embedded images: {embedded}", - [ - "Bilder: {embedded} eingebettet", - "Images : {embedded} incorporées", - "Imágenes: {embedded} incrustadas", - "画像 : {embedded} 個埋め込み", - "图像:嵌入 {embedded} 个", - ], - ), - ( - "Colors and Inks: {process} process, {spot} spot", - [ - "Farben und Druckfarben: {process} Prozessfarben; {spot} Volltonfarben", - "Couleurs et encres : {process} encres quadri ; {spot} encres d’accompagnement", - "Colores y tintas: {process} tintas de cuatricromía; {spot} tintas planas", - "カラーとインキ : プロセスインキ {process} 個、スポットインキ {spot} 個", - "颜色和油墨:{process} 种印刷色油墨;{spot} 种专色油墨", - ], - ), ("Missing", ["Fehlt", "Manquant", "Ausente", "見つかりません", "缺失"]), ("Modified", ["Geändert", "Modifié", "Modificado", "変更", "已修改"]), ("Substituted", ["Ersetzt", "Remplacée", "Sustituida", "代替", "已替换"]), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 2f9d1d060..5bae3381f 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -117,13 +117,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Substituted", "Замінено"), ("Modified", "Змінено"), ("Missing", "Бракує"), - ("Colors and Inks: {process} process, {spot} spot", "Кольори й фарби: тріадних {process}; плашкових {spot}"), - ("Embedded images: {embedded}", "Зображення: вбудовано {embedded}"), - ( - "Links and Images: {found} linked, {modified} changed, {missing} not found", - "Зв’язки й зображення: знайдено {found}; змінено {modified}, бракує {missing}", - ), - ("Fonts: {used} in use, {missing} unavailable", "Шрифти: використано {used}, бракує {missing}"), ("Instructions:", "Інструкції:"), ("Email:", "Ел. пошта:"), ("Fax:", "Факс:"), From d21cf7c2b106ac543728c865a7470dbca231cd8c Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 00:13:40 +0000 Subject: [PATCH 407/431] Integration: #1503 Esc or Enter during an eyedropper drag cancels only the drag --- crates/ui-egui/src/dialogs.rs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7bbe2acd7..e675c2db8 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -8889,6 +8889,8 @@ fn nums(d: &Dialog, k: &str) -> Option<[f64; N]> { Some(out) } +const EYEDROPPER_DRAG: &str = "color_picker_eyedropper_dragging"; + /// The Color Picker's eyedropper at `at`, as InDesign's: dragged onto the page, it takes the colour /// shown under the pointer when it is let go. The colour taken, if any. fn eyedropper(app: &mut DesignApp, ui: &mut egui::Ui, at: egui::Pos2, tip: String, t: &crate::theme::Tokens) -> Option<[u8; 3]> { @@ -8899,13 +8901,23 @@ fn eyedropper(app: &mut DesignApp, ui: &mut egui::Ui, at: egui::Pos2, tip: Strin ui.painter().rect_filled(er, 3.0, t.hover); } crate::icons::paint(ui.painter(), egui::Rect::from_center_size(er.center(), egui::vec2(15.0, 15.0)), "tool-eyedropper", t.icon); + // Esc during the drag cancels just the drag (the picker's own Esc/Enter handling stands down + // while `EYEDROPPER_DRAG` is set), and the release takes nothing. + let cancel_id = egui::Id::new("color_picker_eyedropper_cancelled"); if resp.dragged() { ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { + ui.data_mut(|d| d.insert_temp(cancel_id, true)); + } + ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); } + ui.data_mut(|d| d.insert_temp(egui::Id::new(EYEDROPPER_DRAG), resp.dragged())); + let cancelled = resp.drag_stopped() && ui.data_mut(|d| d.remove_temp::(cancel_id)).unwrap_or(false); // Let go over the picker itself it takes nothing, though a page may lie under it. (Its modal // backdrop shares the picker's layer and covers the screen: the picker is where it draws.) let mut taken = None; if resp.drag_stopped() + && !cancelled && let Some(pos) = ui.input(|i| i.pointer.latest_pos()) && !ui.clip_rect().contains(pos) { @@ -9030,8 +9042,15 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { // The footer's padded OK key, which every catalog has. let ok = pill(ui, 0, tr(" OK ").trim(), true); let cancel = pill(ui, 1, &tr("Cancel"), false); - let enter = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); - let esc = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); + let dragging = ui.data(|d| d.get_temp::(egui::Id::new(EYEDROPPER_DRAG))).unwrap_or(false); + let (enter, esc) = if dragging { + (false, false) + } else { + ( + ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)), + ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)), + ) + }; if ok || cancel { d.fields.insert("_close".into(), json!(if cancel { "cancel" } else { "ok" })); } else if enter || esc { From 93b44a3fcfc72d2355db4372c9597ab7216bd4cf Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 00:13:40 +0000 Subject: [PATCH 408/431] Integration: ROADMAP progress-log order for the anchor point lines --- ROADMAP.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 16eefcf26..449101ba9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -456,6 +456,8 @@ 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-21** · Properties for points selected with Direct Selection, as InDesign's: the title is "Anchor Point(s)", and the panel shows Transform, Appearance, Convert Points (Plain, Corner, Smooth, Symmetrical) and the Delete Point(s) quick action (`path.deletePoints`, one undo step). Found in Zero2Graphic's *How to Page Layout design in Adobe InDesign 2022 CC*. No readiness percentage change. +- **2026-10-20** · Direct Selection selects anchor points: a marquee selects the points inside it on every path it reaches, a click selects one point and Shift+click adds or removes one, selected points show solid, and dragging one (Shift: at 45° steps) or pressing the arrow keys moves them all in one step (`selection.set {anchors}`, `path.moveAnchors {points}`). 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). @@ -502,8 +504,6 @@ Newest first. - **2026-10-20** · Type tool: a drag that starts in an empty frame draws a new text frame over it, as in InDesign; a click still turns the frame into a text frame. No readiness percentage change. - **2026-10-20** · Measures in centimetres show four decimals, as InDesign's fields do (a 1p gutter reads 0.4233 cm, not 0.423 cm). No readiness percentage change. - **2026-10-21** · Text Frame Options is laid out as InDesign's, with Preview: General, Column Rules, Baseline Options, Auto-Size; framed Columns (Number and Width, Gutter and Maximum, Balance Columns), Inset Spacing (Top/Bottom, the link, Left/Right) and Vertical Justification (Align, Paragraph Spacing Limit) with spinner fields; Fixed Number shows each column's width as the fields change. Preview shows the change on the page until OK (one undo step) or Cancel. Justify honours the Paragraph Spacing Limit (`paragraphSpacingLimit` on `object.textFrameOptions`): the space goes between paragraphs up to the limit, the rest between lines. Found in the Learn Skills Daily course, chapter 18. No readiness percentage change. -- **2026-10-21** · Properties for points selected with Direct Selection, as InDesign's: the title is "Anchor Point(s)", and the panel shows Transform, Appearance, Convert Points (Plain, Corner, Smooth, Symmetrical) and the Delete Point(s) quick action (`path.deletePoints`, one undo step). Found in Zero2Graphic's *How to Page Layout design in Adobe InDesign 2022 CC*. No readiness percentage change. -- **2026-10-20** · Direct Selection selects anchor points: a marquee selects the points inside it on every path it reaches, a click selects one point and Shift+click adds or removes one, selected points show solid, and dragging one (Shift: at 45° steps) or pressing the arrow keys moves them all in one step (`selection.set {anchors}`, `path.moveAnchors {points}`). No readiness percentage change. - **2026-10-21** · Swatches panel: a swatch is renamed in place, as in InDesign: click it, then click its name again (a slow double-click), type, and Enter (Escape keeps the name). Everything that uses it follows; a double-click still opens Swatch Options, and [None], [Registration], [Paper] and [Black] keep their names. Found in the Learn Skills Daily course, chapter 23. No readiness percentage change. - **2026-10-20** · The loaded place cursor shows a thumbnail of the graphic, as InDesign's does; dragging with it draws the frame at the graphic's proportions from where the drag started, with its scale beside the pointer, and places exactly that frame. No readiness percentage change. - **2026-10-21** · Drop Shadow, Inner Shadow and Outer Glow have a blend mode, as InDesign's (Effects dialog › Blending › Mode; `mode` on `object.dropShadow`, `object.innerShadow`, `object.outerGlow`; IDML `BlendMode`). New shadows start Multiply and new outer glows Screen in white ([Paper]); effects saved before keep Normal, so documents look as they did. Found in the Learn Skills Daily course, chapter 14. No readiness percentage change. From 476ab056c25fc136e53be7457722179e02b6dd11 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 00:32:34 +0000 Subject: [PATCH 409/431] Integration: #1503 #1657 clippy: Id::unique for eyedropper ids, split cursor and picker-close helpers --- crates/ui-egui/src/canvas.rs | 33 ++++++++++++++++++------------ crates/ui-egui/src/dialogs.rs | 38 ++++++++++++++++++++--------------- 2 files changed, 42 insertions(+), 29 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4de9c8bfb..21537257f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -327,6 +327,24 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } // Cursor. if let Some(p) = resp.hover_pos().filter(|p| screen.contains(*p) && !elsewhere) { + let c = pointer_cursor(app, ui, &xf, p); + 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); + if c == Cursor::LoadedGraphic { + 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()); + } +} + +/// The cursor under the pointer at `p` over the canvas: the navigation hands, then the guide +/// resize cursors, then the active tool's. +fn pointer_cursor(app: &mut DesignApp, ui: &egui::Ui, xf: &Xf, p: Pos2) -> Cursor { let wants_text = app.session.wants_text(); let space = navigation_space(app, ui.ctx()); let zoom = spring_zoom_mode(app, ui.ctx(), space); @@ -348,7 +366,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let column = dragging.or_else(|| { let menu_open = context_menu_open(ui.ctx()); (!space && !zoom && !middle && !alt_hand && !tool_drag && !menu_open && xf.rot == 0) - .then(|| column_guide_at(app, &xf, p).map(|h| h.0)) + .then(|| column_guide_at(app, xf, p).map(|h| h.0)) .flatten() }); let c = match column { @@ -365,18 +383,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { 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); - if c == Cursor::LoadedGraphic { - 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()); - } + c } /// Longest side of the loaded place cursor's thumbnail, in points. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e675c2db8..f8532d9cc 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -8903,7 +8903,7 @@ fn eyedropper(app: &mut DesignApp, ui: &mut egui::Ui, at: egui::Pos2, tip: Strin crate::icons::paint(ui.painter(), egui::Rect::from_center_size(er.center(), egui::vec2(15.0, 15.0)), "tool-eyedropper", t.icon); // Esc during the drag cancels just the drag (the picker's own Esc/Enter handling stands down // while `EYEDROPPER_DRAG` is set), and the release takes nothing. - let cancel_id = egui::Id::new("color_picker_eyedropper_cancelled"); + let cancel_id = egui::Id::unique("color_picker_eyedropper_cancelled"); if resp.dragged() { ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { @@ -8911,7 +8911,7 @@ fn eyedropper(app: &mut DesignApp, ui: &mut egui::Ui, at: egui::Pos2, tip: Strin } ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); } - ui.data_mut(|d| d.insert_temp(egui::Id::new(EYEDROPPER_DRAG), resp.dragged())); + ui.data_mut(|d| d.insert_temp(egui::Id::unique(EYEDROPPER_DRAG), resp.dragged())); let cancelled = resp.drag_stopped() && ui.data_mut(|d| d.remove_temp::(cancel_id)).unwrap_or(false); // Let go over the picker itself it takes nothing, though a page may lie under it. (Its modal // backdrop shares the picker's layer and covers the screen: the picker is where it draws.) @@ -8927,6 +8927,25 @@ fn eyedropper(app: &mut DesignApp, ui: &mut egui::Ui, at: egui::Pos2, tip: Strin taken } +/// Closes the picker for a click on OK or Cancel, else for Enter (OK) or Esc (Cancel) unless an +/// eyedropper drag is on. +fn picker_close(ui: &mut egui::Ui, d: &mut Dialog, ok: bool, cancel: bool) { + let dragging = ui.data(|d| d.get_temp::(egui::Id::unique(EYEDROPPER_DRAG))).unwrap_or(false); + let (enter, esc) = if dragging { + (false, false) + } else { + ( + ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)), + ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)), + ) + }; + if ok || cancel { + d.fields.insert("_close".into(), json!(if cancel { "cancel" } else { "ok" })); + } else if enter || esc { + d.fields.insert("_close".into(), json!(if enter { "ok" } else { "cancel" })); + } +} + /// 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 @@ -9042,20 +9061,7 @@ fn color_picker(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { // The footer's padded OK key, which every catalog has. let ok = pill(ui, 0, tr(" OK ").trim(), true); let cancel = pill(ui, 1, &tr("Cancel"), false); - let dragging = ui.data(|d| d.get_temp::(egui::Id::new(EYEDROPPER_DRAG))).unwrap_or(false); - let (enter, esc) = if dragging { - (false, false) - } else { - ( - ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)), - ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)), - ) - }; - if ok || cancel { - d.fields.insert("_close".into(), json!(if cancel { "cancel" } else { "ok" })); - } else if enter || esc { - d.fields.insert("_close".into(), json!(if enter { "ok" } else { "cancel" })); - } + picker_close(ui, d, ok, cancel); // The C/M/Y/K the fields show: the percentages typed or given, else the colour converted. let cmyk = nums::<4>(d, "cmyk").map(|v| v.map(|x| x as f32)).unwrap_or_else(|| { designcraft_color::Color::Rgb { r: p.rgb[0], g: p.rgb[1], b: p.rgb[2] } From b1d213ba6e96d2b69bf7c8774ad9355a6b5972ab Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 00:32:54 +0000 Subject: [PATCH 410/431] Integration: #1566 hyphenation limits count the word after an elision --- crates/compose/src/hyphen.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/compose/src/hyphen.rs b/crates/compose/src/hyphen.rs index 174adb575..be9e7b7e6 100644 --- a/crates/compose/src/hyphen.rs +++ b/crates/compose/src/hyphen.rs @@ -238,6 +238,7 @@ pub fn hyphen_points_in(word: &str, lim: &Limits, lang: Lang) -> Vec { } let min_before = lim.after_first.max(1); let min_after = lim.before_last.max(1); + let elides = matches!(lang, Lang::Patterns(hypher::Lang::French | hypher::Lang::Italian)); // Run bounds of each point's letter run (trimmed of apostrophes). let run_of = |p: usize| { let mut a = p; @@ -254,6 +255,10 @@ pub fn hyphen_points_in(word: &str, lim: &Limits, lang: Lang) -> Vec { while b > a && is_apostrophe(chars[b - 1]) { b -= 1; } + // After an elision the word is the part after its last apostrophe: the limits count that. + if elides && let Some(ap) = chars[a..b].iter().rposition(|&c| is_apostrophe(c)) { + a += ap + 1; + } (a, b) }; pts.into_iter() @@ -538,6 +543,16 @@ mod tests { assert_eq!(h("beveger"), "be-ve-ger"); } + /// The paragraph limits count the letters of the word after an elision, not the elided prefix. + #[test] + fn elision_limits_apply_to_the_word_after_it() { + let fr = Lang::for_language("French").unwrap(); + let lim = Limits { min_word: 5, after_first: 3, before_last: 2, capitalized: true }; + assert_eq!(hyphen_points_in("l'imprimerie", &lim, fr), vec![7, 9]); + let lim = Limits { min_word: 11, ..Limits::default() }; + assert!(hyphen_points_in("l'imprimerie", &lim, fr).is_empty()); + } + /// A word with an elision ("l'", "dell'") hyphenates as the word after it. fn marked(w: &str, l: Lang) -> String { let pts = hyphen_points_in(w, &Limits::default(), l); From 0565eaf3805b7154b665aa49beb1d81ec85ea5f1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 00:33:24 +0000 Subject: [PATCH 411/431] Integration: #1461 drop cap box moves with vertical justification --- crates/compose/src/lib.rs | 11 +++++++++++ crates/compose/src/tests.rs | 15 +++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index d11598865..b19833ff1 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -5408,6 +5408,7 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &de continue; } let _ = a; + let before: Vec = idx.iter().map(|&i| ft.lines[i].baseline).collect(); match f.opts.vertical_justification { VerticalJustification::Center => idx.iter().for_each(|&i| ft.lines[i].baseline += space / 2.0), VerticalJustification::Bottom => idx.iter().for_each(|&i| ft.lines[i].baseline += space), @@ -5449,6 +5450,16 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &de } _ => {} } + // A drop cap moves with its line. + for (&i, b) in idx.iter().zip(before) { + let line = &mut ft.lines[i]; + let dy = line.baseline - b; + if let Some(dc) = &mut line.drop_cap { + dc.baseline += dy; + dc.rect.y0 += dy; + dc.rect.y1 += dy; + } + } } } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index b5c2f8a94..d7b87cea9 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -3745,6 +3745,21 @@ fn text_x(l: &Line, from: usize) -> f64 { l.glyphs.iter().find(|g| g.len > 0 && g.byte >= from).map(|g| g.x).unwrap() } +/// Vertical justification moves the drop cap's box with its line. +#[test] +fn drop_cap_box_follows_vertical_justification() { + let (mut d, sid, fid) = drop_doc_in(&[LOREM; 2].join(" "), Rect::new(0.0, 0.0, 300.0, 400.0), drop_cap(3, 1)); + let top = compose_story(&d, sid, &ComposeOptions::default()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical_justification = designcraft_doc::VerticalJustification::Bottom; + let bottom = compose_story(&d, sid, &ComposeOptions::default()); + let (a, b) = (&top.frame(fid).unwrap().lines[0], &bottom.frame(fid).unwrap().lines[0]); + let dy = b.baseline - a.baseline; + assert!(dy > 1.0); + let (da, db) = (a.drop_cap.unwrap(), b.drop_cap.unwrap()); + assert!((db.baseline - da.baseline - dy).abs() < 1e-6); + assert!((db.rect.y0 - da.rect.y0 - dy).abs() < 1e-6 && (db.rect.y1 - da.rect.y1 - dy).abs() < 1e-6); +} + #[test] fn drop_cap_spans_lines_and_indents_them() { let text = [LOREM; 2].join(" "); From 22a8292db40ffa50668527a740280c53725dd6b3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 00:33:45 +0000 Subject: [PATCH 412/431] Integration: #1451 object.effects patches keep the default mode of a new inner shadow or glow --- crates/engine/src/cmd/object.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 71eea09c2..826f7f0ee 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -2361,6 +2361,13 @@ fn effects_all(s: &mut Session, p: &Value) -> Result { } if let Some(patch) = &patch { let mut cur = serde_json::to_value(&it.effects).map_err(|e| bad(ID, e.to_string()))?; + // Default effects are left out of the serialized form; a patch without `mode` would + // then read the legacy Normal instead of the effect's current (Multiply, Screen) mode. + for (key, now) in [("innerShadow", serde_json::to_value(&it.effects.inner_shadow)), ("outerGlow", serde_json::to_value(&it.effects.outer_glow))] { + if let (Some(o), Ok(now)) = (cur.as_object_mut(), now) { + o.entry(key).or_insert(now); + } + } merge_json(&mut cur, patch); let mut fx: designcraft_doc::Effects = serde_json::from_value(cur).map_err(|e| bad(ID, format!("effects: {e}")))?; sanitize_effects(&mut fx)?; @@ -5763,12 +5770,14 @@ mod effects_command_tests { let it = st.doc.item(ItemId(id)).unwrap(); let e = &it.effects; assert!(e.inner_shadow.on && e.inner_shadow.angle == 120.0 && e.inner_shadow.size == 7.0); + assert_eq!(e.inner_shadow.mode, designcraft_color::BlendMode::Multiply, "a patch without a mode keeps the default"); assert!(e.drop_shadow.on && e.drop_shadow.distance == 10.0 && e.drop_shadow.size == 9.0, "untouched fields stay"); assert_eq!((e.feather, it.opacity, it.blend), (4.0, 0.5, designcraft_color::BlendMode::Multiply)); // Out of range values are kept in range; garbage changes nothing. s.execute("object.effects", &json!({"ids": [id], "effects": {"outerGlow": {"on": true, "opacity": 3, "spread": 400, "size": -5}}})).unwrap(); let og = s.doc().unwrap().doc.item(ItemId(id)).unwrap().effects.outer_glow.clone(); assert_eq!((og.opacity, og.spread, og.size), (1.0, 100.0, 0.0)); + assert_eq!(og.mode, designcraft_color::BlendMode::Screen); let before = s.doc().unwrap().doc.clone(); for bad in [json!({"effects": {"dropShadow": {"distance": "far"}}}), json!({"effects": 5}), json!({"blend": "loud"}), json!({"opacity": "x"})] { From d7c7e88c4661d6bdf405496ab0a76cecff27b780 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 00:34:08 +0000 Subject: [PATCH 413/431] Integration: #1416 path.deletePoints takes named points without a selected object --- crates/engine/src/cmd/anchors.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/anchors.rs b/crates/engine/src/cmd/anchors.rs index 46ec6c9e7..ad310643c 100644 --- a/crates/engine/src/cmd/anchors.rs +++ b/crates/engine/src/cmd/anchors.rs @@ -72,7 +72,7 @@ pub fn specs() -> Vec { [], None, "{points?: [[id, subpath, index]]} — the Direct Selection tool's selected points by default, in one undo step (Properties › Quick Actions › Delete Point(s)); a subpath left with one point goes, and an object with none is removed → {deleted, removed: [id]}", - has_selection, + has_doc, delete_points ), cmd!( @@ -587,6 +587,11 @@ mod tests { assert_eq!(r["removed"], json!([l])); assert!(s.doc().unwrap().doc.item(ItemId(l)).is_none()); assert!(s.execute("path.deletePoints", &json!({"points": []})).is_err()); + // Named points need no selected object. + s.execute("selection.set", &json!({"ids": []})).unwrap(); + assert!(s.doc().unwrap().selection.items.is_empty()); + let r = s.execute("path.deletePoints", &json!({"points": [[b, 0, 0]]})).unwrap(); + assert_eq!(r["deleted"], 1); // Edit › Clear with points selected deletes the points, not their paths. s.execute("selection.set", &json!({"anchors": [[a, 0, 0]]})).unwrap(); s.execute("edit.clear", &json!({})).unwrap(); From 28de0675d3db099480d981b526a3cf9feeb28a86 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 00:34:23 +0000 Subject: [PATCH 414/431] Integration: #1416 erase and smooth clear the item's stored point selection --- crates/engine/src/cmd/anchors.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/anchors.rs b/crates/engine/src/cmd/anchors.rs index ad310643c..46b20ff38 100644 --- a/crates/engine/src/cmd/anchors.rs +++ b/crates/engine/src/cmd/anchors.rs @@ -301,7 +301,7 @@ fn erase_or_smooth(s: &mut Session, p: &Value, smooth: bool) -> Result { .and_then(Value::as_array) .map(|a| a.iter().filter_map(|v| Some(Point::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))).collect()) .unwrap_or_default(); - s.edit(|d, _| { + s.edit(|d, sel| { path_editable(d, id, cmd)?; let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?; let scale = inv.determinant().abs().sqrt().max(1e-9); @@ -326,6 +326,8 @@ fn erase_or_smooth(s: &mut Session, p: &Value, smooth: bool) -> Result { return Err(bad(cmd, "the drag didn't touch the path")); }; let sp = it.path.subpaths[si].clone(); + // The subpath list changes under the stored point indices: they would name other points. + sel.anchors.retain(|a| a.0 != id); if smooth { it.path.subpaths[si] = designcraft_geom::edit_path::smooth(&sp, a, b, tol * scale); } else { @@ -645,10 +647,12 @@ mod tests { let pts: Vec = (0..=20).map(|i| json!({"p": [100.0 + i as f64 * 20.0, if i % 2 == 0 { 300.0 } else { 306.0 }]})).collect(); let id = s.execute("path.create", &json!({"anchors": pts})).unwrap()["id"].as_u64().unwrap(); let n = s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.anchor_count(); + s.execute("selection.set", &json!({"anchors": [[id, 0, 20]]})).unwrap(); let drag: Vec = (0..=12).map(|i| json!([150.0 + i as f64 * 20.0, 303.0])).collect(); s.execute("path.smooth", &json!({"id": id, "points": drag})).unwrap(); let after = s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.anchor_count(); assert!(after < n, "{after} < {n}"); + assert!(s.doc().unwrap().selection.anchors.is_empty(), "stored point indices are stale after the path is rebuilt"); let drag: Vec = (0..=5).map(|i| json!([200.0 + i as f64 * 20.0, 303.0])).collect(); let r = s.execute("path.erase", &json!({"id": id, "points": drag})).unwrap(); assert_eq!(r["subpaths"], 2, "a gap in the middle"); From 9555dbfc48048c0290fd41fa958b176c379863f5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 00:34:31 +0000 Subject: [PATCH 415/431] Integration: #1416 edit.cut removes the copied objects, not only their selected points --- crates/engine/src/cmd/anchors.rs | 4 ++++ crates/engine/src/cmd/edit.rs | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/crates/engine/src/cmd/anchors.rs b/crates/engine/src/cmd/anchors.rs index 46b20ff38..4ccbeec7e 100644 --- a/crates/engine/src/cmd/anchors.rs +++ b/crates/engine/src/cmd/anchors.rs @@ -598,6 +598,10 @@ mod tests { s.execute("selection.set", &json!({"anchors": [[a, 0, 0]]})).unwrap(); s.execute("edit.clear", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc.item(ItemId(a)).unwrap().path.subpaths[0].anchors.len(), 3, "one point gone, the frame stays"); + // Cut copies whole objects, so it removes whole objects. + s.execute("selection.set", &json!({"anchors": [[a, 0, 0]]})).unwrap(); + s.execute("edit.cut", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.item(ItemId(a)).is_none(), "the cut object is gone"); } /// Shift-adding an object to a point selection drops the points, so Clear deletes the objects. diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 37ab535cb..7b9834f91 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -400,6 +400,11 @@ pub fn specs() -> Vec { return Ok(json!({"text": text})); } copy_items(s)?; + // The whole objects are on the clipboard, so the whole objects go, not just selected points. + if p.get("ids").is_none() { + let ids: Vec = s.doc()?.selection.items.iter().map(|i| i.0).collect(); + return s.execute("edit.clear", &json!({"ids": ids})); + } s.execute("edit.clear", p) }), cmd!( From e040354eb4098f6a397c1c71b667a3b6c7ba647d Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 00:34:45 +0000 Subject: [PATCH 416/431] Integration: #1679 table.convertFromText refuses more rows than a table holds --- crates/engine/src/cmd/table/structure.rs | 5 +++++ crates/engine/src/tests/table.rs | 3 +++ 2 files changed, 8 insertions(+) diff --git a/crates/engine/src/cmd/table/structure.rs b/crates/engine/src/cmd/table/structure.rs index a358ee552..0eb0302df 100644 --- a/crates/engine/src/cmd/table/structure.rs +++ b/crates/engine/src/cmd/table/structure.rs @@ -135,6 +135,11 @@ pub(super) fn convert_from_text(s: &mut Session, p: &Value) -> Result { if cols * data.len() as u64 > MAX_CONVERTED_CELLS { return Err(bad(ID, format!("the text makes more than {MAX_CONVERTED_CELLS} cells"))); } + // `Table::new` would clamp the shape and drop the rows past it, with their text. + let (rows, _) = designcraft_doc::clamped_size(data.len(), cols as usize); + if rows < data.len() { + return Err(bad(ID, format!("the text makes {} rows; a table of {cols} columns has at most {rows}", data.len()))); + } let pf = st.paras[pa].clone(); let cf = st.format_after(a).clone(); let mut table = Table::from_strings(id, &data, width, &pf, &cf); diff --git a/crates/engine/src/tests/table.rs b/crates/engine/src/tests/table.rs index 2479ab459..c31a63dd4 100644 --- a/crates/engine/src/tests/table.rs +++ b/crates/engine/src/tests/table.rs @@ -586,6 +586,9 @@ fn convert_text_to_table_with_indesigns_separators() { let long = vec!["a\tb"; MAX_CONVERTED_CELLS as usize / 2 + 1].join("\n"); assert!(convert(&mut s, &long, json!({})).is_err(), "more cells than a table can have"); assert_eq!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text, format!("{long}\nAfter")); + let tall = vec!["a"; designcraft_doc::MAX_TABLE_ROWS + 1].join("\n"); + assert!(convert(&mut s, &tall, json!({})).is_err(), "more rows than a table can have"); + assert_eq!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text, format!("{tall}\nAfter"), "no text is dropped"); let at_most = vec!["x"; MAX_COLUMNS as usize].join("\t"); assert!(convert(&mut s, &at_most, json!({})).is_ok(), "a row as wide as a table can be"); check(&s); From 7d16fffb3932ebbec4400a741fa6e138dc3ccdb5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 00:35:26 +0000 Subject: [PATCH 417/431] Integration: #1451 screened outer glows and blended shadows isolate the spread from the paper --- crates/render/src/color.rs | 6 +++++- crates/render/tests/it/effects.rs | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/render/src/color.rs b/crates/render/src/color.rs index c4406b73c..84d648128 100644 --- a/crates/render/src/color.rs +++ b/crates/render/src/color.rs @@ -202,7 +202,11 @@ pub(crate) fn overprints(f: &Frame, swatch: &str, tint: f32, flag: bool) -> bool pub(crate) fn spread_blends(doc: &Document, sp: &designcraft_doc::Spread) -> bool { let blends = |it: &Item| { let mut any = false; - it.walk(&mut |i| any |= i.blend != Default::default()); + // A screened glow or a shadow with a blend mode blends with the objects below too, not the paper. + let fx_blends = |e: &designcraft_doc::Effects| { + (e.outer_glow.on && e.outer_glow.mode != Default::default()) || (e.drop_shadow.on && e.drop_shadow.mode != Default::default()) + }; + it.walk(&mut |i| any |= i.blend != Default::default() || fx_blends(&i.effects)); any }; sp.items.iter().any(|i| blends(i)) || doc.parents.iter().any(|p| p.items.iter().any(|i| blends(i))) diff --git a/crates/render/tests/it/effects.rs b/crates/render/tests/it/effects.rs index 967856d24..267d538f9 100644 --- a/crates/render/tests/it/effects.rs +++ b/crates/render/tests/it/effects.rs @@ -178,6 +178,23 @@ fn outer_glow_blends_with_its_mode() { assert!(s[2] > n[2] + 25, "screen keeps the backdrop's blue: {s:?} vs normal {n:?}"); } +/// A screened glow over blank paper shows in its colour: it blends with the objects below, and +/// the paper is not one of them. +#[test] +fn screened_outer_glow_shows_on_blank_paper() { + let mut d = doc(); + let id = rect_item(&mut d, Rect::new(50.0, 50.0, 110.0, 110.0), "[Black]"); + let g = &mut d.item_mut(id).unwrap().effects.outer_glow; + g.on = true; + g.size = 12.0; + g.color = "C=0 M=0 Y=100 K=0".into(); + g.opacity = 1.0; + g.mode = designcraft_color::BlendMode::Screen; + let img = render(&d); + let p = img.pixel(46, 80); + assert!(p[2] < 200 && p[0] > p[2] + 40, "yellow glow beside the item: {p:?}"); +} + #[test] fn inner_shadow_darkens_inside_the_lit_edge() { let mut d = doc(); From f4722640fa7e4190026f87e416717cbaf292e692 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 00:35:39 +0000 Subject: [PATCH 418/431] Integration: #1514 Georgian Package Summary keys match the reworded lines --- crates/ui-egui/src/i18n/ka.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 533ec9c57..a2581ab2c 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -110,8 +110,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Substituted", "ჩანაცვლებული"), ("Modified", "შეცვლილი"), ("Missing", "აკლია"), - ("Colors and Inks: {process} Process Inks; {spot} Spot Inks", "ფერები და მელნები: {process} პროცესის მელანი; {spot} სპოტ-საღებავი"), - ("Images: {embedded} Embedded", "გამოსახულებები: ჩაშენებულია {embedded}"), + ("Colors and Inks: {process} process, {spot} spot", "ფერები და მელნები: {process} პროცესის მელანი; {spot} სპოტ-საღებავი"), + ("Embedded images: {embedded}", "გამოსახულებები: ჩაშენებულია {embedded}"), ("Instructions:", "ინსტრუქციები:"), ("Fax:", "ფაქსი:"), ("Address:", "მისამართი:"), From c1ac3c03c87e12f03a76b44b996b030286cb32a3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 00:35:49 +0000 Subject: [PATCH 419/431] Integration: #1566 progress-log line names French and Italian and Gap 13 --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 449101ba9..dfc46b121 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -449,7 +449,7 @@ Newest first. - **2026-10-22** · Hyphenate Across Column (Hyphenation Settings; IDML HyphenateAcrossColumns) is applied: with it off, a paragraph whose line ending in an automatic hyphen would end a column or frame is re-composed without that hyphenation point, so the word moves on whole, as in InDesign. Opened and imported files keep their own setting, so their layouts don't change. No readiness percentage change. - **2026-10-22** · The Paragraph panel is laid out as InDesign's: the nine alignment buttons in groups; indent, spacing and drop cap fields by their icons in two columns, with Space Between Paragraphs Using Same Style (Ignore or a space); Shading and Border with the Paragraph Borders and Shading button and colour; Hyphenate with Do Not Align / Align to Baseline Grid. Its panel menu follows InDesign's order: Paragraph and Single-line Composer, Only Align First Line to Grid, Balance Ragged Lines, Optical Margin Alignment, Justification…, Keep Options…, Span Columns…, Hyphenation…, Drop Caps and Nested Styles…, GREP Styles… (these three now open for the selected paragraphs), Paragraph Rules…, Paragraph Borders and Shading…, Bullets and Numbering…, Restart/Continue Numbering, Define Lists… and the paragraph direction. Convert Bullets and Numbering to Text is listed but not available yet. No readiness percentage change. - **2026-10-22** · Preferences › Units & Increments with no document open, as in InDesign: the ruler units (horizontal and vertical), the ruler origin and the cursor-key and kerning increments set there are what new documents start with, and New Document opens in those units. They are kept with the application preferences (`prefs.set {defaultUnits}`); a document keeps its own once made. Reported by a reader who works in inches and had to change every file. No readiness percentage change. -- **2026-10-22** · French and Italian text is hyphenated, with hyph-utf8's patterns through hypher (hyph-fr 2.13 and hyph-it 4.9, MIT): for the Language set in the Character panel (French, French: Canadian, French: Swiss, Italian, or fr / it locale codes). A word with an elision hyphenates after its apostrophe (l'im-pri-me-rie, dell'im-pa-gi-na-zio-ne), in every patterned language. Gap 6 shrinks. No readiness percentage change. +- **2026-10-22** · French and Italian text is hyphenated, with hyph-utf8's patterns through hypher (hyph-fr 2.13 and hyph-it 4.9, MIT): for the Language set in the Character panel (French, French: Canadian, French: Swiss, Italian, or fr / it locale codes). A word with an elision hyphenates after its apostrophe (l'im-pri-me-rie, dell'im-pa-gi-na-zio-ne), in French and Italian only; other languages leave apostrophe words unbroken. Gap 13 shrinks. No readiness percentage change. - **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. - **2026-10-23** · Type › Text Variables › Convert Variable to Text (`variables.convertToText`): the variable instances in the selected text, in a story, a table cell or a footnote, become plain text of the value each shows on its own page (a running header on page 3 becomes page 3's heading). `variables.convertAllToText {name}` does the same for every instance of one variable in the document, as the Text Variables dialog's Convert to Text button. The variable stays defined; one undo step. No readiness percentage change. From 0e3fc9cfc6af4bce4166eefaa0598b7804443adb Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 00:35:59 +0000 Subject: [PATCH 420/431] Integration: #1497 one copy of the Change Format test, with the null-attribute check --- crates/engine/src/cmd/find.rs | 49 ----------------------------- crates/engine/src/cmd/find/tests.rs | 6 ++++ 2 files changed, 6 insertions(+), 49 deletions(-) diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index 4456dafa0..371b89012 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -856,55 +856,6 @@ Three" ); } - #[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); - // A null attribute is a Change Format too: the found text stays and loses that override. - let before = s.doc().unwrap().doc.story(sid).unwrap().clone(); - s.execute("find.change", &json!({"find": "cook", "change": "", "attrs": {"underline": null}})).unwrap(); - let story = s.doc().unwrap().doc.story(sid).unwrap().clone(); - assert_eq!(story.text, before.text, "the words stay"); - assert_eq!(story.format_after(25).over.underline, None); - // 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 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 65dad887b..348acd294 100644 --- a/crates/engine/src/cmd/find/tests.rs +++ b/crates/engine/src/cmd/find/tests.rs @@ -807,6 +807,12 @@ fn change_format_styles_the_found_text_and_keeps_it() { 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); + // A null attribute is a Change Format too: the found text stays and loses that override. + let before = s.doc().unwrap().doc.story(sid).unwrap().clone(); + s.execute("find.change", &json!({"find": "cook", "change": "", "attrs": {"underline": null}})).unwrap(); + let story = s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert_eq!(story.text, before.text, "the words stay"); + assert_eq!(story.format_after(25).over.underline, None); // 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")); From 8ef2702660b3a0f7c856ab93ecb022f91d0e554f Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 00:37:39 +0000 Subject: [PATCH 421/431] Integration: cargo fmt --- crates/engine/src/cmd/object.rs | 4 +- crates/ui-egui/src/canvas.rs | 76 ++++++++++++++++----------------- 2 files changed, 41 insertions(+), 39 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 826f7f0ee..aac0d0eb1 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -2363,7 +2363,9 @@ fn effects_all(s: &mut Session, p: &Value) -> Result { let mut cur = serde_json::to_value(&it.effects).map_err(|e| bad(ID, e.to_string()))?; // Default effects are left out of the serialized form; a patch without `mode` would // then read the legacy Normal instead of the effect's current (Multiply, Screen) mode. - for (key, now) in [("innerShadow", serde_json::to_value(&it.effects.inner_shadow)), ("outerGlow", serde_json::to_value(&it.effects.outer_glow))] { + for (key, now) in + [("innerShadow", serde_json::to_value(&it.effects.inner_shadow)), ("outerGlow", serde_json::to_value(&it.effects.outer_glow))] + { if let (Some(o), Ok(now)) = (cur.as_object_mut(), now) { o.entry(key).or_insert(now); } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 21537257f..3177a8ed4 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -345,44 +345,44 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { /// The cursor under the pointer at `p` over the canvas: the navigation hands, then the guide /// resize cursors, then the active tool's. fn pointer_cursor(app: &mut DesignApp, ui: &egui::Ui, xf: &Xf, p: Pos2) -> Cursor { - let wants_text = app.session.wants_text(); - let space = navigation_space(app, ui.ctx()); - let zoom = spring_zoom_mode(app, ui.ctx(), space); - let middle = ui.input(|i| i.pointer.middle_down()); - let alt_hand: bool = ui.data(|d| d.get_temp(egui::Id::unique(("canvas_alt_hand", app.pane)))).unwrap_or(false); - let tool_drag: bool = ui.data(|d| d.get_temp(egui::Id::unique(("canvas_pointer_down", app.pane)))).unwrap_or(false); - let alt_text = wants_text && !tool_drag && ui.input(|i| i.modifiers.alt); - let c = if middle || alt_hand { - Cursor::HandGrab - } else if zoom { - if ui.input(|i| i.modifiers.alt) { Cursor::ZoomOut } else { Cursor::ZoomIn } - } else if (space && !ui.input(|i| i.modifiers.command)) || alt_text { - Cursor::Hand - } else { - app.session.cursor(xf.to_canvas(p), ui.input(|i| mods(i, false)), app.view_info()) - }; - // A column guide that a press would drag, or one being dragged: a resize cursor across it. - let dragging = ui.data(|d| d.get_temp::(egui::Id::unique((COLUMN_DRAG, app.pane)))).map(|g| g.spread); - let column = dragging.or_else(|| { - let menu_open = context_menu_open(ui.ctx()); - (!space && !zoom && !middle && !alt_hand && !tool_drag && !menu_open && xf.rot == 0) - .then(|| column_guide_at(app, xf, p).map(|h| h.0)) - .flatten() - }); - let c = match column { - Some(r) if spread_x_across(app, r) => Cursor::ResizeH, - 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). 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, - }; + let wants_text = app.session.wants_text(); + let space = navigation_space(app, ui.ctx()); + let zoom = spring_zoom_mode(app, ui.ctx(), space); + let middle = ui.input(|i| i.pointer.middle_down()); + let alt_hand: bool = ui.data(|d| d.get_temp(egui::Id::unique(("canvas_alt_hand", app.pane)))).unwrap_or(false); + let tool_drag: bool = ui.data(|d| d.get_temp(egui::Id::unique(("canvas_pointer_down", app.pane)))).unwrap_or(false); + let alt_text = wants_text && !tool_drag && ui.input(|i| i.modifiers.alt); + let c = if middle || alt_hand { + Cursor::HandGrab + } else if zoom { + if ui.input(|i| i.modifiers.alt) { Cursor::ZoomOut } else { Cursor::ZoomIn } + } else if (space && !ui.input(|i| i.modifiers.command)) || alt_text { + Cursor::Hand + } else { + app.session.cursor(xf.to_canvas(p), ui.input(|i| mods(i, false)), app.view_info()) + }; + // A column guide that a press would drag, or one being dragged: a resize cursor across it. + let dragging = ui.data(|d| d.get_temp::(egui::Id::unique((COLUMN_DRAG, app.pane)))).map(|g| g.spread); + let column = dragging.or_else(|| { + let menu_open = context_menu_open(ui.ctx()); + (!space && !zoom && !middle && !alt_hand && !tool_drag && !menu_open && xf.rot == 0) + .then(|| column_guide_at(app, xf, p).map(|h| h.0)) + .flatten() + }); + let c = match column { + Some(r) if spread_x_across(app, r) => Cursor::ResizeH, + 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). 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, + }; c } From 9ff71e1d0a2e9da25f143353b2ab9c1f9169944f Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 00:39:01 +0000 Subject: [PATCH 422/431] Integration: #1503 clippy: return the guide cursor match directly --- crates/ui-egui/src/canvas.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3177a8ed4..ed207c34b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -378,12 +378,11 @@ fn pointer_cursor(app: &mut DesignApp, ui: &egui::Ui, xf: &Xf, p: Pos2) -> Curso // 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) { + 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, - }; - c + } } /// Longest side of the loaded place cursor's thumbnail, in points. From baf6e1b85e07fca0168962dbc8993fd033bedda2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 01:02:16 +0000 Subject: [PATCH 423/431] Integration: #1676 the Insert Table limits test uses the limits, not sizes above them Main refuses a size outside the limits instead of clamping it, so the test inserts the largest table and checks one step over each limit is refused. --- crates/engine/src/tests/table.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tests/table.rs b/crates/engine/src/tests/table.rs index c31a63dd4..77bcb9bfd 100644 --- a/crates/engine/src/tests/table.rs +++ b/crates/engine/src/tests/table.rs @@ -503,7 +503,10 @@ fn insert_table_with_a_style_is_one_step() { assert!(s.execute("table.insert", &json!({"tableStyle": bad})).is_err(), "{bad}"); } assert!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().tables.is_empty()); - let r = s.execute("table.insert", &json!({"rows": 900, "cols": 300, "headerRows": 150, "tableStyle": "Grid"})).unwrap(); + for over in [json!({"rows": 501}), json!({"cols": 201}), json!({"headerRows": 101})] { + assert!(s.execute("table.insert", &over).is_err(), "{over}"); + } + let r = s.execute("table.insert", &json!({"rows": 500, "cols": 200, "headerRows": 100, "tableStyle": "Grid"})).unwrap(); let t = s.execute("table.get", &json!({"table": r["table"]})).unwrap(); assert_eq!(t["style"], "Grid"); let n = |k: &str| t[k].as_array().unwrap().len() as u64; From a4becc8da030c323bb9495ae7f70d149ac213298 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 30 Oct 2026 22:59:26 -0400 Subject: [PATCH 424/431] Release 0.15.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 42 +++++++++++++++++++++--------------------- Cargo.toml | 2 +- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 935d51547..bd8638a90 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1306,7 +1306,7 @@ dependencies = [ [[package]] name = "designcraft" -version = "0.14.0" +version = "0.15.0" dependencies = [ "arboard", "craft-update", @@ -1330,7 +1330,7 @@ dependencies = [ [[package]] name = "designcraft-cli" -version = "0.14.0" +version = "0.15.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1346,7 +1346,7 @@ dependencies = [ [[package]] name = "designcraft-color" -version = "0.14.0" +version = "0.15.0" dependencies = [ "craft-cms", "kurbo", @@ -1358,7 +1358,7 @@ dependencies = [ [[package]] name = "designcraft-compose" -version = "0.14.0" +version = "0.15.0" dependencies = [ "designcraft-color", "designcraft-doc", @@ -1378,7 +1378,7 @@ dependencies = [ [[package]] name = "designcraft-doc" -version = "0.14.0" +version = "0.15.0" dependencies = [ "chrono", "designcraft-color", @@ -1391,7 +1391,7 @@ dependencies = [ [[package]] name = "designcraft-engine" -version = "0.14.0" +version = "0.15.0" dependencies = [ "caseless", "craft-atomic", @@ -1431,7 +1431,7 @@ dependencies = [ [[package]] name = "designcraft-epub" -version = "0.14.0" +version = "0.15.0" dependencies = [ "chrono", "designcraft-color", @@ -1441,7 +1441,7 @@ dependencies = [ [[package]] name = "designcraft-fonts" -version = "0.14.0" +version = "0.15.0" dependencies = [ "core-text", "harfrust 0.13.3", @@ -1454,7 +1454,7 @@ dependencies = [ [[package]] name = "designcraft-format" -version = "0.14.0" +version = "0.15.0" dependencies = [ "designcraft-doc", "designcraft-images", @@ -1466,7 +1466,7 @@ dependencies = [ [[package]] name = "designcraft-geom" -version = "0.14.0" +version = "0.15.0" dependencies = [ "flo_curves", "kurbo", @@ -1479,7 +1479,7 @@ dependencies = [ [[package]] name = "designcraft-idml" -version = "0.14.0" +version = "0.15.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1494,7 +1494,7 @@ dependencies = [ [[package]] name = "designcraft-images" -version = "0.14.0" +version = "0.15.0" dependencies = [ "craft-cms", "designcraft-color", @@ -1511,7 +1511,7 @@ dependencies = [ [[package]] name = "designcraft-mcp" -version = "0.14.0" +version = "0.15.0" dependencies = [ "designcraft-engine", "designcraft-geom", @@ -1524,7 +1524,7 @@ dependencies = [ [[package]] name = "designcraft-pdf" -version = "0.14.0" +version = "0.15.0" dependencies = [ "chrono", "craft-cms", @@ -1549,7 +1549,7 @@ dependencies = [ [[package]] name = "designcraft-pptx" -version = "0.14.0" +version = "0.15.0" dependencies = [ "chrono", "designcraft-color", @@ -1567,7 +1567,7 @@ dependencies = [ [[package]] name = "designcraft-render" -version = "0.14.0" +version = "0.15.0" dependencies = [ "craft-pdf", "designcraft-color", @@ -1586,7 +1586,7 @@ dependencies = [ [[package]] name = "designcraft-textimport" -version = "0.14.0" +version = "0.15.0" dependencies = [ "designcraft-doc", "quick-xml 0.41.0", @@ -1595,7 +1595,7 @@ dependencies = [ [[package]] name = "designcraft-tools" -version = "0.14.0" +version = "0.15.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1607,7 +1607,7 @@ dependencies = [ [[package]] name = "designcraft-ui-egui" -version = "0.14.0" +version = "0.15.0" dependencies = [ "craft-dialogs", "craft-keymap", @@ -1639,7 +1639,7 @@ dependencies = [ [[package]] name = "designcraft-web" -version = "0.14.0" +version = "0.15.0" dependencies = [ "designcraft-engine", "designcraft-format", @@ -7603,7 +7603,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.14.0" +version = "0.15.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 22ab1e32d..8c2c072bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"] [workspace.package] -version = "0.14.0" +version = "0.15.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 1ecb13b2b7dbf81c0f999c8163357ffc4c756b7b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 30 Oct 2026 23:15:39 -0400 Subject: [PATCH 425/431] Flatpak bundle: skip the debuginfo pass for cups-client (runner has no eu-strip) Co-Authored-By: Claude Opus 5.5 --- packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml index a18f2c306..58f648ee5 100644 --- a/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml @@ -49,6 +49,10 @@ modules: # the socket path matches the host's, which --socket=cups mounts. - name: cups-client buildsystem: simple + # The release runner's flatpak-builder has no eu-strip (elfutils), which the debuginfo pass + # runs on every binary this module compiles. + build-options: + no-debuginfo: true build-commands: - ./configure --prefix=/app --sysconfdir=/etc --localstatedir=/var --with-rundir=/run/cups --with-domainsocket=/run/cups/cups.sock --disable-shared --with-components=libcups From 8029fdd0743e0e89ba94bd717b360be9c3132696 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 30 Oct 2026 23:15:43 -0400 Subject: [PATCH 426/431] Release 0.15.1 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 42 +++++++++++++++++++++--------------------- Cargo.toml | 2 +- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bd8638a90..182c34ae9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1306,7 +1306,7 @@ dependencies = [ [[package]] name = "designcraft" -version = "0.15.0" +version = "0.15.1" dependencies = [ "arboard", "craft-update", @@ -1330,7 +1330,7 @@ dependencies = [ [[package]] name = "designcraft-cli" -version = "0.15.0" +version = "0.15.1" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1346,7 +1346,7 @@ dependencies = [ [[package]] name = "designcraft-color" -version = "0.15.0" +version = "0.15.1" dependencies = [ "craft-cms", "kurbo", @@ -1358,7 +1358,7 @@ dependencies = [ [[package]] name = "designcraft-compose" -version = "0.15.0" +version = "0.15.1" dependencies = [ "designcraft-color", "designcraft-doc", @@ -1378,7 +1378,7 @@ dependencies = [ [[package]] name = "designcraft-doc" -version = "0.15.0" +version = "0.15.1" dependencies = [ "chrono", "designcraft-color", @@ -1391,7 +1391,7 @@ dependencies = [ [[package]] name = "designcraft-engine" -version = "0.15.0" +version = "0.15.1" dependencies = [ "caseless", "craft-atomic", @@ -1431,7 +1431,7 @@ dependencies = [ [[package]] name = "designcraft-epub" -version = "0.15.0" +version = "0.15.1" dependencies = [ "chrono", "designcraft-color", @@ -1441,7 +1441,7 @@ dependencies = [ [[package]] name = "designcraft-fonts" -version = "0.15.0" +version = "0.15.1" dependencies = [ "core-text", "harfrust 0.13.3", @@ -1454,7 +1454,7 @@ dependencies = [ [[package]] name = "designcraft-format" -version = "0.15.0" +version = "0.15.1" dependencies = [ "designcraft-doc", "designcraft-images", @@ -1466,7 +1466,7 @@ dependencies = [ [[package]] name = "designcraft-geom" -version = "0.15.0" +version = "0.15.1" dependencies = [ "flo_curves", "kurbo", @@ -1479,7 +1479,7 @@ dependencies = [ [[package]] name = "designcraft-idml" -version = "0.15.0" +version = "0.15.1" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1494,7 +1494,7 @@ dependencies = [ [[package]] name = "designcraft-images" -version = "0.15.0" +version = "0.15.1" dependencies = [ "craft-cms", "designcraft-color", @@ -1511,7 +1511,7 @@ dependencies = [ [[package]] name = "designcraft-mcp" -version = "0.15.0" +version = "0.15.1" dependencies = [ "designcraft-engine", "designcraft-geom", @@ -1524,7 +1524,7 @@ dependencies = [ [[package]] name = "designcraft-pdf" -version = "0.15.0" +version = "0.15.1" dependencies = [ "chrono", "craft-cms", @@ -1549,7 +1549,7 @@ dependencies = [ [[package]] name = "designcraft-pptx" -version = "0.15.0" +version = "0.15.1" dependencies = [ "chrono", "designcraft-color", @@ -1567,7 +1567,7 @@ dependencies = [ [[package]] name = "designcraft-render" -version = "0.15.0" +version = "0.15.1" dependencies = [ "craft-pdf", "designcraft-color", @@ -1586,7 +1586,7 @@ dependencies = [ [[package]] name = "designcraft-textimport" -version = "0.15.0" +version = "0.15.1" dependencies = [ "designcraft-doc", "quick-xml 0.41.0", @@ -1595,7 +1595,7 @@ dependencies = [ [[package]] name = "designcraft-tools" -version = "0.15.0" +version = "0.15.1" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1607,7 +1607,7 @@ dependencies = [ [[package]] name = "designcraft-ui-egui" -version = "0.15.0" +version = "0.15.1" dependencies = [ "craft-dialogs", "craft-keymap", @@ -1639,7 +1639,7 @@ dependencies = [ [[package]] name = "designcraft-web" -version = "0.15.0" +version = "0.15.1" dependencies = [ "designcraft-engine", "designcraft-format", @@ -7603,7 +7603,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.15.0" +version = "0.15.1" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 8c2c072bd..0e21b3140 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"] [workspace.package] -version = "0.15.0" +version = "0.15.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 8ce8fb132c196c6230b626b917f006c21bba2ec7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 30 Oct 2026 23:25:32 -0400 Subject: [PATCH 427/431] Flatpak bundle: skip the debuginfo pass manifest-wide (runner has no eu-strip) Co-Authored-By: Claude Opus 5.5 --- .../linux/flatpak/ai.storyteller.designcraft.bundle.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml index 58f648ee5..750400657 100644 --- a/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml @@ -43,16 +43,17 @@ finish-args: - --filesystem=xdg-documents - --filesystem=xdg-pictures # Home/host filesystem access is deliberately not granted. +# The release runner's flatpak-builder has no eu-strip (elfutils), which its debuginfo pass runs +# on cups-client's unstripped tools, in whichever module comes first after they're installed. The +# app's own binaries come from the release tarball, already stripped. +build-options: + no-debuginfo: true 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 - # The release runner's flatpak-builder has no eu-strip (elfutils), which the debuginfo pass - # runs on every binary this module compiles. - build-options: - no-debuginfo: true build-commands: - ./configure --prefix=/app --sysconfdir=/etc --localstatedir=/var --with-rundir=/run/cups --with-domainsocket=/run/cups/cups.sock --disable-shared --with-components=libcups From 7e70203206531f0fde1dd5feb2b7d94768842ebc Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 30 Oct 2026 23:25:34 -0400 Subject: [PATCH 428/431] Release 0.15.2 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 42 +++++++++++++++++++++--------------------- Cargo.toml | 2 +- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 182c34ae9..efac3b68b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1306,7 +1306,7 @@ dependencies = [ [[package]] name = "designcraft" -version = "0.15.1" +version = "0.15.2" dependencies = [ "arboard", "craft-update", @@ -1330,7 +1330,7 @@ dependencies = [ [[package]] name = "designcraft-cli" -version = "0.15.1" +version = "0.15.2" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1346,7 +1346,7 @@ dependencies = [ [[package]] name = "designcraft-color" -version = "0.15.1" +version = "0.15.2" dependencies = [ "craft-cms", "kurbo", @@ -1358,7 +1358,7 @@ dependencies = [ [[package]] name = "designcraft-compose" -version = "0.15.1" +version = "0.15.2" dependencies = [ "designcraft-color", "designcraft-doc", @@ -1378,7 +1378,7 @@ dependencies = [ [[package]] name = "designcraft-doc" -version = "0.15.1" +version = "0.15.2" dependencies = [ "chrono", "designcraft-color", @@ -1391,7 +1391,7 @@ dependencies = [ [[package]] name = "designcraft-engine" -version = "0.15.1" +version = "0.15.2" dependencies = [ "caseless", "craft-atomic", @@ -1431,7 +1431,7 @@ dependencies = [ [[package]] name = "designcraft-epub" -version = "0.15.1" +version = "0.15.2" dependencies = [ "chrono", "designcraft-color", @@ -1441,7 +1441,7 @@ dependencies = [ [[package]] name = "designcraft-fonts" -version = "0.15.1" +version = "0.15.2" dependencies = [ "core-text", "harfrust 0.13.3", @@ -1454,7 +1454,7 @@ dependencies = [ [[package]] name = "designcraft-format" -version = "0.15.1" +version = "0.15.2" dependencies = [ "designcraft-doc", "designcraft-images", @@ -1466,7 +1466,7 @@ dependencies = [ [[package]] name = "designcraft-geom" -version = "0.15.1" +version = "0.15.2" dependencies = [ "flo_curves", "kurbo", @@ -1479,7 +1479,7 @@ dependencies = [ [[package]] name = "designcraft-idml" -version = "0.15.1" +version = "0.15.2" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1494,7 +1494,7 @@ dependencies = [ [[package]] name = "designcraft-images" -version = "0.15.1" +version = "0.15.2" dependencies = [ "craft-cms", "designcraft-color", @@ -1511,7 +1511,7 @@ dependencies = [ [[package]] name = "designcraft-mcp" -version = "0.15.1" +version = "0.15.2" dependencies = [ "designcraft-engine", "designcraft-geom", @@ -1524,7 +1524,7 @@ dependencies = [ [[package]] name = "designcraft-pdf" -version = "0.15.1" +version = "0.15.2" dependencies = [ "chrono", "craft-cms", @@ -1549,7 +1549,7 @@ dependencies = [ [[package]] name = "designcraft-pptx" -version = "0.15.1" +version = "0.15.2" dependencies = [ "chrono", "designcraft-color", @@ -1567,7 +1567,7 @@ dependencies = [ [[package]] name = "designcraft-render" -version = "0.15.1" +version = "0.15.2" dependencies = [ "craft-pdf", "designcraft-color", @@ -1586,7 +1586,7 @@ dependencies = [ [[package]] name = "designcraft-textimport" -version = "0.15.1" +version = "0.15.2" dependencies = [ "designcraft-doc", "quick-xml 0.41.0", @@ -1595,7 +1595,7 @@ dependencies = [ [[package]] name = "designcraft-tools" -version = "0.15.1" +version = "0.15.2" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1607,7 +1607,7 @@ dependencies = [ [[package]] name = "designcraft-ui-egui" -version = "0.15.1" +version = "0.15.2" dependencies = [ "craft-dialogs", "craft-keymap", @@ -1639,7 +1639,7 @@ dependencies = [ [[package]] name = "designcraft-web" -version = "0.15.1" +version = "0.15.2" dependencies = [ "designcraft-engine", "designcraft-format", @@ -7603,7 +7603,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.15.1" +version = "0.15.2" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 0e21b3140..0c692c191 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"] [workspace.package] -version = "0.15.1" +version = "0.15.2" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 06d29fe3a63f4447879dc4928f85bcf87a5aec36 Mon Sep 17 00:00:00 2001 From: texjer Date: Fri, 30 Oct 2026 12:24:07 -0400 Subject: [PATCH 429/431] Parents: single-sided pages in a spread show the parent page in their place --- ROADMAP.md | 1 + crates/doc/src/build.rs | 13 ++++++++++--- crates/doc/src/page.rs | 10 ++++++++++ crates/idml/src/import/spread.rs | 6 +++--- crates/idml/src/tests/facing_ltr.rs | 23 ++++++++++++++++++++++- 5 files changed, 46 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index dfc46b121..a2f19c725 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -388,6 +388,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-30** · Without facing pages, a page of a multi-page spread shows the parent page in its place, as in InDesign: the second page of a two-page spread shows the parent's second page. Every single-sided page showed the parent's first page, so an InDesign book set up without facing pages but laid out in two-page spreads repeated the left parent page's items (a black heading bar) on its right pages and lost the right parent page's. No readiness percentage change. - **2026-10-30** · File > Package opens a dialog, as the reference app's: a Summary of fonts, links and images and inks, pages for each, a Printing Instructions step, and a PDF made with the options of a preset (#1514, #1515). - **2026-10-30** · IDML import reads TOC separators and entry styles written with the `$ID/` prefix or as `Properties` children, and the TOC separator notation (`^t`, `^y`, `^m`, `^>`) is shared by the dialog and IDML, with a special-character menu on the separator field (#1249, #1255). - **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. diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index c7259f317..1789092ff 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -661,11 +661,18 @@ impl Document { } /// Parent page (spread index, page index) applied to `page`: the page on its side of its - /// parent spread. + /// parent spread, or in its place for single-sided pages ([`Spread::parent_page_at`]). pub fn parent_page_of(&self, page: &Page) -> Option<(usize, usize)> { let pi = self.parent_index(page.parent?)?; // A parent spread can come without pages from a file. - Some((pi, self.parents.get(pi)?.parent_page_for_side(page.side)?)) + let parent = self.parents.get(pi)?; + // Only a single-sided page's position matters: look for it only then. + let index = if page.side == PageSide::Single && parent.pages.len() >= 2 { + self.spreads.iter().find_map(|s| s.pages.iter().position(|p| p.id == page.id)).unwrap_or(0) + } else { + 0 + }; + Some((pi, parent.parent_page_at(page.side, index)?)) } /// The parent page applied to `page`. @@ -777,7 +784,7 @@ impl Document { c } }; - chain.into_iter().filter_map(|i| Some((i, self.parents.get(i)?.parent_page_for_side(pg.side)?))).collect() + chain.into_iter().filter_map(|i| Some((i, self.parents.get(i)?.parent_page_at(pg.side, page)?))).collect() } /// Whether basing parent `pi` on `base` would make a `based_on` cycle (or names no parent). diff --git a/crates/doc/src/page.rs b/crates/doc/src/page.rs index 7041f7ed8..caf1c102e 100644 --- a/crates/doc/src/page.rs +++ b/crates/doc/src/page.rs @@ -452,6 +452,16 @@ impl Spread { PageSide::Single => Some(0), } } + /// The parent page shown behind page `index` of a document spread, on `side`: by side for + /// facing pages ([`Self::parent_page_for_side`]); by position for single-sided pages in a + /// spread of several, as InDesign does, so the second page of a two-page spread shows the + /// parent's second page (the last one, for spreads longer than the parent). + pub fn parent_page_at(&self, side: PageSide, index: usize) -> Option { + match side { + PageSide::Single if self.pages.len() >= 2 => Some(index.min(self.pages.len() - 1)), + _ => self.parent_page_for_side(side), + } + } /// Index of the page under spread x (nearest page for the pasteboard). pub fn page_at_x(&self, x: f64) -> Option { if self.pages.is_empty() { diff --git a/crates/idml/src/import/spread.rs b/crates/idml/src/import/spread.rs index e2caa6b18..ca9a3d746 100644 --- a/crates/idml/src/import/spread.rs +++ b/crates/idml/src/import/spread.rs @@ -70,7 +70,7 @@ impl<'r> Importer<'r> { } /// A page's margins and columns, and whether they are local (differ from its parent page's). - fn page_margins(&self, p: &El, r: &Rect, master: bool, parent: Option, side: PageSide) -> (Margins, Columns, bool) { + fn page_margins(&self, p: &El, r: &Rect, master: bool, parent: Option, (side, index): (PageSide, usize)) -> (Margins, Columns, bool) { // A document page's margins are local when they differ from its parent page's; a // page without its own takes the parent page's. let from_parent = if master { @@ -78,7 +78,7 @@ impl<'r> Importer<'r> { } else { parent .and_then(|id| self.parents.iter().find(|sp| sp.id == id)) - .and_then(|sp| sp.pages.get(sp.parent_page_for_side(side)?)) + .and_then(|sp| sp.pages.get(sp.parent_page_at(side, index)?)) .map(|q| (q.margins, q.columns.clone())) }; let (margins, columns) = match (p.find("MarginPreference"), &from_parent) { @@ -126,7 +126,7 @@ impl<'r> Importer<'r> { PageSide::Right }; let parent = p.get("AppliedMaster").and_then(|m| self.parent_ids.get(m)).copied(); - let (margins, columns, local_margins) = self.page_margins(p, r, master, parent, side); + let (margins, columns, local_margins) = self.page_margins(p, r, master, parent, (side, i)); out_pages.push(Page { id: pid, width: r.width(), diff --git a/crates/idml/src/tests/facing_ltr.rs b/crates/idml/src/tests/facing_ltr.rs index b1c322ecc..45d1dc065 100644 --- a/crates/idml/src/tests/facing_ltr.rs +++ b/crates/idml/src/tests/facing_ltr.rs @@ -27,6 +27,11 @@ fn story(id: &str, text: &str) -> String { /// right page a guide at 300 and a "Right head". Three pages: 1 alone, then 2–3. Rulers measure /// from each page. fn left_to_right_fixture() -> Vec { + fixture(true) +} + +/// [`left_to_right_fixture`], with facing pages on or off. +fn fixture(facing: bool) -> Vec { let margins = r#""#; let page = |id: &str, x: f64, applied: bool, guide: Option| { let applied = if applied { r#" AppliedMaster="m1""# } else { "" }; @@ -48,7 +53,7 @@ fn left_to_right_fixture() -> Vec { r#""# ); let prefs = format!( - r#""# + r#""# ); zip_files(&[ ("designmap.xml", &map), @@ -109,3 +114,19 @@ fn left_to_right_right_pages_take_mirrored_margins_and_the_right_parent_page() { let again = import_idml(&export_idml(&d)).unwrap(); assert_right_pages_mirror_left_pages(&again); } + +/// Without facing pages, a page of a two-page spread shows the parent page in its place, as in +/// InDesign: page 3, the second page of its spread, shows the parent's second page, not the +/// first page's items again. +#[test] +fn single_sided_pages_show_the_parent_page_in_their_place() { + let d = import_idml(&fixture(false)).unwrap(); + assert!(d.spreads.iter().flat_map(|s| &s.pages).all(|p| p.side == PageSide::Single)); + assert_eq!(shown_heads(&d, 0), ["Left head"], "a page alone shows the parent's first page"); + assert_eq!(shown_heads(&d, 1), ["Left head"]); + assert_eq!(shown_heads(&d, 2), ["Right head"]); + // What the renderer draws behind each page of the second spread. + let (ppi, _) = d.parent_page_for(1).unwrap(); + assert_eq!(d.shown_parent_pages(designcraft_doc::SpreadRef::Doc(1), 0), [(ppi, 0)]); + assert_eq!(d.shown_parent_pages(designcraft_doc::SpreadRef::Doc(1), 1), [(ppi, 1)]); +} From dbdf034cb0e7163e3b18d9616d0c5858708d221d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 31 Oct 2026 00:49:25 -0400 Subject: [PATCH 430/431] Toolbar: merge the flyout click comments into one Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/toolbar.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index de06cc26c..cf16fdcb3 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -104,12 +104,11 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { 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). - // Prefer the primary-release event's modifiers: Alt may already be up - // by the time this frame reads i.modifiers. - // 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. + // group, as the letter-key cycle does. Read the modifiers from the + // primary-release event (Alt may already be up by the time this frame + // reads i.modifiers), preferring a release on this slot; egui still + // counts a click when the pointer drifts a little outside, so a release + // whose press started here counts too, and one pressed elsewhere doesn't. let click_mods = ui.input(|i| { let press_here = i.pointer.press_origin().is_some_and(|o| resp.rect.contains(o)); i.events From 61687039eceb45a854afa885b304189325e73787 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 31 Oct 2026 00:51:03 -0400 Subject: [PATCH 431/431] Toolbar: rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/toolbar.rs | 4 +--- crates/ui-egui/src/toolbar/tests.rs | 14 ++------------ 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index cf16fdcb3..ec5b6f67b 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -152,9 +152,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { app.ui.dialog = Some(crate::dialogs::Dialog::new("polygonSettings", cur)); } // Double-click a transform tool: its Object › Transform dialog. - if plain_double - && let Some(cmd) = transform_dialog_of(shown.id) - { + if plain_double && let Some(cmd) = transform_dialog_of(shown.id) { crate::menus::activate(app, cmd, &Value::Null); } if slot_click { diff --git a/crates/ui-egui/src/toolbar/tests.rs b/crates/ui-egui/src/toolbar/tests.rs index 899f4855b..7a0948817 100644 --- a/crates/ui-egui/src/toolbar/tests.rs +++ b/crates/ui-egui/src/toolbar/tests.rs @@ -397,19 +397,9 @@ fn alt_click_cycles_the_tool_flyout_group() { 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)); - h.input_mut().events.push(Event::PointerButton { - pos: at, - button: PointerButton::Primary, - pressed: true, - modifiers: press_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.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: press_modifiers }); h.run_steps(3); }; // Break the double-click window between independent clicks (~0.4 s at 1 ms/step).