mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-30 05:06:48 -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:
co-authored by
Claude Opus 5.5
parent
ca343e8985
commit
6f2ddfbd7c
@@ -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).
|
||||
|
||||
@@ -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", "段", "栏"]),
|
||||
];
|
||||
|
||||
|
||||
@@ -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"),
|
||||
];
|
||||
|
||||
@@ -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