mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-30 13:36:52 -06:00
M0: PowerPoint-style egui UI, slide show engine, icons, desktop app
- ui-egui: title bar, ribbon with every tab (Home, Insert, Draw, Design, Transitions, Animations, Slide Show, Record, Review, View) plus contextual Shape/Picture Format, Table Design/Layout, Chart Design, Playback and Slide Master tabs; thumbnail pane with sections and drag reorder; canvas with selection handles, smart guides, marquee, in-place text editing; notes pane; status bar with views and zoom; task panes (Format Shape, Background, Animation, Selection, Comments, Design Ideas); dialogs; Slide Sorter, Notes Page, Outline view; command palette; control channel - anim: timelines for every animation effect, transition layers for every transition, morph pairing, show navigation (hidden slides, custom shows, loops) - show: full-screen slide show with transitions, animations, pen, black/white screens, presenter view window, rehearse timings, reading view - icons: 238 original icons drawn in code - render: filters force single-threaded rendering (regression test), panic guard - engine: chart type/data/options, SmartArt-style graphics - apps/slidecraft: desktop host with control server, file dialogs, clipboard images - placeholder app icon rendered by SlideCraft itself Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
6078b68e97
commit
e62b7ccd15
@@ -50,6 +50,34 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("insert.dateTime", "Date & Time", ["Insert", "Text"], None, "{format?: datetime1..}", has_slide, date_time),
|
||||
cmd!("insert.symbol", "Symbol", ["Insert", "Symbols"], None, "{text: character(s)}", has_slide, symbol),
|
||||
cmd!("insert.hyperlink", "Link", ["Insert", "Links"], Some("Cmd+K"), "{url? | slide?: index, tooltip?, ids?}", has_text_or_shapes, hyperlink),
|
||||
cmd!("chart.type", "Change Chart Type", ["Chart Design", "Type"], None, "{type, id?}", has_selection, chart_type_cmd),
|
||||
cmd!(
|
||||
"chart.data",
|
||||
"Edit Data",
|
||||
["Chart Design", "Data"],
|
||||
None,
|
||||
"{categories?: [..], series?: [{name, values}], id?}",
|
||||
has_selection,
|
||||
chart_data
|
||||
),
|
||||
cmd!(
|
||||
"chart.options",
|
||||
"Chart Elements",
|
||||
["Chart Design", "Chart Layouts"],
|
||||
None,
|
||||
"{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, id?}",
|
||||
has_selection,
|
||||
chart_options
|
||||
),
|
||||
cmd!(
|
||||
"insert.smartArt",
|
||||
"SmartArt",
|
||||
["Insert", "Illustrations"],
|
||||
None,
|
||||
"{kind: list|process|cycle|hierarchy|pyramid|matrix, items: [text]}",
|
||||
has_slide,
|
||||
smart_art
|
||||
),
|
||||
cmd!(
|
||||
"insert.actionButton",
|
||||
"Action Buttons",
|
||||
@@ -539,3 +567,169 @@ fn action_button(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn _kinds() -> RunKind {
|
||||
RunKind::Text
|
||||
}
|
||||
|
||||
fn with_chart(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut Chart) -> Result<()>) -> Result<Value> {
|
||||
edit_shapes(s, p, cmd, |sh| match &mut sh.kind {
|
||||
ShapeKind::Chart(c) => f(c),
|
||||
_ => Err(bad(cmd, "not a chart")),
|
||||
})
|
||||
}
|
||||
|
||||
fn chart_type_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let k = chart_type(str_param(p, "type").unwrap_or("column"));
|
||||
with_chart(s, p, "chart.type", |c| {
|
||||
c.kind = k;
|
||||
c.vary_colors = matches!(k, ChartType::Pie | ChartType::Doughnut) || c.series.len() == 1 && c.vary_colors;
|
||||
c.raw = None;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn chart_data(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cats: Option<Vec<String>> = p
|
||||
.get("categories")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().map(|v| v.as_str().map(String::from).unwrap_or_else(|| v.to_string())).collect());
|
||||
let series: Option<Vec<slidecraft_model::chart::Series>> = p.get("series").and_then(Value::as_array).map(|a| {
|
||||
a.iter()
|
||||
.map(|sv| slidecraft_model::chart::Series {
|
||||
name: sv.get("name").and_then(Value::as_str).unwrap_or("Series").to_string(),
|
||||
values: sv.get("values").and_then(Value::as_array).map(|v| v.iter().map(Value::as_f64).collect()).unwrap_or_default(),
|
||||
..Default::default()
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
with_chart(s, p, "chart.data", |c| {
|
||||
if let Some(cs) = &cats {
|
||||
c.categories = cs.clone();
|
||||
}
|
||||
if let Some(se) = &series {
|
||||
// Keep series formatting by position.
|
||||
let old = std::mem::take(&mut c.series);
|
||||
c.series = se
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| {
|
||||
let mut x = old.get(i).cloned().unwrap_or_default();
|
||||
x.name = n.name.clone();
|
||||
x.values = n.values.clone();
|
||||
x
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
c.raw = None;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn chart_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
with_chart(s, p, "chart.options", |c| {
|
||||
if let Some(t) = p.get("title") {
|
||||
c.title = t.as_str().map(String::from);
|
||||
}
|
||||
if let Some(l) = p.get("legend") {
|
||||
c.legend = l.as_str().map(String::from);
|
||||
}
|
||||
if let Some(v) = bool_param(p, "dataLabels") {
|
||||
c.data_labels = v;
|
||||
}
|
||||
if let Some(v) = bool_param(p, "gridlines") {
|
||||
c.gridlines = v;
|
||||
}
|
||||
if let Some(v) = str_param(p, "palette") {
|
||||
c.palette = v.to_string();
|
||||
}
|
||||
c.raw = None;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// SmartArt-style graphics built from shapes (grouped), with our own layouts.
|
||||
fn smart_art(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let kind = str_param(p, "kind").unwrap_or("process").to_string();
|
||||
let items: Vec<String> =
|
||||
p.get("items").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(String::from).collect()).unwrap_or_default();
|
||||
let items = if items.is_empty() { vec!["Text".to_string(); 3] } else { items };
|
||||
let n = items.len().clamp(1, 12);
|
||||
let size = s.doc()?.doc.slide_size;
|
||||
let area = Xfrm::new(size.width * 0.1, size.height * 0.28, size.width * 0.8, size.height * 0.6);
|
||||
let slots = [SchemeSlot::Accent1, SchemeSlot::Accent2, SchemeSlot::Accent3, SchemeSlot::Accent4, SchemeSlot::Accent5, SchemeSlot::Accent6];
|
||||
let mut shapes: Vec<(String, Xfrm, usize)> = vec![];
|
||||
match kind.as_str() {
|
||||
"cycle" => {
|
||||
let r = area.h.min(area.w) * 0.38;
|
||||
let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0);
|
||||
let d = r * 0.62;
|
||||
for i in 0..n {
|
||||
let a = (i as f64 / n as f64 * 360.0 - 90.0).to_radians();
|
||||
shapes.push(("ellipse".into(), Xfrm::new(cx + r * a.cos() - d / 2.0, cy + r * a.sin() - d / 2.0, d, d), i));
|
||||
}
|
||||
}
|
||||
"hierarchy" => {
|
||||
let w = area.w / (n.max(2) - 1) as f64 * 0.8;
|
||||
shapes.push(("roundRect".into(), Xfrm::new(area.x + area.w / 2.0 - w / 2.0, area.y, w, area.h * 0.3), 0));
|
||||
for i in 1..n {
|
||||
let x = area.x + (i - 1) as f64 * area.w / (n - 1).max(1) as f64;
|
||||
shapes.push(("roundRect".into(), Xfrm::new(x + w * 0.1, area.y + area.h * 0.55, w, area.h * 0.3), i));
|
||||
}
|
||||
}
|
||||
"pyramid" => {
|
||||
let h = area.h / n as f64;
|
||||
for i in 0..n {
|
||||
let w = area.w * 0.25 + area.w * 0.75 * (i as f64 + 1.0) / n as f64;
|
||||
shapes.push(("trapezoid".into(), Xfrm::new(area.x + (area.w - w) / 2.0, area.y + i as f64 * h, w, h * 0.95), i));
|
||||
}
|
||||
}
|
||||
"matrix" => {
|
||||
let (w, h) = (area.w / 2.0, area.h / 2.0);
|
||||
for i in 0..n.min(4) {
|
||||
shapes.push((
|
||||
"roundRect".into(),
|
||||
Xfrm::new(area.x + (i % 2) as f64 * w + 4.0, area.y + (i / 2) as f64 * h + 4.0, w - 8.0, h - 8.0),
|
||||
i,
|
||||
));
|
||||
}
|
||||
}
|
||||
"list" => {
|
||||
let h = area.h / n as f64;
|
||||
for i in 0..n {
|
||||
shapes.push(("roundRect".into(), Xfrm::new(area.x, area.y + i as f64 * h, area.w, h * 0.85), i));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let gap = 24.0;
|
||||
let w = (area.w - gap * (n as f64 - 1.0)) / n as f64;
|
||||
for i in 0..n {
|
||||
shapes.push((
|
||||
if i + 1 < n { "homePlate".into() } else { "roundRect".into() },
|
||||
Xfrm::new(area.x + i as f64 * (w + gap), area.y + area.h * 0.25, w, area.h * 0.5),
|
||||
i,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ids = vec![];
|
||||
for (preset, x, i) in shapes {
|
||||
let mut sh = Shape {
|
||||
xfrm: Some(x),
|
||||
geom: Geom::preset(&preset),
|
||||
style: Some(ShapeStyle::accent(slots.get(i % 6).copied().unwrap_or(SchemeSlot::Accent1))),
|
||||
..Default::default()
|
||||
};
|
||||
let mut body = TextBody::from_text(items.get(i).map(String::as_str).unwrap_or(""));
|
||||
body.body.autofit = Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 });
|
||||
for para in &mut body.paragraphs {
|
||||
para.props.align = Some(slidecraft_model::text::Align::Center);
|
||||
for r in &mut para.runs {
|
||||
r.props.size = Some(20.0);
|
||||
}
|
||||
}
|
||||
sh.text = Some(body);
|
||||
ids.push(add_shape(s, sh, false)?.0);
|
||||
}
|
||||
let g = s.execute("arrange.group", &json!({"ids": ids}))?;
|
||||
if let Some(gid) = g.get("id").and_then(Value::as_u64) {
|
||||
let _ = s.execute("shape.rename", &json!({"id": gid, "name": format!("SmartArt ({kind})")}));
|
||||
}
|
||||
Ok(g)
|
||||
}
|
||||
|
||||
@@ -242,7 +242,7 @@ pub(crate) fn shape_of(s: &Session, id: ShapeId) -> Result<slidecraft_model::Sha
|
||||
}
|
||||
|
||||
/// Effective box of a shape (placeholders inherit theirs).
|
||||
pub(crate) fn xfrm_of(doc: &slidecraft_model::Presentation, sel: &crate::Selection, shape: &slidecraft_model::Shape) -> Xfrm {
|
||||
pub fn xfrm_of(doc: &slidecraft_model::Presentation, sel: &crate::Selection, shape: &slidecraft_model::Shape) -> Xfrm {
|
||||
if let Some(x) = shape.xfrm {
|
||||
return x;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user