refactor(ui-egui): split dialogs.rs into one file per dialog under dialogs/

This commit is contained in:
aiongg
2026-10-26 22:08:44 +00:00
parent 462310faed
commit c8a6363a0f
23 changed files with 3409 additions and 3333 deletions
File diff suppressed because it is too large Load Diff
+66
View File
@@ -0,0 +1,66 @@
use super::*;
/// Room the About credits need for their table; the About tab keeps its compact size.
pub(super) const CREDITS_SIZE: egui::Vec2 = vec2(640.0, 380.0);
/// The Contributors / Models body in a block of its own fixed size, below the tab row. Sizing the
/// dialog's `ui` instead (`set_min_size` / `set_max_height`) squeezed the tab row and the first
/// credits row together, so the text was garbled and the tabs stopped answering (#843).
pub(super) fn credits_block(ui: &mut Ui, models: bool) -> Rect {
ui.allocate_ui(CREDITS_SIZE, |ui| {
ui.set_min_size(CREDITS_SIZE);
if models { crate::credits::models_ui(ui) } else { crate::credits::contributors_ui(ui) }
})
.response
.rect
}
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,
));
}
+11
View File
@@ -0,0 +1,11 @@
/// "Mark as decorative" starts checked when every selected shape is already decorative.
pub(super) 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
}
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
use super::*;
/// The Columns dialog's changed fields as `format.columns` params: Number and Spacing (inches,
/// sent in points). A field left as the dialog showed it is left out, so each selected text box
/// keeps its own value; `None` when neither field changed.
fn columns_value(d: &Dialog, shown_count: &str, shown_spacing: &str) -> Result<Option<Value>, String> {
let mut p = serde_json::Map::new();
let count = field_of(d, "count");
if count != shown_count {
let n = count.trim().parse::<u32>().ok().filter(|n| (1..=16).contains(n)).ok_or("Number must be a whole number from 1 to 16")?;
p.insert("count".into(), json!(n));
}
let spacing = field_of(d, "spacing");
if spacing != shown_spacing {
let inches = spacing
.trim()
.trim_end_matches("in")
.trim_end_matches('"')
.trim()
.parse::<f64>()
.ok()
.filter(|v| v.is_finite() && (0.0..=22.0).contains(v))
.ok_or("Spacing must be from 0 to 22 inches")?;
p.insert("spacing".into(), json!(inches * 72.0));
}
Ok((!p.is_empty()).then_some(Value::Object(p)))
}
/// Columns: the number of columns in the text box and the space between them. OK applies one
/// `format.columns` call with the fields that changed, or none when neither did; Cancel leaves
/// the text box as it was.
pub(super) fn columns_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
let count = d.params.get("count").and_then(Value::as_u64).unwrap_or(1).to_string();
let spacing = num_text(d.params.get("spacing").and_then(Value::as_f64).unwrap_or(0.0) / 72.0);
field(ui, d, "Number:", "count", &count);
field(ui, d, "Spacing (in):", "spacing", &spacing);
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 applied = columns_value(d, &count, &spacing).and_then(|p| match p {
Some(p) => app.run("format.columns", p).map(|_| ()),
None => Ok(()),
});
match applied {
Ok(()) => return true,
Err(e) => {
d.fields.insert("error".into(), e);
return false;
}
}
}
cancel
}
+83
View File
@@ -0,0 +1,83 @@
use super::*;
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"),
];
/// 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.
pub(super) 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
}
/// Render language and format controls; OK applies `insert.dateTime`, and the return value says
/// whether the dialog should close.
pub(super) 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());
}
}
});
let shown = lang_label.to_string();
opened.response.widget_info(move || {
let mut info = egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Language");
info.current_text_value = Some(shown.clone());
info
});
});
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
}
+41
View File
@@ -0,0 +1,41 @@
pub(super) 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("+-", "±")
}
+60
View File
@@ -0,0 +1,60 @@
use super::*;
/// 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.
pub(super) 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| {
let field = ui.add_sized(vec2(120.0, 20.0), egui::Label::new("Size:"));
let combo = 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());
}
}
});
let _ = combo.response.labelled_by(field.id);
});
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)
}
+91
View File
@@ -0,0 +1,91 @@
use super::*;
/// Home › Find / Replace: step through matches (slide text, table cells, notes) or replace them.
pub(super) fn find_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
let replace_mode = d.get("replace", "0") == "1";
// "Find what:" has the keyboard when the dialog opens, so Ctrl+F and typing finds (#951).
let mut what = d.get("text", "");
ui.horizontal(|ui| {
ui.add_sized(vec2(120.0, 20.0), egui::Label::new("Find what:"));
let r = ui.add(egui::TextEdit::singleline(&mut what).desired_width(180.0));
// Enter finds the next match and leaves the keyboard here, so the search can be changed.
if !d.fields.contains_key("focused") || (r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) {
r.request_focus();
d.fields.insert("focused".into(), "1".into());
}
});
d.fields.insert("text".into(), what);
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
}
+162
View File
@@ -0,0 +1,162 @@
use super::*;
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);
}
/// Home › Font › Font…, the shape menu and Cmd+T. One OK applies every field together.
pub(super) 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| {
let field = ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Font"));
let combo = 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());
}
}
});
});
let _ = combo.response.labelled_by(field.id);
});
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",
"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", "Strikethrough", "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", "baseline", &[("none", "None"), ("super", "Superscript"), ("sub", "Subscript")]);
ui.add_space(8.0);
ui.label("Caps");
choice(ui, d, "font_dialog_caps", "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;
let combo = 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());
}
}
});
combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Spacing"));
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
}
@@ -0,0 +1,38 @@
use super::*;
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.
pub(super) 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
}
+181
View File
@@ -0,0 +1,181 @@
use super::*;
/// What Insert Hyperlink would edit on the current selection.
#[derive(Clone, Debug, PartialEq)]
pub(super) 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)]
pub(super) struct HyperlinkSeed {
/// `url`, `email` or `slide`.
pub(super) mode: &'static str,
pub(super) url: String,
pub(super) email: String,
pub(super) slide: usize,
pub(super) tip: String,
pub(super) 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.
pub(super) preserve: bool,
}
impl HyperlinkSeed {
/// A new link: the address starts at `https://`, as the dialog always did.
pub(super) 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.
pub(super) 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
}
pub(super) 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.
pub(super) 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,
}
}
pub(super) 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.
pub(super) 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.
pub(super) 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,
}
}
pub(super) 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}),
}
}
+28
View File
@@ -0,0 +1,28 @@
use super::*;
/// 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))
}
+197
View File
@@ -0,0 +1,197 @@
use super::*;
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", "");
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)),
})
}
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.
pub(super) 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",
"Alignment",
"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", "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| {
let field = 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");
let combo = 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));
}
}
});
let _ = combo.response.labelled_by(field.id);
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
}
+339
View File
@@ -0,0 +1,339 @@
use super::*;
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(std::ffi::OsStr::to_str).filter(|s| !s.is_empty()).unwrap_or("picture").to_string();
Some(json!({"name": name, "data": deckcraft_engine::cmd::base64_encode(&loaded)}))
}
/// Draw an accessible album combo box, preserving its current/default selection and storing a
/// newly chosen key; the caller supplies the visible label.
fn album_choice(ui: &mut Ui, d: &mut Dialog, id: &str, name: &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());
}
}
});
let access = name.to_string();
let shown = label.to_string();
opened.response.widget_info(move || {
let mut info = egui::WidgetInfo::labeled(egui::Role::ComboBox, true, access.clone());
info.current_text_value = Some(shown.clone());
info
});
}
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.
pub(super) 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",
"Picture 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 shape",
"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", "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));
});
}
+26
View File
@@ -0,0 +1,26 @@
use super::*;
/// What the Preferences dialog can change, as it was when the dialog opened.
pub(super) 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).
pub(super) 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;
}
}
+207
View File
@@ -0,0 +1,207 @@
use super::*;
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 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"}))
}
pub(super) 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
}
+29
View File
@@ -0,0 +1,29 @@
use super::*;
/// A Slide Size field set to `pt`: two decimals of the unit shown, the exact points kept beside.
pub(super) fn put_slide_dim(d: &mut Dialog, key: &str, pt: f64, ppu: f64) {
d.fields.insert(key.into(), format!("{:.2}", pt / ppu));
d.fields.insert(format!("{key}Pt"), pt.to_string());
}
/// A Slide Size field in points: the exact value while the text is still its rendering, else
/// the number typed (`default` when it is not one).
pub(super) fn slide_dim_points(d: &Dialog, key: &str, ppu: f64, default: f64) -> f64 {
let text = field_of(d, key);
if let Some(pt) = exact_of(d, key)
&& text == format!("{:.2}", pt / ppu)
{
return pt;
}
text.trim().trim_end_matches(['"', '%', ' ']).parse::<f64>().ok().filter(|v| v.is_finite()).map_or(default, |v| v * ppu)
}
/// The unit's abbreviation in a field label, "Width (cm):".
pub(super) fn unit_abbr(unit: deckcraft_geom::Unit) -> &'static str {
match unit {
deckcraft_geom::Unit::Inches => "in",
deckcraft_geom::Unit::Centimeters => "cm",
deckcraft_geom::Unit::Points => "pt",
deckcraft_geom::Unit::Pixels => "px",
}
}
+91
View File
@@ -0,0 +1,91 @@
use super::*;
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.
pub(super) 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| {
let l = ui.label("Tab stop position (in):");
let mut v = d.get("newpos", "");
ui.add(egui::TextEdit::singleline(&mut v).desired_width(60.0)).labelled_by(l.id);
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
}
@@ -0,0 +1,95 @@
use super::*;
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());
}
/// Draw one theme-colour row with its hex editor and store the value for the scheme slot.
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::Role::TextInput, 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.
pub(super) 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::Role::TextInput, 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
}
+114
View File
@@ -0,0 +1,114 @@
use super::*;
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
}
/// Draw an accessible font combo box and update the selected family; the caller supplies the
/// visible label.
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::Role::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.
pub(super) 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::Role::TextInput, 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
}
+50
View File
@@ -0,0 +1,50 @@
/// Seconds for a Trim Media field. Three decimal places, trailing zeros dropped, so the text
/// round-trips the millisecond value the clip stores.
pub(super) 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.
pub(super) 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.
pub(super) struct TrimSeed {
pub(super) start: String,
pub(super) end: String,
pub(super) fade_in: String,
pub(super) 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.
pub(super) 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)
}
+62
View File
@@ -0,0 +1,62 @@
use super::*;
/// 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.
pub(super) 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| {
let l = ui.label("Percent:");
let edit = ui.add(egui::TextEdit::singleline(&mut percent).desired_width(72.0)).labelled_by(l.id);
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
}