mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-28 20:26:57 -06:00
feat(ui): Tint and Apply To in the Properties Fill and Stroke menus
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 <noreply@anthropic.com>
This commit is contained in:
committed by
paaspaas00
co-authored by
Claude Opus 5.5
parent
d020b4e85d
commit
a1c12e7d2e
@@ -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.
|
||||
|
||||
@@ -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", "段", "栏"]),
|
||||
];
|
||||
|
||||
|
||||
@@ -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"),
|
||||
];
|
||||
|
||||
@@ -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::<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| {
|
||||
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::<bool>(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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user