Merge PR #500 from NikitkaRa: Format Shape: line compound, cap, join, arrows and sizes; lock aspect ratio

# Conflicts:
#	crates/engine/src/tests.rs
This commit is contained in:
Brandon Thomas
2026-10-14 13:08:46 -04:00
4 changed files with 128 additions and 5 deletions
+31 -3
View File
@@ -51,7 +51,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Shape Outline",
["Shape Format", "Shape Styles"],
None,
"{color?, none?, width?: pt, dash?: solid|dot|dash|lgDash|dashDot|sysDot|sysDash, cap?, join?, head?: none|triangle|stealth|diamond|oval|arrow, tail?, reset?, ids?}",
"{color?, none?, width?: pt, dash?: solid|dot|dash|lgDash|dashDot|sysDot|sysDash, cap?: flat|round|square, join?: round|bevel|miter, compound?: single|double|thickThin|thinThick|triple, head?: none|triangle|stealth|diamond|oval|arrow, tail?, headSize?: sm|med|lg, tailSize?, reset?, ids?}",
has_selection,
line
),
@@ -350,6 +350,15 @@ fn line(s: &mut Session, p: &Value) -> Result<Value> {
"square" => deckcraft_model::style::LineCap::Square,
_ => deckcraft_model::style::LineCap::Flat,
});
let compound = str_param(p, "compound").map(|c| match c {
"double" => deckcraft_model::style::Compound::Double,
"thickThin" => deckcraft_model::style::Compound::ThickThin,
"thinThick" => deckcraft_model::style::Compound::ThinThick,
"triple" => deckcraft_model::style::Compound::Triple,
_ => deckcraft_model::style::Compound::Single,
});
let end_size = |k: &str| str_param(p, k).filter(|v| matches!(*v, "sm" | "med" | "lg")).map(str::to_string);
let (head_size, tail_size) = (end_size("headSize"), end_size("tailSize"));
let join = str_param(p, "join").map(|c| match c {
"bevel" => deckcraft_model::style::LineJoin::Bevel,
"miter" => deckcraft_model::style::LineJoin::Miter,
@@ -376,11 +385,20 @@ fn line(s: &mut Session, p: &Value) -> Result<Value> {
if dash.is_some() {
l.dash.clone_from(&dash);
}
let keep_size = |old: &Option<LineEnd>, new: &Option<LineEnd>| {
new.clone().map(|mut n| {
if let Some(o) = old {
n.w.clone_from(&o.w);
n.len.clone_from(&o.len);
}
n
})
};
if let Some(h) = &head {
l.head.clone_from(h);
l.head = keep_size(&l.head, h);
}
if let Some(t) = &tail {
l.tail.clone_from(t);
l.tail = keep_size(&l.tail, t);
}
if cap.is_some() {
l.cap = cap;
@@ -388,6 +406,16 @@ fn line(s: &mut Session, p: &Value) -> Result<Value> {
if join.is_some() {
l.join = join;
}
if compound.is_some() {
l.compound = compound;
}
// Arrowhead size (width and length together), kept when the kind changes.
for (end, size) in [(&mut l.head, &head_size), (&mut l.tail, &tail_size)] {
if let (Some(e), Some(sz)) = (end.as_mut(), size) {
e.w.clone_from(sz);
e.len.clone_from(sz);
}
}
Ok(())
})
}
+24
View File
@@ -1207,3 +1207,27 @@ fn pdf_options_take_paper_and_orientation() {
assert!(cmd::file::pdf_options(&json!({"paper": "tabloid"}), 1).is_err());
assert!(cmd::file::pdf_options(&json!({"orientation": "sideways"}), 1).is_err());
}
#[test]
fn shape_line_sets_compound_and_arrow_sizes() {
// The Format Shape pane's Line page: compound type, arrowheads and their sizes.
use deckcraft_model::style::Compound;
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
s.execute("shape.insert", &json!({"preset": "line", "rect": [100, 100, 200, 0]})).unwrap();
s.execute("shape.line", &json!({"compound": "thickThin", "head": "oval", "tail": "triangle", "tailSize": "lg"})).unwrap();
let line = |s: &Session| s.doc().unwrap().selected_shapes()[0].line.clone().unwrap();
let l = line(&s);
assert_eq!(l.compound, Some(Compound::ThickThin));
assert_eq!(l.head.as_ref().map(|e| (e.kind.as_str(), e.w.as_str())), Some(("oval", "med")));
assert_eq!(l.tail.as_ref().map(|e| (e.kind.as_str(), e.w.as_str(), e.len.as_str())), Some(("triangle", "lg", "lg")));
// Changing the arrow keeps its size; an unknown size is ignored; "none" removes it.
s.execute("shape.line", &json!({"tail": "stealth", "headSize": "huge"})).unwrap();
let l = line(&s);
assert_eq!(l.tail.as_ref().map(|e| (e.kind.as_str(), e.w.as_str())), Some(("stealth", "lg")));
assert_eq!(l.head.as_ref().map(|e| e.w.as_str()), Some("med"));
s.execute("shape.line", &json!({"head": "none", "compound": "single"})).unwrap();
let l = line(&s);
assert!(l.head.is_none());
assert_eq!(l.compound, Some(Compound::Single));
}
+72 -1
View File
@@ -156,11 +156,18 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) {
}
}
"size" => {
let lock_id = egui::Id::new("fmt-lock-aspect");
let mut lock: bool = ui.data_mut(|d| d.get_temp(lock_id).unwrap_or(false));
let locked = lock;
let mut fields = |ui: &mut Ui, label: &str, id: &str, v: f64, key: &str, cmd: &str, unit: f64| {
ui.horizontal(|ui| {
ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label));
if let Some(nv) = widgets::spinner(ui, id, v / unit, 0.1, if unit == 72.0 { "\"" } else { "°" }, 70.0) {
run(app, cmd, json!({key: nv * unit}));
let mut p = json!({key: nv * unit});
if cmd == "shape.resize" && locked {
p["lockAspect"] = json!(true);
}
run(app, cmd, p);
}
});
};
@@ -168,6 +175,9 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) {
fields(ui, "Height", "fh", x.h, "h", "shape.resize", 72.0);
fields(ui, "Width", "fw", x.w, "w", "shape.resize", 72.0);
fields(ui, "Rotation", "fr", x.rot, "deg", "shape.rotate", 1.0);
if ui.checkbox(&mut lock, "Lock aspect ratio").changed() {
ui.data_mut(|d| d.insert_temp(lock_id, lock));
}
ui.add_space(6.0);
ui.label(egui::RichText::new("Position").font(theme::bold(13.0)));
fields(ui, "Horizontal", "fx", x.x, "x", "shape.move", 72.0);
@@ -337,6 +347,67 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) {
}
}
});
line_details(app, ui, &sh);
}
}
}
/// A labelled drop-down on the Format Shape pane: picks one of `items` (label, value) and runs
/// `shape.line` with `{key: value}`.
fn line_choice(app: &mut SlideApp, ui: &mut Ui, label: &str, key: &str, current: &str, items: &[(&str, &str)]) {
ui.horizontal(|ui| {
ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label));
let shown = items.iter().find(|(_, v)| *v == current).map_or(current, |(l, _)| *l);
egui::ComboBox::from_id_salt(("fmt-line", key)).selected_text(shown).width(120.0).show_ui(ui, |ui| {
for (l, v) in items {
if ui.selectable_label(*v == current, *l).clicked() {
run(app, "shape.line", json!({key: v}));
}
}
});
});
}
/// The rest of PowerPoint's Line page: compound type, cap and join types, and the begin and end
/// arrows with their sizes.
fn line_details(app: &mut SlideApp, ui: &mut Ui, sh: &deckcraft_model::Shape) {
use deckcraft_model::style::{Compound, LineCap, LineJoin};
let l = sh.line.clone().unwrap_or_default();
let compound = match l.compound.unwrap_or_default() {
Compound::Single => "single",
Compound::Double => "double",
Compound::ThickThin => "thickThin",
Compound::ThinThick => "thinThick",
Compound::Triple => "triple",
};
line_choice(
app,
ui,
"Compound type",
"compound",
compound,
&[("Single", "single"), ("Double", "double"), ("Thick Thin", "thickThin"), ("Thin Thick", "thinThick"), ("Triple", "triple")],
);
let cap = match l.cap.unwrap_or_default() {
LineCap::Flat => "flat",
LineCap::Round => "round",
LineCap::Square => "square",
};
line_choice(app, ui, "Cap type", "cap", cap, &[("Flat", "flat"), ("Round", "round"), ("Square", "square")]);
let join = match l.join.unwrap_or_default() {
LineJoin::Round => "round",
LineJoin::Bevel => "bevel",
LineJoin::Miter => "miter",
};
line_choice(app, ui, "Join type", "join", join, &[("Round", "round"), ("Bevel", "bevel"), ("Miter", "miter")]);
let arrows = [("None", "none"), ("Arrow", "triangle"), ("Open Arrow", "arrow"), ("Stealth", "stealth"), ("Diamond", "diamond"), ("Oval", "oval")];
let sizes = [("Small", "sm"), ("Medium", "med"), ("Large", "lg")];
for (label, key, size_label, size_key, end) in
[("Begin arrow", "head", "Begin size", "headSize", &l.head), ("End arrow", "tail", "End size", "tailSize", &l.tail)]
{
line_choice(app, ui, label, key, end.as_ref().map_or("none", |e| e.kind.as_str()), &arrows);
if let Some(e) = end {
line_choice(app, ui, size_label, size_key, &e.w, &sizes);
}
}
}
+1 -1
View File
@@ -61,7 +61,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, gradient editor, line, some effects, size; no 3-D, thin picture/texture options |
| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line (dash, compound, cap, join, begin/end arrows and sizes), some effects, size (lock aspect ratio); no 3-D, thin picture/texture options |
| Format Background pane | yes | yes |
| 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 |
| Animation Pane | list + advanced timeline | list, reorder, timing; no timeline |