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.
This commit is contained in:
dotCooCoo
2026-10-24 14:13:22 -07:00
parent 38d2ade2b7
commit 2ddd2abe67
7 changed files with 290 additions and 26 deletions
+1
View File
@@ -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) |
+1
View File
@@ -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;
+5
View File
@@ -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<Item = &'static str> {
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
+128 -2
View File
@@ -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<PatternFill> {
let t = Tokens::get(ui.ctx());
let mut out: Option<PatternFill> = 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
}
+23 -23
View File
@@ -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)]
+131
View File
@@ -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())));
}
+1 -1
View File
@@ -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 |