Files
deckcraft/crates/ui-egui/src/dialogs.rs
T
Brandon Thomas e74015490b Merge PR #660 from NikitkaRa: Show: play a custom show
# Conflicts:
#	crates/anim/src/show.rs
#	crates/engine/src/cmd/review.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/show.rs
2026-10-14 13:49:10 -04:00

1235 lines
56 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Dialogs (Insert Table, Slide Size, Header & Footer, Hyperlink, Set Up Show, Zoom, …) and the
//! About window. The start screen is File › Home (`backstage.rs`).
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2};
use serde_json::{Value, json};
use crate::SlideApp;
use crate::theme::{self, Tokens};
#[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",
"sizePosition" => "Size and Position",
"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",
"pasteSpecial" => "Paste Special",
"accessibility" => "Accessibility Checker",
"language" => "Language",
"customShow" => "Custom Shows",
"chartData" => "Chart Data",
"trim" => "Trim Media",
"smartart" => "Choose a SmartArt Graphic",
"bullets" => "Bullets and Numbering",
"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 (w, h) = (num(d, "w", cur.width / 72.0), num(d, "h", cur.height / 72.0));
ui.horizontal(|ui| {
ui.label("Orientation:");
let portrait = ui.radio(h > w, "Portrait").clicked();
let landscape = ui.radio(w >= h, "Landscape").clicked();
// Picking the other orientation swaps the width and height fields.
if portrait && w > h || landscape && h > w {
d.fields.insert("w".into(), format!("{h:.2}"));
d.fields.insert("h".into(), format!("{w:.2}"));
}
});
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
}
"sizePosition" => size_position_dialog(app, ui, d),
"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 names: Vec<String> = app.session.active().map(|s| s.doc.custom_shows.iter().map(|c| c.name.clone()).collect()).unwrap_or_default();
let chosen = d.fields.entry("custom".into()).or_insert_with(|| sh.custom_show.clone().unwrap_or_default()).clone();
ui.label("Slides");
let label = if chosen.is_empty() { "All slides".to_string() } else { chosen.clone() };
egui::ComboBox::from_id_salt("setup_custom").selected_text(label).show_ui(ui, |ui| {
if ui.selectable_label(chosen.is_empty(), "All slides").clicked() {
d.fields.insert("custom".into(), String::new());
}
for n in &names {
if ui.selectable_label(&chosen == n, n).clicked() {
d.fields.insert("custom".into(), n.clone());
}
}
});
let (ok, cancel) = buttons(ui, "OK");
if ok {
let ty = d.get("type", "speaker");
let custom = d.fields.get("custom").cloned().unwrap_or_default();
run(app, "show.setup", json!({"type": ty, "loop": lp, "noNarration": nn, "noAnimation": na, "useTimings": ut, "custom": custom}));
}
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
}
"close" => {
// File › Close and Ctrl+W on a presentation with unsaved changes.
let Some(name) = app.session.active().map(|d| d.title()) else { return true };
ui.label(egui::RichText::new(format!("Do you want to save the changes you made to “{name}”?")).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() {
run(app, "file.close", json!({}));
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)) {
app.save();
// In the browser a save is a download: the presentation stays "unsaved".
let web = app.services.pick_save.is_none() && app.services.download.is_some();
if web || app.session.active().is_some_and(|d| !d.is_dirty()) {
run(app, "file.close", json!({}));
}
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
}
"bullets" => {
// The current paragraph's list, as the dialog's starting point.
let cur = app.session.execute("format.state", &json!({})).ok().unwrap_or(Value::Null);
let numbered = cur.get("numbering").and_then(Value::as_bool).unwrap_or(false);
let tab = d.get("tab", if numbered { "numbered" } else { "bulleted" });
ui.horizontal(|ui| {
for (k, l) in [("bulleted", "Bulleted"), ("numbered", "Numbered")] {
if ui.selectable_label(tab == k, l).clicked() {
d.fields.insert("tab".into(), k.into());
}
}
});
ui.separator();
let style_key = if tab == "numbered" { "scheme" } else { "char" };
let current = d.get(style_key, if tab == "numbered" { "arabicPeriod" } else { "•" });
let styles: &[(&str, &str)] = if tab == "numbered" {
&[
("arabicPeriod", "1. 2. 3."),
("arabicParenR", "1) 2) 3)"),
("romanUcPeriod", "I. II. III."),
("romanLcPeriod", "i. ii. iii."),
("alphaUcPeriod", "A. B. C."),
("alphaLcParenR", "a) b) c)"),
("alphaLcPeriod", "a. b. c."),
]
} else {
&[("•", "•"), ("○", "○"), ("■", "■"), ("□", "□"), ("◆", "◆"), ("➢", "➢"), ("✓", "✓"), ("–", "–"), ("★", "★")]
};
egui::Grid::new("bullet-styles").show(ui, |ui| {
for (i, (k, l)) in styles.iter().enumerate() {
let r = ui.add_sized(
vec2(if tab == "numbered" { 88.0 } else { 44.0 }, 40.0),
egui::Button::selectable(current == *k, egui::RichText::new(*l).size(16.0)),
);
if r.clicked() {
d.fields.insert(style_key.into(), (*k).into());
}
if (i + 1) % if tab == "numbered" { 4 } else { 5 } == 0 {
ui.end_row();
}
}
});
if tab == "bulleted" {
field(ui, d, "Custom character:", "custom", "");
} else {
field(ui, d, "Start at:", "start", "1");
}
field(ui, d, "Size (% of text):", "size", "100");
let colors = [
("", "Automatic"),
("tx1", "Text"),
("accent1", "Accent 1"),
("accent2", "Accent 2"),
("accent3", "Accent 3"),
("accent4", "Accent 4"),
("accent5", "Accent 5"),
("accent6", "Accent 6"),
];
let color = d.get("color", "");
ui.horizontal(|ui| {
ui.add_sized(vec2(120.0, 20.0), egui::Label::new("Color:"));
egui::ComboBox::from_id_salt("bullet-color")
.selected_text(colors.iter().find(|c| c.0 == color).map_or("Automatic", |c| c.1))
.show_ui(ui, |ui| {
for (k, l) in colors {
if ui.selectable_label(color == k, l).clicked() {
d.fields.insert("color".into(), k.into());
}
}
});
});
ui.horizontal(|ui| {
if ui.button("None").on_hover_text("Remove bullets and numbering").clicked() {
d.fields.insert("none".into(), "1".into());
}
});
let (ok, cancel) = buttons(ui, "OK");
let none = d.fields.contains_key("none");
if none {
run(app, if tab == "numbered" { "format.numbering" } else { "format.bullets" }, json!({"on": false}));
} else if ok {
let mut p = json!({"on": true});
if let Some(size) = d.get("size", "100").trim().trim_end_matches('%').parse::<f64>().ok().filter(|v| v.is_finite()) {
p["size"] = json!(size.clamp(25.0, 400.0));
}
if !color.is_empty() {
p["color"] = json!(color);
}
if tab == "numbered" {
p["scheme"] = json!(d.get("scheme", "arabicPeriod"));
p["start"] = json!(d.get("start", "1").trim().parse::<u64>().unwrap_or(1).clamp(1, 32767));
run(app, "format.numbering", p);
} else {
let custom = d.get("custom", "");
let ch = custom.chars().find(|c| !c.is_whitespace()).map(String::from).unwrap_or_else(|| d.get("char", "•"));
p["char"] = json!(ch);
run(app, "format.bullets", p);
}
}
ok || cancel || none
}
"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 extra = if fmt == "pdf" { pdf_params } else { json!({}) };
app.export_as(&fmt, extra);
}
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
}
"pasteSpecial" => {
const OPTS: [(&str, &str); 4] = [
("destinationTheme", "Use Destination Theme"),
("keepSource", "Keep Source Formatting"),
("picture", "Picture (PNG)"),
("text", "Keep Text Only"),
];
let has_shapes = !app.session.clipboard.shapes.is_empty();
let mut cur = d.get("as", "destinationTheme");
ui.label("As:");
for (id, label) in OPTS {
let ok = has_shapes || matches!(id, "destinationTheme" | "text");
ui.add_enabled_ui(ok, |ui| ui.radio_value(&mut cur, id.to_string(), label));
}
d.fields.insert("as".into(), cur.clone());
let (ok, cancel) = buttons(ui, "OK");
if ok {
run(app, "edit.pasteSpecial", json!({"as": cur}));
}
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();
let mut started = false;
for s in &shows {
ui.horizontal(|ui| {
ui.label(format!("{} ({} slides)", s.name, s.slides.len()));
if ui.small_button("Show").clicked() {
run(app, "show.start", json!({"custom": s.name}));
started = true;
}
if ui.small_button("Delete").clicked() {
let _ = app.run("show.customShow", json!({"name": s.name, "delete": true}));
}
});
}
if started {
return 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 num_text(v: f64) -> String {
crate::widgets::fmt_num(v, "")
}
fn field_of(d: &Dialog, key: &str) -> String {
d.fields.get(key).cloned().unwrap_or_default()
}
fn parse_shown(s: &str) -> Option<f64> {
let t = s.trim().trim_end_matches(['%', '"']).trim();
if t.is_empty() {
return None;
}
t.parse().ok().filter(|v: &f64| v.is_finite())
}
fn put_dim(d: &mut Dialog, key: &str, exact: f64, shown: String) {
d.fields.insert(key.into(), shown.clone());
d.fields.insert(format!("seed{key}"), shown.clone());
d.fields.insert(format!("prev{key}"), shown);
d.fields.insert(format!("{key}Pt"), exact.to_string());
}
fn publish_dim(d: &mut Dialog, key: &str, exact: f64, shown: String) {
d.fields.insert(key.into(), shown.clone());
d.fields.insert(format!("seed{key}"), shown);
d.fields.insert(format!("{key}Pt"), exact.to_string());
}
fn edited(d: &Dialog, key: &str) -> bool {
field_of(d, key) != field_of(d, &format!("seed{key}"))
}
fn exact_of(d: &Dialog, key: &str) -> Option<f64> {
parse_shown(&field_of(d, &format!("{key}Pt")))
}
fn selection_xfrm(app: &SlideApp) -> Option<deckcraft_geom::Xfrm> {
let st = app.session.active()?;
let id = *st.selection.shapes.first()?;
let sh = st.shape(id)?;
Some(deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh))
}
fn seed_size_position(app: &SlideApp, d: &mut Dialog) {
if d.fields.contains_key("baseW") {
return;
}
let num = |k: &str| d.params.get(k).and_then(Value::as_f64).filter(|v| v.is_finite());
let sel = selection_xfrm(app);
let x = num("x").or_else(|| sel.map(|b| b.x)).unwrap_or(0.0);
let y = num("y").or_else(|| sel.map(|b| b.y)).unwrap_or(0.0);
let w = num("w").or_else(|| sel.map(|b| b.w)).unwrap_or(72.0);
let h = num("h").or_else(|| sel.map(|b| b.h)).unwrap_or(72.0);
let rot = num("rot").or_else(|| sel.map(|b| b.rot)).unwrap_or(0.0);
let sw = num("scaleW").unwrap_or(100.0);
let sh = num("scaleH").unwrap_or(100.0);
let lock = d.params.get("lockAspect").and_then(Value::as_bool).unwrap_or(false);
put_dim(d, "x", x, num_text(x / 72.0));
put_dim(d, "y", y, num_text(y / 72.0));
put_dim(d, "w", w, num_text(w / 72.0));
put_dim(d, "h", h, num_text(h / 72.0));
put_dim(d, "rot", rot, num_text(rot));
put_dim(d, "scaleW", sw, num_text(sw));
put_dim(d, "scaleH", sh, num_text(sh));
d.fields.insert("baseW".into(), w.to_string());
d.fields.insert("baseH".into(), h.to_string());
let bit = if lock { "1" } else { "0" };
d.fields.insert("lock".into(), bit.into());
d.fields.insert("prevlock".into(), bit.into());
}
fn scale_of(pt: f64, base: f64) -> f64 {
if base > 0.0 { pt / base * 100.0 } else { 100.0 }
}
fn current_points(d: &Dialog, key: &str) -> Option<f64> {
if edited(d, key) { parse_shown(&field_of(d, key)).map(|inches| inches * 72.0) } else { exact_of(d, key) }
}
/// Keep scale tied to size, and the other side tied when aspect is locked.
fn reconcile_size_fields(d: &mut Dialog) {
let lock = field_of(d, "lock") == "1";
let was_locked = field_of(d, "prevlock") == "1";
let base_w = parse_shown(&field_of(d, "baseW")).unwrap_or(1.0);
let base_h = parse_shown(&field_of(d, "baseH")).unwrap_or(1.0);
let ar = if base_h > 0.0 { base_w / base_h } else { 1.0 };
let (w_s, h_s, sw_s, sh_s) = (field_of(d, "w"), field_of(d, "h"), field_of(d, "scaleW"), field_of(d, "scaleH"));
let (prev_w, prev_h, prev_sw, prev_sh) = (field_of(d, "prevw"), field_of(d, "prevh"), field_of(d, "prevscaleW"), field_of(d, "prevscaleH"));
if w_s != prev_w {
if let Some(inches) = parse_shown(&w_s) {
let w_pt = inches * 72.0;
d.fields.insert("wPt".into(), w_pt.to_string());
let pct = scale_of(w_pt, base_w);
publish_dim(d, "scaleW", pct, num_text(pct));
if lock {
let h_pt = if ar > 0.0 { w_pt / ar } else { w_pt };
publish_dim(d, "h", h_pt, num_text(h_pt / 72.0));
let hp = scale_of(h_pt, base_h);
publish_dim(d, "scaleH", hp, num_text(hp));
}
}
} else if h_s != prev_h {
if let Some(inches) = parse_shown(&h_s) {
let h_pt = inches * 72.0;
d.fields.insert("hPt".into(), h_pt.to_string());
let pct = scale_of(h_pt, base_h);
publish_dim(d, "scaleH", pct, num_text(pct));
if lock {
let w_pt = h_pt * ar;
publish_dim(d, "w", w_pt, num_text(w_pt / 72.0));
let wp = scale_of(w_pt, base_w);
publish_dim(d, "scaleW", wp, num_text(wp));
}
}
} else if sw_s != prev_sw {
if let Some(pct) = parse_shown(&sw_s) {
let w_pt = base_w * pct / 100.0;
d.fields.insert("scaleWPt".into(), pct.to_string());
publish_dim(d, "w", w_pt, num_text(w_pt / 72.0));
if lock {
let h_pt = if ar > 0.0 { w_pt / ar } else { w_pt };
publish_dim(d, "h", h_pt, num_text(h_pt / 72.0));
publish_dim(d, "scaleH", pct, num_text(pct));
}
}
} else if sh_s != prev_sh {
if let Some(pct) = parse_shown(&sh_s) {
let h_pt = base_h * pct / 100.0;
d.fields.insert("scaleHPt".into(), pct.to_string());
publish_dim(d, "h", h_pt, num_text(h_pt / 72.0));
if lock {
let w_pt = h_pt * ar;
publish_dim(d, "w", w_pt, num_text(w_pt / 72.0));
publish_dim(d, "scaleW", pct, num_text(pct));
}
}
} else if lock
&& !was_locked
&& let Some(w_pt) = current_points(d, "w")
{
let h_pt = if ar > 0.0 { w_pt / ar } else { w_pt };
publish_dim(d, "h", h_pt, num_text(h_pt / 72.0));
let hp = scale_of(h_pt, base_h);
publish_dim(d, "scaleH", hp, num_text(hp));
let wp = scale_of(w_pt, base_w);
publish_dim(d, "scaleW", wp, num_text(wp));
}
for key in ["x", "y", "w", "h", "rot", "scaleW", "scaleH"] {
let v = field_of(d, key);
d.fields.insert(format!("prev{key}"), v);
}
d.fields.insert("prevlock".into(), if lock { "1".into() } else { "0".into() });
}
fn shown_points(d: &Dialog, key: &str, label: &str) -> Result<f64, String> {
if edited(d, key) {
let n = parse_shown(&field_of(d, key)).ok_or_else(|| format!("{label} must be a number"))?;
return Ok(n * 72.0);
}
exact_of(d, key).ok_or_else(|| format!("{label} must be a number"))
}
fn shown_number(d: &Dialog, key: &str, label: &str) -> Result<f64, String> {
if edited(d, key) {
return parse_shown(&field_of(d, key)).ok_or_else(|| format!("{label} must be a number"));
}
exact_of(d, key).ok_or_else(|| format!("{label} must be a number"))
}
fn size_position_value(d: &Dialog) -> Result<Value, String> {
let x = shown_points(d, "x", "Horizontal")?;
let y = shown_points(d, "y", "Vertical")?;
let rot = shown_number(d, "rot", "Rotation")?;
let base_w = parse_shown(&field_of(d, "baseW")).unwrap_or(1.0);
let base_h = parse_shown(&field_of(d, "baseH")).unwrap_or(1.0);
let mut w = if edited(d, "w") {
shown_points(d, "w", "Width")?
} else if edited(d, "scaleW") {
base_w * shown_number(d, "scaleW", "Scale width")? / 100.0
} else {
shown_points(d, "w", "Width")?
};
let mut h = if edited(d, "h") {
shown_points(d, "h", "Height")?
} else if edited(d, "scaleH") {
base_h * shown_number(d, "scaleH", "Scale height")? / 100.0
} else {
shown_points(d, "h", "Height")?
};
if field_of(d, "lock") == "1" {
let ar = if base_h > 0.0 { base_w / base_h } else { 1.0 };
let width_drives = edited(d, "w") || edited(d, "scaleW") || (!edited(d, "h") && !edited(d, "scaleH"));
if width_drives {
h = if ar > 0.0 { w / ar } else { h };
} else {
w = h * ar;
}
}
Ok(json!({"x": x, "y": y, "w": w, "h": h, "rot": rot, "lockAspect": field_of(d, "lock") == "1"}))
}
fn text_row(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str) {
let mut v = field_of(d, key);
ui.horizontal(|ui| {
ui.add_sized(vec2(148.0, 20.0), egui::Label::new(label));
ui.add(egui::TextEdit::singleline(&mut v).desired_width(96.0));
});
d.fields.insert(key.into(), v);
}
fn size_position_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
seed_size_position(app, d);
ui.label(egui::RichText::new("Size").strong());
text_row(ui, d, "Height (in):", "h");
text_row(ui, d, "Width (in):", "w");
text_row(ui, d, "Rotation (°):", "rot");
text_row(ui, d, "Scale height (%):", "scaleH");
text_row(ui, d, "Scale width (%):", "scaleW");
check(ui, d, "Lock aspect ratio", "lock", false);
ui.add_space(8.0);
ui.label(egui::RichText::new("Position").strong());
ui.label("From the top-left corner of the slide");
text_row(ui, d, "Horizontal (in):", "x");
text_row(ui, d, "Vertical (in):", "y");
reconcile_size_fields(d);
let (ok, cancel) = buttons(ui, "OK");
if ok {
match size_position_value(d) {
Ok(p) => return app.run("shape.sizePosition", p).is_ok(),
Err(msg) => {
app.set_status(msg);
return false;
}
}
}
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,
));
}
#[cfg(test)]
#[path = "size_position_tests.rs"]
mod size_position_tests;