diff --git a/ROADMAP.md b/ROADMAP.md index dfbf6fac..e110d8ae 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -152,6 +152,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-21 | Groups nest at most 32 deep (`arrange.group` refuses more), as deep as PPTX import reads and slides draw: a deck grouped 39 or more levels deep saved without complaint and then could not be opened again | | 2026-10-20 | Chart number formats with explicit empty positive, negative or zero sections hide the corresponding labels, including all values with three semicolons; a blank whole format still uses General. Synthetic formatter regressions | | 2026-10-22 | Chart bars and their legend keys draw the series' own pattern fill (`a:pattFill`, now read and written) and draw nothing for `a:noFill`, with the series outline where it has one, as PowerPoint for Mac does (synthetic probe); they took the palette's colour before. Lines, areas and markers use a pattern's foreground colour | +| 2026-10-24 | Format Shape and Format Background ▸ Pattern fill: a gallery of all 54 preset patterns, drawn in the current colors, with Foreground and Background color menus. Format Shape could only set 25 % in accent 1 on background 1, and Format Background listed 10 presets by their XML names | | 2026-10-21 | Reuse Slides: pick which slides of the other presentation to insert from a list (`slide.reuseList`, #1103) | | 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members | | 2026-10-18 | Slide thumbnails and the Slide Sorter render only the slides in view: on a deck of more than 600 slides they used to evict their own thumbnails, so the pane flashed white and never finished (#969) | diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index fa3f6459..fcda1413 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -371,6 +371,8 @@ pub(crate) fn ok() -> Result { } /// Parse a colour param: `"#RRGGBB"`, `"accent1"`…, or `{"scheme":"accent2","lumMod":75000}`. +/// A preset or system colour keeps its base: `{"preset":"red"}`, +/// `{"system":"windowText","last":"#000000"}`. pub(crate) fn color_param(p: &Value, key: &str) -> Option { let v = p.get(key)?; color_value(v) @@ -391,6 +393,11 @@ pub(crate) fn color_value(v: &Value) -> Option { Value::Object(o) => { let mut c = if let Some(s) = o.get("scheme").and_then(Value::as_str) { ColorRef::scheme(deckcraft_color::SchemeSlot::from_xml(s)?) + } else if let Some(name) = o.get("preset").and_then(Value::as_str) { + ColorRef { base: deckcraft_model::ColorBase::Preset { name: name.to_string() }, mods: vec![] } + } else if let Some(name) = o.get("system").and_then(Value::as_str) { + let last = deckcraft_color::Rgba::from_hex(o.get("last").and_then(Value::as_str)?)?; + ColorRef { base: deckcraft_model::ColorBase::System { name: name.to_string(), last }, mods: vec![] } } else { ColorRef::rgb(deckcraft_color::Rgba::from_hex(o.get("rgb").and_then(Value::as_str)?)?) }; @@ -639,4 +646,16 @@ mod tests { assert!(!needs_arguments(save.params)); assert!(needs_arguments(audio.params)); } + + #[test] + fn preset_and_system_colors_keep_their_base() { + use deckcraft_color::{ColorTransform, Rgba}; + use deckcraft_model::{ColorBase, ColorRef}; + use serde_json::json; + let preset = color_value(&json!({"preset": "red", "lumMod": 50_000})); + assert_eq!(preset, Some(ColorRef { base: ColorBase::Preset { name: "red".into() }, mods: vec![ColorTransform::LumMod(50_000)] })); + let system = color_value(&json!({"system": "windowText", "last": "#123456"})); + assert_eq!(system, Some(ColorRef { base: ColorBase::System { name: "windowText".into(), last: Rgba::rgb(0x12, 0x34, 0x56) }, mods: vec![] })); + assert_eq!(color_value(&json!({"system": "windowText"})), None, "a system color needs its last value"); + } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index e951cbdd..00ebe093 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -53,6 +53,7 @@ pub use extent::{Region, TILE, layout_extent, pasteboard_region, slide_extent}; pub use images::{decode as decode_image, gif_frame}; pub use morph_path::PathMorph; pub use morph_text::TextMorph; +pub use pattern::{is_fg as pattern_is_fg, presets as pattern_presets, tile as pattern_tile}; pub use placed::{Placed, PlacedLink, PlacedShape, PlacedText, place_master_texts, place_slide, text_outline}; pub use table::cell_body as table_cell_body; pub use table::cell_rects as table_cell_rects; diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index 1c26c977..367facd2 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -71,6 +71,11 @@ const TILES: &[(&str, [u8; 8])] = &[ ("zigZag", [0x81, 0x42, 0x24, 0x18, 0x81, 0x42, 0x24, 0x18]), ]; +/// Every preset name, in the order of the tiles. +pub fn presets() -> impl Iterator { + TILES.iter().map(|(n, _)| *n) +} + /// The 8×8 tile of preset `name` (`pct50` for an unknown name). pub fn tile(name: &str) -> [u8; 8] { TILES diff --git a/crates/ui-egui/src/fillui.rs b/crates/ui-egui/src/fillui.rs index 584d309a..47fcfa89 100644 --- a/crates/ui-egui/src/fillui.rs +++ b/crates/ui-egui/src/fillui.rs @@ -1,9 +1,10 @@ //! The gradient editor of the Format pane (shape fill and slide background): preset variations, -//! type, direction, angle and a stop bar (add, drag, recolour, set transparency, remove stops). +//! type, direction, angle and a stop bar (add, drag, recolour, set transparency, remove stops); and +//! the pattern editor: a gallery of the preset patterns with foreground and background colors. use deckcraft_color::{ColorScheme, ColorTransform, SchemeSlot}; use deckcraft_model::ColorRef; -use deckcraft_model::style::{Gradient, GradientShape, GradientStop}; +use deckcraft_model::style::{Gradient, GradientShape, GradientStop, PatternFill}; use egui::{Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2}; use serde_json::{Value, json}; @@ -321,3 +322,128 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> ui.data_mut(|d| d.insert_temp(sel_id, sel)); out } + +/// The gallery names of the DrawingML preset patterns. +const PATTERN_LABELS: [(&str, &str); 54] = [ + ("pct5", "5%"), + ("pct10", "10%"), + ("pct20", "20%"), + ("pct25", "25%"), + ("pct30", "30%"), + ("pct40", "40%"), + ("pct50", "50%"), + ("pct60", "60%"), + ("pct70", "70%"), + ("pct75", "75%"), + ("pct80", "80%"), + ("pct90", "90%"), + ("horz", "Horizontal"), + ("vert", "Vertical"), + ("ltHorz", "Light horizontal"), + ("ltVert", "Light vertical"), + ("dkHorz", "Dark horizontal"), + ("dkVert", "Dark vertical"), + ("narHorz", "Narrow horizontal"), + ("narVert", "Narrow vertical"), + ("dashHorz", "Dashed horizontal"), + ("dashVert", "Dashed vertical"), + ("cross", "Cross"), + ("dnDiag", "Downward diagonal"), + ("upDiag", "Upward diagonal"), + ("ltDnDiag", "Light downward diagonal"), + ("ltUpDiag", "Light upward diagonal"), + ("dkDnDiag", "Dark downward diagonal"), + ("dkUpDiag", "Dark upward diagonal"), + ("wdDnDiag", "Wide downward diagonal"), + ("wdUpDiag", "Wide upward diagonal"), + ("dashDnDiag", "Dashed downward diagonal"), + ("dashUpDiag", "Dashed upward diagonal"), + ("diagCross", "Diagonal cross"), + ("smCheck", "Small checkerboard"), + ("lgCheck", "Large checkerboard"), + ("smGrid", "Small grid"), + ("lgGrid", "Large grid"), + ("dotGrid", "Dotted grid"), + ("smConfetti", "Small confetti"), + ("lgConfetti", "Large confetti"), + ("horzBrick", "Horizontal brick"), + ("diagBrick", "Diagonal brick"), + ("solidDmnd", "Solid diamond"), + ("openDmnd", "Outlined diamond"), + ("dotDmnd", "Dotted diamond"), + ("plaid", "Plaid"), + ("sphere", "Sphere"), + ("weave", "Weave"), + ("divot", "Divot"), + ("shingle", "Shingle"), + ("wave", "Wave"), + ("trellis", "Trellis"), + ("zigZag", "Zig zag"), +]; + +/// The name the gallery shows for preset `name` (the preset itself when it has none). +pub fn pattern_label(name: &str) -> &str { + PATTERN_LABELS.iter().find(|(n, _)| *n == name).map_or(name, |(_, l)| l) +} + +/// The command parameter for `pt` (`shape.fill {pattern}` / `design.background {pattern}`). +pub fn pattern_json(pt: &PatternFill) -> Value { + json!({"preset": pt.preset, "fg": cref_param(&pt.fg), "bg": cref_param(&pt.bg)}) +} + +/// Paint preset `name` into `r`, two points per pattern cell. +fn paint_pattern(p: &egui::Painter, r: Rect, name: &str, fg: Color32, bg: Color32) { + const CELL: f32 = 2.0; + p.rect_filled(r, CornerRadius::ZERO, bg); + let tile = deckcraft_render::pattern_tile(name); + let (nx, ny) = ((r.width() / CELL).floor().max(0.0) as usize, (r.height() / CELL).floor().max(0.0) as usize); + for y in 0..ny { + for x in 0..nx { + if deckcraft_render::pattern_is_fg(&tile, x, y) { + let min = r.min + vec2(x as f32 * CELL, y as f32 * CELL); + p.rect_filled(Rect::from_min_size(min, vec2(CELL, CELL)), CornerRadius::ZERO, fg); + } + } + } +} + +/// The editor for `pt`: a gallery of every preset drawn in the current colors, then Foreground and +/// Background color menus. Returns the new pattern when the user changed something. +pub fn pattern_editor(ui: &mut Ui, pt: &PatternFill, sc: &ColorScheme) -> Option { + let t = Tokens::get(ui.ctx()); + let mut out: Option = None; + let (fg, bg) = (c32(&pt.fg, sc), c32(&pt.bg, sc)); + ui.label("Pattern"); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(2.0, 2.0); + for name in deckcraft_render::pattern_presets() { + let on = pt.preset == name; + let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); + paint_pattern(ui.painter(), r.shrink(2.0), name, fg, bg); + let stroke = if on || resp.hovered() || resp.has_focus() { Stroke::new(2.0, t.accent) } else { Stroke::new(1.0, t.border) }; + ui.painter().rect_stroke(r.shrink(1.0), CornerRadius::same(2), stroke, egui::StrokeKind::Inside); + let label = pattern_label(name); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, on, label)); + if resp.on_hover_text(label).clicked() && !on { + out = Some(PatternFill { preset: name.to_string(), ..pt.clone() }); + } + } + }); + for (label, foreground) in [("Foreground", true), ("Background", false)] { + ui.horizontal(|ui| { + ui.add_sized(vec2(80.0, 20.0), egui::Label::new(label)); + let cur = if foreground { &pt.fg } else { &pt.bg }; + let (sw, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); + ui.painter().rect(sw, CornerRadius::same(3), c32(cur, sc), Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + let r = widgets::drop_button(ui, "", vec2(24.0, 20.0), true); + let name = format!("{label} color"); + r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name)); + widgets::color_popup(&r).show(|ui| { + if let Some(Some(c)) = widgets::color_grid(ui, sc, None) { + out = Some(if foreground { PatternFill { fg: c, ..pt.clone() } } else { PatternFill { bg: c, ..pt.clone() } }); + } + }); + }); + } + out +} diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index ec947182..da28ac80 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -478,11 +478,18 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { } } } - color_button(app, ui, "Color", Some("No Fill"), |c| match c { - Some(c) => ("shape.fill".into(), json!({"color": cref_param(&c)})), - None => ("shape.fill".into(), json!({"none": true})), - }); + // A pattern takes its colors from its own Foreground and Background menus below. + let pattern = matches!(sh.fill, Some(Fill::Pattern(_))); + if !pattern { + color_button(app, ui, "Color", Some("No Fill"), |c| match c { + Some(c) => ("shape.fill".into(), json!({"color": cref_param(&c)})), + None => ("shape.fill".into(), json!({"none": true})), + }); + } ui.horizontal(|ui| { + if pattern { + return; + } ui.label("Transparency"); // Seed from this shape. A shared temp defaulted to 0, so a shape that was // already 40% transparent showed as opaque, and leaving the slider wrote 0. @@ -513,6 +520,11 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { run(app, "shape.fill", json!({"gradient": crate::fillui::gradient_json(&ng)})); } } + if let Some(Fill::Pattern(pt)) = &sh.fill + && let Some(np) = crate::fillui::pattern_editor(ui, pt, &scheme(app)) + { + run(app, "shape.fill", json!({"pattern": crate::fillui::pattern_json(&np)})); + } ui.add_space(8.0); ui.label(egui::RichText::new("Line").font(theme::bold(13.0))); let none = matches!(sh.line.as_ref().and_then(|l| l.fill.as_ref()), Some(Fill::None)); @@ -874,25 +886,9 @@ fn background(app: &mut SlideApp, ui: &mut Ui) { } } Some(Fill::Pattern(pt)) => { - current = Some(json!({"pattern": {"preset": pt.preset, "fg": cref_param(&pt.fg), "bg": cref_param(&pt.bg)}})); - ui.horizontal_wrapped(|ui| { - for preset in ["pct5", "pct20", "pct50", "ltHorz", "ltVert", "smGrid", "dkDnDiag", "wave", "dotGrid", "weave"] { - if ui.selectable_label(pt.preset == preset, preset).clicked() { - run(app, "design.background", json!({"pattern": {"preset": preset, "fg": cref_param(&pt.fg), "bg": cref_param(&pt.bg)}})); - } - } - }); - for (label, fg) in [("Foreground", true), ("Background", false)] { - ui.horizontal(|ui| { - ui.label(label); - let r = widgets::drop_button(ui, "Color", vec2(70.0, 20.0), true); - widgets::color_popup(&r).show(|ui| { - if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) { - let (f, b) = if fg { (c, pt.bg.clone()) } else { (pt.fg.clone(), c) }; - run(app, "design.background", json!({"pattern": {"preset": pt.preset, "fg": cref_param(&f), "bg": cref_param(&b)}})); - } - }); - }); + current = Some(json!({"pattern": crate::fillui::pattern_json(pt)})); + if let Some(np) = crate::fillui::pattern_editor(ui, pt, &sc) { + run(app, "design.background", json!({"pattern": crate::fillui::pattern_json(&np)})); } } Some(Fill::Picture(pf)) => { @@ -1482,6 +1478,10 @@ mod smartart_tests; #[path = "background_tests.rs"] mod background_tests; +#[cfg(test)] +#[path = "pattern_fill_tests.rs"] +mod pattern_fill_tests; + #[cfg(test)] mod tests { #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] diff --git a/crates/ui-egui/src/pattern_fill_tests.rs b/crates/ui-egui/src/pattern_fill_tests.rs new file mode 100644 index 00000000..065b03f5 --- /dev/null +++ b/crates/ui-egui/src/pattern_fill_tests.rs @@ -0,0 +1,184 @@ +//! Format Shape and Format Background ▸ Pattern fill: the preset gallery and the Foreground and +//! Background color menus. + +use deckcraft_color::Rgba; +use deckcraft_engine::Session; +use deckcraft_model::style::PatternFill; +use deckcraft_model::{Background, ColorBase, ColorRef, Fill, ShapeId}; +use egui::accesskit::Role; +use egui::{Event, Key, Modifiers, PointerButton}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +fn harness(mut app: SlideApp, pane: &str) -> Harness<'static, SlideApp> { + app.ui.pane = Some(pane.into()); + let mut h = Harness::builder().with_size(egui::vec2(1800.0, 1100.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h +} + +/// A new deck with one rectangle selected and filled with `pct25` in accent 1 on background 1. +fn patterned_shape() -> (Session, ShapeId) { + let mut s = Session::with_new(); + let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 200.0, 120.0]})).unwrap(); + let id = r.get("id").and_then(|v| v.as_u64()).unwrap(); + s.execute("edit.select", &json!({"ids": [id]})).unwrap(); + s.execute("shape.fill", &json!({"pattern": {"preset": "pct25", "fg": "accent1", "bg": "bg1"}})).unwrap(); + (s, ShapeId(u32::try_from(id).unwrap())) +} + +fn shape_pattern(h: &Harness<'_, SlideApp>, id: ShapeId) -> PatternFill { + match &h.state().session.active().unwrap().shape(id).unwrap().fill { + Some(Fill::Pattern(pt)) => pt.clone(), + other => panic!("expected a pattern fill, got {other:?}"), + } +} + +fn background_pattern(h: &Harness<'_, SlideApp>) -> PatternFill { + match &h.state().session.active().unwrap().doc.slides[0].background { + Some(Background::Fill { fill: Fill::Pattern(pt) }) => pt.clone(), + other => panic!("expected a pattern background, got {other:?}"), + } +} + +/// Open the color menu named `button` and pick `hex` from its More Colors field. +fn pick_hex(h: &mut Harness<'_, SlideApp>, button: &str, hex: &str) { + h.get_by_label(button).click(); + h.run_steps(2); + // The hex field is the text input on the "More Colors… #" label's row, to its right. + let label = h.get_by_label("More Colors… #").rect(); + let field = h + .get_all_by_role(Role::TextInput) + .find(|n| n.rect().min.x >= label.max.x - 1.0 && (n.rect().center().y - label.center().y).abs() < 6.0) + .map(|n| n.rect().center()) + .expect("the More Colors hex field"); + 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); +} + +#[test] +fn shape_pattern_fill_picks_a_preset_from_the_gallery() { + let (s, id) = patterned_shape(); + let mut h = harness(SlideApp::new(s, Services::default()), "format"); + let _ = h.get_by_label("Pattern fill"); + assert!(h.query_by_label("Transparency").is_none(), "a pattern has no solid-fill transparency"); + assert_eq!(deckcraft_render::pattern_presets().count(), 54); + for name in deckcraft_render::pattern_presets() { + let _ = h.get_by_label(crate::fillui::pattern_label(name)); + } + let undo = h.state().session.active().unwrap().history.undo.len(); + h.get_by_label("Dark downward diagonal").click(); + h.run_steps(2); + let pt = shape_pattern(&h, id); + assert_eq!(pt.preset, "dkDnDiag"); + assert_eq!((pt.fg, pt.bg), (ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1), ColorRef::scheme(deckcraft_color::SchemeSlot::Bg1))); + assert_eq!(h.state().session.active().unwrap().history.undo.len(), undo + 1, "one undo step"); +} + +#[test] +fn shape_pattern_fill_keeps_preset_and_system_colors() { + let (mut s, id) = patterned_shape(); + let fg = json!({"preset": "red", "lumMod": 50000}); + let bg = json!({"system": "window", "last": "#F0F0F0"}); + s.execute("shape.fill", &json!({"pattern": {"preset": "pct25", "fg": fg, "bg": bg}})).unwrap(); + let mut h = harness(SlideApp::new(s, Services::default()), "format"); + let before = shape_pattern(&h, id); + assert!(matches!(before.fg.base, ColorBase::Preset { .. }) && matches!(before.bg.base, ColorBase::System { .. })); + h.get_by_label("Zig zag").click(); + h.run_steps(2); + let pt = shape_pattern(&h, id); + assert_eq!(pt.preset, "zigZag"); + assert_eq!((&pt.fg, &pt.bg), (&before.fg, &before.bg), "a preset change keeps both colors"); + pick_hex(&mut h, "Foreground color", "00FF00"); + assert_eq!(shape_pattern(&h, id).bg, before.bg, "a foreground change keeps the background"); +} + +#[test] +fn pattern_gallery_shows_which_swatch_has_keyboard_focus() { + let (s, _) = patterned_shape(); + let mut h = harness(SlideApp::new(s, Services::default()), "format"); + let r = h.get_by_label("Zig zag").rect(); + let accent = crate::theme::Tokens::get(&h.ctx).accent; + let outlined = |h: &Harness<'_, SlideApp>| { + h.output().shapes.iter().any(|c| match &c.shape { + egui::Shape::Rect(rs) => { + rs.stroke.width == 2.0 && rs.stroke.color == accent && (rs.rect.center() - r.center()).length() < 0.5 && rs.rect.width() < r.width() + } + _ => false, + }) + }; + assert!(!outlined(&h), "an unselected swatch has a plain border"); + h.get_by_label("Zig zag").focus(); + h.run_steps(2); + assert!(h.get_by_label("Zig zag").is_focused()); + assert!(outlined(&h), "the focused swatch is outlined"); +} + +#[test] +fn shape_solid_fill_keeps_its_transparency_without_a_pattern_gallery() { + let (mut s, _) = patterned_shape(); + s.execute("shape.fill", &json!({"color": "accent2"})).unwrap(); + let h = harness(SlideApp::new(s, Services::default()), "format"); + let _ = h.get_by_label("Transparency"); + assert!(h.query_by_label("Foreground color").is_none()); + assert!(h.query_by_label("Zig zag").is_none()); +} + +#[test] +fn shape_pattern_fill_sets_its_foreground_and_background_colors() { + let (s, id) = patterned_shape(); + let mut h = harness(SlideApp::new(s, Services::default()), "format"); + pick_hex(&mut h, "Foreground color", "00FF00"); + let pt = shape_pattern(&h, id); + assert_eq!(pt.fg, ColorRef::rgb(Rgba::from_hex("00FF00").unwrap())); + assert_eq!(pt.preset, "pct25", "the pattern stays"); + pick_hex(&mut h, "Background color", "112233"); + let pt = shape_pattern(&h, id); + assert_eq!(pt.bg, ColorRef::rgb(Rgba::from_hex("112233").unwrap())); + assert_eq!(pt.fg, ColorRef::rgb(Rgba::from_hex("00FF00").unwrap()), "the foreground stays"); +} + +#[test] +fn background_pattern_fill_offers_every_preset_and_both_colors() { + let mut s = Session::with_new(); + s.execute("design.background", &json!({"pattern": {"preset": "pct20", "fg": "accent1", "bg": "bg1"}})).unwrap(); + let mut h = harness(SlideApp::new(s, Services::default()), "background"); + h.get_by_label("Zig zag").click(); + h.run_steps(2); + assert_eq!(background_pattern(&h).preset, "zigZag"); + pick_hex(&mut h, "Background color", "FFEE00"); + let pt = background_pattern(&h); + assert_eq!((pt.preset.as_str(), pt.bg), ("zigZag", ColorRef::rgb(Rgba::from_hex("FFEE00").unwrap()))); +} + +#[test] +fn background_pattern_fill_keeps_a_system_color() { + let mut s = Session::with_new(); + let bg = json!({"system": "window", "last": "#F0F0F0"}); + s.execute("design.background", &json!({"pattern": {"preset": "pct20", "fg": "accent1", "bg": bg}})).unwrap(); + let mut h = harness(SlideApp::new(s, Services::default()), "background"); + let before = background_pattern(&h).bg; + assert!(matches!(before.base, ColorBase::System { .. })); + h.get_by_label("Zig zag").click(); + h.run_steps(2); + let pt = background_pattern(&h); + assert_eq!((pt.preset.as_str(), pt.bg), ("zigZag", before)); +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 0d691bcd..7f36a25a 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -604,7 +604,27 @@ pub fn cref_param(c: &deckcraft_model::ColorRef) -> Value { } Value::Object(o) } - _ => json!("#000000"), + deckcraft_model::ColorBase::Preset { name } => { + let mut o = serde_json::Map::new(); + o.insert("preset".into(), json!(name)); + for m in &c.mods { + if let Some(v) = m.value() { + o.insert(m.xml_name().into(), json!(v)); + } + } + Value::Object(o) + } + deckcraft_model::ColorBase::System { name, last } => { + let mut o = serde_json::Map::new(); + o.insert("system".into(), json!(name)); + o.insert("last".into(), json!(format!("#{}", last.hex()))); + for m in &c.mods { + if let Some(v) = m.value() { + o.insert(m.xml_name().into(), json!(v)); + } + } + Value::Object(o) + } } } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5abaf255..b38b9919 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -65,7 +65,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Pane / dialog | PowerPoint | DeckCraft | |---|---|---| -| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill (solid transparency follows the selection), gradient editor, picture/texture fill (source, tile, offsets, scale, alignment, mirror, transparency, rotate with shape), line (dash, compound, cap, join, begin/end arrows and sizes), shadow/glow/reflection details (colour, transparency, blur, angle, distance, size), soft edges, 3-D format and rotation (bevels, depth, contour, material, lighting, camera presets, X/Y/Z rotation), size (lock aspect ratio) | +| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill (solid transparency follows the selection), gradient editor, picture/texture fill (source, tile, offsets, scale, alignment, mirror, transparency, rotate with shape), pattern fill (gallery of the 54 presets, foreground and background colors), line (dash, compound, cap, join, begin/end arrows and sizes), shadow/glow/reflection details (colour, transparency, blur, angle, distance, size), soft edges, 3-D format and rotation (bevels, depth, contour, material, lighting, camera presets, X/Y/Z rotation), size (lock aspect ratio) | | Slide Master | Insert Layout, Rename, Delete, Preserve, Insert Placeholder, Title and Footers | Insert Layout, Rename, Delete, Insert Placeholder, and Preserve, Title and Footers checkboxes (`master.preserve`, `master.placeholders`). A master thumbnail selects the master | | Format Background pane | yes | yes: solid transparency; picture transparency, tile, offset, scale, alignment and mirror; Apply to All copies a picture. Stretch insets and rotate-with-shape are command parameters | | Selection Pane | front-most first, groups fold, drag to restack, Bring Forward / Send Backward arrows, Show All / Hide All, eye, padlock (Lock), rename in place | same: drag restacks among siblings (a child stays in its group), Ctrl+click toggles, Shift+click selects a run, arrows / Home / End / F2 / Esc in the list, right-click menu |