From a1c12e7d2e874ac670f83fa19751d6ea1b04f4eb Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sun, 11 Oct 2026 20:21:51 +0200 Subject: [PATCH 1/2] feat(ui): Tint and Apply To in the Properties Fill and Stroke menus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InDesign's Fill and Stroke menus in Properties › Appearance have a Tint field and, for a text frame, Apply To: Frame or Text. DesignCraft's listed only the swatches, so a tint needed the Swatches panel and a text frame's text colour needed a text selection. The menus now have both: the tint applies to the fill or stroke of the frame (object.fill / object.stroke) or of its text (type.char fillTint / strokeTint); Apply To: Text colours the whole story. With text selected the menu applies to the selected text, with its tint. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/i18n.rs | 2 + crates/ui-egui/src/i18n/it.rs | 2 + crates/ui-egui/src/panels/properties.rs | 159 +++++++++++++++++++++--- 4 files changed, 148 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c76..a8a5518e6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Newest first. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. +- **2026-10-11** · Properties › Appearance's Fill and Stroke menus gain InDesign's Tint field and, for a text frame, Apply To: Frame / Text. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5976d7d07..4fd31f58a 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2811,6 +2811,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Paragraph Rules", ["Absatzlinien", "Filets de paragraphe", "Filetes de párrafo", "段落境界線", "段落线"]), ("Rule Below", ["Linie unterhalb", "Filet après", "Filete después", "後境界線", "下方横线"]), ("Tint:", ["Farbton:", "Teinte:", "Matiz:", "濃淡:", "色调:"]), + ("Apply To:", ["Anwenden auf:", "Appliquer à :", "Aplicar a:", "適用先:", "应用于:"]), + ("Frame", ["Rahmen", "Bloc", "Marco", "フレーム", "框架"]), ("Column", ["Spalte", "Colonne", "Columna", "段", "栏"]), ]; diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 34766cf22..69fe3873e 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1493,4 +1493,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paragraph Rules", "Filetti paragrafo"), ("Rule Below", "Filetto sotto"), ("Tint:", "Tinta:"), + ("Apply To:", "Applica a:"), + ("Frame", "Cornice"), ]; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 49cb920f2..55263ecb5 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -574,9 +574,7 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); let resp = ui.interact(chip, ui.id().with("fillchip"), Sense::click()).on_hover_text(format!("Fill: {}", i.fill)); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Fill swatch")); - swatch_menu(app, &resp, &doc, &i.fill, |app, n| { - let _ = app.run("object.fill", json!({"swatch": n})); - }); + swatch_menu(app, &resp, &doc, &i.fill, false, i.is_text && i.count == 1, false); crate::rtl::paint( ui.painter(), b.min + vec2(34.0, FIELD_H / 2.0), @@ -594,9 +592,7 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); let resp = ui.interact(chip, ui.id().with("strokechip"), Sense::click()).on_hover_text(format!("Stroke: {}", i.stroke)); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Stroke swatch")); - swatch_menu(app, &resp, &doc, &i.stroke, |app, n| { - let _ = app.run("object.stroke", json!({"swatch": n})); - }); + swatch_menu(app, &resp, &doc, &i.stroke, true, i.is_text && i.count == 1, 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() { app.ui.open_panel = Some("stroke".into()); } @@ -665,23 +661,91 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { }); } -/// Swatch list popup attached to `resp`. -fn swatch_menu(app: &mut DesignApp, resp: &egui::Response, doc: &designcraft_doc::Document, cur: &str, on_pick: impl FnOnce(&mut DesignApp, String)) { +/// 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. +fn swatch_menu( + app: &mut DesignApp, + resp: &egui::Response, + doc: &designcraft_doc::Document, + cur: &str, + stroke: bool, + text_frame: bool, + text_only: bool, +) { + let lang = app.ui.language.clone(); + let tr = |s: &'static str| crate::i18n::tr(&lang, s); + let to_text_id = egui::Id::new(("swatch_apply_to_text", stroke)); + let to_text = text_only || text_frame && resp.ctx.data(|d| d.get_temp::(to_text_id)).unwrap_or(false); + // What the popup shows and changes: the frame's fill or stroke, or its text's. + let (key, tint_key) = if stroke { ("stroke", "strokeTint") } else { ("fill", "fillTint") }; + let (cur, tint) = if to_text { + let chars = super::text_attrs(app).map(|a| a["chars"].clone()).unwrap_or_default(); + (chars[key].as_str().unwrap_or("").to_string(), chars[tint_key].as_f64().unwrap_or(1.0)) + } else { + (cur.to_string(), f64::from(super::sel_tint(app, stroke))) + }; let mut picked = None; - egui::Popup::menu(resp).show(|ui| { - ui.set_min_width(200.0); + let mut new_tint = None; + let mut switch = None; + egui::Popup::menu(resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + ui.set_min_width(220.0); ui.set_max_width(280.0); + if text_frame { + ui.horizontal(|ui| { + crate::rtl::label(ui, tr("Apply To:")); + for (label, text) in [("Frame", false), ("Text", true)] { + if ui.selectable_label(to_text == text, crate::rtl::widget(ui, tr(label))).clicked() { + switch = Some(text); + } + } + }); + } + ui.horizontal(|ui| { + crate::rtl::label(ui, tr("Tint:")); + if let Some(v) = NumField::number(if stroke { "stroke_tint" } else { "fill_tint" }, Some((tint * 100.0).round()), "%", 0) + .width(60.0) + .range(0.0, 100.0) + .show(ui) + { + new_tint = Some(v / 100.0); + } + }); + ui.separator(); egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { for sw in &doc.swatches { - if super::swatch_menu_row(ui, doc, &sw.name, cur) { + if super::swatch_menu_row(ui, doc, &sw.name, &cur) { picked = Some(sw.name.clone()); ui.close(); } } }); }); - if let Some(p) = picked { - on_pick(app, p); + if let Some(t) = switch { + resp.ctx.data_mut(|d| d.insert_temp(to_text_id, t)); + } + let cmd = |app: &mut DesignApp, attrs: Value| { + if to_text { + let _ = app.run("type.char", json!({ "attrs": attrs })); + } else { + let mut p = attrs.as_object().cloned().unwrap_or_default(); + // object.fill / object.stroke name the swatch `swatch` and the tint `tint`. + if let Some(v) = p.remove(key) { + p.insert("swatch".into(), v); + } + if let Some(v) = p.remove(tint_key) { + p.insert("tint".into(), v); + } + if !stroke && !p.contains_key("swatch") { + p.insert("swatch".into(), json!(cur)); + } + let _ = app.run(if stroke { "object.stroke" } else { "object.fill" }, Value::Object(p)); + } + }; + if let Some(n) = picked { + cmd(app, json!({ key: n })); + } else if let Some(t) = new_tint { + cmd(app, json!({ tint_key: t })); } } @@ -948,9 +1012,7 @@ 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}")); - swatch_menu(app, &resp, &doc, &sw, |app, n| { - let _ = app.run("type.char", json!({"attrs": {key: n}})); - }); + swatch_menu(app, &resp, &doc, &sw, k != 0, false, true); if k == 0 { crate::rtl::paint( ui.painter(), @@ -2207,4 +2269,69 @@ mod tests { let after = h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().fill.swatch.clone(); assert_eq!(after, name, "clicking the swatch chip applies it"); } + + 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| { + 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 + } + + const CYAN: &str = "C=100 M=0 Y=0 K=0"; + + #[test] + fn the_fill_menu_sets_a_tint() { + 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]})).unwrap()["id"].as_u64().unwrap(); + app.run("object.fill", json!({"swatch": CYAN, "ids": [id]})).unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let mut h = panel(app); + h.get_by_label("Fill swatch").click(); + h.run_steps(3); + // A frame that isn't a text frame: no Apply To. + assert!(h.query_by_label("Apply To:").is_none()); + // The field on the Tint row (Opacity also reads 100%). + let row = h.get_by_label("Tint:").rect(); + h.query_all_by(|n| n.role() == egui::accesskit::Role::TextInput && n.value().as_deref() == Some("100%")) + .find(|n| (n.rect().center().y - row.center().y).abs() < 8.0) + .expect("the Tint field") + .click(); + 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("25".into())); + h.key_press(egui::Key::Enter); + h.run_steps(3); + let fill = h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().fill.clone(); + assert_eq!((fill.swatch.as_str(), fill.tint), (CYAN, 0.25), "the tint, on the same swatch"); + } + + #[test] + fn apply_to_text_colours_a_text_frames_text_not_the_frame() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let r = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Heading"})).unwrap(); + let (id, story) = (r["id"].as_u64().unwrap(), designcraft_doc::StoryId(r["story"].as_u64().unwrap())); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let mut h = panel(app); + h.get_by_label("Fill swatch").click(); + h.run_steps(3); + 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; + assert_eq!(doc.story(story).unwrap().char_format_at(0).over.fill.as_deref(), Some(CYAN), "the text is cyan"); + assert_eq!(doc.item(designcraft_doc::ids::ItemId(id)).unwrap().fill.swatch, "[None]", "the frame isn't"); + } } From 2c708ce297f67dbd61277c174556e8a9774dcbe0 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 00:06:18 +0200 Subject: [PATCH 2/2] fix(ui): A tint keeps each object's swatch, and no stroke stays none Changing the Tint in the Fill menu sent the first object's swatch with it, so with several objects selected every fill became that swatch; and a Stroke tint on a frame without a stroke gave it the default 1 pt black stroke. object.fill now takes a tint alone and keeps each object's swatch, and object.stroke with only a tint leaves a [None] stroke alone; the menu sends just the tint. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/object.rs | 67 ++++++++++++++++++++++--- crates/ui-egui/src/panels/properties.rs | 31 ++++++++++-- 2 files changed, 89 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index cd13be117..46458c518 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -179,12 +179,34 @@ pub fn specs() -> Vec { ) } ), - cmd!("object.fill", "Fill", [], None, "{swatch (a swatch's name, e.g. [Paper]), tint?: 0..1, ids?}", has_selection, |s, p| { - let sw = str_param(p, "swatch").ok_or_else(|| bad("object.fill", "missing swatch"))?.to_string(); - known_swatch(&s.doc()?.doc, &sw, "object.fill", "swatch")?; - let tint = f64_or(p, "tint", 1.0) as f32; - set_flag(s, p, move |i| i.fill = Fill { swatch: sw.clone(), tint, ..i.fill.clone() }, false) - }), + cmd!( + "object.fill", + "Fill", + [], + None, + "{swatch? (a swatch's name, e.g. [Paper]), tint?: 0..1, ids?} — a swatch (tint 100% unless given), or a tint alone that keeps each object's swatch", + has_selection, + |s, p| { + let sw = str_param(p, "swatch").map(str::to_string); + let tint = p.get("tint").and_then(Value::as_f64).filter(|t| t.is_finite()).map(|t| t.clamp(0.0, 1.0) as f32); + if sw.is_none() && tint.is_none() { + return Err(bad("object.fill", "missing swatch")); + } + if let Some(name) = &sw { + known_swatch(&s.doc()?.doc, name, "object.fill", "swatch")?; + } + set_flag( + s, + p, + move |i| { + let swatch = sw.clone().unwrap_or_else(|| i.fill.swatch.clone()); + let tint = tint.unwrap_or(if sw.is_some() { 1.0 } else { i.fill.tint }); + i.fill = Fill { swatch, tint, ..i.fill.clone() }; + }, + false, + ) + } + ), cmd!( "object.stroke", "Stroke", @@ -199,10 +221,15 @@ pub fn specs() -> Vec { } } let p2 = p.clone(); + // A tint alone changes no stroke into one (it would become the default 1 pt black). + let tint_only = p.as_object().is_some_and(|o| o.keys().all(|k| k == "tint" || k == "ids")); set_flag( s, p, move |i| { + if tint_only && i.stroke.is_none() { + return; + } let mut st = if i.stroke.is_none() && p2.get("swatch").is_none() { Stroke::default() } else { i.stroke.clone() }; if let Some(v) = p2.get("swatch").and_then(Value::as_str) { st.swatch = v.into(); @@ -2813,3 +2840,31 @@ mod swatch_name_tests { } } } + +#[cfg(test)] +mod tint_alone_tests { + use serde_json::json; + + #[test] + fn a_tint_alone_keeps_each_swatch_and_leaves_no_stroke_alone() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [60, 0, 110, 50]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [a]})).unwrap(); + s.execute("object.fill", &json!({"swatch": "C=100 M=0 Y=0 K=0", "ids": [b]})).unwrap(); + s.execute("object.stroke", &json!({"swatch": "[None]", "ids": [a, b]})).unwrap(); + // Fill tint on both: each keeps its swatch. + s.execute("object.fill", &json!({"tint": 0.25, "ids": [a, b]})).unwrap(); + let item = |s: &crate::Session, id: u64| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().clone(); + assert_eq!((item(&s, a).fill.swatch.as_str(), item(&s, a).fill.tint), ("[Black]", 0.25)); + assert_eq!((item(&s, b).fill.swatch.as_str(), item(&s, b).fill.tint), ("C=100 M=0 Y=0 K=0", 0.25)); + // A swatch alone is 100%. + s.execute("object.fill", &json!({"swatch": "[Paper]", "ids": [a]})).unwrap(); + assert_eq!(item(&s, a).fill.tint, 1.0); + // A stroke tint on no stroke: still no stroke. + s.execute("object.stroke", &json!({"tint": 0.5, "ids": [a]})).unwrap(); + assert!(item(&s, a).stroke.is_none(), "{:?}", item(&s, a).stroke); + assert!(s.execute("object.fill", &json!({"ids": [a]})).is_err(), "neither swatch nor tint"); + } +} diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 55263ecb5..81d37599b 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -736,9 +736,6 @@ fn swatch_menu( if let Some(v) = p.remove(tint_key) { p.insert("tint".into(), v); } - if !stroke && !p.contains_key("swatch") { - p.insert("swatch".into(), json!(cur)); - } let _ = app.run(if stroke { "object.stroke" } else { "object.fill" }, Value::Object(p)); } }; @@ -2316,6 +2313,34 @@ mod tests { assert_eq!((fill.swatch.as_str(), fill.tint), (CYAN, 0.25), "the tint, on the same swatch"); } + #[test] + fn a_tint_on_several_objects_keeps_each_ones_swatch() { + 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": [220, 72, 300, 200]})).unwrap()["id"].as_u64().unwrap(); + app.run("object.fill", json!({"swatch": CYAN, "ids": [a]})).unwrap(); + app.run("object.fill", json!({"swatch": "[Black]", "ids": [b]})).unwrap(); + app.run("selection.set", json!({"ids": [a, b]})).unwrap(); + let mut h = panel(app); + h.get_by_label("Fill swatch").click(); + h.run_steps(3); + let row = h.get_by_label("Tint:").rect(); + h.query_all_by(|n| n.role() == egui::accesskit::Role::TextInput && n.value().as_deref() == Some("100%")) + .find(|n| (n.rect().center().y - row.center().y).abs() < 8.0) + .expect("the Tint field") + .click(); + 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("25".into())); + h.key_press(egui::Key::Enter); + h.run_steps(3); + let doc = &h.state().session.active().unwrap().doc; + let fill = |id: u64| doc.item(designcraft_doc::ids::ItemId(id)).unwrap().fill.clone(); + assert_eq!((fill(a).swatch.as_str(), fill(a).tint), (CYAN, 0.25)); + assert_eq!((fill(b).swatch.as_str(), fill(b).tint), ("[Black]", 0.25), "the second keeps its own swatch"); + } + #[test] fn apply_to_text_colours_a_text_frames_text_not_the_frame() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());