From 16c5d6a6a36d046c31e44360d96640a481d81523 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 21:45:42 +0300 Subject: [PATCH] Format Shape: line compound, cap, join, arrows and sizes; lock aspect ratio The Format Shape pane's Line page had only colour, width and dash. It now has PowerPoint's compound type, cap type, join type, begin/end arrow type and begin/end arrow size drop-downs, and the Size page has Lock aspect ratio for the width and height fields. shape.line gains compound, headSize and tailSize, and changing an arrow's kind keeps its size. Co-authored-by: Cursor --- crates/engine/src/cmd/shape.rs | 34 ++++++++++++++-- crates/engine/src/tests.rs | 24 +++++++++++ crates/ui-egui/src/panes.rs | 73 +++++++++++++++++++++++++++++++++- docs/ui-parity.md | 2 +- 4 files changed, 128 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index b4c6aa4e..6ed152c5 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -51,7 +51,7 @@ pub fn specs() -> Vec { "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 { "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 { if dash.is_some() { l.dash.clone_from(&dash); } + let keep_size = |old: &Option, new: &Option| { + 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 { 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(()) }) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..37d2a4d4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -835,3 +835,27 @@ fn slide_ranges_parse_like_powerpoints_print_box() { let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap(); assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped"); } + +#[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)); +} diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 2b3ada3a..bdf17829 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -155,11 +155,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); } }); }; @@ -167,6 +174,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); @@ -335,6 +345,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); } } } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c403f05e..56502376 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -58,7 +58,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 |