From 2ddd2abe674b276b58abec135e2b44ca39144d8f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 14:04:09 -0700 Subject: [PATCH] Format: Pattern fill shows a gallery of every preset with Foreground and Background colors Format Shape's Pattern fill set 25 % in accent 1 on background 1 and offered no way to change it, and Format Background listed 10 of the 54 presets by their XML names. Both panes now show the same editor: a gallery of the 54 DrawingML presets drawn in the fill's own colors, each named in words, and Foreground and Background color menus. With a pattern fill, Format Shape no longer shows the solid-fill Color button, which replaced the pattern with a solid fill, or the Transparency slider, which did not change a pattern. The renderer exports the preset list and tiles for the gallery to draw. --- ROADMAP.md | 1 + crates/render/src/lib.rs | 1 + crates/render/src/pattern.rs | 5 + crates/ui-egui/src/fillui.rs | 130 +++++++++++++++++++++- crates/ui-egui/src/panes.rs | 46 ++++---- crates/ui-egui/src/pattern_fill_tests.rs | 131 +++++++++++++++++++++++ docs/ui-parity.md | 2 +- 7 files changed, 290 insertions(+), 26 deletions(-) create mode 100644 crates/ui-egui/src/pattern_fill_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 73b62ee6..7da0c81b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -149,6 +149,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/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..f2b2bf9f 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() { 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 14ac9344..1829b458 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -475,11 +475,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. @@ -510,6 +517,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)); @@ -871,25 +883,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)) => { @@ -1479,6 +1475,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..3b4a2d71 --- /dev/null +++ b/crates/ui-egui/src/pattern_fill_tests.rs @@ -0,0 +1,131 @@ +//! 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, 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_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()))); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 3cd55535..da2f259b 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 |