From 6f2ddfbd7c79bdacfb76661b2cdb5185c027c932 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sun, 11 Oct 2026 20:21:51 +0200 Subject: [PATCH] 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"); + } }