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"); + } }