From 076de16622e599e2b88e7559671d7e566ded546e Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 20 Oct 2026 22:34:16 +0200 Subject: [PATCH] Properties: Apply To Frame or Text for several text frames, as InDesign offers Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/panels/properties.rs | 36 +++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..4289a88ce 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-20** · Properties › Appearance: with several text frames selected, the Fill and Stroke popups offer Apply To Frame or Text, as with one frame, so their text can be coloured at once, as in InDesign. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 44acf6e2c..f2ceeeaba 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -777,6 +777,10 @@ pub(crate) fn corner_type_dropdown(app: &mut DesignApp, ui: &mut Ui, shape: Corn fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { let t = Tokens::get(ui.ctx()); let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; + // Every selected object is a text frame: the popups offer Apply To Frame or Text, for one + // frame or several, as InDesign's do. + let text_frames = + i.is_text && app.session.active().is_some_and(|st| st.selection.items.iter().all(|id| doc.item(*id).is_some_and(|it| it.is_text_frame()))); // Fill. let b = block(ui, FIELD_H); let (fc, fg) = widgets::swatch_colors(&doc, &i.fill, 1.0); @@ -789,7 +793,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, false, i.is_text && i.count == 1, false); + swatch_menu(app, &resp, &doc, &i.fill, false, text_frames, false); crate::rtl::paint( ui.painter(), b.min + vec2(34.0, FIELD_H / 2.0), @@ -811,7 +815,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, true, i.is_text && i.count == 1, false); + swatch_menu(app, &resp, &doc, &i.stroke, true, text_frames, 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() { show_panel(app, "stroke"); } @@ -3419,6 +3423,34 @@ mod tests { assert_eq!(doc.item(designcraft_doc::ids::ItemId(id)).unwrap().fill.swatch, "[None]", "the frame isn't"); } + #[test] + fn apply_to_text_colours_the_text_of_several_text_frames() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let mut ids = vec![]; + let mut stories = vec![]; + for x in [72, 250, 430] { + let r = app.run("frame.create", json!({"rect": [x, 72, x + 150, 140], "content": "text", "text": "75%"})).unwrap(); + ids.push(r["id"].as_u64().unwrap()); + stories.push(designcraft_doc::StoryId(r["story"].as_u64().unwrap())); + } + app.run("selection.set", json!({"ids": ids})).unwrap(); + let mut h = panel(app); + h.get_by_label("Fill swatch").click(); + h.run_steps(3); + // Three text frames, as one: Apply To is offered. + assert!(h.query_by_label("Apply To:").is_some()); + 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; + for (id, story) in ids.iter().zip(&stories) { + assert_eq!(doc.story(*story).unwrap().char_format_at(0).over.fill.as_deref(), Some(CYAN), "every frame's text is cyan"); + assert_eq!(doc.item(designcraft_doc::ids::ItemId(*id)).unwrap().fill.swatch, "[None]", "the frames aren't"); + } + } + #[test] fn transform_flips_and_rotates_from_properties() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());