From 40f6f1756bc001082e3476b4dfd75b09649fcd01 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 16:26:06 +0200 Subject: [PATCH] Shape Fill and Shape Outline: the button applies the last colour picked from its menu (part of #1080) --- crates/ui-egui/src/last_color_tests.rs | 142 +++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 8 ++ crates/ui-egui/src/ribbon.rs | 30 ++++-- docs/ui-parity.md | 1 + 4 files changed, 174 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/src/last_color_tests.rs diff --git a/crates/ui-egui/src/last_color_tests.rs b/crates/ui-egui/src/last_color_tests.rs new file mode 100644 index 00000000..4a90587f --- /dev/null +++ b/crates/ui-egui/src/last_color_tests.rs @@ -0,0 +1,142 @@ +//! Shape Fill and Shape Outline remember the last colour picked from their menu, and a click on +//! the button itself applies it again to the next selection, as in PowerPoint (#1080). +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use deckcraft_engine::Session; +use deckcraft_model::{ColorBase, ColorRef, Fill, ShapeId}; +use egui::{Event, Key, Modifiers, PointerButton}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +/// Two rectangles, the first selected; the second starts red with a blue outline. +fn harness(tab: &str) -> (Harness<'static, SlideApp>, [ShapeId; 2]) { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let mut ids = [ShapeId(0); 2]; + for (i, x) in [100.0, 300.0].iter().enumerate() { + let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 100.0, 80.0, 60.0]})).unwrap(); + ids[i] = ShapeId(r["id"].as_u64().unwrap() as u32); + } + s.execute("selection.select", &json!({"ids": [ids[1].0]})).unwrap(); + s.execute("shape.fill", &json!({"color": "#FF0000"})).unwrap(); + s.execute("shape.line", &json!({"color": "#0000FF", "width": 3})).unwrap(); + s.execute("selection.select", &json!({"ids": [ids[0].0]})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + app.ui.tab = tab.into(); + let mut h = Harness::builder().with_size(egui::vec2(1800.0, 900.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + (h, ids) +} + +fn select(h: &mut Harness<'static, SlideApp>, id: ShapeId) { + h.state_mut().session.execute("selection.select", &json!({"ids": [id.0]})).unwrap(); + h.run_steps(3); +} + +/// Open the drop-down arrow of a split button, type a hex colour into "More Colors…" and press Enter. +fn pick_hex(h: &mut Harness<'static, SlideApp>, button: &str, hex: &str) { + let r = h.get_by_label(button).rect(); + let arrow = egui::pos2(r.max.x - 5.0, r.center().y); + h.input_mut().events.push(Event::PointerMoved(arrow)); + h.run_steps(2); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: arrow, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(2); + } + h.run_steps(2); + // The ribbon has text fields of its own; the open menu hangs below them. + let lowest = |h: &Harness<'static, SlideApp>| { + h.get_all_by_role(egui::accesskit::Role::TextInput).max_by(|a, b| a.rect().min.y.total_cmp(&b.rect().min.y)).unwrap().rect().center() + }; + let field = lowest(h); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: field, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(2); + } + h.input_mut().events.push(Event::Text(hex.into())); + h.run_steps(2); + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + } + h.run_steps(2); +} + +fn fill_rgb(h: &Harness<'static, SlideApp>, id: ShapeId) -> Option { + let sh = h.state().session.active().unwrap().shape(id).unwrap(); + match sh.fill.as_ref()? { + Fill::Solid { color: ColorRef { base: ColorBase::Rgb { rgb }, .. } } => Some(rgb.hex()), + _ => None, + } +} + +fn line_rgb(h: &Harness<'static, SlideApp>, id: ShapeId) -> Option { + let sh = h.state().session.active().unwrap().shape(id).unwrap(); + match sh.line.as_ref()?.fill.as_ref()? { + Fill::Solid { color: ColorRef { base: ColorBase::Rgb { rgb }, .. } } => Some(rgb.hex()), + _ => None, + } +} + +fn fill_reapplies(tab: &str) { + let (mut h, [a, b]) = harness(tab); + pick_hex(&mut h, "Shape Fill", "00AA55"); + assert_eq!(fill_rgb(&h, a).as_deref(), Some("00AA55"), "the menu pick applies to the selection"); + select(&mut h, b); + assert_eq!(fill_rgb(&h, b).as_deref(), Some("FF0000"), "selecting alone changes nothing"); + h.get_by_label("Shape Fill").click(); + h.run_steps(3); + assert_eq!(fill_rgb(&h, b).as_deref(), Some("00AA55"), "the button applies the last colour, not Accent 1"); +} + +fn outline_reapplies(tab: &str) { + let (mut h, [a, b]) = harness(tab); + pick_hex(&mut h, "Shape Outline", "AA5500"); + assert_eq!(line_rgb(&h, a).as_deref(), Some("AA5500"), "the menu pick applies to the selection"); + select(&mut h, b); + assert_eq!(line_rgb(&h, b).as_deref(), Some("0000FF"), "selecting alone changes nothing"); + h.get_by_label("Shape Outline").click(); + h.run_steps(3); + assert_eq!(line_rgb(&h, b).as_deref(), Some("AA5500"), "the button applies the last colour, not Text 1"); +} + +#[test] +fn home_shape_fill_button_reapplies_the_last_colour() { + fill_reapplies("Home"); +} + +#[test] +fn shape_format_shape_fill_button_reapplies_the_last_colour() { + fill_reapplies("Shape Format"); +} + +#[test] +fn home_shape_outline_button_reapplies_the_last_colour() { + outline_reapplies("Home"); +} + +#[test] +fn shape_format_shape_outline_button_reapplies_the_last_colour() { + outline_reapplies("Shape Format"); +} + +#[test] +fn before_any_pick_the_buttons_still_apply_accent_1_and_text_1() { + let (mut h, [a, _]) = harness("Home"); + h.get_by_label("Shape Fill").click(); + h.run_steps(3); + h.get_by_label("Shape Outline").click(); + h.run_steps(3); + let sh = h.state().session.active().unwrap().shape(a).unwrap(); + assert!(matches!(&sh.fill, Some(Fill::Solid { color: ColorRef { base: ColorBase::Scheme { .. }, .. } }))); + assert!(sh.line.is_some()); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index eef9b1b3..3d623948 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -433,6 +433,10 @@ struct AnimPreview { } pub struct SlideApp { + /// Shape Fill's last colour (a command colour parameter): the button applies it again. Accent 1 until one is picked. + pub last_fill: serde_json::Value, + /// Shape Outline's last colour, likewise. Text 1 until one is picked. + pub last_outline: serde_json::Value, /// Table Design's border pen (`color`, `width`, `dash` for `table.borders`). pub table_pen: serde_json::Value, pub session: Session, @@ -533,6 +537,8 @@ impl SlideApp { pub fn new(session: Session, mut services: Services) -> Self { let audio_out = services.audio_out.take(); SlideApp { + last_fill: serde_json::json!("accent1"), + last_outline: serde_json::json!("tx1"), table_pen: serde_json::json!({"width": 1.0}), session, services, @@ -2056,6 +2062,8 @@ mod huge_deck_tests; #[cfg(test)] mod ime_tests; #[cfg(test)] +mod last_color_tests; +#[cfg(test)] mod replacefonts_tests; #[cfg(test)] mod slide_size_tests; diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 3a679dd1..7007f674 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -925,13 +925,17 @@ fn drawing_group(app: &mut SlideApp, ui: &mut Ui) { let sc = scheme(app); let (m, a) = small_button_ex(ui, Icon::ShapeFill, "Shape Fill", sel, false, true); if m.clicked() { - run(app, "shape.fill", json!({"color": "accent1"})); + let c = app.last_fill.clone(); + run(app, "shape.fill", json!({"color": c})); } if let Some(a) = a { widgets::color_popup(&a).show(|ui| { if let Some(c) = widgets::color_grid(ui, &sc, Some("No Fill")) { match c { - Some(c) => run(app, "shape.fill", json!({"color": cref_param(&c)})), + Some(c) => { + app.last_fill = cref_param(&c); + run(app, "shape.fill", json!({"color": cref_param(&c)})); + } None => run(app, "shape.fill", json!({"none": true})), } } @@ -939,7 +943,8 @@ fn drawing_group(app: &mut SlideApp, ui: &mut Ui) { } let (m, a) = small_button_ex(ui, Icon::ShapeOutline, "Shape Outline", sel, false, true); if m.clicked() { - run(app, "shape.line", json!({"color": "tx1", "width": 1})); + let c = app.last_outline.clone(); + run(app, "shape.line", json!({"color": c, "width": 1})); } if let Some(a) = a { widgets::color_popup(&a).show(|ui| outline_menu(app, ui, &sc)); @@ -955,7 +960,10 @@ fn drawing_group(app: &mut SlideApp, ui: &mut Ui) { pub fn outline_menu(app: &mut SlideApp, ui: &mut Ui, sc: &deckcraft_color::ColorScheme) { if let Some(c) = widgets::color_grid(ui, sc, Some("No Outline")) { match c { - Some(c) => run(app, "shape.line", json!({"color": cref_param(&c)})), + Some(c) => { + app.last_outline = cref_param(&c); + run(app, "shape.line", json!({"color": cref_param(&c)})); + } None => run(app, "shape.line", json!({"none": true})), } } @@ -2436,13 +2444,17 @@ fn shape_format(app: &mut SlideApp, ui: &mut Ui) { rows(ui, |ui| { let (m, a) = small_button_ex(ui, Icon::ShapeFill, "Shape Fill", sel, false, true); if m.clicked() { - run(app, "shape.fill", json!({"color": "accent1"})); + let c = app.last_fill.clone(); + run(app, "shape.fill", json!({"color": c})); } if let Some(a) = a { widgets::color_popup(&a).show(|ui| { if let Some(c) = widgets::color_grid(ui, &sc, Some("No Fill")) { match c { - Some(c) => run(app, "shape.fill", json!({"color": cref_param(&c)})), + Some(c) => { + app.last_fill = cref_param(&c); + run(app, "shape.fill", json!({"color": cref_param(&c)})); + } None => run(app, "shape.fill", json!({"none": true})), } } @@ -2452,7 +2464,11 @@ fn shape_format(app: &mut SlideApp, ui: &mut Ui) { } }); } - let (_, a) = small_button_ex(ui, Icon::ShapeOutline, "Shape Outline", sel, false, true); + let (m, a) = small_button_ex(ui, Icon::ShapeOutline, "Shape Outline", sel, false, true); + if m.clicked() { + let c = app.last_outline.clone(); + run(app, "shape.line", json!({"color": c, "width": 1})); + } if let Some(a) = a { widgets::color_popup(&a).show(|ui| outline_menu(app, ui, &sc)); } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 3b1fedf7..96ec3935 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -18,6 +18,7 @@ ends and are omitted from thumbnails, slide shows and exports. | Element | PowerPoint for Mac | DeckCraft | Gap | |---|---|---|---| +| 2026-10-23 | minor | Shape Fill and Shape Outline (Home and Shape Format): the button applies the last colour picked from its menu, Accent 1 and Text 1 until one is picked. Line weight and dashes, Font Color and Highlight stay as they were (#1080) | | Title bar: AutoSave, Home, Save, Undo/Redo, document title, Share, search | yes | yes, plus Export; no Share (cloud) | #71 macOS window controls misaligned | | Native macOS menu bar (File, Edit, View, Insert, Format, Arrange, Tools, Slide Show, Window, Help) | yes | **no** (`menus::menu_tree()` is defined but not installed) | 3–5 h | | Menu-bar items with a DeckCraft equivalent | 217 app-specific items | 128 matched by name (59%, heuristic lower bound) | See [target-app-parity.md](target-app-parity.md) |