mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-26 03:06:56 -06:00
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:
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", "最近使用", "最近"]),
|
||||
|
||||
@@ -1679,4 +1679,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Outside:", "Esterno:"),
|
||||
("Bleed:", "Pagina al vivo:"),
|
||||
("Slug:", "Indicazioni:"),
|
||||
("Apply To:", "Applica a:"),
|
||||
("Frame", "Cornice"),
|
||||
];
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user