From 83f2f252c7330bc1cef68694600b54761611b63a Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 22:08:23 +0200 Subject: [PATCH 1/6] 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 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 2/6] 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 5ab5f6aae6c84a56ee543ae4fcf2292193d97b71 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sun, 25 Oct 2026 17:21:05 +0100 Subject: [PATCH 3/6] 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 --- 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 { "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 { 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; 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 { 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 b0c1bc47479a8582163a50c7f693c82aaf23ced5 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:26:17 +0100 Subject: [PATCH 4/6] Tests: the branch's dialog tests beside main's dialog test helpers (dialogs/tests.rs) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 113 ---------------------------- crates/ui-egui/src/dialogs/tests.rs | 113 ++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 113 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1eccf0ac3..4704cc870 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -186,119 +186,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - #[test] - fn corner_options_dialog_sets_each_corner_with_preview_and_one_undo_step() { - use designcraft_geom::corners::CornerShape; - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let id = app.run("frame.create", json!({"rect": [72, 72, 360, 360]})).unwrap()["id"].as_u64().unwrap(); - app.run("selection.set", json!({"ids": [id]})).unwrap(); - let corners = |app: &DesignApp| app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().corners.corners; - let undo = |app: &DesignApp| app.session.doc().unwrap().history.undo.len(); - let set = |app: &mut DesignApp, k: &str, v: Value| { - app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); - }; - let u0 = undo(&app); - let ctx = dialog_context("en"); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); - // Object › Corner Options… opens InDesign's dialog, not a form of the command's params. - crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); - assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some(CORNER_OPTIONS)); - dialog_frame(&mut app, &ctx, screen, vec![]); - let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - for expected in ["Corner Options", "Corner Size and Shape:", "Preview", "OK", "Cancel"] { - visible_label(&labels, screen, expected); - } - assert!(!labels.iter().any(|(t, _, _)| t.trim() == "Ids"), "no raw command fields"); - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!((d.s("size1"), d.s("shape1"), d.b("link")), ("0p0".into(), "none".into(), true), "seeded from the frame, linked"); - // Linked: a corner changed is all four. - set(&mut app, "size0", json!("1p0")); - dialog_frame(&mut app, &ctx, screen, vec![]); - assert_eq!(app.ui.dialog.as_ref().unwrap().s("size2"), "1p0"); - // Unlinked, only the top-right corner rounds, as in the tutorial. - set(&mut app, "link", json!(false)); - for k in 0..4 { - set(&mut app, &format!("size{k}"), json!("0")); - } - set(&mut app, "size1", json!("3 cm")); - set(&mut app, "shape1", json!("rounded")); - set(&mut app, "preview", json!(true)); - dialog_frame(&mut app, &ctx, screen, vec![]); - assert_eq!(corners(&app)[1].shape, CornerShape::Rounded, "Preview shows it"); - assert_eq!(undo(&app), u0, "without an undo step"); - set(&mut app, "preview", json!(false)); - dialog_frame(&mut app, &ctx, screen, vec![]); - assert_eq!(corners(&app)[1].shape, CornerShape::None, "Preview off puts it back"); - set(&mut app, "preview", json!(true)); - dialog_frame(&mut app, &ctx, screen, vec![]); - confirm(&mut app).unwrap(); - let c = corners(&app); - assert_eq!(c.map(|c| c.shape), [CornerShape::None, CornerShape::Rounded, CornerShape::None, CornerShape::None]); - assert!((c[1].size - 3.0 / 2.54 * 72.0).abs() < 1e-6, "{}", c[1].size); - assert_eq!(undo(&app), u0 + 1, "one undo step"); - // Reopened, it shows the frame's corners, no longer linked. - crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); - dialog_frame(&mut app, &ctx, screen, vec![]); - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!((d.s("shape1"), d.s("shape0"), d.b("link")), ("rounded".into(), "none".into(), false)); - // A size that does not parse keeps the dialog open with the error; so does a shape the - // dialog doesn't offer (rather than another shape taking its place). - set(&mut app, "size0", json!("abc")); - assert!(confirm(&mut app).is_err()); - assert!(app.ui.dialog.is_some()); - set(&mut app, "size0", json!("0")); - set(&mut app, "shape0", json!("spiky")); - assert!(confirm(&mut app).is_err()); - assert!(app.ui.dialog.is_some()); - cancel(&mut app); - assert_eq!(corners(&app), c); - } - - /// Several objects: the dialog shows the first one's corners, and OK sets only the corners - /// edited; the others keep each object's own. A linked edit sets all four. - #[test] - fn corner_options_keep_each_objects_untouched_corners() { - use designcraft_geom::corners::CornerShape; - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let a = app.run("frame.create", json!({"rect": [72, 72, 200, 200]})).unwrap()["id"].as_u64().unwrap(); - let b = app.run("frame.create", json!({"rect": [272, 72, 400, 200]})).unwrap()["id"].as_u64().unwrap(); - app.run("object.cornerOptions", json!({"ids": [b], "shape": "bevel", "size": 12})).unwrap(); - app.run("selection.set", json!({"ids": [a, b]})).unwrap(); - let corners = |app: &DesignApp, id: u64| app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().corners.corners; - let set = |app: &mut DesignApp, k: &str, v: Value| { - app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); - }; - let ctx = dialog_context("en"); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); - let b0 = corners(&app, b); - // Unlinked: only the top-right corner rounds, on both. - crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); - dialog_frame(&mut app, &ctx, screen, vec![]); - set(&mut app, "link", json!(false)); - set(&mut app, "size1", json!("1p0")); - set(&mut app, "shape1", json!("rounded")); - dialog_frame(&mut app, &ctx, screen, vec![]); - confirm(&mut app).unwrap(); - let (ca, cb) = (corners(&app, a), corners(&app, b)); - assert_eq!(ca.map(|c| c.shape), [CornerShape::None, CornerShape::Rounded, CornerShape::None, CornerShape::None]); - assert_eq!(cb.map(|c| c.shape), [CornerShape::Bevel, CornerShape::Rounded, CornerShape::Bevel, CornerShape::Bevel], "b keeps its others"); - assert_eq!((cb[0], cb[2], cb[3]), (b0[0], b0[2], b0[3])); - assert_eq!(ca[1], cb[1]); - // Linked: a corner set to the first object's own value still sets all four, on both. - crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); - dialog_frame(&mut app, &ctx, screen, vec![]); - set(&mut app, "link", json!(true)); - dialog_frame(&mut app, &ctx, screen, vec![]); - set(&mut app, "size0", json!("0p6")); - dialog_frame(&mut app, &ctx, screen, vec![]); - confirm(&mut app).unwrap(); - let (ca, cb) = (corners(&app, a), corners(&app, b)); - assert_eq!(ca, cb, "all four, on both"); - assert!(ca.iter().all(|c| c.shape == CornerShape::None && (c.size - 6.0).abs() < 1e-6), "{ca:?}"); - } - #[test] fn color_theme_cancel_is_read_only_and_confirm_adds_the_preview() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 84cbf3bea..7d91c4533 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3521,3 +3521,116 @@ fn character_style_options_set_the_digits() { assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); assert_eq!(char_style(&app, "Emphasis").unwrap().chars.digits, Some(designcraft_doc::Digits::Farsi)); } + +#[test] +fn corner_options_dialog_sets_each_corner_with_preview_and_one_undo_step() { + use designcraft_geom::corners::CornerShape; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 360, 360]})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let corners = |app: &DesignApp| app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().corners.corners; + let undo = |app: &DesignApp| app.session.doc().unwrap().history.undo.len(); + let set = |app: &mut DesignApp, k: &str, v: Value| { + app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); + }; + let u0 = undo(&app); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); + // Object › Corner Options… opens InDesign's dialog, not a form of the command's params. + crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some(CORNER_OPTIONS)); + dialog_frame(&mut app, &ctx, screen, vec![]); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for expected in ["Corner Options", "Corner Size and Shape:", "Preview", "OK", "Cancel"] { + visible_label(&labels, screen, expected); + } + assert!(!labels.iter().any(|(t, _, _)| t.trim() == "Ids"), "no raw command fields"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.s("size1"), d.s("shape1"), d.b("link")), ("0p0".into(), "none".into(), true), "seeded from the frame, linked"); + // Linked: a corner changed is all four. + set(&mut app, "size0", json!("1p0")); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("size2"), "1p0"); + // Unlinked, only the top-right corner rounds, as in the tutorial. + set(&mut app, "link", json!(false)); + for k in 0..4 { + set(&mut app, &format!("size{k}"), json!("0")); + } + set(&mut app, "size1", json!("3 cm")); + set(&mut app, "shape1", json!("rounded")); + set(&mut app, "preview", json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(corners(&app)[1].shape, CornerShape::Rounded, "Preview shows it"); + assert_eq!(undo(&app), u0, "without an undo step"); + set(&mut app, "preview", json!(false)); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(corners(&app)[1].shape, CornerShape::None, "Preview off puts it back"); + set(&mut app, "preview", json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + confirm(&mut app).unwrap(); + let c = corners(&app); + assert_eq!(c.map(|c| c.shape), [CornerShape::None, CornerShape::Rounded, CornerShape::None, CornerShape::None]); + assert!((c[1].size - 3.0 / 2.54 * 72.0).abs() < 1e-6, "{}", c[1].size); + assert_eq!(undo(&app), u0 + 1, "one undo step"); + // Reopened, it shows the frame's corners, no longer linked. + crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); + dialog_frame(&mut app, &ctx, screen, vec![]); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.s("shape1"), d.s("shape0"), d.b("link")), ("rounded".into(), "none".into(), false)); + // A size that does not parse keeps the dialog open with the error; so does a shape the + // dialog doesn't offer (rather than another shape taking its place). + set(&mut app, "size0", json!("abc")); + assert!(confirm(&mut app).is_err()); + assert!(app.ui.dialog.is_some()); + set(&mut app, "size0", json!("0")); + set(&mut app, "shape0", json!("spiky")); + assert!(confirm(&mut app).is_err()); + assert!(app.ui.dialog.is_some()); + cancel(&mut app); + assert_eq!(corners(&app), c); +} + +/// Several objects: the dialog shows the first one's corners, and OK sets only the corners +/// edited; the others keep each object's own. A linked edit sets all four. +#[test] +fn corner_options_keep_each_objects_untouched_corners() { + use designcraft_geom::corners::CornerShape; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let a = app.run("frame.create", json!({"rect": [72, 72, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let b = app.run("frame.create", json!({"rect": [272, 72, 400, 200]})).unwrap()["id"].as_u64().unwrap(); + app.run("object.cornerOptions", json!({"ids": [b], "shape": "bevel", "size": 12})).unwrap(); + app.run("selection.set", json!({"ids": [a, b]})).unwrap(); + let corners = |app: &DesignApp, id: u64| app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().corners.corners; + let set = |app: &mut DesignApp, k: &str, v: Value| { + app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); + }; + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); + let b0 = corners(&app, b); + // Unlinked: only the top-right corner rounds, on both. + crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); + dialog_frame(&mut app, &ctx, screen, vec![]); + set(&mut app, "link", json!(false)); + set(&mut app, "size1", json!("1p0")); + set(&mut app, "shape1", json!("rounded")); + dialog_frame(&mut app, &ctx, screen, vec![]); + confirm(&mut app).unwrap(); + let (ca, cb) = (corners(&app, a), corners(&app, b)); + assert_eq!(ca.map(|c| c.shape), [CornerShape::None, CornerShape::Rounded, CornerShape::None, CornerShape::None]); + assert_eq!(cb.map(|c| c.shape), [CornerShape::Bevel, CornerShape::Rounded, CornerShape::Bevel, CornerShape::Bevel], "b keeps its others"); + assert_eq!((cb[0], cb[2], cb[3]), (b0[0], b0[2], b0[3])); + assert_eq!(ca[1], cb[1]); + // Linked: a corner set to the first object's own value still sets all four, on both. + crate::menus::activate(&mut app, "object.cornerOptions", &Value::Null); + dialog_frame(&mut app, &ctx, screen, vec![]); + set(&mut app, "link", json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + set(&mut app, "size0", json!("0p6")); + dialog_frame(&mut app, &ctx, screen, vec![]); + confirm(&mut app).unwrap(); + let (ca, cb) = (corners(&app, a), corners(&app, b)); + assert_eq!(ca, cb, "all four, on both"); + assert!(ca.iter().all(|c| c.shape == CornerShape::None && (c.size - 6.0).abs() < 1e-6), "{ca:?}"); +} From f1a0b1eb29927239abe729f086617c1867ecca03 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 18:27:22 +0100 Subject: [PATCH 5/6] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 4704cc870..7adb3f622 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -7244,7 +7244,7 @@ fn corner_options_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) let key = format!("shape{k}"); let shape: CornerShape = d.fields.get(&key).and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); let resp = crate::panels::properties::corner_shape_button(ui, shape, 46.0); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, format!("{} {}", tr(tip), tr(shape.label())))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, format!("{} {}", tr(tip), tr(shape.label())))); egui::Popup::menu(&resp).show(|ui| { ui.set_min_width(150.0); for s in CornerShape::ALL { @@ -7271,7 +7271,7 @@ fn corner_options_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) crate::icons::button(ui, if linked { "link" } else { "link-broken" }, 20.0, linked, &tip) }) .inner; - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &tip)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &tip)); if resp.clicked() { d.fields.insert("link".into(), json!(!linked)); } From d99434b18a68d15260c12fa29e40867a58aa3f96 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 18:27:45 +0100 Subject: [PATCH 6/6] Corner Options: the corner shape button re-exported from main's split Properties module Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index a05eaccc5..888302763 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -31,7 +31,7 @@ use flex::flex_section; pub(crate) use links::run_update_links; pub use links::{info_panel, links_panel, preflight_panel, update_links}; use object::{align_row, appearance_section, guide_section, quick_actions_object, transform_section, wrap_section}; -pub(crate) use object::{corner_type_dropdown, ref_point_proxy, stroke_type_dropdown}; +pub(crate) use object::{corner_shape_button, corner_type_dropdown, ref_point_proxy, stroke_type_dropdown}; use object_panels::pathfinder_row; pub use object_panels::{align_panel, attributes_panel, effects_panel, pathfinder_panel, stroke_panel, wrap_panel}; pub(crate) use text::{LANGUAGES, kerning_control, language_menu};