From 208facfb925e6167566d036769d8631bbbc4eace Mon Sep 17 00:00:00 2001 From: Joseph Anglada Date: Wed, 4 Nov 2026 20:47:56 -0400 Subject: [PATCH] SmartArt: custom colours per item from the Text Pane (#3123) Co-Authored-By: Claude Sonnet 5.5 --- crates/doc/src/smart_art.rs | 36 ++++- crates/docx/src/smart_art.rs | 102 +++++++++++--- crates/engine/src/cmd/smart_art.rs | 38 ++++- crates/engine/src/tests.rs | 42 ++++++ .../ui-egui/src/dialogs/dialogs_smart_art.rs | 131 +++++++++++++++++- crates/ui-egui/src/ribbon/charts.rs | 10 +- 6 files changed, 327 insertions(+), 32 deletions(-) diff --git a/crates/doc/src/smart_art.rs b/crates/doc/src/smart_art.rs index 16ed949d06..51e7609626 100644 --- a/crates/doc/src/smart_art.rs +++ b/crates/doc/src/smart_art.rs @@ -12,6 +12,8 @@ use serde::{Deserialize, Serialize}; +use crate::props::Rgb; + /// Most items in a SmartArt graphic. pub const MAX_ITEMS: usize = 100; /// Longest item text, characters. @@ -198,11 +200,14 @@ pub struct SmartArtItem { pub text: String, #[serde(default)] pub level: u8, + /// Fill of the item's shape in place of the style's colour. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub color: Option, } impl SmartArtItem { pub fn new(text: impl Into, level: u8) -> SmartArtItem { - SmartArtItem { text: text.into(), level } + SmartArtItem { text: text.into(), level, color: None } } } @@ -257,6 +262,35 @@ impl SmartArtSpec { } } + /// Whether any item has its own colour. + pub fn has_custom_colors(&self) -> bool { + self.items.iter().any(|it| it.color.is_some()) + } + + /// Give every item the style's colour again. + pub fn clear_custom_colors(&mut self) { + for it in &mut self.items { + it.color = None; + } + } + + /// The theme accent (1–6) the style gives item `i`; `None` when the item has no shape of + /// its own (it is text inside another item's shape). + pub fn style_accent(&self, i: usize) -> Option { + let it = self.items.get(i)?; + let tops = || self.items.iter().take(i).filter(|o| o.level == 0).count(); + match self.layout { + SmartArtLayout::Hierarchy => Some(self.colors.accent(i)), + SmartArtLayout::Radial if i == 0 => Some(self.colors.accent(0)), + SmartArtLayout::Radial => { + let parents = self.parents(); + let spoke = |j: usize| j > 0 && matches!(parents.get(j), Some(Some(0)) | Some(None)); + spoke(i).then(|| self.colors.accent((1..i).filter(|j| spoke(*j)).count() + 1)) + } + _ => (it.level == 0).then(|| self.colors.accent(tops())), + } + } + /// The graphic after `sanitize`. pub fn sanitized(mut self) -> SmartArtSpec { self.sanitize(); diff --git a/crates/docx/src/smart_art.rs b/crates/docx/src/smart_art.rs index 9378192a87..eedf6f459d 100644 --- a/crates/docx/src/smart_art.rs +++ b/crates/docx/src/smart_art.rs @@ -86,6 +86,8 @@ enum Paint { None, /// Theme accent `n` (1–6), lightened to `tint` (thousandths of a percent kept) when set. Accent(usize, Option), + /// A colour of its own, lightened to `tint` when set. + Rgb(Rgb, Option), /// The theme's light background colour. Light, } @@ -150,6 +152,22 @@ impl Sh { } } + /// Accent fills and outlines in item `i`'s own colour, when it has one. Light outlines + /// keep the theme's background colour so adjacent nodes remain separated. + fn recolor(&mut self, spec: &SmartArtSpec, i: usize) { + let Some(c) = spec.items.get(i).and_then(|it| it.color) else { return }; + for p in [&mut self.fill, &mut self.line] { + if let Paint::Accent(_, tint) = *p { + *p = Paint::Rgb(c, tint); + } + } + } + + fn item(mut self, spec: &SmartArtSpec, i: usize) -> Sh { + self.recolor(spec, i); + self + } + fn with_text(mut self, text: Vec<(String, bool)>) -> Sh { self.text = text; self @@ -226,7 +244,7 @@ fn basic_list(spec: &SmartArtSpec, w: f32, h: f32) -> Vec { let in_row = if r + 1 == rows { n - r * cols } else { cols }; let x0 = (w - (in_row as f32 * bw + (in_row - 1) as f32 * gx)) / 2.0; let b = [x0 + c as f32 * (bw + gx), y0 + r as f32 * (bh + gy), bw, bh]; - out.push(Sh::node("rect", b, spec.colors.accent(k)).with_text(paras(spec, i, true))); + out.push(Sh::node("rect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true))); } out } @@ -247,11 +265,14 @@ fn process(spec: &SmartArtSpec, w: f32, h: f32) -> Vec { let mut a = Sh::node("rightArrow", [gx + (gap - aw) / 2.0, h / 2.0 - ah / 2.0, aw, ah], spec.colors.accent(k - 1)); a.fill = Paint::Accent(spec.colors.accent(k - 1), Some(60_000)); a.line = Paint::None; + if let Some(&i) = tops.get(k - 1) { + a.recolor(spec, i); + } out.push(a); } for (k, &i) in tops.iter().enumerate() { let b = [k as f32 * (nw + gap), y, nw, nh]; - out.push(Sh::node("roundRect", b, spec.colors.accent(k)).with_text(paras(spec, i, true))); + out.push(Sh::node("roundRect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true))); } out } @@ -294,6 +315,9 @@ fn cycle(spec: &SmartArtSpec, w: f32, h: f32) -> Vec { a.rot = (mid + 90.0).rem_euclid(360.0); a.fill = Paint::Accent(spec.colors.accent(k), Some(60_000)); a.line = Paint::None; + if let Some(&i) = tops.get(k) { + a.recolor(spec, i); + } out.push(a); } } @@ -301,7 +325,7 @@ fn cycle(spec: &SmartArtSpec, w: f32, h: f32) -> Vec { for (k, &i) in tops.iter().enumerate() { let (x, y) = if n == 1 { (cx, cy) } else { polar(cx, cy, r, angle(k, n)) }; let b = [x - d / 2.0, y - d / 2.0, d, d]; - out.push(Sh::node("ellipse", b, spec.colors.accent(k)).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET))); + out.push(Sh::node("ellipse", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET))); } out } @@ -360,19 +384,19 @@ fn hierarchy(spec: &SmartArtSpec, w: f32, h: f32) -> Vec { let (px, pb) = (cx(i), top(i) + nh); let ct = ks.first().map_or(pb, |&k| top(k)); let mid = (pb + ct) / 2.0; - out.push(Sh::line(px, pb, px, mid, accent)); + out.push(Sh::line(px, pb, px, mid, accent).item(spec, i)); let xs: Vec = ks.iter().map(|&k| cx(k)).collect(); let (lo, hi) = xs.iter().fold((px, px), |(lo, hi), &x| (lo.min(x), hi.max(x))); if hi - lo > 0.5 { - out.push(Sh::line(lo, mid, hi, mid, accent)); + out.push(Sh::line(lo, mid, hi, mid, accent).item(spec, i)); } for x in xs { - out.push(Sh::line(x, mid, x, ct, accent)); + out.push(Sh::line(x, mid, x, ct, accent).item(spec, i)); } } for i in 0..n { let b = [cx(i) - nw / 2.0, top(i), nw, nh]; - out.push(Sh::node("roundRect", b, spec.colors.accent(i)).with_text(paras(spec, i, false))); + out.push(Sh::node("roundRect", b, spec.colors.accent(i)).item(spec, i).with_text(paras(spec, i, false))); } out } @@ -400,7 +424,7 @@ fn pyramid(spec: &SmartArtSpec, w: f32, h: f32) -> Vec { let mid_w = (top_w + bot_w) / 2.0 * 0.8; s.text_box([w / 2.0 - mid_w / 2.0, b[1] + bh * 0.08, mid_w, bh * 0.84]) }; - out.push(sh.with_text(paras(spec, i, true))); + out.push(sh.item(spec, i).with_text(paras(spec, i, true))); } out } @@ -418,7 +442,7 @@ fn radial(spec: &SmartArtSpec, w: f32, h: f32) -> Vec { if n == 0 { let d = m * 0.5; let b = [cx - d / 2.0, cy - d / 2.0, d, d]; - out.push(Sh::node("ellipse", b, spec.colors.accent(0)).with_text(paras(spec, 0, false)).text_box(inset(b, ROUND_INSET))); + out.push(Sh::node("ellipse", b, spec.colors.accent(0)).item(spec, 0).with_text(paras(spec, 0, false)).text_box(inset(b, ROUND_INSET))); return out; } let step = if n == 1 { 1.0 } else { (std::f32::consts::PI / n as f32).sin() }; @@ -432,14 +456,14 @@ fn radial(spec: &SmartArtSpec, w: f32, h: f32) -> Vec { let at = |k: usize| polar(cx, cy, r, angle(k, n)); for k in 0..n { let (x, y) = at(k); - out.push(Sh::line(cx, cy, x, y, spec.colors.accent(0))); + out.push(Sh::line(cx, cy, x, y, spec.colors.accent(0)).item(spec, 0)); } let bc = [cx - dc / 2.0, cy - dc / 2.0, dc, dc]; - out.push(Sh::node("ellipse", bc, spec.colors.accent(0)).with_text(paras(spec, 0, false)).text_box(inset(bc, ROUND_INSET))); + out.push(Sh::node("ellipse", bc, spec.colors.accent(0)).item(spec, 0).with_text(paras(spec, 0, false)).text_box(inset(bc, ROUND_INSET))); for (k, &i) in spokes.iter().enumerate() { let (x, y) = at(k); let b = [x - ds / 2.0, y - ds / 2.0, ds, ds]; - out.push(Sh::node("ellipse", b, spec.colors.accent(k + 1)).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET))); + out.push(Sh::node("ellipse", b, spec.colors.accent(k + 1)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET))); } out } @@ -457,7 +481,7 @@ fn matrix(spec: &SmartArtSpec, w: f32, h: f32) -> Vec { for (k, &i) in tops.iter().enumerate() { let (r, c) = (k / cols, k % cols); let b = [x0 + c as f32 * cell + gap / 2.0, y0 + r as f32 * cell + gap / 2.0, cell - gap, cell - gap]; - out.push(Sh::node("roundRect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)).text_box(inset(b, 0.1))); + out.push(Sh::node("roundRect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, 0.1))); } out } @@ -478,22 +502,22 @@ fn venn(spec: &SmartArtSpec, w: f32, h: f32) -> Vec { (x, y, deg) }; let mut out = Vec::new(); - for (j, _) in tops.iter().enumerate() { + for (j, &i) in tops.iter().enumerate() { let (x, y, _) = place(j); let mut s = Sh::node("ellipse", [x - r, y - r, 2.0 * r, 2.0 * r], spec.colors.accent(j)); s.fill = Paint::Accent(spec.colors.accent(j), Some(40_000)); s.alpha = Some(60_000); s.line = Paint::None; - out.push(s); + out.push(s.item(spec, i)); } // Every outline over every fill, so each circle shows whole. - for (j, _) in tops.iter().enumerate() { + for (j, &i) in tops.iter().enumerate() { let (x, y, _) = place(j); let mut s = Sh::node("ellipse", [x - r, y - r, 2.0 * r, 2.0 * r], spec.colors.accent(j)); s.fill = Paint::None; s.line = Paint::Accent(spec.colors.accent(j), None); s.line_w = 1.5; - out.push(s); + out.push(s.item(spec, i)); } for (j, &i) in tops.iter().enumerate() { let (x, y, deg) = place(j); @@ -554,6 +578,23 @@ fn paint(w: &mut W, p: Paint, alpha: Option) { w.empty("a:schemeClr", &[("val", "lt1")]); w.close("a:solidFill"); } + Paint::Rgb(c, tint) => { + w.open("a:solidFill", &[]); + let val = c.hex(); + if tint.is_none() && alpha.is_none() { + w.empty("a:srgbClr", &[("val", &val)]); + } else { + w.open("a:srgbClr", &[("val", &val)]); + if let Some(t) = tint { + w.empty("a:tint", &[("val", &t.to_string())]); + } + if let Some(a) = alpha { + w.empty("a:alpha", &[("val", &a.to_string())]); + } + w.close("a:srgbClr"); + } + w.close("a:solidFill"); + } Paint::Accent(n, tint) => { w.open("a:solidFill", &[]); let val = format!("accent{}", n.clamp(1, 6)); @@ -743,7 +784,16 @@ pub fn data_xml(spec: &SmartArtSpec, drawing_rel: &str) -> String { let (node, cxn, par, sib) = item_ids(i); w.open("dgm:pt", &[("modelId", &node)]); w.empty("dgm:prSet", &[]); - w.empty("dgm:spPr", &[]); + match it.color { + Some(c) => { + w.open("dgm:spPr", &[]); + w.open("a:solidFill", &[]); + w.empty("a:srgbClr", &[("val", &c.hex())]); + w.close("a:solidFill"); + w.close("dgm:spPr"); + } + None => w.empty("dgm:spPr", &[]), + } w.open("dgm:t", &[]); w.empty("a:bodyPr", &[]); w.empty("a:lstStyle", &[]); @@ -1062,7 +1112,9 @@ pub(crate) fn spec_of(data: &El) -> Option { .zip(&levels) .map(|(p, l)| { let text: String = p.child("dgm:t")?.child("a:p")?.children("a:r").filter_map(|r| r.child("a:t")).map(|t| t.text()).collect(); - Some(SmartArtItem { text, level: (*l)? }) + let color = + p.child("dgm:spPr").and_then(|s| s.child("a:solidFill")).and_then(|f| f.child("a:srgbClr")).and_then(|c| Rgb::parse(c.attr("val")?)); + Some(SmartArtItem { text, level: (*l)?, color }) }) .collect::>>()?; let spec = SmartArtSpec { layout, items, colors }.sanitized(); @@ -1138,6 +1190,18 @@ mod tests { } } + #[test] + fn an_items_own_colour_is_saved_drawn_and_read_back() { + let mut spec = SmartArtSpec::sample(SmartArtLayout::Process); + spec.items[1].color = Some(Rgb(0x12, 0x34, 0x56)); + let spec = spec.sanitized(); + assert_eq!(spec_of(&parse(&data_xml(&spec, "rId9"))).as_ref(), Some(&spec)); + let drawing = drawing_xml(&spec, 400.0, 250.0); + assert_eq!(drawing.matches(" Vec { vec![ CommandSpec::new("insert.smartArt", "SmartArt", "Insert › Illustrations", insert).params( - r#"{"layout"?: "basicList|process|cycle|hierarchy|pyramid|radial|matrix|venn", "items"?: [string | {"text": string, "level"?: 0-7}], "colors"?: "accent1…accent6|colorful", "width"?: pt, "height"?: pt} (no items: sample items for the layout)"#, + r#"{"layout"?: "basicList|process|cycle|hierarchy|pyramid|radial|matrix|venn", "items"?: [string | {"text": string, "level"?: 0-7, "color"?: RRGGBB}], "colors"?: "accent1…accent6|colorful", "width"?: pt, "height"?: pt} (no items: sample items for the layout)"#, ), CommandSpec::new("smartArt.items", "Edit Items", "SmartArt Design › Create Graphic", |s, v| { let items = items_of(v.get("items").ok_or_else(|| CmdError::Params(format!("`items` is required: {ITEMS}")))?)?; @@ -38,7 +38,7 @@ pub fn specs() -> Vec { Ok(()) }) }) - .params(r#"{"items": [string | {"text": string, "level"?: 0-7}]} (replaces the outline)"#) + .params(r#"{"items": [string | {"text": string, "level"?: 0-7, "color"?: RRGGBB}]} (replaces the outline)"#) .when(has_smart_art), CommandSpec::new("smartArt.textPane", "Text Pane", "SmartArt Design › Create Graphic", |s, v| { let on = p::bool(v, "value").unwrap_or(!s.view.smart_art_pane); @@ -124,14 +124,36 @@ pub fn specs() -> Vec { let colors = colors_of(v)?.ok_or_else(|| CmdError::Params(format!("`variant` is required: {COLORS}")))?; edit(s, move |g| { g.colors = colors; + g.clear_custom_colors(); Ok(()) }) }) - .params(r#"{"variant": "accent1|accent2|accent3|accent4|accent5|accent6|colorful"}"#) + .params(r#"{"variant": "accent1|accent2|accent3|accent4|accent5|accent6|colorful"} (the items' own colours are dropped)"#) + .when(has_smart_art), + CommandSpec::new("smartArt.itemColor", "Item Color", "SmartArt Design › SmartArt Styles", |s, v| { + let i = index(v, "index")?; + let color = match v.get("color") { + None | Some(Value::Null) => None, + Some(Value::String(c)) => Some(Rgb::parse(c).ok_or_else(|| CmdError::Params("`color`: RRGGBB".into()))?), + Some(_) => return Err(CmdError::Params("`color`: RRGGBB or null".into())), + }; + edit(s, move |g| { + let i = item_at(g, i)?; + if g.style_accent(i).is_none() { + return Err(CmdError::Failed("this item has no shape of its own: it is text in another item's shape".into())); + } + if let Some(it) = g.items.get_mut(i) { + it.color = color; + } + Ok(()) + }) + }) + .params(r#"{"index"?: item index (default: the last), "color"?: "RRGGBB" (none: the style's colour)}"#) .when(has_smart_art), CommandSpec::new("smartArt.reset", "Reset Graphic", "SmartArt Design › Reset", |s, _| { edit(s, |g| { g.colors = SmartArtColors::default(); + g.clear_custom_colors(); Ok(()) }) }) @@ -235,7 +257,13 @@ fn items_of(v: &Value) -> Result, CmdError> { Some(other) => clean_text(&other.to_string()), }; let level = p::u64(it, "level").unwrap_or(0).min(u64::from(MAX_LEVEL)); - Ok(SmartArtItem::new(text, u8::try_from(level).unwrap_or(MAX_LEVEL))) + let mut item = SmartArtItem::new(text, u8::try_from(level).unwrap_or(MAX_LEVEL)); + item.color = match it.get("color") { + None | Some(Value::Null) => None, + Some(Value::String(c)) => Some(Rgb::parse(c).ok_or_else(|| CmdError::Params("`color`: RRGGBB".into()))?), + Some(_) => return Err(CmdError::Params("`color`: RRGGBB or null".into())), + }; + Ok(item) } _ => Err(CmdError::Params(format!("`items`: {ITEMS}"))), }) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a77c122baf..348845e023 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -9659,6 +9659,47 @@ fn smart_art_edits_undo_one_step_each() { run(&mut s, "picture.size", json!({"width": 200, "height": 120, "lockAspect": false})); } +/// An item's own colour survives item edits and layout changes, and a style colour drops it. +#[test] +fn smart_art_item_colors_stay_until_a_style_is_picked() { + let mut s = s(); + run(&mut s, "insert.smartArt", json!({"layout": "process"})); + let colors = |s: &Session| cmd::smart_art::selected_smart_art(s).unwrap().items.iter().map(|i| i.color.map(|c| c.hex())).collect::>(); + assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors()); + run(&mut s, "smartArt.itemColor", json!({"index": 1, "color": "FF8800"})); + assert_eq!(colors(&s), [None, Some("FF8800".to_string()), None, None]); + run(&mut s, "smartArt.items", json!({"items": [{"text": "A"}, {"text": "B", "color": "#ff8800"}]})); + run(&mut s, "smartArt.layout", json!({"layout": "cycle"})); + run(&mut s, "smartArt.addShape", json!({"after": 0})); + assert_eq!(colors(&s), [None, None, Some("FF8800".to_string())]); + assert!(s.run("smartArt.itemColor", &json!({"index": 0, "color": "nope"})).is_err()); + run(&mut s, "smartArt.itemColor", json!({"index": 2})); + assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors()); + run(&mut s, "smartArt.itemColor", json!({"index": 0, "color": "00FF00"})); + run(&mut s, "smartArt.colors", json!({"variant": "accent2"})); + assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors()); + run(&mut s, "edit.undo", json!({})); + assert!(cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors(), "undo brings the colours back"); +} + +/// A malformed colour must not silently replace a coloured item with its style colour. +#[test] +fn smart_art_item_colors_reject_invalid_values_without_changing_the_graphic() { + let mut s = s(); + run(&mut s, "insert.smartArt", json!({"items": [{"text": "Keep", "color": "123456"}]})); + let before = cmd::smart_art::selected_smart_art(&s).unwrap(); + for command in ["insert.smartArt", "smartArt.items"] { + for color in [json!("nope"), json!(42), json!(true), json!([]), json!({})] { + assert!(s.run(command, &json!({"items": [{"text": "Replace", "color": color}]})).is_err(), "{command}: {color}"); + assert_eq!(cmd::smart_art::selected_smart_art(&s).unwrap(), before); + } + run(&mut s, command, json!({"items": [{"text": "Default", "color": null}, {"text": "Omitted"}]})); + assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors()); + run(&mut s, "edit.undo", json!({})); + assert_eq!(cmd::smart_art::selected_smart_art(&s).unwrap(), before); + } +} + /// #497: hostile SmartArt params are capped or refused, never a panic. #[test] fn smart_art_hostile_params_are_capped() { @@ -9680,6 +9721,7 @@ fn smart_art_hostile_params_are_capped() { "smartArt.demote", "smartArt.layout", "smartArt.colors", + "smartArt.itemColor", "smartArt.reset", "smartArt.textPane", ] { diff --git a/crates/ui-egui/src/dialogs/dialogs_smart_art.rs b/crates/ui-egui/src/dialogs/dialogs_smart_art.rs index 21eaeba12e..3d2dbf4b31 100644 --- a/crates/ui-egui/src/dialogs/dialogs_smart_art.rs +++ b/crates/ui-egui/src/dialogs/dialogs_smart_art.rs @@ -12,6 +12,7 @@ use wordcraft_doc::smart_art::{MAX_ITEMS, SmartArtColors, SmartArtLayout, SmartA use crate::WordApp; use crate::theme::{Tokens, c32, regular, semibold}; +use crate::widgets; /// The picker's categories (`All` first), in the order shown. pub const CATEGORIES: [&str; 8] = ["All", "List", "Process", "Cycle", "Hierarchy", "Relationship", "Matrix", "Pyramid"]; @@ -151,6 +152,21 @@ pub fn swatches(p: &Painter, r: Rect, colors: SmartArtColors, theme: &[Rgb]) { } } +/// Swatches in `r` of the colours the items of `g` have now: their own or the style's. +pub fn custom_swatches(p: &Painter, r: Rect, g: &SmartArtSpec, theme: &[Rgb]) { + let shown: Vec = (0..g.items.len()) + .filter_map(|i| match g.items.get(i)?.color { + Some(c) => Some(c32(c)), + None => Some(accent(theme, g.style_accent(i)?)), + }) + .take(5) + .collect(); + let w = r.width() / shown.len().max(1) as f32; + for (k, c) in shown.into_iter().enumerate() { + p.rect_filled(Rect::from_min_size(pos2(r.left() + k as f32 * w, r.top()), vec2(w - 2.0, r.height())), 2.0, c); + } +} + /// Height of the picker's three columns, points: three rows of 96 pt tiles and the gaps between them. const PICKER_HEIGHT: f32 = 3.0 * 96.0 + 2.0 * 8.0; @@ -262,8 +278,12 @@ pub fn picker_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SmartArtForm) -> bool { /// The `smartArt.items` params for `g` with item `i`'s text replaced by `text`. pub fn items_with(g: &SmartArtSpec, i: usize, text: &str) -> Value { - let items: Vec = - g.items.iter().enumerate().map(|(j, it)| json!({"text": if j == i { text } else { it.text.as_str() }, "level": it.level})).collect(); + let items: Vec = g + .items + .iter() + .enumerate() + .map(|(j, it)| json!({"text": if j == i { text } else { it.text.as_str() }, "level": it.level, "color": it.color.map(|c| c.hex())})) + .collect(); json!({"items": items}) } @@ -275,7 +295,9 @@ pub fn items_without(g: &SmartArtSpec, i: usize) -> Value { .iter() .enumerate() .filter(|(j, _)| *j != i) - .map(|(j, it)| json!({"text": it.text, "level": if under.contains(&j) { it.level.saturating_sub(1) } else { it.level }})) + .map(|(j, it)| { + json!({"text": it.text, "level": if under.contains(&j) { it.level.saturating_sub(1) } else { it.level }, "color": it.color.map(|c| c.hex())}) + }) .collect(); json!({"items": items}) } @@ -353,6 +375,49 @@ pub(crate) fn type_into(app: &mut WordApp, typed: &str) -> SmartArtTypeResult { SmartArtTypeResult::Changed } +/// Side of an item's colour button, points. +const SWATCH: f32 = 18.0; + +/// Id of item `i`'s colour button. +fn color_button_id(i: usize) -> egui::Id { + egui::Id::unique(("smart_art_color_button", i)) +} + +/// The button at the right of item `i`'s row: its shape's colour (the style's until changed). +/// A click offers the colour grid; the style's colour is offered back. +fn color_button(app: &mut WordApp, ui: &mut Ui, g: &SmartArtSpec, i: usize) { + let t = Tokens::get(ui.ctx()); + let (rect, _) = ui.allocate_exact_size(vec2(SWATCH, SWATCH), Sense::hover()); + let resp = ui.interact(rect, color_button_id(i), Sense::click()); + let label = g + .items + .get(i) + .filter(|it| !it.text.is_empty()) + .map_or_else(|| format!("{} ({})", tl!("Shape color"), i + 1), |it| format!("{}: {}", tl!("Shape color"), it.text)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), label.as_str())); + let Some(accent_n) = g.style_accent(i) else { return }; + let theme = app.session.doc.settings.theme_colors.clone(); + let own = g.items.get(i).and_then(|it| it.color); + let fill = own.map_or_else(|| accent(&theme, accent_n), c32); + ui.painter().rect_filled(rect, 3.0, fill); + let line = if resp.hovered() { t.accent } else { t.border_strong }; + ui.painter().rect_stroke(rect, 3.0, Stroke::new(1.0, line), egui::StrokeKind::Inside); + let resp = resp.on_hover_text(tl!("Shape color")); + egui::Popup::menu(&resp).show(|ui| { + let mut pick = widgets::color_grid(ui, &theme).map(|hex| json!({"index": i, "color": hex})); + ui.add_space(2.0); + ui.separator(); + if ui.add_enabled(own.is_some(), egui::Button::new(tl!("Style Color")).frame(false)).clicked() { + pick = Some(json!({"index": i})); + } + if let Some(params) = pick { + app.smart_art_typing = None; + let _ = app.run("smartArt.itemColor", params); + ui.close(); + } + }); +} + /// The Text Pane: a row per item, indented by level. Typing changes the graphic as you go (one /// undo step per item typed in); Enter adds an item after the current one; Tab demotes the /// focused item and Shift+Tab promotes it, as Word's Text Pane does. @@ -372,9 +437,9 @@ pub fn text_pane(app: &mut WordApp, ui: &mut Ui) { ui.label(egui::RichText::new("•").color(t.text_dim)); let mut text = it.text.clone(); let item_name = format!("{} {}", tl!("Text Pane"), i + 1); - let r = ui - .add(egui::TextEdit::singleline(&mut text).id_salt(("smart_art_item", i)).desired_width(f32::INFINITY)) - .accessible_name(item_name); + let width = (ui.available_width() - SWATCH - ui.spacing().item_spacing.x).max(40.0); + let r = ui.add(egui::TextEdit::singleline(&mut text).id_salt(("smart_art_item", i)).desired_width(width)).accessible_name(item_name); + color_button(app, ui, &g, i); if want_focus == Some(i) { r.request_focus(); } @@ -438,6 +503,22 @@ pub fn text_pane(app: &mut WordApp, ui: &mut Ui) { mod tests { use super::*; + #[test] + fn color_buttons_expose_item_names_and_empty_row_indices() { + use egui_kittest::kittest::Queryable; + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + let mut spec = SmartArtSpec::sample(SmartArtLayout::Process); + spec.items[0].text = "Named".into(); + spec.items[1].text.clear(); + let mut h = egui_kittest::Harness::builder().build_ui(move |ui| { + color_button(&mut app, ui, &spec, 0); + color_button(&mut app, ui, &spec, 1); + }); + h.run_steps(2); + h.get_by_label("Shape color: Named"); + h.get_by_label("Shape color (2)"); + } + #[test] fn removing_an_item_lifts_the_items_under_it() { let g = SmartArtSpec::sample(SmartArtLayout::Hierarchy); @@ -447,6 +528,44 @@ mod tests { assert_eq!(items_with(&g, 0, "Boss")["items"][0]["text"], "Boss"); } + /// The colour button opens a grid: a swatch sets the item's own colour and Style Color clears it. + #[test] + fn the_color_button_sets_and_clears_an_items_colour() { + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + a.run("insert.smartArt", json!({"layout": "process"})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, crate::theme::Appearance::Light); + let mut n = 0u32; + let mut frame = |ctx: &egui::Context, events: Vec, a: &mut WordApp| { + n += 1; + let input = egui::RawInput { time: Some(f64::from(n) / 60.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() }; + ctx.run_ui(input, |ui| text_pane(a, ui)).drop_without_applying_deltas(); + }; + let click = |ctx: &egui::Context, pos: egui::Pos2, a: &mut WordApp, frame: &mut dyn FnMut(&egui::Context, Vec, &mut WordApp)| { + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(ctx, vec![egui::Event::PointerMoved(pos), button(true)], a); + frame(ctx, vec![button(false)], a); + frame(ctx, vec![], a); + }; + let colour = + |a: &WordApp| wordcraft_engine::cmd::smart_art::selected_smart_art(&a.session).and_then(|g| g.items.get(1).and_then(|i| i.color)); + frame(&ctx, vec![], &mut a); + let button = ctx.read_response(color_button_id(1)).expect("item 1 has a colour button"); + let popup = egui::Popup::default_response_id(&button); + click(&ctx, button.rect.center(), &mut a, &mut frame); + assert!(egui::Popup::is_id_open(&ctx, popup), "the button opens the colour grid"); + let area = ctx.memory(|m| m.area_rect(popup)).expect("the grid is drawn"); + // The first theme swatch: under the "Theme Colors" label at the popup's top left. + click(&ctx, area.min + vec2(14.0, 38.0), &mut a, &mut frame); + assert!(colour(&a).is_some(), "a swatch gives the item its own colour"); + let button = ctx.read_response(color_button_id(1)).unwrap(); + click(&ctx, button.rect.center(), &mut a, &mut frame); + let area = ctx.memory(|m| m.area_rect(popup)).expect("the grid is drawn again"); + click(&ctx, pos2(area.min.x + 30.0, area.max.y - 14.0), &mut a, &mut frame); + assert_eq!(colour(&a), None, "Style Color clears it"); + } + /// #2832: the separators between the picker's columns made the dialog as tall as the window. #[test] fn the_picker_row_stays_within_its_height() { diff --git a/crates/ui-egui/src/ribbon/charts.rs b/crates/ui-egui/src/ribbon/charts.rs index e4b9d42bc5..ed3944e371 100644 --- a/crates/ui-egui/src/ribbon/charts.rs +++ b/crates/ui-egui/src/ribbon/charts.rs @@ -59,8 +59,9 @@ pub(super) fn smart_art_design(app: &mut WordApp, ui: &mut Ui) { app, crate::widgets::MenuButtonOptions::new("smartArtColors", Some("Change\nColors"), "Change Colors", true, ""), |ui, app| { + let custom = g.has_custom_colors(); for colors in SmartArtColors::ALL { - let on = g.colors == colors; + let on = g.colors == colors && !custom; let resp = ui.add(egui::Button::new(crate::dialogs_smart_art::colors_label(colors)).selected(on).min_size(vec2(200.0, 24.0))); let sw = Rect::from_min_size(pos2(resp.rect.right() - 74.0, resp.rect.center().y - 6.0), vec2(66.0, 12.0)); crate::dialogs_smart_art::swatches(ui.painter(), sw, colors, &theme); @@ -69,6 +70,13 @@ pub(super) fn smart_art_design(app: &mut WordApp, ui: &mut Ui) { ui.close(); } } + // Active while items carry their own colours; set in the Text Pane. + let resp = ui.add_enabled(custom, egui::Button::new(tl!("Custom")).selected(custom).min_size(vec2(200.0, 24.0))); + let sw = Rect::from_min_size(pos2(resp.rect.right() - 74.0, resp.rect.center().y - 6.0), vec2(66.0, 12.0)); + crate::dialogs_smart_art::custom_swatches(ui.painter(), sw, &g, &theme); + if !custom { + resp.on_disabled_hover_text(tl!("Set the colors in the Text Pane to use custom colors.")); + } }, ); });