From 6f2ddfbd7c79bdacfb76661b2cdb5185c027c932 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 9a0f71e70..7f4b1f727 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-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-10 (UI.4–UI.5):** Panel tabs can move into other groups, split the dock, float, resize and return to their saved locations through shared docking transactions. Layouts persist in custom workspaces; UI and control commands share the same validated action path. Shared pane geometry and widget-state theme application retain all five palettes and local fonts. Split panes send keyboard input only to the focused canvas and scope contextual toolbar IDs per pane. No readiness percentage change. - **2026-10-10** · Version 0.6.0; releases add a Linux riscv64 tar.gz (cross-compiled, CLI smoke-tested under QEMU). diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index e5e8a8d08..f559a7775 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2810,6 +2810,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 c91afd6ba..f4b0bc0b8 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1492,4 +1492,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 60bf43b9e842ca1e8477c5e0b9b5129a281f13c1 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 | 63 +++++++++++++++++++++++-- crates/ui-egui/src/panels/properties.rs | 31 ++++++++++-- 2 files changed, 86 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 17c44c27a..b9847432b 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -160,11 +160,31 @@ pub fn specs() -> Vec { ) } ), - cmd!("object.fill", "Fill", [], None, "{swatch, tint?: 0..1, ids?}", has_selection, |s, p| { - let sw = str_param(p, "swatch").ok_or_else(|| bad("object.fill", "missing swatch"))?.to_string(); - 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?, 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")); + } + 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", @@ -174,10 +194,15 @@ pub fn specs() -> Vec { has_selection, |s, p| { 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(); @@ -2714,3 +2739,31 @@ mod blend_mode_tests { assert_eq!(it.opacity, 1.0); } } + +#[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());