Files
deckcraft/crates/ui-egui/src/dialogs.rs
T

3687 lines
163 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, Font, Paragraph, Tabs, Header & Footer, Hyperlink, Set Up Show, Zoom, …) and the
//! About window. The start screen is File › Home (`backstage.rs`).
use deckcraft_color::SchemeSlot;
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",
"tabs" => "Tabs",
"font" => "Font",
"about" => "About DeckCraft",
"preferences" => "Preferences",
"autocorrect" => "AutoCorrect",
"gridOptions" => "Grid Options",
"export" => "Export",
"symbol" => "Symbol",
"photoAlbum" => "Photo Album",
"replaceFonts" => "Replace Font",
"equation" => "Equation",
"dateTime" => "Date and Time",
"spelling" => "Spelling",
"pasteSpecial" => "Paste Special",
"find" => "Find and Replace",
"accessibility" => "Accessibility Checker",
"language" => "Language",
"customShow" => "Custom Shows",
"chartData" => "Chart Data",
"trim" => "Trim Media",
"smartart" => "Choose a SmartArt Graphic",
"bullets" => "Bullets and Numbering",
"action" => "Action Settings",
"quit" => "DeckCraft",
"themeColors" => "Create New Theme Colors",
"themeFonts" => "Create New Theme Fonts",
_ => "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;
let mut window = 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);
// Two columns of colour rows plus Save must fit the first frame (Theme Colors), as must Save
// in Theme Fonts. A later resize clips the buttons (the window size sticks) and clicks miss them.
if d.id == "themeColors" {
window = window.default_size(vec2(760.0, 420.0)).min_width(720.0).min_height(420.0);
} else if d.id == "themeFonts" {
window = window.default_size(vec2(460.0, 320.0)).min_width(420.0).min_height(300.0);
}
window.show(ctx, |ui| {
ui.set_min_width(match d.id.as_str() {
"themeColors" => 700.0,
"themeFonts" => 400.0,
_ => 340.0,
});
if d.id == "themeColors" {
ui.set_min_height(360.0);
} else if d.id == "themeFonts" {
ui.set_min_height(280.0);
}
close = body(app, ui, &mut d);
});
let escape = ctx.input(|i| i.key_pressed(egui::Key::Escape));
if open && !close && !escape {
app.dialog = Some(d);
} else if !close && d.id == "preferences" {
// Esc or the window's close button: the same as Cancel.
restore_preferences(app, &d);
} else if app.dialog.is_none()
&& let Some(ret) = d.params.get("returnParams").filter(|v| v.is_object()).cloned()
{
// Tabs… opened from Paragraph: closing it (OK, Cancel, Esc or the window) returns there.
let id = d.params.get("returnId").and_then(Value::as_str).unwrap_or("paragraph");
let mut back = Dialog::new(id);
back.params = ret;
app.dialog = Some(back);
}
}
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)
}
/// What the Preferences dialog can change, as it was when the dialog opened.
fn preferences_snapshot(app: &SlideApp) -> Value {
json!({
"dark": app.ui.brightness == theme::Brightness::Dark,
"uiScale": app.ui.ui_scale,
"prefs": serde_json::to_value(&app.session.prefs).unwrap_or(Value::Null),
})
}
/// Puts back the settings the Preferences dialog opened with (Cancel, Esc, the close button).
fn restore_preferences(app: &mut SlideApp, d: &Dialog) {
let Some(before) = d.params.get("before") else { return };
if let Some(dark) = before.get("dark").and_then(Value::as_bool)
&& dark != (app.ui.brightness == theme::Brightness::Dark)
{
let _ = app.run("view.dark", json!({ "on": dark }));
}
if let Some(scale) = before.get("uiScale").and_then(Value::as_f64) {
app.ui.ui_scale = scale as f32;
}
if let Some(prefs) = before.get("prefs").and_then(|p| serde_json::from_value(p.clone()).ok()) {
app.session.prefs = prefs;
}
}
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);
}
/// "Mark as decorative" starts checked when every selected shape is already decorative.
fn alt_text_starts_decorative(decorative: impl IntoIterator<Item = bool>) -> bool {
let mut any = false;
for on in decorative {
if !on {
return false;
}
any = true;
}
any
}
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)
}
fn album_items(d: &Dialog) -> Option<Vec<Value>> {
let raw = d.fields.get("items").map(String::as_str).unwrap_or("[]");
serde_json::from_str::<Value>(raw).ok().and_then(|v| v.as_array().cloned())
}
fn store_album_items(d: &mut Dialog, items: &[Value]) {
let raw = serde_json::to_string(items).unwrap_or_else(|_| "[]".into());
d.fields.insert("items".into(), raw);
}
fn album_item_label(v: &Value) -> String {
if let Some(text) = v.get("text").and_then(Value::as_str) {
let text = text.trim();
if text.is_empty() { "Text Box".to_string() } else { format!("Text: {text}") }
} else {
v.get("name").and_then(Value::as_str).filter(|s| !s.is_empty()).unwrap_or("Picture").to_string()
}
}
fn pick_album_picture(app: &mut SlideApp) -> Option<Value> {
let path = if let Some(pick) = app.services.pick_open.as_mut() {
pick("picture")
} else {
app.set_status("Pass pictures to insert.photoAlbum");
None
};
let path = path?;
let loaded = if let Some(read) = app.services.read.as_ref() {
match read(&path) {
Ok(bytes) => bytes,
Err(e) => {
app.set_status(e);
return None;
}
}
} else {
#[cfg(not(target_arch = "wasm32"))]
{
match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(e) => {
app.set_status(format!("{path}: {e}"));
return None;
}
}
}
#[cfg(target_arch = "wasm32")]
{
app.set_status("The web build inserts album pictures from data passed to insert.photoAlbum");
return None;
}
};
if loaded.is_empty() {
app.set_status("That file is empty");
return None;
}
if loaded.len() > 32 * 1024 * 1024 {
app.set_status("That picture is larger than 32 MB");
return None;
}
let name = std::path::Path::new(&path).file_name().and_then(|n| n.to_str()).filter(|s| !s.is_empty()).unwrap_or("picture").to_string();
Some(json!({"name": name, "data": deckcraft_engine::cmd::base64_encode(&loaded)}))
}
fn album_choice(ui: &mut Ui, d: &mut Dialog, id: &str, key: &str, default: &str, choices: &[(&str, &str)]) {
let cur = d.get(key, default);
let label = choices.iter().find(|(k, _)| *k == cur).map(|(_, l)| *l).unwrap_or(default);
let opened = egui::ComboBox::from_id_salt(id).selected_text(label).width(220.0).show_ui(ui, |ui| {
for (k, l) in choices {
if ui.selectable_label(cur == *k, *l).clicked() {
d.fields.insert(key.into(), (*k).to_string());
}
}
});
opened.response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, label));
}
const ALBUM_THEMES: &[&str] = &["DeckCraft", "Harbor", "Ember", "Meadow", "Nocturne", "Paper", "Coral Reef", "Slate"];
fn current_theme(app: &SlideApp) -> String {
app.session
.active()
.and_then(|d| d.doc.masters.first())
.map(|m| m.theme.name.clone())
.filter(|n| !n.is_empty())
.unwrap_or_else(|| "DeckCraft".into())
}
fn album_json_id(v: &Value) -> Option<u64> {
v.as_u64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))
}
/// Copy a command's dialog params into fields once. A fresh album has no `items`.
fn seed_photo_album(app: &SlideApp, d: &mut Dialog) {
if d.fields.contains_key("seeded") {
return;
}
d.fields.insert("seeded".into(), "1".into());
let p = d.params.clone();
if let Some(items) = p.get("items").and_then(Value::as_array) {
store_album_items(d, items);
d.fields.insert("editing".into(), "1".into());
}
for key in ["layout", "frame", "theme"] {
if let Some(v) = p.get(key).and_then(Value::as_str).filter(|s| !s.is_empty()) {
d.fields.insert(key.into(), v.to_string());
}
}
for key in ["captions", "grayscale"] {
if let Some(v) = p.get(key).and_then(Value::as_bool) {
d.fields.insert(key.into(), if v { "1".into() } else { "0".into() });
}
}
if let Some(id) = p.get("album").and_then(album_json_id).filter(|n| *n != 0) {
d.fields.insert("album".into(), id.to_string());
}
let theme_was = d.fields.get("theme").cloned().filter(|s| !s.is_empty()).unwrap_or_else(|| current_theme(app));
if !d.fields.contains_key("theme") {
d.fields.insert("theme".into(), theme_was.clone());
}
d.fields.entry("themeWas".into()).or_insert(theme_was);
}
fn selected_album_picture(d: &Dialog, items: &[Value]) -> bool {
let i = d.fields.get("sel").and_then(|s| s.parse::<usize>().ok()).unwrap_or(0);
items.get(i).is_some_and(|item| item.get("data").is_some() || item.get("path").is_some())
}
fn with_selected_picture(d: &mut Dialog, f: impl FnOnce(&mut serde_json::Map<String, Value>)) {
let Some(mut list) = album_items(d) else { return };
let i = d.fields.get("sel").and_then(|s| s.parse::<usize>().ok()).unwrap_or(0);
let Some(item) = list.get_mut(i) else { return };
if item.get("data").is_none() && item.get("path").is_none() {
return;
}
let Some(obj) = item.as_object_mut() else { return };
f(obj);
store_album_items(d, &list);
}
/// Photo Album. Returns true when the dialog should close.
fn photo_album_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
seed_photo_album(app, d);
ui.set_min_width(440.0);
egui::ScrollArea::vertical().max_height(520.0).id_salt("album_body").show(ui, |ui| {
ui.set_min_width(420.0);
ui.label("Pictures in album");
let items = album_items(d).unwrap_or_default();
let mut sel = d.get("sel", "0").parse::<usize>().unwrap_or(0);
// Fixed height so adding pictures does not push Create past the window.
egui::ScrollArea::vertical().max_height(120.0).min_scrolled_height(120.0).auto_shrink([false, false]).id_salt("album_list").show(ui, |ui| {
if items.is_empty() {
ui.label(egui::RichText::new("No pictures yet").color(Tokens::get(ui.ctx()).text_dim));
}
for (i, item) in items.iter().enumerate() {
if ui.selectable_label(sel == i, album_item_label(item)).clicked() {
sel = i;
}
}
});
d.fields.insert("sel".into(), sel.to_string());
ui.horizontal(|ui| {
if ui.button("Add Pictures…").clicked()
&& let Some(item) = pick_album_picture(app)
{
match album_items(d) {
Some(mut list) if list.len() < 48 => {
list.push(item);
let next = list.len().saturating_sub(1);
store_album_items(d, &list);
d.fields.insert("sel".into(), next.to_string());
}
Some(_) => app.set_status("A photo album holds at most 48 pictures or text boxes"),
None => app.set_status("The picture list is invalid"),
}
}
if ui.button("New Text Box").clicked() {
let text = d.get("boxText", "Text Box");
let text = text.trim();
if text.is_empty() {
app.set_status("Type the text for the new text box");
} else if text.chars().count() > 400 {
app.set_status("Text is longer than 400 characters");
} else if let Some(mut list) = album_items(d) {
if list.len() >= 48 {
app.set_status("A photo album holds at most 48 pictures or text boxes");
} else {
list.push(json!({"text": text}));
let next = list.len().saturating_sub(1);
store_album_items(d, &list);
d.fields.insert("sel".into(), next.to_string());
}
}
}
if ui.button("Remove").clicked()
&& let Some(mut list) = album_items(d)
&& let Some(i) = d.get("sel", "0").parse::<usize>().ok()
&& i < list.len()
{
list.remove(i);
store_album_items(d, &list);
let next = i.min(list.len().saturating_sub(1));
d.fields.insert("sel".into(), next.to_string());
}
});
field(ui, d, "Text box:", "boxText", "Text Box");
let picture = selected_album_picture(d, &items);
ui.label(album_picture_status(&items, sel));
ui.horizontal_wrapped(|ui| {
ui.add_enabled_ui(picture, |ui| {
if ui.button("Rotate Left").clicked() {
with_selected_picture(d, |obj| {
let cur = obj.get("rot").and_then(Value::as_f64).unwrap_or(0.0);
let next = ((cur - 90.0) % 360.0 + 360.0) % 360.0;
obj.insert("rot".into(), json!(next));
});
}
if ui.button("Rotate Right").clicked() {
with_selected_picture(d, |obj| {
let cur = obj.get("rot").and_then(Value::as_f64).unwrap_or(0.0);
let next = ((cur + 90.0) % 360.0 + 360.0) % 360.0;
obj.insert("rot".into(), json!(next));
});
}
if ui.button("More Brightness").clicked() {
nudge_picture(d, "brightness", 0.1);
}
if ui.button("Less Brightness").clicked() {
nudge_picture(d, "brightness", -0.1);
}
if ui.button("More Contrast").clicked() {
nudge_picture(d, "contrast", 0.1);
}
if ui.button("Less Contrast").clicked() {
nudge_picture(d, "contrast", -0.1);
}
});
});
ui.add_space(4.0);
ui.label("Picture layout");
album_choice(
ui,
d,
"album_layout",
"layout",
"1",
&[
("fit", "Fit to slide"),
("1", "1 picture"),
("2", "2 pictures"),
("4", "4 pictures"),
("1title", "1 picture with title"),
("2title", "2 pictures with title"),
("4title", "4 pictures with title"),
],
);
ui.label("Frame shape");
album_choice(
ui,
d,
"album_frame",
"frame",
"rect",
&[("none", "None"), ("rect", "Rectangle"), ("rounded", "Rounded"), ("simple", "Simple, white")],
);
check(ui, d, "Captions under pictures", "captions", false);
check(ui, d, "ALL pictures black and white", "grayscale", false);
ui.label("Album theme");
album_choice(ui, d, "album_theme", "theme", "DeckCraft", &ALBUM_THEMES.iter().map(|n| (*n, *n)).collect::<Vec<_>>());
});
let captions = d.fields.get("captions").is_some_and(|v| v == "1");
let grayscale = d.fields.get("grayscale").is_some_and(|v| v == "1");
let editing = d.fields.get("editing").is_some_and(|v| v == "1");
let (ok, cancel) = buttons(ui, if editing { "Update" } else { "Create" });
if ok {
let Some(list) = album_items(d) else {
app.set_status("The picture list is invalid");
return false;
};
if list.is_empty() {
app.set_status("Add a picture or a text box first");
return false;
}
let layout = d.get("layout", "1");
let frame = d.get("frame", "rect");
let theme = d.get("theme", "DeckCraft");
let theme_was = d.fields.get("themeWas").cloned().unwrap_or_default();
let mut payload = json!({"pictures": list, "layout": layout, "frame": frame, "captions": captions, "grayscale": grayscale});
if theme != theme_was
&& let Some(obj) = payload.as_object_mut()
{
obj.insert("theme".into(), json!(theme));
}
if editing {
if let Some(album) = d.fields.get("album").filter(|s| !s.is_empty() && s.as_str() != "0")
&& let Ok(id) = album.parse::<u64>()
&& let Some(obj) = payload.as_object_mut()
{
obj.insert("album".into(), json!(id));
}
return app.run("insert.photoAlbumEdit", payload).is_ok();
}
return app.run("insert.photoAlbum", payload).is_ok();
}
cancel
}
fn album_picture_status(items: &[Value], sel: usize) -> String {
let Some(item) = items.get(sel) else {
return "Select a picture to rotate it or change brightness and contrast".into();
};
if item.get("data").is_none() && item.get("path").is_none() {
return "Text boxes have no picture options".into();
}
let rot = item.get("rot").and_then(Value::as_f64).unwrap_or(0.0);
let brightness = item.get("brightness").and_then(Value::as_f64).unwrap_or(0.0);
let contrast = item.get("contrast").and_then(Value::as_f64).unwrap_or(0.0);
format!("Rotation {rot:.0}°, brightness {brightness:.1}, contrast {contrast:.1}")
}
fn nudge_picture(d: &mut Dialog, key: &str, delta: f64) {
with_selected_picture(d, |obj| {
let cur = obj.get(key).and_then(Value::as_f64).unwrap_or(0.0);
let next = (((cur + delta) * 10.0).round() / 10.0).clamp(-1.0, 1.0);
obj.insert(key.into(), json!(next));
});
}
fn seed_theme_colors(d: &mut Dialog) {
if d.fields.contains_key("seeded") {
return;
}
let name = d.params.get("name").and_then(Value::as_str).unwrap_or("Custom");
d.fields.insert("schemeName".into(), name.to_string());
let obj = d.params.get("colors").and_then(Value::as_object);
for slot in SchemeSlot::THEME {
let hex = obj.and_then(|o| o.get(slot.xml_name())).and_then(Value::as_str).unwrap_or("000000");
d.fields.insert(slot.xml_name().into(), hex.to_string());
}
d.fields.insert("seeded".into(), "1".into());
}
fn theme_color_row(ui: &mut Ui, d: &mut Dialog, slot: SchemeSlot) {
let key = slot.xml_name();
let mut v = d.get(key, "000000");
ui.horizontal(|ui| {
ui.add_sized(vec2(188.0, 18.0), egui::Label::new(slot.label()));
let fill = deckcraft_color::Rgba::from_hex(&v).map(theme::to_color32).unwrap_or_else(|| Color32::from_gray(200));
let (rect, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
ui.painter().rect(rect, CornerRadius::same(2), fill, Stroke::new(1.0, Tokens::get(ui.ctx()).border), egui::StrokeKind::Inside);
let resp = ui.add(egui::TextEdit::singleline(&mut v).desired_width(84.0).id_salt(("theme-hex", key)));
let shown = v.clone();
resp.widget_info(move || {
let mut info = egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, format!("{} hex", slot.label()));
info.current_text_value = Some(shown.clone());
info
});
});
d.fields.insert(key.into(), v);
}
fn theme_color_payload(d: &mut Dialog) -> Result<Value, String> {
let name = d.get("schemeName", "").trim().to_string();
if name.is_empty() {
return Err("Enter a scheme name.".into());
}
let mut colors = serde_json::Map::new();
for slot in SchemeSlot::THEME {
let raw = d.get(slot.xml_name(), "");
let Some(rgb) = deckcraft_color::Rgba::from_hex(raw.trim()) else {
return Err(format!("{} needs a colour like #RRGGBB.", slot.label()));
};
colors.insert(slot.xml_name().into(), Value::String(format!("#{}", rgb.hex())));
}
Ok(json!({"name": name, "colors": colors}))
}
/// Create New Theme Colors. Returns true when the dialog should close.
fn theme_colors_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
seed_theme_colors(d);
// The window keeps the height of its contents and will not grow after the first frame, so the
// rows stay short enough that Save is inside that rect.
ui.style_mut().spacing.interact_size.y = 16.0;
ui.style_mut().spacing.item_spacing.y = 2.0;
ui.style_mut().spacing.button_padding.y = 2.0;
let left = [SchemeSlot::Dk1, SchemeSlot::Lt1, SchemeSlot::Dk2, SchemeSlot::Lt2, SchemeSlot::Hlink, SchemeSlot::FolHlink];
let right = [SchemeSlot::Accent1, SchemeSlot::Accent2, SchemeSlot::Accent3, SchemeSlot::Accent4, SchemeSlot::Accent5, SchemeSlot::Accent6];
egui::Grid::new("theme-colors").spacing(vec2(18.0, 4.0)).show(ui, |ui| {
for (a, b) in left.into_iter().zip(right) {
theme_color_row(ui, d, a);
theme_color_row(ui, d, b);
ui.end_row();
}
});
ui.add_space(6.0);
let mut name = d.get("schemeName", "");
ui.horizontal(|ui| {
ui.label("Name:");
let resp = ui.add(egui::TextEdit::singleline(&mut name).desired_width(280.0).id_salt("schemeName"));
let shown = name.clone();
resp.widget_info(move || {
let mut info = egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, "Scheme name");
info.current_text_value = Some(shown.clone());
info
});
});
d.fields.insert("schemeName".into(), name);
ui.label(egui::RichText::new("Applies to this presentation's theme colours.").size(11.0).color(Tokens::get(ui.ctx()).text_dim));
let (ok, cancel) = buttons(ui, "Save");
if ok {
match theme_color_payload(d) {
Ok(payload) => return app.run("design.colors", payload).is_ok(),
Err(msg) => {
app.set_status(msg);
return false;
}
}
}
cancel
}
fn seed_theme_fonts(d: &mut Dialog) {
if d.fields.contains_key("seeded") {
return;
}
let name = d.params.get("name").and_then(Value::as_str).unwrap_or("Custom");
let major = d.params.get("major").and_then(Value::as_str).unwrap_or("Inter");
let minor = d.params.get("minor").and_then(Value::as_str).unwrap_or(major);
d.fields.insert("schemeName".into(), name.to_string());
d.fields.insert("major".into(), major.to_string());
d.fields.insert("minor".into(), minor.to_string());
d.fields.insert("seeded".into(), "1".into());
}
fn theme_font_choices(app: &SlideApp, selected: &str) -> Vec<String> {
let mut v = crate::ribbon::font_families(app);
for scheme in deckcraft_model::theme::builtin_font_schemes() {
v.push(scheme.major.latin);
v.push(scheme.minor.latin);
}
if !selected.is_empty() {
v.push(selected.to_string());
}
let mut seen = std::collections::HashSet::new();
v.retain(|face| !face.is_empty() && seen.insert(face.clone()));
v
}
fn font_combo(ui: &mut Ui, label: &str, choices: &[String], selected: &mut String) {
let resp = egui::ComboBox::from_id_salt(label).selected_text(selected.as_str()).width(240.0).show_ui(ui, |ui| {
for face in choices {
if ui.selectable_label(selected == face, face).clicked() {
*selected = face.clone();
}
}
});
let shown = selected.clone();
let access = label.to_string();
resp.response.widget_info(move || {
let mut info = egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, access.clone());
info.current_text_value = Some(shown.clone());
info
});
}
fn theme_font_payload(d: &mut Dialog) -> Result<Value, String> {
let name = d.get("schemeName", "").trim().to_string();
if name.is_empty() {
return Err("Enter a scheme name.".into());
}
let major = d.get("major", "").trim().to_string();
if major.is_empty() {
return Err("Choose a heading font.".into());
}
let minor = d.get("minor", "").trim().to_string();
if minor.is_empty() {
return Err("Choose a body font.".into());
}
Ok(json!({"name": name, "major": major, "minor": minor}))
}
/// Create New Theme Fonts. Returns true when the dialog should close.
fn theme_fonts_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
seed_theme_fonts(d);
let major = d.get("major", "Inter");
let minor = d.get("minor", "Inter");
let headings = theme_font_choices(app, &major);
let bodies = theme_font_choices(app, &minor);
let mut major = major;
let mut minor = minor;
ui.horizontal(|ui| {
ui.add_sized(vec2(110.0, 18.0), egui::Label::new("Heading:"));
font_combo(ui, "Heading font", &headings, &mut major);
});
ui.horizontal(|ui| {
ui.add_sized(vec2(110.0, 18.0), egui::Label::new("Body:"));
font_combo(ui, "Body font", &bodies, &mut minor);
});
d.fields.insert("major".into(), major.clone());
d.fields.insert("minor".into(), minor.clone());
ui.add_space(4.0);
// Theme faces are not egui families; naming an unbound family panics. The lines show the
// chosen names at heading and body sizes.
ui.label(egui::RichText::new(format!("Heading sample {major}")).size(20.0));
ui.label(egui::RichText::new(format!("Body sample {minor}")).size(14.0));
ui.add_space(4.0);
let mut name = d.get("schemeName", "");
ui.horizontal(|ui| {
ui.add_sized(vec2(110.0, 18.0), egui::Label::new("Name:"));
let resp = ui.add(egui::TextEdit::singleline(&mut name).desired_width(240.0).id_salt("schemeName"));
let shown = name.clone();
resp.widget_info(move || {
let mut info = egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, "Scheme name");
info.current_text_value = Some(shown.clone());
info
});
});
d.fields.insert("schemeName".into(), name);
ui.label(egui::RichText::new("Heading and body fonts for this presentation.").size(11.0).color(Tokens::get(ui.ctx()).text_dim));
let (ok, cancel) = buttons(ui, "Save");
if ok {
match theme_font_payload(d) {
Ok(payload) => return app.run("design.fonts", payload).is_ok(),
Err(msg) => {
app.set_status(msg);
return false;
}
}
}
cancel
}
/// What Insert Hyperlink would edit on the current selection.
#[derive(Clone, Debug, PartialEq)]
enum CurrentLink {
/// Nothing is linked. OK sets a new link (a caret at a word boundary inserts text).
None,
/// One link covers the text span or every selected shape.
One(deckcraft_model::text::Hyperlink),
/// The selection mixes different links. OK must not replace them until a new target is chosen.
Mixed,
}
/// Fields the Insert Hyperlink dialog opens with.
#[derive(Clone, Debug, PartialEq)]
struct HyperlinkSeed {
/// `url`, `email` or `slide`.
mode: &'static str,
url: String,
email: String,
slide: usize,
tip: String,
highlight: bool,
/// The action is not a web address, email or slide this dialog edits (or the selection mixes
/// links). OK leaves it in place until the user picks a different target.
preserve: bool,
}
impl HyperlinkSeed {
/// A new link: the address starts at `https://`, as the dialog always did.
fn blank() -> Self {
HyperlinkSeed { mode: "url", url: "https://".into(), email: String::new(), slide: 0, tip: String::new(), highlight: false, preserve: false }
}
}
/// Turn one hyperlink into dialog fields. A slide that is no longer in the deck, and actions this
/// dialog does not edit (next slide, a program, …), are preserved so OK does not retarget them.
fn seed_of_link(link: &deckcraft_model::text::Hyperlink, slide_ids: &[deckcraft_model::SlideId]) -> HyperlinkSeed {
use deckcraft_model::text::Action;
let mut seed = HyperlinkSeed {
mode: "url",
url: String::new(),
email: String::new(),
slide: 0,
tip: link.tooltip.clone(),
highlight: link.highlight_click,
preserve: true,
};
match &link.action {
Action::Url { url } => {
seed.preserve = false;
if let Some(email) = url.strip_prefix("mailto:") {
seed.mode = "email";
seed.email = email.to_string();
seed.url = "https://".into();
} else {
seed.url = url.clone();
}
}
Action::Slide { slide } => {
if let Some(i) = slide_ids.iter().position(|id| id == slide) {
seed.preserve = false;
seed.mode = "slide";
seed.slide = i;
seed.url = "https://".into();
}
}
_ => {}
}
seed
}
fn seed_from(current: CurrentLink, slide_ids: &[deckcraft_model::SlideId]) -> HyperlinkSeed {
match current {
CurrentLink::None => HyperlinkSeed::blank(),
CurrentLink::Mixed => HyperlinkSeed { preserve: true, url: String::new(), ..HyperlinkSeed::blank() },
CurrentLink::One(link) => seed_of_link(&link, slide_ids),
}
}
/// Links on the half-open span `[a, b)`. A point span is a new link: Insert Hyperlink inserts
/// there instead of editing a neighbouring run.
fn link_covering(body: &deckcraft_model::text::TextBody, a: (usize, usize), b: (usize, usize)) -> CurrentLink {
if a == b {
return CurrentLink::None;
}
let mut seen: Option<Option<deckcraft_model::text::Hyperlink>> = None;
for (pi, para) in body.paragraphs.iter().enumerate() {
if pi < a.0 || pi > b.0 {
continue;
}
let from = if pi == a.0 { a.1 } else { 0 };
let to = if pi == b.0 { b.1 } else { usize::MAX };
if from >= to {
continue;
}
let mut acc = 0usize;
let mut covered = false;
for run in &para.runs {
let len = run.char_len();
let end = acc + len;
if len > 0 && end > from && acc < to {
covered = true;
match &seen {
None => seen = Some(run.props.link.clone()),
Some(prev) if prev == &run.props.link => {}
Some(_) => return CurrentLink::Mixed,
}
}
acc = end;
}
if !covered && para.char_len() == 0 {
match &seen {
None => seen = Some(para.end_props.link.clone()),
Some(prev) if prev == &para.end_props.link => {}
Some(_) => return CurrentLink::Mixed,
}
}
}
match seen {
Some(Some(link)) => CurrentLink::One(link),
_ => CurrentLink::None,
}
}
fn common_click(clicks: impl IntoIterator<Item = Option<deckcraft_model::text::Hyperlink>>) -> CurrentLink {
let mut clicks = clicks.into_iter();
let Some(first) = clicks.next() else { return CurrentLink::None };
for next in clicks {
if next != first {
return CurrentLink::Mixed;
}
}
match first {
Some(link) => CurrentLink::One(link),
None => CurrentLink::None,
}
}
/// The link Insert Hyperlink would edit: text when a caret or selection is active, otherwise the
/// click action shared by the selected shapes.
fn hyperlink_seed_of(st: Option<&deckcraft_engine::DocState>) -> HyperlinkSeed {
let Some(st) = st else { return HyperlinkSeed::blank() };
let ids: Vec<deckcraft_model::SlideId> = st.doc.slides.iter().map(|s| s.id).collect();
let current = if let Some(t) = &st.selection.text {
match deckcraft_engine::cmd::text::body_of(st, t) {
Some(body) => {
let (a, b) = deckcraft_engine::cmd::text::format_range(st, t);
link_covering(body, a, b)
}
None => CurrentLink::None,
}
} else {
common_click(st.selected_shapes().into_iter().map(|sh| sh.click.clone()))
};
seed_from(current, &ids)
}
/// OK writes a link unless the dialog is holding an action it does not edit and the user has not
/// chosen a new target.
fn hyperlink_should_apply(seed: &HyperlinkSeed, mode: &str, url: &str, email: &str, slide: usize) -> bool {
if !seed.preserve {
return true;
}
if mode != seed.mode {
return true;
}
match mode {
"email" => email != seed.email,
"slide" => slide != seed.slide,
_ => url != seed.url,
}
}
fn hyperlink_command(mode: &str, url: &str, email: &str, slide: usize, tip: &str, highlight: bool) -> Value {
match mode {
"slide" => json!({"slide": slide, "tooltip": tip, "highlightClick": highlight}),
"email" => json!({"url": format!("mailto:{email}"), "tooltip": tip, "highlightClick": highlight}),
_ => json!({"url": url, "tooltip": tip, "highlightClick": highlight}),
}
}
/// Preset percents in the Zoom dialog, largest first.
const ZOOM_PRESETS: &[u32] = &[400, 200, 150, 100, 75, 66, 50, 33];
/// A typed zoom percent as a scale in `0.1..=4` (10%–400%). `None` when it is not a finite number.
pub fn parse_zoom_percent(text: &str) -> Option<f32> {
let t = text.trim().trim_end_matches('%').trim();
if t.is_empty() {
return None;
}
let v = t.parse::<f64>().ok().filter(|n| n.is_finite())?;
Some(((v / 100.0) as f32).clamp(0.1, 4.0))
}
/// Zoom dialog: a percent or Fit. Nothing changes until OK.
fn zoom_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
if !d.fields.contains_key("mode") {
let shown = app.ui.zoom.unwrap_or(app.canvas_scale);
let pct = if shown.is_finite() { (shown * 100.0).round().clamp(10.0, 400.0) } else { 100.0 };
d.fields.insert("mode".into(), if app.ui.zoom.is_none() { "fit".into() } else { "percent".into() });
d.fields.insert("percent".into(), format!("{pct:.0}"));
}
let mut mode = d.get("mode", "percent");
let mut percent = d.get("percent", "100");
ui.label("Zoom to");
if ui.radio(mode == "fit", "Fit").clicked() {
mode = "fit".into();
}
for pct in ZOOM_PRESETS {
let on = mode == "percent" && percent == pct.to_string();
if ui.radio(on, format!("{pct}%")).clicked() {
mode = "percent".into();
percent = pct.to_string();
}
}
ui.horizontal(|ui| {
ui.label("Percent:");
let edit = ui.add(egui::TextEdit::singleline(&mut percent).desired_width(72.0));
if edit.changed() {
mode = "percent".into();
}
ui.label("%");
});
d.fields.insert("mode".into(), mode.clone());
d.fields.insert("percent".into(), percent.clone());
let (ok, cancel) = buttons(ui, "OK");
if ok {
if mode == "fit" {
app.ui.zoom = None;
return true;
}
match parse_zoom_percent(&percent) {
Some(scale) => app.ui.zoom = Some(scale),
None => {
app.set_status("Enter a zoom percent");
return false;
}
}
}
ok || cancel
}
/// Seconds for a Trim Media field. Three decimal places, trailing zeros dropped, so the text
/// round-trips the millisecond value the clip stores.
fn ms_as_seconds(ms: u32) -> String {
let s = format!("{:.3}", f64::from(ms) / 1000.0);
let trimmed = s.trim_end_matches('0').trim_end_matches('.');
if trimmed.is_empty() { "0".to_string() } else { trimmed.to_string() }
}
/// Parse a seconds field back to milliseconds. Blank, junk and negatives become 0; the result is
/// rounded so `0.333` is 333 ms, not 332.
fn seconds_text_to_ms(text: &str) -> u32 {
let v = text.trim().parse::<f64>().ok().filter(|v| v.is_finite()).unwrap_or(0.0);
let ms = (v.max(0.0) * 1000.0).round();
if ms >= f64::from(u32::MAX) { u32::MAX } else { ms as u32 }
}
/// Start, end trim, fade in and fade out, as the Trim Media dialog shows them.
struct TrimSeed {
start: String,
end: String,
fade_in: String,
fade_out: String,
}
impl TrimSeed {
fn zeros() -> Self {
TrimSeed { start: "0".into(), end: "0".into(), fade_in: "0".into(), fade_out: "0".into() }
}
fn from_clip(m: &deckcraft_model::MediaClip) -> Self {
TrimSeed {
start: ms_as_seconds(m.trim_start_ms),
end: ms_as_seconds(m.trim_end_ms),
fade_in: ms_as_seconds(m.fade_in_ms),
fade_out: ms_as_seconds(m.fade_out_ms),
}
}
}
/// The first selected audio or video. Other shapes in the selection are skipped. No media means
/// the old zeros, which OK then stores.
fn trim_seed(st: Option<&deckcraft_engine::DocState>) -> TrimSeed {
let Some(st) = st else { return TrimSeed::zeros() };
st.selected_shapes()
.into_iter()
.find_map(|sh| match &sh.kind {
deckcraft_model::ShapeKind::Media(m) => Some(TrimSeed::from_clip(m)),
_ => None,
})
.unwrap_or_else(TrimSeed::zeros)
}
/// 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 seed = hyperlink_seed_of(app.session.active());
let slide_default = seed.slide.to_string();
let mode = d.get("mode", seed.mode);
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", &slide_default).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", &seed.email),
_ => field(ui, d, "Address:", "url", &seed.url),
}
field(ui, d, "ScreenTip:", "tip", &seed.tip);
let (ok, cancel) = buttons(ui, "OK");
if ok {
let mode_now = d.get("mode", seed.mode);
let url = d.get("url", &seed.url);
let email = d.get("email", &seed.email);
let slide = d.get("slide", &slide_default).parse::<usize>().unwrap_or(0);
let tip = d.get("tip", &seed.tip);
if hyperlink_should_apply(&seed, &mode_now, &url, &email, slide) {
run(app, "insert.hyperlink", hyperlink_command(&mode_now, &url, &email, slide, &tip, seed.highlight));
}
}
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" => zoom_dialog(app, ui, d),
"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
}
"action" => {
use deckcraft_model::text::Action;
let tab = d.get("tab", "click");
// Start from the selected shape's current setting for this tab.
let (cur, slides, shows) = app
.session
.active()
.map(|st| {
let sh = st.selected_shapes().first().map(|x| if tab == "hover" { x.hover.clone() } else { x.click.clone() }).unwrap_or(None);
let slides: Vec<(deckcraft_model::SlideId, String)> =
st.doc.slides.iter().enumerate().map(|(i, sl)| (sl.id, format!("{}. {}", i + 1, sl.title()))).collect();
let shows: Vec<String> = st.doc.custom_shows.iter().map(|c| c.name.clone()).collect();
(sh, slides, shows)
})
.unwrap_or_default();
let (cur_kind, cur_arg) = match cur.as_ref().map(|h| &h.action) {
None => ("none", String::new()),
Some(Action::NextSlide) => ("next", String::new()),
Some(Action::PreviousSlide) => ("previous", String::new()),
Some(Action::FirstSlide) => ("first", String::new()),
Some(Action::LastSlide) => ("last", String::new()),
Some(Action::LastViewed) => ("lastViewed", String::new()),
Some(Action::EndShow) => ("endShow", String::new()),
Some(Action::PlayMedia) => ("playMedia", String::new()),
Some(Action::Url { url }) => ("url", url.clone()),
Some(Action::CustomShow { name, .. }) => ("customShow", name.clone()),
Some(Action::Program { path }) => ("program", path.clone()),
Some(Action::Slide { slide }) => ("slide", slides.iter().position(|(id, _)| id == slide).unwrap_or(0).to_string()),
};
ui.horizontal(|ui| {
for (k, l) in [("click", "Mouse Click"), ("hover", "Mouse Over")] {
if ui.selectable_label(tab == k, l).clicked() && tab != k {
d.fields.clear();
d.fields.insert("tab".into(), k.into());
}
}
});
ui.separator();
ui.label(if tab == "hover" { "Action on mouse over" } else { "Action on click" });
let kind = d.get("kind", cur_kind);
let hyperlink = !matches!(kind.as_str(), "none" | "program" | "playMedia");
let pick = |d: &mut Dialog, ui: &mut Ui, k: &str, l: &str| {
if ui.radio(d.get("kind", cur_kind) == k, l).clicked() {
d.fields.insert("kind".into(), k.into());
}
};
pick(d, ui, "none", "None");
ui.horizontal(|ui| {
if ui.radio(hyperlink, "Hyperlink to:").clicked() && !hyperlink {
d.fields.insert("kind".into(), "next".into());
}
let targets = [
("next", "Next Slide"),
("previous", "Previous Slide"),
("first", "First Slide"),
("last", "Last Slide"),
("lastViewed", "Last Slide Viewed"),
("endShow", "End Show"),
("customShow", "Custom Show…"),
("slide", "Slide…"),
("url", "URL…"),
];
let shown = targets.iter().find(|t| t.0 == kind).map_or("Next Slide", |t| t.1);
ui.add_enabled_ui(hyperlink, |ui| {
egui::ComboBox::from_id_salt("action-target").selected_text(shown).show_ui(ui, |ui| {
for (k, l) in targets {
if ui.selectable_label(kind == k, l).clicked() {
d.fields.insert("kind".into(), k.into());
}
}
});
});
});
let kind = d.get("kind", cur_kind);
match kind.as_str() {
"url" => field(ui, d, "Address:", "arg", if cur_kind == "url" { &cur_arg } else { "https://" }),
"slide" => {
let sel = d.get("arg", if cur_kind == "slide" { &cur_arg } else { "0" }).parse::<usize>().unwrap_or(0);
egui::ScrollArea::vertical().max_height(140.0).show(ui, |ui| {
for (i, (_, t)) in slides.iter().enumerate() {
if ui.selectable_label(sel == i, t).clicked() {
d.fields.insert("arg".into(), i.to_string());
}
}
});
}
"customShow" => {
if shows.is_empty() {
ui.label("This presentation has no custom shows (Slide Show ▸ Custom Slide Show).");
}
let sel = d.get("arg", if cur_kind == "customShow" { &cur_arg } else { shows.first().map_or("", String::as_str) });
for n in &shows {
if ui.selectable_label(&sel == n, n).clicked() {
d.fields.insert("arg".into(), n.clone());
}
}
}
_ => {}
}
pick(d, ui, "program", "Run program:");
if d.get("kind", cur_kind) == "program" {
field(ui, d, "Program:", "arg", if cur_kind == "program" { &cur_arg } else { "" });
}
pick(d, ui, "playMedia", "Play the shape's media");
let hl = check(ui, d, "Highlight click", "highlight", cur.as_ref().is_none_or(|h| h.highlight_click));
let (ok, cancel) = buttons(ui, "OK");
if ok {
let kind = d.get("kind", cur_kind);
let arg = d.get("arg", &cur_arg);
let mut p = json!({"on": tab, "action": kind, "highlight": hl});
match kind.as_str() {
"url" => p["url"] = json!(arg),
"slide" => p["slide"] = json!(arg.parse::<usize>().unwrap_or(0)),
"customShow" => p["show"] = json!(arg),
"program" => p["path"] = json!(arg),
_ => {}
}
run(app, "shape.action", p);
}
ok || cancel
}
"altText" => {
let (cur, deco_now) = app
.session
.active()
.map(|s| {
let shapes = s.selected_shapes();
let cur = shapes.first().map(|x| x.descr.clone()).unwrap_or_default();
let deco = alt_text_starts_decorative(shapes.iter().map(|x| x.decorative));
(cur, deco)
})
.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", deco_now);
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
}
"font" => font_dialog(app, ui, d),
"paragraph" => paragraph_dialog(app, ui, d),
"tabs" => tabs_dialog(app, ui, d),
"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"),
("Animated GIF (.gif)", "gif"),
] {
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 png = matches!(fmt.as_str(), "png" | "pngAll");
if png {
ui.add_space(4.0);
let mut clear = d.get("pngTransparent", "false") == "true";
if ui
.checkbox(&mut clear, "Transparent background")
.on_hover_text("Leave the slide background out: only what is on the slide is drawn")
.changed()
{
d.fields.insert("pngTransparent".into(), clear.to_string());
}
}
let gif_params = if fmt == "gif" { gif_options(app, ui, d) } else { Ok(Value::Null) };
if let Err(e) = &gif_params {
ui.label(egui::RichText::new(e).color(Tokens::get(ui.ctx()).text_dim));
}
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 gif_params = match gif_params {
Ok(v) => v,
// Keep the dialog open so the range or width can be corrected.
Err(e) => {
app.set_status(e);
return false;
}
};
let extra = match fmt.as_str() {
"pdf" => pdf_params,
"gif" => gif_params,
_ if png && d.get("pngTransparent", "false") == "true" => json!({"transparentBackground": true}),
_ => json!({}),
};
app.export_as(&fmt, extra);
}
ok || cancel
}
"replaceFonts" => {
let used: Vec<String> =
app.session.execute("font.used", &json!({})).ok().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default();
let mut from = d.get("from", used.first().map(String::as_str).unwrap_or(""));
let mut to = d.get("to", "");
egui::Grid::new("replace_fonts").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| {
ui.label("Replace:");
egui::ComboBox::from_id_salt("rf_from").width(240.0).selected_text(from.clone()).show_ui(ui, |ui| {
for f in &used {
ui.selectable_value(&mut from, f.clone(), f);
}
});
ui.end_row();
ui.label("With:");
ui.vertical(|ui| {
ui.add(egui::TextEdit::singleline(&mut to).desired_width(240.0).hint_text("Font name"));
let needle = to.trim().to_lowercase();
egui::ScrollArea::vertical().max_height(140.0).id_salt("rf_list").show(ui, |ui| {
for f in
crate::ribbon::font_families(app).iter().filter(|f| needle.is_empty() || f.to_lowercase().contains(&needle)).take(200)
{
if ui.selectable_label(f.eq_ignore_ascii_case(to.trim()), f).clicked() {
to = f.clone();
}
}
});
});
ui.end_row();
});
if let Some(msg) = d.fields.get("result") {
ui.label(egui::RichText::new(msg.clone()).color(Tokens::get(ui.ctx()).text_dim));
}
d.fields.insert("from".into(), from.clone());
d.fields.insert("to".into(), to.clone());
let (ok, cancel) = buttons(ui, "Replace");
if ok && !from.is_empty() && !to.trim().is_empty() {
let n = app.run("font.replace", json!({"from": from, "to": to.trim()})).ok().and_then(|v| v["replaced"].as_u64()).unwrap_or(0);
let msg =
if n == 0 { format!("“{from}” isn't used explicitly.") } else { format!("Replaced “{from}” with “{}”.", to.trim()) };
d.fields.insert("result".into(), msg);
d.fields.remove("from");
// Stay open, like PowerPoint, so several fonts can be replaced in a row.
return cancel;
}
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
}
"find" => find_dialog(app, ui, d),
"spelling" => {
// Words skipped with Ignore All while the dialog is open, one per line.
let ignored = d.get("ignore", "");
let ignore: Vec<&str> = ignored.lines().filter(|l| !l.is_empty()).collect();
let v = app.session.execute("review.spelling", &json!({"ignore": ignore})).unwrap_or_default();
let list = v.as_array().cloned().unwrap_or_default();
let info = app.session.execute("review.dictionary", &json!({})).unwrap_or_default();
let available = info.get("available").and_then(Value::as_bool).unwrap_or(false);
match info.get("found").and_then(Value::as_str) {
Some(f) if available => {
ui.label(egui::RichText::new(format!("Dictionary: {}", f.rsplit(['/', '\\']).next().unwrap_or(f))).small().weak())
}
_ if available => {
ui.label(egui::RichText::new(format!("Dictionary: {}", info.get("lang").and_then(Value::as_str).unwrap_or(""))).small().weak())
}
_ => ui.label(
egui::RichText::new(format!(
"No {} dictionary is installed, so only common misspellings are found. Put a Hunspell .aff/.dic pair in {}.",
info.get("lang").and_then(Value::as_str).unwrap_or("en-US"),
info.get("folders")
.and_then(Value::as_array)
.and_then(|a| a.get(1).or(a.first()))
.and_then(Value::as_str)
.unwrap_or("a dictionaries folder")
))
.small()
.weak(),
),
};
if list.is_empty() {
ui.label("The spelling check is complete. No issues found.");
}
let mut add_ignore = None;
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
for item in &list {
let w = item.get("word").and_then(Value::as_str).unwrap_or("");
let slide = item.get("slide").cloned().unwrap_or_default();
let shape = item.get("shape").cloned().unwrap_or_default();
ui.horizontal(|ui| {
ui.label(format!("Slide {}: “{w}”", slide.as_u64().unwrap_or(0) + 1));
if ui.small_button("Go to").clicked() {
let _ = app.run("slide.go", json!({"index": slide}));
let _ = app.run("edit.select", json!({"ids": [shape]}));
}
if ui.small_button("Ignore All").clicked() {
add_ignore = Some(w.to_string());
}
if ui.small_button("Add to Dictionary").clicked()
&& let Err(e) = app.session.execute("review.addToDictionary", &json!({"word": w}))
{
app.set_status(e.to_string());
}
});
let sug: Vec<String> = item
.get("suggestions")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect())
.unwrap_or_default();
if item.get("repeated").and_then(Value::as_bool).unwrap_or(false) && !item.get("notes").and_then(Value::as_bool).unwrap_or(false)
{
ui.horizontal(|ui| {
ui.add_space(16.0);
ui.label(egui::RichText::new("Repeated word").weak());
if ui.small_button("Delete Repeated Word").clicked() {
let from = item.get("deleteFrom").and_then(Value::as_u64).unwrap_or(0) as usize;
let end = item.get("end").and_then(Value::as_u64).unwrap_or(0) as usize;
let para = item.get("para").and_then(Value::as_u64).unwrap_or(0) as usize;
let text = app.session.active().and_then(|st| {
let sh = st.shape(deckcraft_model::ShapeId(shape.as_u64()? as u32))?;
let pp = match (item.get("row").and_then(Value::as_u64), item.get("col").and_then(Value::as_u64), &sh.kind) {
(Some(r), Some(c), deckcraft_model::ShapeKind::Table(tb)) => {
tb.cell(r as usize, c as usize)?.text.paragraphs.get(para)?.text()
}
_ => sh.text.as_ref()?.paragraphs.get(para)?.text(),
};
Some(pp.chars().skip(from).take(end.saturating_sub(from)).collect::<String>())
});
if let Some(gap) = text {
let mut p = json!({"shape": shape, "para": para, "start": from, "word": gap, "to": ""});
if let (Some(o), Some(r), Some(c)) = (p.as_object_mut(), item.get("row"), item.get("col")) {
o.insert("row".into(), r.clone());
o.insert("col".into(), c.clone());
}
run(app, "review.spellingChange", p);
}
}
});
}
if !sug.is_empty() {
ui.horizontal_wrapped(|ui| {
ui.add_space(16.0);
ui.label(egui::RichText::new("Change to:").weak());
for to in &sug {
let r = ui.small_button(to.as_str()).on_hover_text("Click to change this one; Shift-click to change all");
if r.clicked() {
let all = ui.input(|i| i.modifiers.shift);
if item.get("notes").and_then(Value::as_bool).unwrap_or(false) {
// A word in the notes: change that line of the slide's notes.
let si = slide.as_u64().unwrap_or(0) as usize;
let notes =
app.session.active().and_then(|d| d.doc.slides.get(si)).map(|s| s.notes_text()).unwrap_or_default();
let (pi, st) = (
item.get("para").and_then(Value::as_u64).unwrap_or(0) as usize,
item.get("start").and_then(Value::as_u64).unwrap_or(0) as usize,
);
let lines: Vec<String> = notes
.split('\n')
.enumerate()
.map(|(i, l)| {
if i != pi {
return l.to_string();
}
let c: Vec<char> = l.chars().collect();
let n = w.chars().count();
if c.get(st..st + n).is_some_and(|x| x.iter().collect::<String>() == w) {
c[..st].iter().chain(to.chars().collect::<Vec<_>>().iter()).chain(c[st + n..].iter()).collect()
} else {
l.to_string()
}
})
.collect();
run(app, "slide.notes", json!({"text": lines.join("\n"), "index": si}));
} else {
let mut p = if all {
json!({"word": w, "to": to, "all": true})
} else {
json!({"shape": shape, "word": w, "to": to, "para": item.get("para"), "start": item.get("start")})
};
if !all && let (Some(o), Some(r), Some(c)) = (p.as_object_mut(), item.get("row"), item.get("col")) {
o.insert("row".into(), r.clone());
o.insert("col".into(), c.clone());
}
run(app, "review.spellingChange", p);
}
}
}
});
}
}
});
if let Some(w) = add_ignore {
d.fields.insert("ignore".into(), format!("{ignored}{w}\n"));
}
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" => {
// Starts from the selected text's language and Do Not Check Spelling.
let (cur, np) = selected_language(app);
let lang = d.get("lang", &cur);
let mut no_proof = d.get("noProof", if np { "1" } else { "" }) == "1";
ui.label("Mark selected text as:");
egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| {
// The selection's own language first when the list doesn't have it.
let own = (!crate::squiggles::LANGS.iter().any(|(_, t)| t.eq_ignore_ascii_case(&cur))).then(|| (cur.clone(), cur.clone()));
let all = own.into_iter().chain(crate::squiggles::LANGS.iter().map(|(n, t)| (n.to_string(), t.to_string())));
for (name, tag) in all {
let installed = deckcraft_engine::spell::dictionary(&tag).is_some();
let label = if installed { format!("{name} (dictionary)") } else { name };
if ui.selectable_label(lang.eq_ignore_ascii_case(&tag), label).clicked() {
d.fields.insert("lang".into(), tag);
}
}
});
ui.add_space(4.0);
ui.label(egui::RichText::new("Spelling is checked for languages marked (dictionary).").small().weak());
if ui.checkbox(&mut no_proof, "Do not check spelling or grammar").changed() {
d.fields.insert("noProof".into(), if no_proof { "1".into() } else { String::new() });
}
let (ok, cancel) = buttons(ui, "OK");
if ok {
let lang = d.get("lang", &cur);
run(app, "format.language", json!({"lang": lang, "noProof": no_proof}));
}
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" => {
let seed = trim_seed(app.session.active());
field(ui, d, "Start time (s):", "start", &seed.start);
field(ui, d, "End trim (s):", "end", &seed.end);
field(ui, d, "Fade in (s):", "fin", &seed.fade_in);
field(ui, d, "Fade out (s):", "fout", &seed.fade_out);
let (ok, cancel) = buttons(ui, "OK");
if ok {
run(
app,
"media.options",
json!({
"trimStart": seconds_text_to_ms(&d.get("start", &seed.start)),
"trimEnd": seconds_text_to_ms(&d.get("end", &seed.end)),
"fadeIn": seconds_text_to_ms(&d.get("fin", &seed.fade_in)),
"fadeOut": seconds_text_to_ms(&d.get("fout", &seed.fade_out)),
}),
);
}
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<_>>()}));
app.ui.pane = Some("smartart".into());
}
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
}
"autocorrect" => {
let pr = &mut app.session.prefs;
ui.checkbox(&mut pr.ac_two_initials, "Correct TWo INitial CApitals");
ui.checkbox(&mut pr.ac_sentence_caps, "Capitalize first letter of sentences");
ui.checkbox(&mut pr.ac_days, "Capitalize names of days");
ui.checkbox(&mut pr.ac_caps_lock, "Correct accidental use of cAPS LOCK key");
ui.checkbox(&mut pr.ac_fractions, "Replace fractions (1/2) with fraction characters (½)");
ui.checkbox(&mut pr.ac_ordinals, "Ordinals (1st) with superscript");
ui.checkbox(&mut pr.autocorrect, "Replace text as you type");
ui.add_space(4.0);
let mut from = d.get("from", "");
let mut to = d.get("to", "");
ui.horizontal(|ui| {
ui.label("Replace");
ui.add(egui::TextEdit::singleline(&mut from).desired_width(90.0));
ui.label("With");
ui.add(egui::TextEdit::singleline(&mut to).desired_width(130.0));
if ui.add_enabled(!from.trim().is_empty(), egui::Button::new("Add")).clicked() {
run(app, "text.autocorrect", json!({"add": [from.trim(), to]}));
from.clear();
to.clear();
}
});
d.fields.insert("from".into(), from.clone());
d.fields.insert("to".into(), to);
let list = app.session.execute("text.autocorrect", &json!({})).ok().and_then(|v| v.get("entries").cloned());
let entries: Vec<(String, String)> = list.and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default();
let q = from.trim().to_lowercase();
egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| {
egui::Grid::new("ac-list").striped(true).show(ui, |ui| {
for (k, v) in entries.iter().filter(|(k, _)| q.is_empty() || k.to_lowercase().starts_with(&q)) {
ui.label(k);
ui.label(v);
if ui.small_button("Delete").clicked() {
run(app, "text.autocorrect", json!({"remove": k}));
}
ui.end_row();
}
});
});
let (ok, cancel) = buttons(ui, "OK");
ok || cancel
}
"gridOptions" => grid_options(app, ui, d),
"preferences" => {
// The controls apply as they change, so the dialog remembers what it opened with and
// Cancel (or Esc, or the close button: see `show`) puts that back.
if d.params.get("before").is_none() {
d.params = json!({ "before": preferences_snapshot(app) });
}
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}));
}
// While the pointer holds the slider its value only waits in the dialog: applying it
// would rescale the slider under the pointer and run it to an end (#767). It applies
// on release, and at once from the keyboard.
let pending = d.fields.get("uiScale").and_then(|v| v.parse::<f32>().ok());
let mut scale = pending.unwrap_or(app.ui.ui_scale);
let r = ui.add(egui::Slider::new(&mut scale, 0.75..=1.75).text("Interface size"));
if r.is_pointer_button_down_on() {
if r.changed() || pending.is_some() {
d.fields.insert("uiScale".into(), scale.to_string());
}
} else {
if r.changed() || pending.is_some() {
app.ui.ui_scale = scale;
}
d.fields.remove("uiScale");
}
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.horizontal(|ui| {
ui.checkbox(&mut app.session.prefs.autocorrect, "AutoCorrect as you type");
if ui.button("AutoCorrect Options…").clicked() {
*d = Dialog::new("autocorrect");
}
});
if d.id != "preferences" {
return false;
}
ui.horizontal(|ui| {
ui.label("Your name (comments):");
ui.text_edit_singleline(&mut app.session.prefs.author);
});
let (ok, cancel) = buttons(ui, "Done");
if cancel {
restore_preferences(app, d);
}
ok || cancel
}
"photoAlbum" => photo_album_dialog(app, ui, d),
"themeColors" => theme_colors_dialog(app, ui, d),
"themeFonts" => theme_fonts_dialog(app, ui, d),
"dateTime" => date_time_dialog(app, ui, d),
_ => {
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
}
const DATE_LANGS: &[(&str, &str)] = &[
("en-US", "English (United States)"),
("en-GB", "English (United Kingdom)"),
("fr-FR", "French (France)"),
("ja-JP", "Japanese"),
("sv-SE", "Swedish"),
];
const DATE_KINDS: [&str; 13] = [
"datetime1",
"datetime2",
"datetime3",
"datetime4",
"datetime5",
"datetime6",
"datetime7",
"datetime8",
"datetime9",
"datetime10",
"datetime11",
"datetime12",
"datetime13",
];
/// Samples `field_text` can compute for `lang`. Named months and AM/PM exist for English only;
/// other languages keep the numeric date and the 24-hour times.
fn date_rows(lang: &str) -> Vec<(&'static str, String)> {
let now = deckcraft_text::datetime::DateTime::now_local();
let mut rows = Vec::new();
for kind in DATE_KINDS {
if let Some(now) = now
&& let Some(sample) = deckcraft_text::datetime::field_text(kind, Some(lang), now)
{
rows.push((kind, sample));
}
}
if rows.is_empty() {
rows.push(("datetime1", "datetime1".into()));
}
rows
}
fn date_time_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
ui.set_min_width(440.0);
ui.label("Available formats:");
let lang = d.get("language", "en-US");
let rows = date_rows(&lang);
if !rows.iter().any(|(k, _)| *k == d.get("format", "datetime1"))
&& let Some((k, _)) = rows.first()
{
d.fields.insert("format".into(), (*k).to_string());
}
let format = d.get("format", "datetime1");
egui::ScrollArea::vertical().max_height(280.0).min_scrolled_height(220.0).auto_shrink([false, false]).show(ui, |ui| {
for (kind, sample) in &rows {
if ui.selectable_label(format == *kind, sample).clicked() {
d.fields.insert("format".into(), (*kind).to_string());
}
}
});
let lang_label = DATE_LANGS.iter().find(|(k, _)| *k == lang).map(|(_, l)| *l).unwrap_or("English (United States)");
ui.horizontal(|ui| {
ui.label("Language:");
let opened = egui::ComboBox::from_id_salt("date-lang").selected_text(lang_label).width(240.0).show_ui(ui, |ui| {
for (k, l) in DATE_LANGS {
if ui.selectable_label(lang == *k, *l).clicked() {
d.fields.insert("language".into(), (*k).to_string());
}
}
});
opened.response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, lang_label));
});
ui.label(
egui::RichText::new("Month names and AM/PM are listed for English. Other languages list the numeric date and 24-hour time.")
.size(11.0)
.color(Tokens::get(ui.ctx()).text_dim),
);
let update = check(ui, d, "Update automatically", "update", true);
let (ok, cancel) = buttons(ui, "OK");
if ok {
let lang = d.get("language", "en-US");
let mut format = d.get("format", "datetime1");
let probe = deckcraft_text::datetime::DateTime::new(2026, 10, 9, 20, 5, 7);
if deckcraft_text::datetime::field_text(&format, Some(&lang), probe).is_none() {
format = "datetime1".into();
}
return app.run("insert.dateTime", json!({"format": format, "update": update, "language": lang})).is_ok();
}
cancel
}
fn format_spacing(v: f64) -> String {
if !v.is_finite() {
return "6".into();
}
if (v - v.round()).abs() < 1e-6 && v.abs() < 1.0e6 { format!("{}", v.round() as i64) } else { format!("{v:.2}") }
}
/// Grid Options: spacing, snap and whether the grid and guides are drawn. OK applies one
/// `view.gridOptions` call; Cancel leaves the presentation as it was.
fn grid_options(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
ui.label(egui::RichText::new("Snap").font(theme::bold(13.0)));
let snap = check(ui, d, "Snap objects to grid", "snap", app.session.prefs.snap_to_grid);
let smart = check(ui, d, "Snap objects to other objects", "smart", app.session.prefs.smart_guides);
let spacing_default = format_spacing(app.session.prefs.grid_spacing);
field(ui, d, "Spacing (pt):", "spacing", &spacing_default);
ui.add_space(6.0);
ui.label(egui::RichText::new("Display").font(theme::bold(13.0)));
let gridlines = check(ui, d, "Display grid on screen", "gridlines", app.ui.gridlines);
let guides = check(ui, d, "Display drawing guides on screen", "guides", app.ui.guides);
if let Some(err) = d.fields.get("error").cloned().filter(|s| !s.is_empty()) {
ui.add_space(4.0);
ui.label(egui::RichText::new(err).color(Color32::from_rgb(0xB0, 0x30, 0x30)));
}
let (ok, cancel) = buttons(ui, "OK");
if ok {
let spacing = num(d, "spacing", app.session.prefs.grid_spacing);
match app.run("view.gridOptions", json!({"spacing": spacing, "snap": snap, "smartGuides": smart, "gridlines": gridlines, "guides": guides})) {
Ok(_) => return true,
Err(e) => {
d.fields.insert("error".into(), e);
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,
));
}
/// The selection's proofing language and whether it's marked Do Not Check Spelling: the run at
/// the caret, else the first run of the first selected shape; US English when none says.
pub fn selected_language(app: &SlideApp) -> (String, bool) {
let Some(st) = app.session.active() else { return ("en-US".into(), false) };
let run = |sh: &deckcraft_model::Shape, at: Option<(usize, usize)>| -> Option<deckcraft_model::RunProps> {
let t = sh.text.as_ref()?;
if let Some((p, c)) = at {
let para = t.paragraphs.get(p)?;
let mut n = 0;
for r in &para.runs {
n += r.char_len();
if c <= n {
return Some(r.props.clone());
}
}
return para.runs.last().map(|r| r.props.clone());
}
t.paragraphs.iter().flat_map(|p| &p.runs).next().map(|r| r.props.clone())
};
let props = match &st.selection.text {
Some(t) if !t.notes && t.cell.is_none() => st.shape(t.shape).and_then(|sh| run(sh, Some(t.caret))),
_ => st.selection.shapes.first().and_then(|id| st.shape(*id)).and_then(|sh| run(sh, None)),
};
let props = props.unwrap_or_default();
(props.lang.unwrap_or_else(|| "en-US".into()), props.no_proof == Some(true))
}
fn seed_once(d: &mut Dialog, key: &str, val: String) {
d.fields.entry(key.to_string()).or_insert(val);
}
fn json_text(v: Option<&Value>, default: &str) -> String {
match v {
Some(Value::String(s)) => s.clone(),
Some(Value::Number(n)) => n.as_f64().map(|x| crate::widgets::fmt_num(x, "")).unwrap_or_else(|| default.to_string()),
Some(Value::Bool(true)) => "1".into(),
Some(Value::Bool(false)) => "0".into(),
_ => default.to_string(),
}
}
fn color_label(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Object(o) => o.get("scheme").or_else(|| o.get("rgb")).and_then(Value::as_str).unwrap_or("").to_string(),
_ => String::new(),
}
}
fn seed_font_fields(d: &mut Dialog) {
seed_once(d, "family", json_text(d.params.get("family"), ""));
seed_once(d, "size", json_text(d.params.get("size"), "18"));
seed_once(d, "bold", json_text(d.params.get("bold"), "0"));
seed_once(d, "italic", json_text(d.params.get("italic"), "0"));
seed_once(d, "underline", json_text(d.params.get("underline"), "none"));
seed_once(d, "strike", json_text(d.params.get("strike"), "none"));
seed_once(d, "baseline", json_text(d.params.get("baseline"), "none"));
seed_once(d, "caps", json_text(d.params.get("caps"), "none"));
seed_once(d, "spacing", json_text(d.params.get("spacing"), "0"));
let shown = d.params.get("color").map(color_label).unwrap_or_default();
seed_once(d, "color", shown.clone());
seed_once(d, "colorSeed", shown);
}
fn choice(ui: &mut Ui, d: &mut Dialog, salt: &str, key: &str, choices: &[(&str, &str)]) {
let cur = d.get(key, "");
let label = choices.iter().find(|(v, _)| *v == cur).map(|(_, l)| *l).unwrap_or(cur.as_str());
egui::ComboBox::from_id_salt(salt).selected_text(label).width(140.0).show_ui(ui, |ui| {
for (v, l) in choices {
if ui.selectable_label(cur == *v, *l).clicked() {
d.fields.insert((*key).into(), (*v).into());
}
}
});
}
fn labeled_text(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, width: f32) -> String {
let mut v = d.get(key, "");
let l = ui.label(label);
ui.add(egui::TextEdit::singleline(&mut v).desired_width(width)).labelled_by(l.id);
d.fields.insert(key.into(), v.clone());
v
}
fn finite_pt(text: &str) -> Option<f64> {
text.trim().trim_end_matches("pt").trim().parse::<f64>().ok().filter(|v| v.is_finite())
}
/// Home › Font › Font…, the shape menu and Cmd+T. One OK applies every field together.
fn font_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
ui.set_min_width(460.0);
seed_font_fields(d);
let mut fams = crate::ribbon::font_families(app);
let family = d.get("family", "");
if !family.is_empty() && !fams.iter().any(|f| f == &family) {
fams.insert(0, family.clone());
}
ui.horizontal(|ui| {
ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Font"));
egui::ComboBox::from_id_salt("font_dialog_family").selected_text(if family.is_empty() { " " } else { family.as_str() }).width(300.0).show_ui(
ui,
|ui| {
egui::ScrollArea::vertical().max_height(280.0).show(ui, |ui| {
for f in &fams {
if ui.selectable_label(family == *f, f).clicked() {
d.fields.insert("family".into(), f.clone());
}
}
});
},
);
});
ui.horizontal(|ui| {
let _ = labeled_text(ui, d, "Size", "size", 64.0);
ui.add_space(16.0);
check(ui, d, "Bold", "bold", false);
check(ui, d, "Italic", "italic", false);
});
ui.horizontal(|ui| {
ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Underline"));
choice(
ui,
d,
"font_dialog_underline",
"underline",
&[("none", "None"), ("sng", "Single"), ("dbl", "Double"), ("heavy", "Heavy"), ("dotted", "Dotted"), ("dash", "Dash"), ("wavy", "Wavy")],
);
ui.add_space(8.0);
let _ = labeled_text(ui, d, "Color", "color", 110.0);
});
ui.horizontal(|ui| {
ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Strikethrough"));
choice(ui, d, "font_dialog_strike", "strike", &[("none", "None"), ("sng", "Single"), ("dbl", "Double")]);
});
ui.horizontal(|ui| {
ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Baseline"));
choice(ui, d, "font_dialog_baseline", "baseline", &[("none", "None"), ("super", "Superscript"), ("sub", "Subscript")]);
ui.add_space(8.0);
ui.label("Caps");
choice(ui, d, "font_dialog_caps", "caps", &[("none", "None"), ("small", "Small caps"), ("all", "All caps")]);
});
ui.horizontal(|ui| {
ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Spacing"));
let mut spacing = d.get("spacing", "0");
let edited = ui.add(egui::TextEdit::singleline(&mut spacing).desired_width(64.0).hint_text("pt"));
let mut picked = None;
egui::ComboBox::from_id_salt("font_dialog_spacing")
.selected_text(match spacing.as_str() {
"-3" => "Very tight",
"-1.5" => "Tight",
"0" => "Normal",
"3" => "Loose",
"6" => "Very loose",
other => other,
})
.width(120.0)
.show_ui(ui, |ui| {
for (v, l) in [("-3", "Very tight"), ("-1.5", "Tight"), ("0", "Normal"), ("3", "Loose"), ("6", "Very loose")] {
if ui.selectable_label(spacing == v, l).clicked() {
picked = Some(v.to_string());
}
}
});
let _ = edited;
if let Some(v) = picked {
d.fields.insert("spacing".into(), v);
} else {
d.fields.insert("spacing".into(), spacing);
}
});
let bold = d.get("bold", "0") == "1";
let italic = d.get("italic", "0") == "1";
let underline = d.get("underline", "none");
let strike = d.get("strike", "none");
let shown = finite_pt(&d.get("size", "18")).unwrap_or(18.0).clamp(12.0, 28.0) as f32;
let mut sample = egui::RichText::new("AaBbCcDd").size(shown);
if bold {
sample = sample.strong();
}
if italic {
sample = sample.italics();
}
if underline != "none" {
sample = sample.underline();
}
if strike != "none" {
sample = sample.strikethrough();
}
ui.add_space(6.0);
ui.label(sample);
let (ok, cancel) = buttons(ui, "OK");
if ok {
let family = d.get("family", "");
let family = family.trim();
if family.is_empty() {
app.set_status("Choose a font");
return false;
}
let Some(size) = finite_pt(&d.get("size", "")) else {
app.set_status("Font size must be a number");
return false;
};
let Some(spacing) = finite_pt(&d.get("spacing", "0")) else {
app.set_status("Character spacing must be a number");
return false;
};
let mut params = json!({
"family": family,
"size": size,
"bold": d.get("bold", "0") == "1",
"italic": d.get("italic", "0") == "1",
"underline": d.get("underline", "none"),
"strike": d.get("strike", "none"),
"baseline": d.get("baseline", "none"),
"caps": d.get("caps", "none"),
"spacing": spacing,
});
let color = d.get("color", "");
let seed = d.get("colorSeed", "");
if color == seed {
if let Some(c) = d.params.get("color").filter(|v| !v.is_null()) {
params["color"] = c.clone();
}
} else if !color.trim().is_empty() {
params["color"] = json!(color.trim());
}
let _ = app.run("format.fontDialog", params);
}
ok || cancel
}
fn pt_text(v: Option<&Value>, default: f64) -> String {
match v.and_then(Value::as_f64).filter(|n| n.is_finite()) {
Some(n) => crate::widgets::fmt_num(n, ""),
None => crate::widgets::fmt_num(default, ""),
}
}
fn inches_text(v: Option<&Value>) -> String {
crate::widgets::fmt_num(v.and_then(Value::as_f64).filter(|n| n.is_finite()).unwrap_or(0.0) / 72.0, "")
}
fn seed_paragraph_fields(d: &mut Dialog) {
seed_once(d, "align", json_text(d.params.get("align"), "left"));
seed_once(d, "before", inches_text(d.params.get("indentLeft")));
seed_once(d, "after", inches_text(d.params.get("indentRight")));
seed_once(d, "special", json_text(d.params.get("special"), "none"));
seed_once(d, "by", inches_text(d.params.get("specialBy")));
seed_once(d, "spaceBefore", pt_text(d.params.get("spaceBefore"), 0.0));
seed_once(d, "spaceBeforeSeed", pt_text(d.params.get("spaceBefore"), 0.0));
seed_once(d, "spaceAfter", pt_text(d.params.get("spaceAfter"), 0.0));
seed_once(d, "spaceAfterSeed", pt_text(d.params.get("spaceAfter"), 0.0));
seed_once(d, "lineKind", json_text(d.params.get("lineKind"), "multiple"));
seed_once(d, "lineValue", pt_text(d.params.get("lineValue"), 1.0));
}
fn paragraph_return_params(d: &mut Dialog) -> Value {
let before = finite_pt(&d.get("before", "0")).unwrap_or(0.0) * 72.0;
let after = finite_pt(&d.get("after", "0")).unwrap_or(0.0) * 72.0;
let by = finite_pt(&d.get("by", "0")).unwrap_or(0.0) * 72.0;
let space_before = finite_pt(&d.get("spaceBefore", "0")).unwrap_or(0.0);
let space_after = finite_pt(&d.get("spaceAfter", "0")).unwrap_or(0.0);
let line_value = finite_pt(&d.get("lineValue", "1")).unwrap_or(1.0);
let before_same = d.get("spaceBefore", "") == d.get("spaceBeforeSeed", "");
let after_same = d.get("spaceAfter", "") == d.get("spaceAfterSeed", "");
let v = json!({
"align": d.get("align", "left"),
"indentLeft": before,
"indentRight": after,
"special": d.get("special", "none"),
"specialBy": by,
"spaceBefore": space_before,
"spaceBeforeKind": if before_same { d.params.get("spaceBeforeKind").and_then(Value::as_str).unwrap_or("pts") } else { "pts" },
"spaceAfter": space_after,
"spaceAfterKind": if after_same { d.params.get("spaceAfterKind").and_then(Value::as_str).unwrap_or("pts") } else { "pts" },
"lineKind": d.get("lineKind", "multiple"),
"lineValue": line_value,
"tabs": d.params.get("tabs").cloned().unwrap_or_else(|| json!([])),
"defaultTab": d.params.get("defaultTab").cloned().unwrap_or_else(|| json!(72.0)),
});
v
}
fn line_preset_label(kind: &str, value: &str) -> &'static str {
if kind == "exactly" {
"Exactly"
} else {
match value {
"1" => "Single",
"1.5" => "1.5 lines",
"2" => "Double",
_ => "Multiple",
}
}
}
/// Home › Paragraph launcher. One OK applies alignment, indentation and spacing together.
fn paragraph_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
ui.set_min_width(460.0);
seed_paragraph_fields(d);
ui.horizontal(|ui| {
ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Alignment"));
choice(
ui,
d,
"paragraph_align",
"align",
&[
("left", "Left"),
("center", "Center"),
("right", "Right"),
("justify", "Justify"),
("justLow", "Justify low"),
("distributed", "Distributed"),
],
);
});
ui.label(egui::RichText::new("Indentation").strong());
ui.horizontal(|ui| {
let _ = labeled_text(ui, d, "Before text", "before", 64.0);
ui.label("in");
ui.add_space(12.0);
let _ = labeled_text(ui, d, "After text", "after", 64.0);
ui.label("in");
});
ui.horizontal(|ui| {
ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Special"));
choice(ui, d, "paragraph_special", "special", &[("none", "None"), ("first", "First line"), ("hanging", "Hanging")]);
ui.add_space(8.0);
let _ = labeled_text(ui, d, "By", "by", 64.0);
ui.label("in");
});
ui.label(egui::RichText::new("Spacing").strong());
ui.horizontal(|ui| {
let _ = labeled_text(ui, d, "Spacing before", "spaceBefore", 64.0);
ui.label("pt");
ui.add_space(12.0);
let _ = labeled_text(ui, d, "Spacing after", "spaceAfter", 64.0);
ui.label("pt");
});
let mut picked: Option<(&'static str, Option<&'static str>)> = None;
ui.horizontal(|ui| {
ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Line spacing"));
let kind = d.get("lineKind", "multiple");
let value = d.get("lineValue", "1");
egui::ComboBox::from_id_salt("paragraph_line").selected_text(line_preset_label(&kind, &value)).width(140.0).show_ui(ui, |ui| {
for (label, k, v) in [
("Single", "multiple", Some("1")),
("1.5 lines", "multiple", Some("1.5")),
("Double", "multiple", Some("2")),
("Multiple", "multiple", None),
("Exactly", "exactly", None),
] {
if ui.selectable_label(line_preset_label(&kind, &value) == label, label).clicked() {
picked = Some((k, v));
}
}
});
ui.add_space(8.0);
let _ = labeled_text(ui, d, "At", "lineValue", 64.0);
});
if let Some((kind, value)) = picked {
d.fields.insert("lineKind".into(), kind.into());
if let Some(v) = value {
d.fields.insert("lineValue".into(), v.into());
} else if kind == "exactly" && finite_pt(&d.get("lineValue", "1")).is_some_and(|n| n <= 9.99) {
d.fields.insert("lineValue".into(), "18".into());
} else if kind == "multiple" && finite_pt(&d.get("lineValue", "1")).is_some_and(|n| n > 9.99) {
d.fields.insert("lineValue".into(), "1".into());
}
}
let mut open_tabs = false;
ui.horizontal(|ui| {
if ui.button("Tabs…").clicked() {
open_tabs = true;
}
});
let (ok, cancel) = buttons(ui, "OK");
if open_tabs {
let mut tabs = Dialog::new("tabs");
tabs.params = json!({
"tabs": d.params.get("tabs").cloned().unwrap_or_else(|| json!([])),
"defaultTab": d.params.get("defaultTab").cloned().unwrap_or_else(|| json!(72.0)),
"returnId": "paragraph",
"returnParams": paragraph_return_params(d),
});
app.dialog = Some(tabs);
return true;
}
if ok {
let Some(before_in) = finite_pt(&d.get("before", "")) else {
app.set_status("Indent before text must be a number of inches");
return false;
};
let Some(after_in) = finite_pt(&d.get("after", "")) else {
app.set_status("Indent after text must be a number of inches");
return false;
};
let Some(by_in) = finite_pt(&d.get("by", "")) else {
app.set_status("Special indent must be a number of inches");
return false;
};
let Some(space_before) = finite_pt(&d.get("spaceBefore", "")) else {
app.set_status("Spacing before must be a number");
return false;
};
let Some(space_after) = finite_pt(&d.get("spaceAfter", "")) else {
app.set_status("Spacing after must be a number");
return false;
};
let Some(line_value) = finite_pt(&d.get("lineValue", "")) else {
app.set_status("Line spacing must be a number");
return false;
};
let mut params = json!({
"align": d.get("align", "left"),
"indentLeft": before_in * 72.0,
"indentRight": after_in * 72.0,
"special": d.get("special", "none"),
"specialBy": by_in * 72.0,
"spaceBefore": space_before,
"spaceAfter": space_after,
"spaceBeforeKind": if d.get("spaceBefore", "") == d.get("spaceBeforeSeed", "") { d.params.get("spaceBeforeKind").and_then(Value::as_str).unwrap_or("pts") } else { "pts" },
"spaceAfterKind": if d.get("spaceAfter", "") == d.get("spaceAfterSeed", "") { d.params.get("spaceAfterKind").and_then(Value::as_str).unwrap_or("pts") } else { "pts" },
});
if d.get("lineKind", "multiple") == "exactly" {
params["lineSpacingPt"] = json!(line_value);
} else {
params["lineSpacing"] = json!(line_value);
}
if app.run("format.paragraphDialog", params).is_err() {
return false;
}
}
ok || cancel
}
const TAB_KINDS: [(&str, &str); 4] = [("l", "Left"), ("ctr", "Center"), ("r", "Right"), ("dec", "Decimal")];
/// Format › Tabs: the default spacing and the tab stops of the selected paragraphs, edited as a
/// list (inches, as PowerPoint's dialog) and applied together with OK.
fn tabs_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
if !d.fields.contains_key("stops") {
// Opened from Paragraph (or by `format.tabs` with no fields) the stops come with the
// dialog; otherwise they are read from the caret's paragraph.
let r = if d.params.get("tabs").is_some() { d.params.clone() } else { app.session.execute("format.ruler", &json!({})).unwrap_or_default() };
let stops: Vec<String> = r["tabs"]
.as_array()
.map(|a| a.iter().filter_map(|t| Some(format!("{}:{}", t["pos"].as_f64()? / 72.0, t["align"].as_str().unwrap_or("l")))).collect())
.unwrap_or_default();
d.fields.insert("stops".into(), stops.join("|"));
d.fields.insert("default".into(), format!("{}", r["defaultTab"].as_f64().unwrap_or(72.0) / 72.0));
}
let mut stops: Vec<(f64, String)> = d
.get("stops", "")
.split('|')
.filter_map(|x| {
let (p, a) = x.split_once(':')?;
Some((p.parse::<f64>().ok().filter(|v| v.is_finite())?, a.to_string()))
})
.collect();
field(ui, d, "Default tab stops (in):", "default", "1");
ui.add_space(6.0);
ui.label("Tab stops:");
let mut remove = None;
egui::Grid::new("tab_stops").num_columns(3).spacing(vec2(12.0, 4.0)).show(ui, |ui| {
if stops.is_empty() {
ui.label("(none)");
ui.end_row();
}
for (i, (pos, align)) in stops.iter().enumerate() {
ui.label(format!("{pos:.2}\""));
ui.label(TAB_KINDS.iter().find(|k| k.0 == align).map(|k| k.1).unwrap_or("Left"));
if ui.small_button("Clear").on_hover_text(format!("Clear the tab at {pos:.2}\"")).clicked() {
remove = Some(i);
}
ui.end_row();
}
});
if let Some(i) = remove {
stops.remove(i);
}
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label("Tab stop position (in):");
let mut v = d.get("newpos", "");
ui.add(egui::TextEdit::singleline(&mut v).desired_width(60.0));
d.fields.insert("newpos".into(), v);
});
let cur = d.get("newalign", "l");
ui.horizontal(|ui| {
ui.label("Alignment:");
for (k, label) in TAB_KINDS {
if ui.radio(cur == k, label).clicked() {
d.fields.insert("newalign".into(), k.into());
}
}
});
ui.horizontal(|ui| {
if ui.button("Set").clicked()
&& let Some(p) = Some(num(d, "newpos", f64::NAN)).filter(|v| v.is_finite() && *v >= 0.0 && *v <= 56.0)
{
stops.retain(|(q, _)| (q - p).abs() > 0.005);
stops.push((p, d.get("newalign", "l")));
stops.sort_by(|a, b| a.0.total_cmp(&b.0));
d.fields.insert("newpos".into(), String::new());
}
if ui.button("Clear All").clicked() {
stops.clear();
}
});
d.fields.insert("stops".into(), stops.iter().map(|(p, a)| format!("{p}:{a}")).collect::<Vec<_>>().join("|"));
let (ok, cancel) = buttons(ui, "OK");
if ok {
let set: Vec<Value> = stops.iter().map(|(p, a)| json!({"pos": p * 72.0, "align": a})).collect();
let dt = num(d, "default", 1.0).clamp(0.02, 56.0) * 72.0;
if let Err(e) = app.run("format.tabs", json!({"set": set, "defaultTab": dt})) {
app.set_status(e);
} else if let Some(ret) = d.params.get_mut("returnParams").and_then(Value::as_object_mut) {
// Back in Paragraph, its Tabs… shows what was just applied.
ret.insert("tabs".into(), json!(set));
ret.insert("defaultTab".into(), json!(dt));
}
}
ok || cancel
}
/// Home › Find / Replace: step through matches (slide text, table cells, notes) or replace them.
fn find_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
let replace_mode = d.get("replace", "0") == "1";
field(ui, d, "Find what:", "text", "");
if replace_mode {
field(ui, d, "Replace with:", "with", "");
}
let case = check(ui, d, "Match case", "matchCase", false);
let whole = check(ui, d, "Find whole words only", "wholeWords", false);
let text = d.get("text", "");
let q = json!({"text": text, "matchCase": case, "wholeWords": whole});
let mut close = false;
let mut find_next = false;
ui.add_space(6.0);
ui.horizontal(|ui| {
let has = !text.is_empty();
if ui.add_enabled(has, egui::Button::new("Find Next")).clicked() || (has && ui.input(|i| i.key_pressed(egui::Key::Enter))) {
find_next = true;
}
if replace_mode {
if ui.add_enabled(has, egui::Button::new("Replace")).clicked() {
let cur: Option<Value> = serde_json::from_str(&d.get("cur", "")).ok();
if let Some(at) = cur {
let mut p = q.clone();
p["with"] = json!(d.get("with", ""));
p["at"] = at;
if app.session.execute("text.replace", &p).is_ok() {
let n = d.get("next", "1").parse::<usize>().unwrap_or(1);
d.fields.insert("next".into(), n.saturating_sub(1).to_string());
}
}
find_next = true;
}
if ui.add_enabled(has, egui::Button::new("Replace All")).clicked() {
let mut p = q.clone();
p["with"] = json!(d.get("with", ""));
let msg = match app.session.execute("text.replace", &p) {
Ok(v) => format!("Replaced {} occurrence(s).", v.get("replaced").and_then(Value::as_u64).unwrap_or(0)),
Err(_) => format!("DeckCraft can't find “{text}”."),
};
d.fields.insert("status".into(), msg);
d.fields.remove("cur");
}
} else if ui.button("Replace…").clicked() {
d.fields.insert("replace".into(), "1".into());
}
if ui.button("Close").clicked() {
close = true;
}
});
if find_next {
let list = app.session.execute("text.find", &q).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default();
if list.is_empty() {
d.fields.insert("status".into(), format!("DeckCraft can't find “{text}”."));
d.fields.remove("cur");
} else {
let n = d.get("next", "0").parse::<usize>().unwrap_or(0) % list.len();
let m = &list[n];
let _ = app.run("slide.go", json!({"index": m.get("slide").cloned().unwrap_or_default()}));
if let Some(id) = m.get("shape").filter(|v| !v.is_null()) {
let _ = app.run("edit.select", json!({"ids": [id]}));
}
d.fields.insert("cur".into(), m.to_string());
d.fields.insert("next".into(), (n + 1).to_string());
let where_ = if m.get("shape").is_some_and(Value::is_null) { " (notes)" } else { "" };
d.fields.insert(
"status".into(),
format!("Match {} of {} on slide {}{where_}.", n + 1, list.len(), m.get("slide").and_then(Value::as_u64).unwrap_or(0) + 1),
);
}
}
let status = d.get("status", "");
if !status.is_empty() {
ui.add_space(4.0);
ui.label(status);
}
close
}
#[cfg(test)]
#[path = "font_dialog_tests.rs"]
mod font_dialog_tests;
#[cfg(test)]
#[path = "size_position_tests.rs"]
mod size_position_tests;
#[cfg(test)]
#[path = "photo_album_tests.rs"]
mod photo_album_tests;
/// The export dialog's Animated GIF options (size, seconds per slide, loop, slide range, hidden
/// slides) as `file.export` params; a slide range that doesn't fit the deck is an error message.
fn gif_options(app: &SlideApp, ui: &mut Ui, d: &mut Dialog) -> Result<Value, String> {
use deckcraft_engine::gif_export::{MAX_SECONDS, MAX_WIDTH, MIN_SECONDS, MIN_WIDTH, SIZE_PRESETS};
let n = app.session.active().map_or(0, |s| s.doc.slides.len());
let size = d.get("gifSize", "medium");
let size_label = |k: &str| match k {
"small" => "Small (640 px wide)",
"large" => "Large (1280 px wide)",
"extraLarge" => "Extra Large (1920 px wide)",
"custom" => "Custom width",
_ => "Medium (960 px wide)",
};
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.add_sized(vec2(120.0, 20.0), egui::Label::new("Size:"));
egui::ComboBox::from_id_salt("gif_size").selected_text(size_label(&size)).show_ui(ui, |ui| {
for k in SIZE_PRESETS.iter().map(|(k, _)| *k).chain(["custom"]) {
if ui.selectable_label(size == k, size_label(k)).clicked() {
d.fields.insert("gifSize".into(), k.into());
}
}
});
});
if size == "custom" {
field(ui, d, "Width (pixels):", "gifWidth", "960");
}
field(ui, d, "Seconds per slide:", "gifSeconds", "5");
let n_text = n.max(1).to_string();
field(ui, d, "From slide:", "gifFrom", "1");
field(ui, d, "To slide:", "gifTo", &n_text);
let forever = check(ui, d, "Loop forever", "gifLoop", true);
let hidden = check(ui, d, "Include hidden slides", "gifHidden", false);
let width = if size == "custom" {
let w = num(d, "gifWidth", 960.0).round();
if !(f64::from(MIN_WIDTH)..=f64::from(MAX_WIDTH)).contains(&w) {
return Err(format!("Width: enter {MIN_WIDTH} to {MAX_WIDTH} pixels"));
}
json!(w as u32)
} else {
json!(size)
};
let seconds = num(d, "gifSeconds", f64::NAN);
if deckcraft_engine::gif_export::check_seconds(seconds).is_err() {
return Err(format!("Seconds per slide: enter {MIN_SECONDS} to {MAX_SECONDS}"));
}
let mut p = json!({"width": width, "secondsPerSlide": seconds, "loop": forever, "includeHidden": hidden});
let (from, to) = (num(d, "gifFrom", 1.0).round(), num(d, "gifTo", n as f64).round());
if !(from >= 1.0 && from <= to && to <= n as f64) {
return Err(format!("Slides: choose a range from 1 to {n}"));
}
if from > 1.0 || to < n as f64 {
p["slides"] = json!(((from as usize - 1)..(to as usize)).collect::<Vec<_>>());
}
Ok(p)
}
#[cfg(test)]
#[path = "theme_colors_tests.rs"]
mod theme_colors_tests;
#[cfg(test)]
#[path = "theme_fonts_tests.rs"]
mod theme_fonts_tests;
#[cfg(test)]
mod alt_text_tests {
use super::alt_text_starts_decorative;
#[test]
fn decorative_starts_checked_only_when_every_shape_is() {
assert!(!alt_text_starts_decorative([]));
assert!(!alt_text_starts_decorative([false]));
assert!(alt_text_starts_decorative([true]));
assert!(alt_text_starts_decorative([true, true]));
assert!(!alt_text_starts_decorative([true, false]));
}
}
#[cfg(test)]
mod tests {
use deckcraft_model::text::{Action, Hyperlink, Run, TextBody};
use deckcraft_model::{Presentation, SlideId};
use super::{
CurrentLink, HyperlinkSeed, common_click, hyperlink_command, hyperlink_seed_of, hyperlink_should_apply, link_covering, seed_from,
seed_of_link,
};
fn link(action: Action, tip: &str) -> Hyperlink {
Hyperlink { action, tooltip: tip.into(), highlight_click: false, sound: None, stop_sound: false, zoom_return: None }
}
fn url(address: &str, tip: &str) -> Hyperlink {
let mut h = link(Action::Url { url: address.into() }, tip);
h.highlight_click = tip == "glow";
h
}
/// The Export… dialog (Animated GIF) shows the GIF options and exports through `file.export` (#171).
#[test]
fn export_dialog_writes_an_animated_gif() {
use std::cell::RefCell;
use egui::vec2;
use egui_kittest::kittest::Queryable as _;
use serde_json::json;
use crate::SlideApp;
let dir = std::env::temp_dir().join(format!("deckcraft-ui-gif-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("deck.gif");
let picked = path.to_string_lossy().to_string();
let suggested = std::rc::Rc::new(RefCell::new(String::new()));
let seen = suggested.clone();
let services = crate::Services {
pick_save: Some(Box::new(move |s: &str| {
*seen.borrow_mut() = s.to_string();
Some(picked.clone())
})),
..Default::default()
};
let mut session = deckcraft_engine::Session::with_new();
session.execute("slide.new", &json!({})).unwrap();
let mut harness = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SlideApp::new(session, services),
);
harness.run_steps(2);
harness.state_mut().run("app.exportDialog", json!({})).unwrap();
harness.run_steps(2);
harness.get_by_label("Animated GIF (.gif)").click_accesskit();
harness.run_steps(2);
assert_eq!(harness.state().dialog.as_ref().and_then(|d| d.fields.get("fmt")).map(String::as_str), Some("gif"));
harness.get_by_label("Seconds per slide:");
harness.get_by_label("Loop forever").click_accesskit();
harness.run_steps(2);
// A range outside the deck keeps the dialog open with a message, and nothing is written.
harness.state_mut().dialog.as_mut().unwrap().fields.insert("gifTo".into(), "9".into());
harness.run_steps(2);
harness.key_press(egui::Key::Enter);
harness.run_steps(2);
assert!(harness.state().dialog.is_some());
assert!(harness.state().status.as_ref().is_some_and(|(m, _)| m.contains("Slides")), "{:?}", harness.state().status);
assert!(!path.exists());
harness.state_mut().dialog.as_mut().unwrap().fields.insert("gifTo".into(), "2".into());
harness.run_steps(2);
harness.key_press(egui::Key::Enter);
harness.run_steps(2);
assert!(harness.state().dialog.is_none(), "the dialog closes after exporting");
assert!(suggested.borrow().ends_with(".gif"), "{}", suggested.borrow());
let bytes = std::fs::read(&path).unwrap();
assert!(bytes.starts_with(b"GIF89a"));
assert_eq!(u16::from_le_bytes([bytes[6], bytes[7]]), 960, "medium by default");
assert!(!bytes.windows(11).any(|w| w == b"NETSCAPE2.0"), "Loop forever unticked: plays once");
let _ = std::fs::remove_dir_all(&dir);
}
/// The Export… dialog offers "Transparent background" for PNG only, and the picture it
/// writes then leaves the slide background out (#784).
#[test]
fn export_dialog_writes_a_png_with_a_transparent_background() {
use egui::vec2;
use egui_kittest::kittest::Queryable as _;
use serde_json::json;
use crate::SlideApp;
let dir = std::env::temp_dir().join(format!("deckcraft-ui-png-clear-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("slide.png");
let picked = path.to_string_lossy().to_string();
let services = crate::Services { pick_save: Some(Box::new(move |_: &str| Some(picked.clone()))), ..Default::default() };
let mut harness = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SlideApp::new(deckcraft_engine::Session::with_new(), services),
);
harness.run_steps(2);
harness.state_mut().run("app.exportDialog", json!({})).unwrap();
harness.run_steps(2);
assert!(harness.query_by_label("Transparent background").is_none(), "PDF has no transparent background");
harness.get_by_label("JPEG image (current slide)").click_accesskit();
harness.run_steps(2);
assert!(harness.query_by_label("Transparent background").is_none(), "nor has JPEG");
harness.get_by_label("PNG images (current slide)").click_accesskit();
harness.run_steps(2);
harness.get_by_label("Transparent background").click_accesskit();
harness.run_steps(2);
assert_eq!(harness.state().dialog.as_ref().and_then(|d| d.fields.get("pngTransparent")).map(String::as_str), Some("true"));
harness.key_press(egui::Key::Enter);
harness.run_steps(2);
assert!(harness.state().dialog.is_none(), "the dialog closes after exporting");
let img = deckcraft_render::decode_image(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(img.get_pixel(2, 2).0[3], 0, "the background is left out");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn no_link_opens_as_a_new_web_address() {
let seed = seed_from(CurrentLink::None, &[]);
assert_eq!(seed, HyperlinkSeed::blank());
assert!(hyperlink_should_apply(&seed, "url", "https://", "", 0));
}
#[test]
fn web_email_and_slide_links_fill_the_dialog() {
let ids = [SlideId(7), SlideId(9)];
let web = seed_of_link(&url("https://example.org", "See"), &ids);
assert_eq!(web.mode, "url");
assert_eq!(web.url, "https://example.org");
assert_eq!(web.tip, "See");
assert!(!web.preserve);
assert!(hyperlink_should_apply(&web, "url", "https://example.org", "", 0));
let mail = seed_of_link(&url("mailto:a@b.org", "Write"), &ids);
assert_eq!(mail.mode, "email");
assert_eq!(mail.email, "a@b.org");
assert_eq!(mail.tip, "Write");
assert_eq!(hyperlink_command("email", "", "a@b.org", 0, "Write", false)["url"], "mailto:a@b.org");
let slide = seed_of_link(&link(Action::Slide { slide: SlideId(9) }, "There"), &ids);
assert_eq!(slide.mode, "slide");
assert_eq!(slide.slide, 1);
assert_eq!(slide.tip, "There");
assert!(!slide.preserve);
let glow = url("https://example.org", "glow");
assert!(glow.highlight_click);
assert!(seed_of_link(&glow, &ids).highlight);
assert_eq!(hyperlink_command("url", "https://example.org", "", 0, "glow", true)["highlightClick"], true);
}
#[test]
fn actions_this_dialog_does_not_edit_survive_ok() {
let next = seed_of_link(&link(Action::NextSlide, "On"), &[]);
assert!(next.preserve);
assert_eq!(next.tip, "On");
assert_eq!(next.url, "");
assert!(!hyperlink_should_apply(&next, "url", "", "", 0));
assert!(hyperlink_should_apply(&next, "url", "https://example.org", "", 0));
let gone = seed_of_link(&link(Action::Slide { slide: SlideId(99) }, ""), &[SlideId(1)]);
assert!(gone.preserve, "a missing slide is not retargeted to the first slide");
assert!(!hyperlink_should_apply(&gone, "url", "", "", 0));
let mixed = seed_from(CurrentLink::Mixed, &[]);
assert!(mixed.preserve);
assert!(!hyperlink_should_apply(&mixed, "url", "", "", 0));
assert!(hyperlink_should_apply(&mixed, "email", "", "a@b.org", 0));
}
#[test]
fn a_text_span_reports_the_link_it_covers() {
let mut body = TextBody::from_text("Hello world");
body.paragraphs[0].runs.clear();
body.paragraphs[0].runs.push(Run::new("Hello "));
let mut world = Run::new("world");
world.props.link = Some(url("https://example.org", "tip"));
body.paragraphs[0].runs.push(world);
assert_eq!(link_covering(&body, (0, 6), (0, 6)), CurrentLink::None, "a caret inserts; it does not edit the run beside it");
match link_covering(&body, (0, 6), (0, 11)) {
CurrentLink::One(h) => assert_eq!(h.tooltip, "tip"),
other => panic!("linked word, got {other:?}"),
}
assert_eq!(link_covering(&body, (0, 0), (0, 11)), CurrentLink::Mixed);
let mut other = url("https://example.org", "tip");
other.tooltip = "other".into();
assert_eq!(common_click([None, None]), CurrentLink::None);
assert!(matches!(common_click([Some(url("https://example.org", "tip"))]), CurrentLink::One(_)));
assert_eq!(common_click([Some(url("https://example.org", "tip")), Some(other)]), CurrentLink::Mixed);
assert_eq!(common_click(std::iter::empty()), CurrentLink::None);
}
#[test]
fn the_open_dialog_reads_the_caret_word_and_the_shape_click() {
let mut doc = Presentation::default();
let shape_id = {
let slide = std::sync::Arc::make_mut(&mut doc.slides[0]);
let mut body = TextBody::from_text("Hello world");
body.paragraphs[0].runs.clear();
body.paragraphs[0].runs.push(Run::new("Hello "));
let mut world = Run::new("world");
world.props.link = Some(url("https://example.org", "tip"));
body.paragraphs[0].runs.push(world);
slide.shapes[0].text = Some(body);
slide.shapes[0].id
};
let mut st = deckcraft_engine::DocState::new(doc, None, "t".into());
st.selection.text = Some(deckcraft_engine::TextSel { shape: shape_id, anchor: (0, 8), caret: (0, 8), ..Default::default() });
let seed = hyperlink_seed_of(Some(&st));
assert_eq!(seed.mode, "url");
assert_eq!(seed.url, "https://example.org");
assert_eq!(seed.tip, "tip");
assert!(!seed.preserve);
st.selection.text = None;
st.selection.shapes = vec![shape_id];
{
let doc = std::sync::Arc::make_mut(&mut st.doc);
let slide = std::sync::Arc::make_mut(&mut doc.slides[0]);
let shape = slide.shapes.iter_mut().find(|sh| sh.id == shape_id).unwrap();
let mut click = url("https://shape.example", "shape tip");
click.highlight_click = true;
shape.click = Some(click);
}
let seed = hyperlink_seed_of(Some(&st));
assert_eq!(seed.url, "https://shape.example");
assert_eq!(seed.tip, "shape tip");
assert!(seed.highlight);
st.selection.text = Some(deckcraft_engine::TextSel { shape: shape_id, anchor: (0, 11), caret: (0, 11), ..Default::default() });
let seed = hyperlink_seed_of(Some(&st));
assert_eq!(seed, HyperlinkSeed::blank(), "a caret at the end of the word inserts a new link");
}
}
#[cfg(test)]
mod zoom_tests {
use super::parse_zoom_percent;
fn near(v: f32, want: f32) -> bool {
(v - want).abs() < 1.0e-4
}
#[test]
fn zoom_percent_accepts_a_number_and_clamps() {
assert!(parse_zoom_percent("150").is_some_and(|v| near(v, 1.5)));
assert!(parse_zoom_percent("150%").is_some_and(|v| near(v, 1.5)));
assert!(parse_zoom_percent(" 100 ").is_some_and(|v| near(v, 1.0)));
assert!(parse_zoom_percent("1").is_some_and(|v| near(v, 0.1)));
assert!(parse_zoom_percent("900").is_some_and(|v| near(v, 4.0)));
assert!(parse_zoom_percent("").is_none());
assert!(parse_zoom_percent("fit").is_none());
assert!(parse_zoom_percent("nan").is_none());
assert!(parse_zoom_percent("inf").is_none());
}
}
#[cfg(test)]
mod trim_media_tests {
use std::sync::Arc;
use deckcraft_model::{MediaClip, Presentation, ShapeId, ShapeKind};
use super::{ms_as_seconds, seconds_text_to_ms, trim_seed};
#[test]
fn trim_seconds_round_trip_the_stored_milliseconds() {
for ms in [0, 1, 250, 333, 1000, 1500, 60_000] {
let text = ms_as_seconds(ms);
assert_eq!(seconds_text_to_ms(&text), ms, "{text}");
}
assert_eq!(seconds_text_to_ms(""), 0);
assert_eq!(seconds_text_to_ms("nope"), 0);
assert_eq!(seconds_text_to_ms("-2"), 0);
assert_eq!(seconds_text_to_ms("1.5"), 1500);
assert_eq!(seconds_text_to_ms("999999999"), u32::MAX);
}
#[test]
fn trim_dialog_reads_the_selected_clip() {
assert_eq!(trim_seed(None).start, "0");
let mut doc = Presentation::default();
let media_id = {
let slide = Arc::make_mut(&mut doc.slides[0]);
let title = slide.shapes[0].id;
let clip = MediaClip { trim_start_ms: 1500, trim_end_ms: 250, fade_in_ms: 333, fade_out_ms: 1000, ..Default::default() };
let mut media = slide.shapes[0].clone();
media.id = ShapeId(900);
media.kind = ShapeKind::Media(clip);
slide.shapes.push(media);
(title, ShapeId(900))
};
let mut st = deckcraft_engine::DocState::new(doc, None, "t".into());
st.selection.shapes = vec![media_id.0];
assert_eq!(trim_seed(Some(&st)).start, "0", "a title shape is not a clip");
st.selection.shapes = vec![media_id.0, media_id.1];
let seed = trim_seed(Some(&st));
assert_eq!(seed.start, "1.5");
assert_eq!(seed.end, "0.25");
assert_eq!(seed.fade_in, "0.333");
assert_eq!(seed.fade_out, "1");
}
}
#[cfg(test)]
#[path = "spelling_tests.rs"]
mod spelling_tests;
#[cfg(test)]
#[path = "paragraph_dialog_tests.rs"]
mod paragraph_dialog_tests;