mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-25 11:07:01 -06:00
Merge PR #545 from NikitkaRa: Pictures: Picture Styles gallery and Picture Border
# Conflicts: # crates/engine/src/cmd/mod.rs # crates/engine/src/tests.rs
This commit is contained in:
@@ -13,6 +13,7 @@ pub mod inspect;
|
||||
pub mod media;
|
||||
pub mod merge;
|
||||
pub mod points;
|
||||
pub mod picture_style;
|
||||
pub mod review;
|
||||
pub mod selection;
|
||||
pub mod shape;
|
||||
@@ -134,6 +135,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(insert::specs());
|
||||
v.extend(shape::specs());
|
||||
v.extend(background::specs());
|
||||
v.extend(picture_style::specs());
|
||||
v.extend(arrange::specs());
|
||||
v.extend(selection::specs());
|
||||
v.extend(merge::specs());
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Picture Format ▸ Picture Styles: PowerPoint's quick styles for pictures, each a frame (shape,
|
||||
//! outline) and effects applied as one step, replacing the previous style.
|
||||
|
||||
use deckcraft_color::{ColorTransform, Rgba};
|
||||
use deckcraft_model::style::{Effects, Reflection, Shadow};
|
||||
use deckcraft_model::{ColorRef, Geom, Line, Shape};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"picture.style",
|
||||
"Picture Styles",
|
||||
["Picture Format", "Picture Styles"],
|
||||
None,
|
||||
"{style: simpleFrameWhite|dropShadowRectangle|softEdgeOval|… | none (see picture.styles), ids?}",
|
||||
has_selection,
|
||||
style
|
||||
),
|
||||
cmd!(query "picture.styles", "Picture Style Gallery", [], None, "{} → [{id, label}]", always, |_s, _p| Ok(json!(
|
||||
STYLES.iter().map(|(id, label)| json!({"id": id, "label": label})).collect::<Vec<_>>()
|
||||
))),
|
||||
]
|
||||
}
|
||||
|
||||
/// (id, label), in PowerPoint's gallery order.
|
||||
pub const STYLES: [(&str, &str); 24] = [
|
||||
("simpleFrameWhite", "Simple Frame, White"),
|
||||
("beveledMatteWhite", "Beveled Matte, White"),
|
||||
("metalFrame", "Metal Frame"),
|
||||
("dropShadowRectangle", "Drop Shadow Rectangle"),
|
||||
("roundedDiagonalCornerWhite", "Rounded Diagonal Corner, White"),
|
||||
("snipDiagonalCornerWhite", "Snip Diagonal Corner, White"),
|
||||
("moderateFrameWhite", "Moderate Frame, White"),
|
||||
("rotatedWhite", "Rotated, White"),
|
||||
("perspectiveShadowWhite", "Perspective Shadow, White"),
|
||||
("softEdgeRectangle", "Soft Edge Rectangle"),
|
||||
("doubleFrameBlack", "Double Frame, Black"),
|
||||
("thickMatteBlack", "Thick Matte, Black"),
|
||||
("simpleFrameBlack", "Simple Frame, Black"),
|
||||
("beveledOvalBlack", "Beveled Oval, Black"),
|
||||
("compoundFrameBlack", "Compound Frame, Black"),
|
||||
("centerShadowRectangle", "Center Shadow Rectangle"),
|
||||
("softEdgeOval", "Soft Edge Oval"),
|
||||
("reflectedRoundedRectangle", "Reflected Rounded Rectangle"),
|
||||
("reflectedBevelWhite", "Reflected Bevel, White"),
|
||||
("metalRoundedRectangle", "Metal Rounded Rectangle"),
|
||||
("moderateFrameBlack", "Moderate Frame, Black"),
|
||||
("bevelRectangle", "Bevel Rectangle"),
|
||||
("bevelPerspective", "Bevel Perspective"),
|
||||
("metalOval", "Metal Oval"),
|
||||
];
|
||||
|
||||
/// The look of a style: geometry, outline (colour, width) and effects.
|
||||
pub struct Look {
|
||||
pub preset: &'static str,
|
||||
pub frame: Option<(Rgba, f64)>,
|
||||
pub shadow: Option<&'static str>,
|
||||
pub soft_edge: Option<f64>,
|
||||
pub reflection: bool,
|
||||
pub bevel: Option<&'static str>,
|
||||
pub rot: Option<f64>,
|
||||
}
|
||||
|
||||
pub fn look(id: &str) -> Option<Look> {
|
||||
let white = Rgba::rgb(0xFF, 0xFF, 0xFF);
|
||||
let black = Rgba::rgb(0, 0, 0);
|
||||
let silver = Rgba::rgb(0xC0, 0xC0, 0xC0);
|
||||
let l = |preset, frame, shadow| Look { preset, frame, shadow, soft_edge: None, reflection: false, bevel: None, rot: None };
|
||||
Some(match id {
|
||||
"simpleFrameWhite" => l("rect", Some((white, 7.0)), Some("soft")),
|
||||
"beveledMatteWhite" => Look { bevel: Some("circle"), ..l("rect", Some((white, 6.0)), Some("soft")) },
|
||||
"metalFrame" => Look { bevel: Some("angle"), ..l("rect", Some((silver, 7.0)), Some("soft")) },
|
||||
"dropShadowRectangle" => l("rect", None, Some("drop")),
|
||||
"roundedDiagonalCornerWhite" => l("round2DiagRect", Some((white, 6.0)), Some("soft")),
|
||||
"snipDiagonalCornerWhite" => l("snip2DiagRect", Some((white, 6.0)), Some("soft")),
|
||||
"moderateFrameWhite" => l("rect", Some((white, 4.5)), Some("soft")),
|
||||
"rotatedWhite" => Look { rot: Some(356.0), ..l("rect", Some((white, 6.0)), Some("soft")) },
|
||||
"perspectiveShadowWhite" => l("rect", Some((white, 4.0)), Some("perspective")),
|
||||
"softEdgeRectangle" => Look { soft_edge: Some(10.0), ..l("rect", None, None) },
|
||||
"doubleFrameBlack" => l("rect", Some((black, 6.0)), None),
|
||||
"thickMatteBlack" => l("rect", Some((black, 10.0)), Some("soft")),
|
||||
"simpleFrameBlack" => l("rect", Some((black, 5.0)), None),
|
||||
"beveledOvalBlack" => Look { bevel: Some("circle"), ..l("ellipse", Some((black, 4.0)), Some("soft")) },
|
||||
"compoundFrameBlack" => l("rect", Some((black, 4.5)), None),
|
||||
"centerShadowRectangle" => l("rect", None, Some("center")),
|
||||
"softEdgeOval" => Look { soft_edge: Some(12.0), ..l("ellipse", None, None) },
|
||||
"reflectedRoundedRectangle" => Look { reflection: true, ..l("roundRect", None, None) },
|
||||
"reflectedBevelWhite" => Look { reflection: true, bevel: Some("circle"), ..l("rect", Some((white, 4.0)), None) },
|
||||
"metalRoundedRectangle" => Look { bevel: Some("angle"), ..l("roundRect", Some((silver, 6.0)), Some("soft")) },
|
||||
"moderateFrameBlack" => l("rect", Some((black, 4.5)), Some("soft")),
|
||||
"bevelRectangle" => Look { bevel: Some("angle"), ..l("rect", None, Some("soft")) },
|
||||
"bevelPerspective" => Look { bevel: Some("relaxedInset"), ..l("rect", None, Some("perspective")) },
|
||||
"metalOval" => Look { bevel: Some("angle"), ..l("ellipse", Some((silver, 6.0)), Some("soft")) },
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn shadow(kind: &str) -> Shadow {
|
||||
let base = Shadow {
|
||||
color: ColorRef::rgb(Rgba::BLACK).with(ColorTransform::Alpha(40000)),
|
||||
blur: 4.0,
|
||||
dist: 3.0,
|
||||
dir: 45.0,
|
||||
inner: false,
|
||||
sx: 1.0,
|
||||
sy: 1.0,
|
||||
kx: 0.0,
|
||||
ky: 0.0,
|
||||
align: "tl".into(),
|
||||
rotate_with_shape: false,
|
||||
};
|
||||
match kind {
|
||||
"drop" => Shadow { blur: 12.0, dist: 6.0, dir: 90.0, ..base },
|
||||
"center" => Shadow { blur: 16.0, dist: 0.0, ..base },
|
||||
"perspective" => Shadow { sy: 0.23, kx: -20.0, dir: 90.0, dist: 0.0, blur: 6.0, align: "bl".into(), ..base },
|
||||
_ => base,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply style `id` (or clear the style with `none`) to `sh`.
|
||||
pub fn apply(sh: &mut Shape, id: &str) -> bool {
|
||||
if id == "none" {
|
||||
sh.geom = Geom::preset("rect");
|
||||
sh.line = None;
|
||||
sh.effects = None;
|
||||
return true;
|
||||
}
|
||||
let Some(lk) = look(id) else { return false };
|
||||
sh.geom = Geom::preset(lk.preset);
|
||||
sh.line = Some(lk.frame.map_or_else(Line::none, |(c, w)| Line::solid(ColorRef::rgb(c), w)));
|
||||
let e = Effects {
|
||||
outer_shadow: lk.shadow.map(shadow),
|
||||
soft_edge: lk.soft_edge,
|
||||
reflection: lk.reflection.then_some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 }),
|
||||
bevel: lk.bevel.map(str::to_string),
|
||||
..Default::default()
|
||||
};
|
||||
sh.effects = (!e.is_empty()).then_some(e);
|
||||
if let (Some(r), Some(x)) = (lk.rot, sh.xfrm.as_mut()) {
|
||||
x.rot = r;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = str_param(p, "style").ok_or_else(|| bad("picture.style", "missing `style`"))?.to_string();
|
||||
if id != "none" && look(&id).is_none() {
|
||||
return Err(bad("picture.style", format!("unknown style `{id}`; see picture.styles")));
|
||||
}
|
||||
edit_shapes(s, p, "picture.style", |sh| {
|
||||
apply(sh, &id);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -1714,3 +1714,51 @@ fn undo_history_is_capped_by_memory_as_well_as_steps() {
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picture_styles_replace_each_other_in_one_step() {
|
||||
const PNG: &[u8] = &[
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00,
|
||||
0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf,
|
||||
0xc0, 0xf0, 0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60,
|
||||
0x82,
|
||||
];
|
||||
let mut s = session();
|
||||
let data = cmd::base64_encode(PNG);
|
||||
let id = s.execute("insert.picture", &json!({"data": data, "name": "a.png", "rect": [100.0, 100.0, 200.0, 150.0]})).unwrap()["id"].clone();
|
||||
let pic = |s: &Session| s.doc().unwrap().shapes().iter().find(|sh| json!(sh.id) == id).unwrap().clone();
|
||||
assert_eq!(s.execute("picture.styles", &json!({})).unwrap().as_array().unwrap().len(), 24);
|
||||
|
||||
s.execute("picture.style", &json!({"ids": [id], "style": "reflectedRoundedRectangle"})).unwrap();
|
||||
let p = pic(&s);
|
||||
assert_eq!(p.geom.preset_name(), Some("roundRect"));
|
||||
assert!(p.effects.as_ref().unwrap().reflection.is_some());
|
||||
|
||||
// The next style replaces the whole look: no leftover reflection.
|
||||
s.execute("picture.style", &json!({"ids": [id], "style": "simpleFrameWhite"})).unwrap();
|
||||
let p = pic(&s);
|
||||
assert_eq!(p.geom.preset_name(), Some("rect"));
|
||||
let e = p.effects.as_ref().unwrap();
|
||||
assert!(e.reflection.is_none() && e.outer_shadow.is_some());
|
||||
assert_eq!(p.line.as_ref().and_then(|l| l.width), Some(7.0));
|
||||
|
||||
s.execute("picture.style", &json!({"ids": [id], "style": "softEdgeOval"})).unwrap();
|
||||
let p = pic(&s);
|
||||
assert_eq!(p.geom.preset_name(), Some("ellipse"));
|
||||
assert_eq!(p.effects.as_ref().unwrap().soft_edge, Some(12.0));
|
||||
assert!(p.line.as_ref().is_some_and(|l| l.fill == Some(deckcraft_model::Fill::None)));
|
||||
|
||||
// Survives PPTX.
|
||||
let doc = s.doc().unwrap().doc.as_ref().clone();
|
||||
let back = deckcraft_pptx::import(&deckcraft_pptx::export(&doc).unwrap()).unwrap();
|
||||
let bp = back.slides.iter().flat_map(|sl| &sl.shapes).find(|sh| matches!(sh.kind, deckcraft_model::ShapeKind::Picture { .. })).unwrap();
|
||||
assert_eq!(bp.geom.preset_name(), Some("ellipse"));
|
||||
assert_eq!(bp.effects.as_ref().and_then(|e| e.soft_edge), Some(12.0));
|
||||
|
||||
// One undo step per style; none clears; unknown is refused.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(pic(&s).geom.preset_name(), Some("rect"));
|
||||
s.execute("picture.style", &json!({"ids": [id], "style": "none"})).unwrap();
|
||||
assert!(pic(&s).effects.is_none() && pic(&s).line.is_none());
|
||||
assert!(s.execute("picture.style", &json!({"ids": [id], "style": "sparkly"})).is_err());
|
||||
}
|
||||
|
||||
@@ -2351,29 +2351,59 @@ fn picture_format(app: &mut SlideApp, ui: &mut Ui) {
|
||||
});
|
||||
});
|
||||
group(ui, |ui| {
|
||||
let r = big_button(ui, Icon::QuickStyles, "Picture\nStyles", true);
|
||||
egui::Popup::menu(&r).show(|ui| {
|
||||
if ui.button("Simple Frame, White").clicked() {
|
||||
run(app, "shape.line", json!({"color": "#FFFFFF", "width": 6}));
|
||||
run(app, "shape.effects", json!({"shadow": "outer"}));
|
||||
}
|
||||
if ui.button("Drop Shadow Rectangle").clicked() {
|
||||
run(app, "shape.effects", json!({"shadow": "bottom"}));
|
||||
}
|
||||
if ui.button("Rounded Diagonal Corner").clicked() {
|
||||
run(app, "shape.change", json!({"preset": "round2DiagRect"}));
|
||||
}
|
||||
if ui.button("Soft Edge Rectangle").clicked() {
|
||||
run(app, "shape.effects", json!({"softEdges": 10}));
|
||||
}
|
||||
if ui.button("Reflected Rounded Rectangle").clicked() {
|
||||
run(app, "shape.change", json!({"preset": "roundRect"}));
|
||||
run(app, "shape.effects", json!({"reflection": "half"}));
|
||||
}
|
||||
if ui.button("Oval").clicked() {
|
||||
run(app, "shape.change", json!({"preset": "ellipse"}));
|
||||
use deckcraft_engine::cmd::picture_style::{STYLES, look};
|
||||
let names: Vec<String> = STYLES.iter().map(|(_, l)| l.to_string()).collect();
|
||||
if let Some(i) = widgets::gallery(
|
||||
ui,
|
||||
STYLES.len(),
|
||||
vec2(56.0, 44.0),
|
||||
None,
|
||||
|p, r, i| paint_picture_style(p, r, STYLES.get(i).and_then(|(id, _)| look(id))),
|
||||
|i| names.get(i).cloned().unwrap_or_default(),
|
||||
) && let Some((id, _)) = STYLES.get(i)
|
||||
{
|
||||
run(app, "picture.style", json!({"style": id}));
|
||||
}
|
||||
});
|
||||
group(ui, |ui| {
|
||||
let sc = scheme(app);
|
||||
let (m, a) = color_split(ui, Icon::QuickStyles, "Picture Border", Color32::BLACK, true);
|
||||
if m.clicked() {
|
||||
run(app, "shape.line", json!({"color": "#000000", "width": 1.0}));
|
||||
}
|
||||
widgets::color_popup(&a).show(|ui| {
|
||||
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)})),
|
||||
None => run(app, "shape.line", json!({"none": true})),
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
ui.menu_button("Weight", |ui| {
|
||||
for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] {
|
||||
if ui.button(format!("{w} pt")).clicked() {
|
||||
run(app, "shape.line", json!({"width": w}));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.menu_button("Dashes", |ui| {
|
||||
for (l, d) in [
|
||||
("Solid", "solid"),
|
||||
("Round Dot", "sysDot"),
|
||||
("Square Dot", "sysDash"),
|
||||
("Dash", "dash"),
|
||||
("Dash Dot", "dashDot"),
|
||||
("Long Dash", "lgDash"),
|
||||
] {
|
||||
if ui.button(l).clicked() {
|
||||
run(app, "shape.line", json!({"dash": d}));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if ui.small_button("Reset Style").on_hover_text("Remove the picture style").clicked() {
|
||||
run(app, "picture.style", json!({"style": "none"}));
|
||||
}
|
||||
big(app, ui, Icon::AltText, "Alt\nText", "app.dialog", json!({"id": "altText"}));
|
||||
});
|
||||
arrange_group(app, ui);
|
||||
@@ -2487,6 +2517,46 @@ fn table_design(app: &mut SlideApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// A Picture Styles gallery tile: a landscape "picture" in the style's shape and frame.
|
||||
fn paint_picture_style(p: &egui::Painter, r: Rect, lk: Option<deckcraft_engine::cmd::picture_style::Look>) {
|
||||
let Some(lk) = lk else { return };
|
||||
let r = r.shrink(7.0);
|
||||
if lk.shadow.is_some() {
|
||||
p.rect_filled(r.translate(vec2(2.0, 2.0)), CornerRadius::same(2), Color32::from_black_alpha(60));
|
||||
}
|
||||
let sky = Color32::from_rgb(0x7F, 0xB2, 0xE5);
|
||||
let frame = lk.frame.map(|(c, w)| Stroke::new((w as f32 / 2.5).clamp(1.0, 4.0), theme::to_color32(c)));
|
||||
let edge = Stroke::new(0.5, Color32::from_gray(150));
|
||||
match lk.preset {
|
||||
"ellipse" => {
|
||||
p.add(egui::Shape::ellipse_filled(r.center(), r.size() / 2.0, sky));
|
||||
p.add(egui::Shape::ellipse_stroke(r.center(), r.size() / 2.0, frame.unwrap_or(edge)));
|
||||
}
|
||||
preset => {
|
||||
let round = if preset == "rect" { 0 } else { 6 };
|
||||
p.rect_filled(r, CornerRadius::same(round), sky);
|
||||
p.rect_stroke(r, CornerRadius::same(round), frame.unwrap_or(edge), egui::StrokeKind::Middle);
|
||||
}
|
||||
}
|
||||
// Hills, so the tile reads as a picture.
|
||||
let base = r.bottom() - r.height() * 0.15;
|
||||
p.line_segment(
|
||||
[pos2(r.left() + r.width() * 0.2, base), pos2(r.center().x, r.top() + r.height() * 0.45)],
|
||||
Stroke::new(1.5, Color32::from_rgb(0x3A, 0x7D, 0x44)),
|
||||
);
|
||||
p.line_segment(
|
||||
[pos2(r.center().x, r.top() + r.height() * 0.45), pos2(r.right() - r.width() * 0.2, base)],
|
||||
Stroke::new(1.5, Color32::from_rgb(0x3A, 0x7D, 0x44)),
|
||||
);
|
||||
if lk.reflection {
|
||||
p.rect_filled(
|
||||
Rect::from_min_max(pos2(r.left(), r.bottom() + 1.0), pos2(r.right(), r.bottom() + 5.0)),
|
||||
CornerRadius::ZERO,
|
||||
sky.gamma_multiply(0.3),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn paint_table_style(p: &egui::Painter, r: Rect, id: &str, sc: &deckcraft_color::ColorScheme) {
|
||||
let slot = match id.rsplit('-').next().unwrap_or("accent1") {
|
||||
"accent2" => deckcraft_color::SchemeSlot::Accent2,
|
||||
|
||||
@@ -27,7 +27,7 @@ The summary between `<!-- SUMMARY -->
|
||||
| Tables | 89% |
|
||||
| Charts | 65% |
|
||||
| SmartArt | 33% |
|
||||
| Pictures | 48% |
|
||||
| Pictures | 52% |
|
||||
| Media | 67% |
|
||||
| Transitions | 92% |
|
||||
| Animations | 79% |
|
||||
@@ -186,7 +186,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
|
||||
| Pictures | Corrections, colour, transparency | P1 | P |
|
||||
| Pictures | Artistic effects | P2 | M |
|
||||
| Pictures | Remove background | P2 | M |
|
||||
| Pictures | Picture styles and borders | P1 | P |
|
||||
| Pictures | Picture styles and borders | P1 | D |
|
||||
| Pictures | Change / reset picture | P1 | D |
|
||||
| Pictures | Compress pictures | P2 | M |
|
||||
| Pictures | Paste image from clipboard (fails on Windows and Linux, #53) | P0 | P |
|
||||
|
||||
Reference in New Issue
Block a user