Merge #525: Tint and Apply To in the Properties Fill and Stroke menus

Conflict: ROADMAP.md: both progress-log lines
Conflict: crates/engine/src/cmd/object.rs: object.fill takes the PR's swatch?/tint? form with next's known_swatch check (#520) on a given swatch; params doc names the swatch format; both test modules
Conflict: crates/ui-egui/src/i18n/it.rs: both sets of rows
Conflict: crates/ui-egui/src/panels/properties.rs: swatch_menu keeps the PR's popup (Apply To, Tint) with next's swatch_list for the swatches (its own width); tests keep next's Pathfinder test and the PR's three
This commit is contained in:
aiongg
2026-10-12 00:15:19 +00:00
5 changed files with 216 additions and 21 deletions
+1
View File
@@ -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-11** · `story.replaceRange`, `story.setText`, notes, footnotes, endnotes, type on a path and table cells read `\r` and `\r\n` as paragraph returns, as `frame.create` and `text.insert` already did. No readiness percentage change.
- **2026-10-11** · Corner Options keep curved sides curved and round every corner point, including where an arc meets a line (a Pathfinder quarter circle with rounded corners stayed a triangle). No readiness percentage change.
- **2026-10-11:** Type › Justification… (Cmd+Alt+Shift+J, also in the Paragraph panel menu) sets word and letter spacing, glyph scaling and the composer on the selected paragraphs, sharing the Paragraph Style Options section (#531). No readiness percentage change.
+61 -6
View File
@@ -180,12 +180,34 @@ pub fn specs() -> Vec<CommandSpec> {
)
}
),
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(n) = &sw {
known_swatch(&s.doc()?.doc, n, "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",
@@ -200,10 +222,15 @@ pub fn specs() -> Vec<CommandSpec> {
}
}
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();
@@ -3443,3 +3470,31 @@ mod copy_ids_tests {
assert!(ids(&r).iter().all(|i| s.doc().unwrap().doc.item(designcraft_doc::ItemId(*i)).is_some()));
}
}
#[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");
}
}
+2
View File
@@ -3167,6 +3167,8 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Justification…", ["Blocksatzeinstellungen …", "Justification…", "Justificación…", "両端揃え…", "两端对齐…"]),
("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", "段", "栏"]),
// File › New › Document
("Recent", ["Zuletzt verwendet", "Récents", "Recientes", "最近使用", "最近"]),
+2
View File
@@ -1679,4 +1679,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Outside:", "Esterno:"),
("Bleed:", "Pagina al vivo:"),
("Slug:", "Indicazioni:"),
("Apply To:", "Applica a:"),
("Frame", "Cornice"),
];
+150 -15
View File
@@ -608,9 +608,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, |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),
@@ -632,9 +630,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, |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());
}
@@ -694,14 +690,79 @@ 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::<bool>(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| {
picked = super::swatch_list(ui, doc, cur);
let mut new_tint = None;
let mut switch = None;
egui::Popup::menu(resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
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();
picked = super::swatch_list(ui, doc, &cur);
});
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);
}
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 }));
}
}
@@ -977,9 +1038,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(),
@@ -2413,4 +2472,80 @@ mod tests {
let r = items[0].bounds();
assert!((r.x0 - 200.0).abs() < 0.5 && (r.y1 - 200.0).abs() < 0.5 && (r.x1 - 300.0).abs() < 0.5, "{r:?}");
}
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 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());
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");
}
}