mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-26 19:46:58 -06:00
refactor(ui-egui): split dialogs.rs into one file per dialog under dialogs/
This commit is contained in:
+37
-3333
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
));
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("+-", "±")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ¶.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 == ¶.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}),
|
||||
}
|
||||
}
|
||||
@@ -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 ¶.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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user