Files
deckcraft/crates/ui-egui/src/dialogs.rs
T
alexanderalber e8c4c60884 Home: the title bar's Home button opens the start screen (#17) (#49)
It ran file.new, so each click silently added another blank presentation
and looked like it did nothing. Home is now a UI command (app.home) that
shows the start screen over the open presentations, with a "Back to …"
button; Escape or Home again goes back. Creating or opening a presentation
from it leaves it. While it's open, keys don't reach the hidden
presentation (only app shortcuts and Escape). ui.inspect reports `home`.

generated with Claude Code
2026-10-10 06:19:38 -04:00

964 lines
44 KiB
Rust

//! Dialogs (Insert Table, Slide Size, Header & Footer, Hyperlink, Set Up Show, Zoom, …), the start
//! screen and the About window.
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2};
use serde_json::{Value, json};
use crate::theme::{self, Tokens};
use crate::{SlideApp, ribbon};
#[derive(Clone, Debug, serde::Serialize)]
pub struct Dialog {
pub id: String,
pub params: Value,
/// Field values while the dialog is open.
#[serde(skip)]
pub fields: std::collections::HashMap<String, String>,
}
impl Dialog {
pub fn new(id: &str) -> Self {
Dialog { id: id.into(), params: Value::Null, fields: Default::default() }
}
pub fn modal(&self) -> bool {
true
}
fn get(&mut self, k: &str, default: &str) -> String {
self.fields.entry(k.into()).or_insert_with(|| default.to_string()).clone()
}
}
fn title(id: &str) -> &'static str {
match id {
"table" => "Insert Table",
"slideSize" => "Slide Size",
"headerFooter" => "Header and Footer",
"hyperlink" => "Insert Hyperlink",
"setupShow" => "Set Up Slide Show",
"zoom" => "Zoom",
"comment" => "New Comment",
"altText" => "Alt Text",
"outline" => "Slides from Outline",
"renameSection" => "Rename Section",
"renameShape" => "Rename",
"renameLayout" => "Rename Layout",
"paragraph" => "Paragraph",
"about" => "About DeckCraft",
"preferences" => "Preferences",
"export" => "Export",
"symbol" => "Symbol",
"equation" => "Equation",
"spelling" => "Spelling",
"accessibility" => "Accessibility Checker",
"language" => "Language",
"customShow" => "Custom Shows",
"chartData" => "Chart Data",
"trim" => "Trim Media",
"smartart" => "Choose a SmartArt Graphic",
"quit" => "DeckCraft",
_ => "DeckCraft",
}
}
pub fn show(app: &mut SlideApp, ctx: &egui::Context) {
let Some(mut d) = app.dialog.take() else { return };
let mut open = true;
let mut close = false;
egui::Window::new(title(&d.id))
.id(egui::Id::new(("dialog", d.id.clone())))
.collapsible(false)
.resizable(false)
.anchor(Align2::CENTER_CENTER, vec2(0.0, -40.0))
.open(&mut open)
.show(ctx, |ui| {
ui.set_min_width(340.0);
close = body(app, ui, &mut d);
});
if open && !close && !ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
app.dialog = Some(d);
}
}
fn buttons(ui: &mut Ui, ok_label: &str) -> (bool, bool) {
let mut ok = false;
let mut cancel = false;
ui.add_space(8.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let t = Tokens::get(ui.ctx());
if ui.add(egui::Button::new(egui::RichText::new(ok_label).color(t.accent_text)).fill(t.accent).min_size(vec2(72.0, 24.0))).clicked()
|| ui.input(|i| i.key_pressed(egui::Key::Enter))
{
ok = true;
}
if ui.add(egui::Button::new("Cancel").min_size(vec2(72.0, 24.0))).clicked() {
cancel = true;
}
});
(ok, cancel)
}
fn field(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, default: &str) {
let mut v = d.get(key, default);
ui.horizontal(|ui| {
ui.add_sized(vec2(120.0, 20.0), egui::Label::new(label));
ui.add(egui::TextEdit::singleline(&mut v).desired_width(180.0));
});
d.fields.insert(key.into(), v);
}
fn check(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, default: bool) -> bool {
let mut v = d.get(key, if default { "1" } else { "0" }) == "1";
ui.checkbox(&mut v, label);
d.fields.insert(key.into(), if v { "1".into() } else { "0".into() });
v
}
fn num(d: &mut Dialog, key: &str, default: f64) -> f64 {
d.get(key, "").trim().trim_end_matches(['"', '%', ' ']).parse::<f64>().ok().filter(|v| v.is_finite()).unwrap_or(default)
}
/// Returns true when the dialog should close.
fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
let run = |app: &mut SlideApp, id: &str, p: Value| {
let _ = app.run(id, p);
};
match d.id.as_str() {
"table" => {
field(ui, d, "Number of columns:", "cols", "5");
field(ui, d, "Number of rows:", "rows", "2");
let (ok, cancel) = buttons(ui, "Insert");
if ok {
let (r, c) = (num(d, "rows", 2.0) as usize, num(d, "cols", 5.0) as usize);
run(app, "insert.table", json!({"rows": r, "cols": c}));
}
ok || cancel
}
"slideSize" => {
let cur = app.session.active().map(|s| s.doc.slide_size).unwrap_or(deckcraft_model::defaults::WIDE);
ui.label("Slides sized for:");
let preset = d.get("preset", "");
egui::ComboBox::from_id_salt("sizes").selected_text(if preset.is_empty() { "Custom" } else { &preset }).show_ui(ui, |ui| {
for (label, w, h) in deckcraft_model::defaults::SLIDE_SIZES {
if ui.selectable_label(preset == *label, *label).clicked() {
d.fields.insert("preset".into(), label.to_string());
d.fields.insert("w".into(), format!("{:.2}", w / 72.0));
d.fields.insert("h".into(), format!("{:.2}", h / 72.0));
}
}
});
field(ui, d, "Width (in):", "w", &format!("{:.2}", cur.width / 72.0));
field(ui, d, "Height (in):", "h", &format!("{:.2}", cur.height / 72.0));
let maximize = check(ui, d, "Maximize content (else ensure fit)", "max", false);
let (ok, cancel) = buttons(ui, "OK");
if ok {
let (w, h) = (num(d, "w", cur.width / 72.0) * 72.0, num(d, "h", cur.height / 72.0) * 72.0);
run(app, "design.slideSize", json!({"w": w, "h": h, "scale": if maximize { "maximize" } else { "ensureFit" }}));
}
ok || cancel
}
"headerFooter" => {
let hf = app.session.active().map(|s| s.doc.header_footer.clone()).unwrap_or_default();
let date = check(ui, d, "Date and time", "date", hf.date);
field(ui, d, " Fixed date (empty = update automatically):", "dateText", &hf.date_text);
let num_on = check(ui, d, "Slide number", "num", hf.slide_number);
let foot = check(ui, d, "Footer", "footer", hf.footer);
field(ui, d, " Footer text:", "footerText", &hf.footer_text);
let hide = check(ui, d, "Don't show on title slide", "hide", hf.hide_on_title);
ui.add_space(6.0);
let mut apply_all = false;
let mut apply = false;
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
apply_all = ui.button("Apply to All").clicked();
apply = ui.button("Apply").clicked();
if ui.button("Cancel").clicked() {
d.fields.insert("cancel".into(), "1".into());
}
});
if apply || apply_all {
let dt = d.get("dateText", "");
let ft = d.get("footerText", "");
run(
app,
"design.headerFooter",
json!({"date": date, "dateText": dt, "slideNumber": num_on, "footer": foot, "footerText": ft, "hideOnTitle": hide, "all": apply_all}),
);
}
apply || apply_all || d.fields.contains_key("cancel")
}
"hyperlink" => {
let mode = d.get("mode", "url");
ui.horizontal(|ui| {
if ui.selectable_label(mode == "url", "Web Page or File").clicked() {
d.fields.insert("mode".into(), "url".into());
}
if ui.selectable_label(mode == "slide", "This Document").clicked() {
d.fields.insert("mode".into(), "slide".into());
}
if ui.selectable_label(mode == "email", "Email Address").clicked() {
d.fields.insert("mode".into(), "email".into());
}
});
match mode.as_str() {
"slide" => {
let titles: Vec<String> = app
.session
.active()
.map(|s| s.doc.slides.iter().enumerate().map(|(i, sl)| format!("{}. {}", i + 1, sl.title())).collect())
.unwrap_or_default();
let sel = d.get("slide", "0").parse::<usize>().unwrap_or(0);
egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| {
for (i, t) in titles.iter().enumerate() {
if ui.selectable_label(sel == i, t).clicked() {
d.fields.insert("slide".into(), i.to_string());
}
}
});
}
"email" => field(ui, d, "Email address:", "email", ""),
_ => field(ui, d, "Address:", "url", "https://"),
}
field(ui, d, "ScreenTip:", "tip", "");
let (ok, cancel) = buttons(ui, "OK");
if ok {
let tip = d.get("tip", "");
let p = match mode.as_str() {
"slide" => json!({"slide": d.get("slide", "0").parse::<usize>().unwrap_or(0), "tooltip": tip}),
"email" => json!({"url": format!("mailto:{}", d.get("email", "")), "tooltip": tip}),
_ => json!({"url": d.get("url", ""), "tooltip": tip}),
};
run(app, "insert.hyperlink", p);
}
ok || cancel
}
"setupShow" => {
let sh = app.session.active().map(|s| s.doc.show.clone()).unwrap_or_default();
let ty = d.get("type", &sh.show_type);
ui.label("Show type");
for (l, v) in [
("Presented by a speaker (full screen)", "speaker"),
("Browsed by an individual (window)", "browsed"),
("Browsed at a kiosk (full screen)", "kiosk"),
] {
if ui.radio(ty == v, l).clicked() {
d.fields.insert("type".into(), v.into());
}
}
let lp = check(ui, d, "Loop continuously until 'Esc'", "loop", sh.loop_until_esc);
let nn = check(ui, d, "Show without narration", "nn", sh.without_narration);
let na = check(ui, d, "Show without animation", "na", sh.without_animation);
let ut = check(ui, d, "Use timings, if present", "ut", sh.use_timings);
let (ok, cancel) = buttons(ui, "OK");
if ok {
let ty = d.get("type", "speaker");
run(app, "show.setup", json!({"type": ty, "loop": lp, "noNarration": nn, "noAnimation": na, "useTimings": ut}));
}
ok || cancel
}
"quit" => {
let names: Vec<String> = app.session.documents().iter().filter(|d| d.is_dirty()).map(|d| d.title()).collect();
if names.is_empty() {
app.quit_confirmed = true;
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
return true;
}
let what = if names.len() == 1 { format!("“{}”", names[0]) } else { format!("{} presentations", names.len()) };
ui.label(egui::RichText::new(format!("Do you want to save the changes you made to {what}?")).strong());
ui.label("Your changes will be lost if you don't save them.");
ui.add_space(8.0);
let mut done = false;
ui.horizontal(|ui| {
let t = Tokens::get(ui.ctx());
if ui.add(egui::Button::new("Don't Save").min_size(vec2(84.0, 24.0))).clicked() {
let _ = app.session.execute("file.recovery.discard", &json!({}));
app.quit_confirmed = true;
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
done = true;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let save = ui.add(egui::Button::new(egui::RichText::new("Save").color(t.accent_text)).fill(t.accent).min_size(vec2(72.0, 24.0)));
if save.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
if app.save_all() {
app.quit_confirmed = true;
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
}
done = true;
}
if ui.add(egui::Button::new("Cancel").min_size(vec2(72.0, 24.0))).clicked() {
done = true;
}
});
});
done
}
"zoom" => {
for pct in [400, 200, 150, 100, 75, 66, 50, 33] {
if ui.radio(false, format!("{pct}%")).clicked() {
let _ = app.run("view.zoom", json!({"percent": pct}));
}
}
if ui.button("Fit").clicked() {
app.ui.zoom = None;
}
let (ok, cancel) = buttons(ui, "OK");
ok || cancel
}
"comment" => {
let mut v = d.get("text", "");
ui.add(egui::TextEdit::multiline(&mut v).hint_text("Start a conversation").desired_rows(4).desired_width(320.0));
d.fields.insert("text".into(), v.clone());
let (ok, cancel) = buttons(ui, "Post");
if ok && !v.trim().is_empty() {
run(app, "comment.add", json!({"text": v}));
app.ui.pane = Some("comments".into());
}
ok || cancel
}
"altText" => {
let cur = app.session.active().and_then(|s| s.selected_shapes().first().map(|x| x.descr.clone())).unwrap_or_default();
ui.label("How would you describe this object and its context to someone who is blind or has low vision?");
let mut v = d.get("text", &cur);
ui.add(egui::TextEdit::multiline(&mut v).desired_rows(4).desired_width(340.0));
d.fields.insert("text".into(), v.clone());
let deco = check(ui, d, "Mark as decorative", "deco", false);
let (ok, cancel) = buttons(ui, "OK");
if ok {
run(app, "shape.altText", json!({"text": v, "decorative": deco}));
}
ok || cancel
}
"outline" => {
ui.label("Paste or type an outline. Unindented lines become slide titles; tab-indented lines become bullets.");
let mut v = d.get("text", "");
ui.add(egui::TextEdit::multiline(&mut v).desired_rows(10).desired_width(380.0).code_editor());
d.fields.insert("text".into(), v.clone());
let (ok, cancel) = buttons(ui, "Insert");
if ok {
run(app, "slide.fromOutline", json!({"text": v}));
}
ok || cancel
}
"renameSection" | "renameShape" | "renameLayout" => {
let cur = d.params.get("name").and_then(Value::as_str).unwrap_or("").to_string();
field(ui, d, "Name:", "name", &cur);
let (ok, cancel) = buttons(ui, "Rename");
if ok {
let name = d.get("name", "");
match d.id.as_str() {
"renameSection" => run(app, "section.rename", json!({"index": d.params.get("index").cloned().unwrap_or_default(), "name": name})),
"renameShape" => run(app, "shape.rename", json!({"id": d.params.get("id").cloned().unwrap_or_default(), "name": name})),
_ => run(app, "master.renameLayout", json!({"name": name})),
}
}
ok || cancel
}
"paragraph" => {
field(ui, d, "Before text (in):", "left", "0");
field(ui, d, "Special — hanging (in):", "hang", "0");
field(ui, d, "Spacing before (pt):", "before", "0");
field(ui, d, "Spacing after (pt):", "after", "0");
field(ui, d, "Line spacing (lines):", "lines", "1.0");
let (ok, cancel) = buttons(ui, "OK");
if ok {
run(
app,
"format.paragraph",
json!({"indentLeft": num(d, "left", 0.0) * 72.0, "indentFirst": -num(d, "hang", 0.0) * 72.0, "spaceBefore": num(d, "before", 0.0), "spaceAfter": num(d, "after", 0.0), "lineSpacing": num(d, "lines", 1.0)}),
);
}
ok || cancel
}
"export" => {
ui.label("Export the presentation as:");
let fmt = d.get("fmt", "pdf");
for (l, f) in [
("PDF document (.pdf)", "pdf"),
("PNG images (current slide)", "png"),
("PNG images (all slides)", "pngAll"),
("JPEG image (current slide)", "jpeg"),
("PowerPoint Presentation (.pptx)", "pptx"),
("Outline (.txt)", "outline"),
] {
if ui.radio(fmt == f, l).clicked() {
d.fields.insert("fmt".into(), f.into());
}
}
let pdf_layout = d.get("pdfLayout", "slides");
if fmt == "pdf" {
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label("Print layout:");
egui::ComboBox::from_id_salt("pdf_layout")
.selected_text(match pdf_layout.as_str() {
"notes" => "Notes Pages",
"h1" => "Handouts (1 slide per page)",
"h2" => "Handouts (2 slides per page)",
"h3" => "Handouts (3 slides per page)",
"h4" => "Handouts (4 slides per page)",
"h6" => "Handouts (6 slides per page)",
"h9" => "Handouts (9 slides per page)",
_ => "Full Page Slides",
})
.show_ui(ui, |ui| {
for (k, l) in [
("slides", "Full Page Slides"),
("notes", "Notes Pages"),
("h1", "Handouts (1 slide per page)"),
("h2", "Handouts (2 slides per page)"),
("h3", "Handouts (3 slides per page)"),
("h4", "Handouts (4 slides per page)"),
("h6", "Handouts (6 slides per page)"),
("h9", "Handouts (9 slides per page)"),
] {
if ui.selectable_label(pdf_layout == k, l).clicked() {
d.fields.insert("pdfLayout".into(), k.into());
}
}
});
});
let mut hidden = d.get("pdfHidden", "false") == "true";
if ui.checkbox(&mut hidden, "Include hidden slides").changed() {
d.fields.insert("pdfHidden".into(), hidden.to_string());
}
}
let pdf_params = {
let (layout, per) = match pdf_layout.as_str() {
"notes" => ("notes", 0),
l if l.starts_with('h') => ("handouts", l[1..].parse().unwrap_or(6)),
_ => ("slides", 0),
};
json!({"layout": layout, "perPage": per, "includeHidden": d.get("pdfHidden", "false") == "true"})
};
let (ok, cancel) = buttons(ui, "Export");
if ok {
let name = app.session.active().map(|s| s.title()).unwrap_or_else(|| "Presentation".into());
let (ext, all) = match fmt.as_str() {
"pdf" => ("pdf", false),
"pngAll" => ("png", true),
"jpeg" => ("jpg", false),
"pptx" => ("pptx", false),
"outline" => ("txt", false),
_ => ("png", false),
};
let suggested = format!("{name}.{ext}");
if let Some(pick) = app.services.pick_save.as_mut() {
if let Some(path) = pick(&suggested) {
let mut params = json!({"path": path, "all": all});
if ext == "pdf"
&& let (Some(o), Some(extra)) = (params.as_object_mut(), pdf_params.as_object())
{
o.extend(extra.clone());
}
let r = app.session.execute("file.export", &params);
match r {
Ok(_) => app.set_status(format!("Exported {path}")),
Err(e) => app.set_status(e.to_string()),
}
}
} else if let (Some(dl), Some(s)) = (app.services.download.as_mut(), app.session.active()) {
let bytes = match ext {
"png" | "jpg" => {
let img = deckcraft_render::render_slide(
&s.doc,
s.selection.slide,
&deckcraft_render::RenderOpts { scale: 2.0, ..Default::default() },
);
if ext == "png" { img.to_png() } else { img.to_jpeg(92) }
}
"pdf" => deckcraft_pdf_bytes(&s.doc, &pdf_params),
other => deckcraft_engine::cmd::file::save_bytes(&s.doc, other).unwrap_or_default(),
};
dl(&suggested, &bytes);
}
}
ok || cancel
}
"symbol" => {
ui.label("Click a symbol to insert it at the insertion point.");
let groups: [(&str, &str); 6] = [
("Punctuation", "–—‘’“”…•·§¶†‡‰′″‹›«»¡¿"),
("Currency", "$€£¥¢₹₩₽₺₿"),
("Math", "±×÷≠≈≤≥∞√∑∏∫∂∆∇∈∉∩∪⊂⊃∀∃¬∧∨⇒⇔°′"),
("Greek", "αβγδεζηθικλμνξοπρστυφχψωΓΔΘΛΞΠΣΦΨΩ"),
("Arrows", "←↑→↓↔↕⇐⇑⇒⇓↖↗↘↙⟵⟶"),
("Shapes", "■□▪▫▲△▶▷▼▽◀◁◆◇○●◎★☆✓✔✗✘♠♣♥♦"),
];
for (g, chars) in groups {
ui.label(egui::RichText::new(g).font(theme::bold(12.0)));
ui.horizontal_wrapped(|ui| {
for c in chars.chars() {
if ui.add(egui::Button::new(egui::RichText::new(c.to_string()).size(16.0)).min_size(vec2(26.0, 26.0))).clicked() {
if app.session.active().is_some_and(|s| s.selection.text.is_none()) {
let _ = app.run("text.edit", json!({}));
}
let _ = app.run("insert.symbol", json!({"text": c.to_string()}));
}
}
});
}
let (ok, cancel) = buttons(ui, "Close");
ok || cancel
}
"equation" => {
ui.label("Type an equation in linear form (e.g. a^2+b^2=c^2, x=(-b±√(b^2-4ac))/2a).");
let mut v = d.get("eq", "");
ui.add(egui::TextEdit::singleline(&mut v).desired_width(340.0).font(theme::font(15.0)));
d.fields.insert("eq".into(), v.clone());
let (ok, cancel) = buttons(ui, "Insert");
if ok && !v.is_empty() {
let pretty = pretty_equation(&v);
let size = app.session.active().map(|s| s.doc.slide_size).unwrap_or(deckcraft_model::defaults::WIDE);
run(app, "insert.textBox", json!({"rect": [size.width / 2.0 - 150.0, size.height / 2.0 - 25.0, 300, 50], "text": pretty}));
run(app, "text.exit", json!({}));
run(app, "format.font", json!({"family": "Liberation Serif"}));
run(app, "format.italic", json!({"on": true}));
run(app, "format.size", json!({"size": 28}));
}
ok || cancel
}
"spelling" => {
let v = app.session.execute("review.spelling", &json!({})).unwrap_or_default();
let list = v.as_array().cloned().unwrap_or_default();
if list.is_empty() {
ui.label("The spelling check is complete. No issues found.");
}
for item in &list {
ui.horizontal(|ui| {
let w = item.get("word").and_then(Value::as_str).unwrap_or("");
ui.label(format!("Slide {}: “{w}”", item.get("slide").and_then(Value::as_u64).unwrap_or(0) + 1));
if ui.small_button("Go to").clicked() {
let _ = app.run("slide.go", json!({"index": item.get("slide").cloned().unwrap_or_default()}));
}
});
}
let (ok, cancel) = buttons(ui, "Close");
ok || cancel
}
"accessibility" => {
let v = app.session.execute("review.accessibility", &json!({})).unwrap_or_default();
let list = v.as_array().cloned().unwrap_or_default();
if list.is_empty() {
ui.label("No accessibility issues found. People with disabilities should not have difficulty reading this document.");
}
egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
for item in &list {
ui.horizontal(|ui| {
ui.label(format!(
"Slide {}: {}{}",
item.get("slide").and_then(Value::as_u64).unwrap_or(0) + 1,
item.get("issue").and_then(Value::as_str).unwrap_or(""),
item.get("name").and_then(Value::as_str).map(|n| format!(" — {n}")).unwrap_or_default()
));
if ui.small_button("Go to").clicked() {
let _ = app.run("slide.go", json!({"index": item.get("slide").cloned().unwrap_or_default()}));
if let Some(id) = item.get("shape") {
let _ = app.run("edit.select", json!({"ids": [id]}));
}
}
});
}
});
let (ok, cancel) = buttons(ui, "Close");
ok || cancel
}
"language" => {
ui.label("Mark selected text as:");
for l in [
"English (United States)",
"English (United Kingdom)",
"Español",
"Français",
"Deutsch",
"Italiano",
"Português",
"日本語",
"中文",
"العربية",
] {
let _ = ui.selectable_label(l.starts_with("English (United States)"), l);
}
let (ok, cancel) = buttons(ui, "OK");
ok || cancel
}
"customShow" => {
let shows = app.session.active().map(|s| s.doc.custom_shows.clone()).unwrap_or_default();
for s in &shows {
ui.horizontal(|ui| {
ui.label(format!("{} ({} slides)", s.name, s.slides.len()));
if ui.small_button("Delete").clicked() {
let _ = app.run("show.customShow", json!({"name": s.name, "delete": true}));
}
});
}
field(ui, d, "New show name:", "name", "Custom Show 1");
field(ui, d, "Slides (e.g. 1,3,5):", "slides", "1");
let (ok, cancel) = buttons(ui, "Create");
if ok {
let idx: Vec<usize> =
d.get("slides", "").split(',').filter_map(|s| s.trim().parse::<usize>().ok()).filter(|v| *v > 0).map(|v| v - 1).collect();
run(app, "show.customShow", json!({"name": d.get("name", "Custom Show"), "slides": idx}));
}
ok || cancel
}
"chartData" => {
let ch = app.session.active().and_then(|s| {
s.selected_shapes()
.into_iter()
.find_map(|x| if let deckcraft_model::ShapeKind::Chart(c) = &x.kind { Some(((**c).clone(), x.id)) } else { None })
});
let Some((c, id)) = ch else {
ui.label("Select a chart first.");
let (ok, cancel) = buttons(ui, "Close");
return ok || cancel;
};
ui.label("Edit the data: first row = series names, first column = categories.");
let mut grid: Vec<Vec<String>> = d.params.get("grid").and_then(|g| serde_json::from_value(g.clone()).ok()).unwrap_or_else(|| {
let mut g = vec![std::iter::once(String::new()).chain(c.series.iter().map(|s| s.name.clone())).collect::<Vec<_>>()];
for (i, cat) in c.categories.iter().enumerate() {
let mut row = vec![cat.clone()];
for s in &c.series {
row.push(s.values.get(i).copied().flatten().map(|v| v.to_string()).unwrap_or_default());
}
g.push(row);
}
g
});
egui::Grid::new("chartdata").striped(true).show(ui, |ui| {
for row in grid.iter_mut() {
for cell in row.iter_mut() {
ui.add(egui::TextEdit::singleline(cell).desired_width(70.0));
}
ui.end_row();
}
});
ui.horizontal(|ui| {
if ui.button("+ Row").clicked() {
let n = grid.first().map(|r| r.len()).unwrap_or(2);
grid.push(vec![String::new(); n]);
}
if ui.button("+ Series").clicked() {
for r in grid.iter_mut() {
r.push(String::new());
}
}
});
d.params = json!({"grid": grid});
let (ok, cancel) = buttons(ui, "OK");
if ok {
let names: Vec<String> = grid.first().map(|r| r.iter().skip(1).cloned().collect()).unwrap_or_default();
let cats: Vec<String> = grid.iter().skip(1).map(|r| r.first().cloned().unwrap_or_default()).collect();
let series: Vec<Value> = names.iter().enumerate().map(|(k, n)| json!({"name": n, "values": grid.iter().skip(1).map(|r| r.get(k + 1).and_then(|v| v.trim().parse::<f64>().ok())).collect::<Vec<_>>()})).collect();
run(app, "chart.data", json!({"id": id.0, "categories": cats, "series": series}));
}
ok || cancel
}
"trim" => {
field(ui, d, "Start time (s):", "start", "0");
field(ui, d, "End trim (s):", "end", "0");
field(ui, d, "Fade in (s):", "fin", "0");
field(ui, d, "Fade out (s):", "fout", "0");
let (ok, cancel) = buttons(ui, "OK");
if ok {
run(
app,
"media.options",
json!({"trimStart": (num(d, "start", 0.0) * 1000.0) as u64, "trimEnd": (num(d, "end", 0.0) * 1000.0) as u64, "fadeIn": (num(d, "fin", 0.0) * 1000.0) as u64, "fadeOut": (num(d, "fout", 0.0) * 1000.0) as u64}),
);
}
ok || cancel
}
"smartart" => {
ui.label("Pick a graphic; type one item per line.");
let kind = d.get("kind", "process");
ui.horizontal_wrapped(|ui| {
for (k, l) in [
("list", "Basic List"),
("process", "Basic Process"),
("cycle", "Basic Cycle"),
("hierarchy", "Hierarchy"),
("pyramid", "Pyramid"),
("matrix", "Matrix"),
] {
if ui.selectable_label(kind == k, l).clicked() {
d.fields.insert("kind".into(), k.into());
}
}
});
let mut v = d.get("text", "Plan\nBuild\nLaunch");
ui.add(egui::TextEdit::multiline(&mut v).desired_rows(5).desired_width(320.0));
d.fields.insert("text".into(), v.clone());
let (ok, cancel) = buttons(ui, "Insert");
if ok {
run(app, "insert.smartArt", json!({"kind": kind, "items": v.lines().filter(|l| !l.trim().is_empty()).collect::<Vec<_>>()}));
}
ok || cancel
}
"about" => {
let tab = d.get("tab", "about");
ui.horizontal(|ui| {
for (k, l) in [("about", "About"), ("contributors", "Contributors"), ("models", "Models")] {
if ui.selectable_label(tab == k, l).clicked() {
d.fields.insert("tab".into(), k.into());
}
}
});
ui.separator();
match tab.as_str() {
"contributors" | "models" => {
// The credits need room for the table; the About tab keeps its compact size.
ui.set_min_size(vec2(640.0, 380.0));
ui.set_max_height(380.0);
if tab == "models" { crate::credits::models_ui(ui) } else { crate::credits::contributors_ui(ui) }
}
_ => about(app, ui),
}
let (ok, _) = buttons(ui, "Close");
ok
}
"preferences" => {
ui.label(egui::RichText::new("General").font(theme::bold(13.0)));
let mut dark = app.ui.brightness == theme::Brightness::Dark;
if ui.checkbox(&mut dark, "Dark appearance").changed() {
let _ = app.run("view.dark", json!({"on": dark}));
}
let mut scale = app.ui.ui_scale;
if ui.add(egui::Slider::new(&mut scale, 0.75..=1.75).text("Interface size")).changed() {
app.ui.ui_scale = scale;
}
ui.label(egui::RichText::new("Editing").font(theme::bold(13.0)));
ui.checkbox(&mut app.session.prefs.smart_guides, "Show smart guides");
ui.checkbox(&mut app.session.prefs.snap_to_grid, "Snap objects to grid");
ui.checkbox(&mut app.session.prefs.smart_quotes, "Replace straight quotes with smart quotes");
ui.checkbox(&mut app.session.prefs.autocorrect, "AutoCorrect as you type");
ui.horizontal(|ui| {
ui.label("Your name (comments):");
ui.text_edit_singleline(&mut app.session.prefs.author);
});
let (ok, _) = buttons(ui, "Done");
ok
}
_ => {
ui.label(format!("“{}” is not available yet.", d.id));
let (ok, cancel) = buttons(ui, "OK");
ok || cancel
}
}
}
fn pretty_equation(s: &str) -> String {
let sup = |c: char| match c {
'0' => '⁰',
'1' => '¹',
'2' => '²',
'3' => '³',
'4' => '⁴',
'5' => '⁵',
'6' => '⁶',
'7' => '⁷',
'8' => '⁸',
'9' => '⁹',
'n' => 'ⁿ',
'i' => 'ⁱ',
'+' => '⁺',
'-' => '⁻',
_ => c,
};
let mut out = String::new();
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
match c {
'^' => {
while let Some(&n) = chars.peek() {
if n.is_ascii_alphanumeric() || n == '+' || n == '-' {
out.push(sup(n));
chars.next();
if !n.is_ascii_digit() {
break;
}
} else {
break;
}
}
}
'*' => out.push('·'),
_ => out.push(c),
}
}
out.replace("sqrt", "√").replace("<=", "≤").replace(">=", "≥").replace("!=", "≠").replace("pi", "π").replace("+-", "±")
}
pub fn about(_app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
ui.vertical_centered(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(64.0, 64.0), Sense::hover());
paint_logo(ui.painter(), r);
ui.label(egui::RichText::new("DeckCraft").font(theme::bold(22.0)));
ui.label(egui::RichText::new(version_line()).color(t.text_dim));
ui.add_space(6.0);
ui.label("Presentations and slide shows, rebuilt from scratch in pure Rust.");
ui.label("An open-source, clean-room project. Not affiliated with Microsoft.");
ui.add_space(6.0);
ui.hyperlink_to("Join our community on Discord", deckcraft_engine::links::DISCORD);
ui.hyperlink_to("getartcraft.com/apps/deckcraft", deckcraft_engine::links::APP_PAGE);
ui.hyperlink_to("Source on GitHub", deckcraft_engine::links::GITHUB);
ui.add_space(6.0);
ui.label(egui::RichText::new("MIT OR Apache-2.0 · © 2026 ArtCraft Team and the DeckCraft contributors").size(11.0).color(t.text_faint));
});
}
/// "Version X.Y.Z" plus, for release builds, the short commit and build date. The version comes
/// from `[workspace.package] version` (the single source of truth, `cargo xtask version`); the
/// release workflow sets `DECKCRAFT_BUILD_SHA` / `DECKCRAFT_BUILD_DATE` at build time.
pub fn version_line() -> String {
let mut s = format!("Version {}", env!("CARGO_PKG_VERSION"));
let sha = option_env!("DECKCRAFT_BUILD_SHA").map(|s| s.get(..9).unwrap_or(s)).filter(|s| !s.is_empty());
let date = option_env!("DECKCRAFT_BUILD_DATE").filter(|s| !s.is_empty());
match (sha, date) {
(Some(sha), Some(date)) => s.push_str(&format!(" ({sha}, {date})")),
(Some(x), None) | (None, Some(x)) => s.push_str(&format!(" ({x})")),
(None, None) => {}
}
s
}
/// DeckCraft's mark, drawn in code: a slide card with a play triangle in our orange.
pub fn paint_logo(p: &egui::Painter, r: Rect) {
let orange = Color32::from_rgb(0xF2, 0x6B, 0x1D);
let deep = Color32::from_rgb(0xC2, 0x4E, 0x14);
let card = r.shrink(r.width() * 0.08);
p.rect_filled(card.translate(vec2(r.width() * 0.04, r.width() * 0.05)), CornerRadius::same((r.width() * 0.14) as u8), deep);
p.rect_filled(card, CornerRadius::same((r.width() * 0.14) as u8), orange);
let c = card.center();
let s = card.width() * 0.22;
p.add(egui::Shape::convex_polygon(
vec![pos2(c.x - s * 0.7, c.y - s), pos2(c.x + s, c.y), pos2(c.x - s * 0.7, c.y + s)],
Color32::WHITE,
Stroke::NONE,
));
}
/// The start screen (no presentation open): new from themes, open, recent files.
pub fn start_screen(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
egui::ScrollArea::vertical().show(ui, |ui| {
ui.add_space(24.0);
ui.horizontal(|ui| {
ui.add_space(40.0);
let (r, _) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::hover());
paint_logo(ui.painter(), r);
ui.label(egui::RichText::new("DeckCraft").font(theme::bold(26.0)));
});
if app.home_open() {
let title = app.session.active().map(|d| d.title()).unwrap_or_default();
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.add_space(40.0);
if ui.button(format!("← Back to {title}")).on_hover_text("Esc").clicked() {
let _ = app.run("app.home", json!({"on": false}));
}
});
}
ui.add_space(16.0);
ui.horizontal(|ui| {
ui.add_space(40.0);
ui.label(egui::RichText::new("New presentation").font(theme::bold(16.0)));
});
ui.add_space(8.0);
inset_wrapped(ui, |ui| {
for th in deckcraft_model::theme::builtin_themes() {
let (r, resp) = ui.allocate_exact_size(vec2(180.0, 128.0), Sense::click());
let tile = Rect::from_min_size(r.min, vec2(180.0, 101.0));
ribbon::paint_theme_tile(ui.painter(), tile, &th);
if resp.hovered() {
ui.painter().rect_stroke(tile, CornerRadius::same(2), Stroke::new(2.0, t.accent), egui::StrokeKind::Outside);
}
ui.painter().text(pos2(r.min.x, r.max.y - 10.0), Align2::LEFT_CENTER, &th.name, theme::font(12.0), t.text);
if resp.clicked() {
let _ = app.run("file.new", json!({"theme": th.name}));
}
}
});
ui.add_space(16.0);
ui.horizontal(|ui| {
ui.add_space(40.0);
if ui.button("Open…").clicked() {
let _ = app.run("app.openDialog", json!({}));
}
if ui.button("Open the sample deck").clicked()
&& let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session)
{
app.set_status(e.to_string());
}
});
if !app.ui.recent.is_empty() {
ui.add_space(16.0);
ui.horizontal(|ui| {
ui.add_space(40.0);
ui.label(egui::RichText::new("Recent").font(theme::bold(16.0)));
});
for p in app.ui.recent.clone() {
ui.horizontal(|ui| {
ui.add_space(40.0);
if ui.link(&p).clicked()
&& let Err(e) = app.open_path(&p)
{
app.set_status(e);
}
});
}
}
ui.add_space(30.0);
ui.horizontal(|ui| {
ui.add_space(40.0);
ui.hyperlink_to("Join the ArtCraft community on Discord", deckcraft_engine::links::DISCORD);
});
});
}
/// Wrapped rows that all start 40 points in from the left, in line with the start screen's
/// headings. The inset is a margin on the container: an `add_space` inside `horizontal_wrapped`
/// would indent only the first row.
fn inset_wrapped(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui)) {
egui::Frame::NONE.inner_margin(egui::Margin { left: 40, ..Default::default() }).show(ui, |ui| {
ui.horizontal_wrapped(add_contents);
});
}
/// PDF bytes with the export dialog's options (web: downloaded instead of written).
fn deckcraft_pdf_bytes(doc: &deckcraft_model::Presentation, params: &serde_json::Value) -> Vec<u8> {
deckcraft_engine::cmd::file::pdf_bytes(doc, params).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::{Cell, RefCell};
/// Start screen theme tiles that wrap onto a second row keep the 40-point inset (#17).
#[test]
fn inset_wrapped_indents_every_row() {
let left = Cell::new(0.0_f32);
let tiles = RefCell::new(Vec::<Rect>::new());
let mut harness = egui_kittest::Harness::builder().with_size(vec2(500.0, 400.0)).build_ui(|ui| {
left.set(ui.max_rect().left());
tiles.borrow_mut().clear();
inset_wrapped(ui, |ui| {
for _ in 0..4 {
let (r, _) = ui.allocate_exact_size(vec2(180.0, 128.0), Sense::click());
tiles.borrow_mut().push(r);
}
});
});
harness.run();
let tiles = tiles.borrow().clone();
let first_top = tiles.first().map_or(0.0, |r| r.top());
assert!(tiles.iter().any(|r| r.top() > first_top), "tiles should wrap onto a second row: {tiles:?}");
let mut row_top = f32::NEG_INFINITY;
for r in &tiles {
if r.top() > row_top {
row_top = r.top();
assert!((r.left() - (left.get() + 40.0)).abs() < 0.5, "row starts at {} instead of {}", r.left(), left.get() + 40.0);
}
}
}
}