mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-10 18:40:27 -06:00
4869 lines
251 KiB
Rust
4869 lines
251 KiB
Rust
//! Modal dialogs. Each dialog keeps its fields in a JSON map so the control channel can set them
|
||
//! (`ui.dialog.set {field, value}`) and confirm them (`ui.dialog.confirm`) like a user would.
|
||
|
||
use designcraft_geom::{Unit, format_measure, units::parse_measure};
|
||
use serde::Serialize;
|
||
use serde_json::{Map, Value, json};
|
||
|
||
use crate::DesignApp;
|
||
use crate::theme::semibold;
|
||
|
||
#[derive(Clone, Debug, Serialize)]
|
||
pub struct Dialog {
|
||
pub id: String,
|
||
pub fields: Map<String, Value>,
|
||
}
|
||
|
||
impl Dialog {
|
||
pub fn new(id: &str, params: Value) -> Self {
|
||
let mut fields = match params {
|
||
Value::Object(m) => m,
|
||
_ => Map::new(),
|
||
};
|
||
let defaults = match id {
|
||
"newDocument" => {
|
||
json!({"preset": "Letter", "width": "51p0", "height": "66p0", "pages": 1, "facingPages": true, "columns": 1, "gutter": "1p0",
|
||
"marginTop": "3p0", "marginBottom": "3p0", "marginInside": "3p0", "marginOutside": "3p0", "bleed": "0p0", "primaryTextFrame": false})
|
||
}
|
||
"goToPage" => json!({"page": "1"}),
|
||
"qrCode" => json!({"type": "url", "content": "https://", "color": "[Black]"}),
|
||
"insertTable" => json!({"bodyRows": 4, "columns": 4, "headerRows": 0, "footerRows": 0}),
|
||
"insertXref" => json!({"linkTo": "paragraph", "style": "", "target": "", "format": ""}),
|
||
"findChange" => json!({"find": "", "change": "", "grep": false, "caseSensitive": false, "wholeWord": false, "scope": "document"}),
|
||
"documentSetup" => json!({}),
|
||
"pdfExport" => {
|
||
json!({
|
||
"preset": "Desktop Printing",
|
||
"standard": "none",
|
||
"compressImages": false,
|
||
"flatten": "",
|
||
"spreads": false,
|
||
"bleed": true,
|
||
"marksCrop": false,
|
||
"marksBleed": false,
|
||
"marksPageInfo": false,
|
||
"marksWeight": "0.25",
|
||
"marksOffset": "6",
|
||
"tagged": true
|
||
})
|
||
}
|
||
_ => json!({}),
|
||
};
|
||
if let Value::Object(d) = defaults {
|
||
for (k, v) in d {
|
||
fields.entry(k).or_insert(v);
|
||
}
|
||
}
|
||
if id == "footnoteOptions" {
|
||
// Flatten the rule and show special characters as InDesign metacharacters.
|
||
if let Some(Value::Object(r)) = fields.remove("rule") {
|
||
for (k, v) in r {
|
||
fields.insert(format!("rule.{k}"), v);
|
||
}
|
||
}
|
||
let sep = fields.get("separator").and_then(Value::as_str).unwrap_or("").to_string();
|
||
fields.insert("separator".into(), json!(sep.replace('\t', "^t").replace('\u{2003}', "^m").replace('\u{2002}', "^>")));
|
||
fields.entry("tab".to_string()).or_insert(json!("numbering"));
|
||
}
|
||
if id == "paragraphStyleOptions" || id == "characterStyleOptions" {
|
||
fields.entry("section".to_string()).or_insert(json!("general"));
|
||
}
|
||
if id == "characterStyleOptions" {
|
||
fields.entry("section".to_string()).or_insert(json!("general"));
|
||
}
|
||
if id == "frameSize" {
|
||
for k in ["width", "height"] {
|
||
if let Some(v) = fields.get(k).and_then(Value::as_f64) {
|
||
fields.insert(k.into(), json!(format_measure(v, Unit::Picas)));
|
||
}
|
||
}
|
||
}
|
||
Dialog { id: id.into(), fields }
|
||
}
|
||
fn s(&self, k: &str) -> String {
|
||
match self.fields.get(k) {
|
||
Some(Value::String(s)) => s.clone(),
|
||
Some(v) => v.to_string(),
|
||
None => String::new(),
|
||
}
|
||
}
|
||
fn m(&self, k: &str) -> Option<f64> {
|
||
match self.fields.get(k) {
|
||
Some(Value::Number(n)) => n.as_f64(),
|
||
Some(Value::String(s)) => parse_measure(s, Unit::Picas).ok(),
|
||
_ => None,
|
||
}
|
||
}
|
||
/// A measure in points ("6", "6 pt", "0p6", "2 mm").
|
||
fn pt(&self, k: &str) -> Option<f64> {
|
||
match self.fields.get(k) {
|
||
Some(Value::Number(n)) => n.as_f64(),
|
||
Some(Value::String(s)) => parse_measure(s, Unit::Points).ok(),
|
||
_ => None,
|
||
}
|
||
}
|
||
fn b(&self, k: &str) -> bool {
|
||
self.fields.get(k).and_then(Value::as_bool).unwrap_or(false)
|
||
}
|
||
fn n(&self, k: &str) -> Option<f64> {
|
||
match self.fields.get(k) {
|
||
Some(Value::Number(n)) => n.as_f64(),
|
||
Some(Value::String(s)) => s.trim().parse().ok(),
|
||
_ => None,
|
||
}
|
||
}
|
||
}
|
||
|
||
fn text_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, w: f32) {
|
||
let mut s = d.s(key);
|
||
if ui.add(egui::TextEdit::singleline(&mut s).desired_width(w).horizontal_align(egui::Align::Min)).changed() {
|
||
d.fields.insert(key.into(), Value::String(s));
|
||
}
|
||
}
|
||
|
||
/// Edit › Keyboard Shortcuts: every command and its shortcut; click a shortcut, then press the
|
||
/// new keys (Esc cancels, Backspace clears).
|
||
/// Edit › Spelling › User Dictionary: added words and hyphenation exceptions (applied at once).
|
||
fn user_dictionary(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Hyphenation Exceptions")).font(semibold(12.0)));
|
||
ui.label(crate::rtl::widget(
|
||
ui,
|
||
egui::RichText::new(crate::i18n::tr(
|
||
&app.ui.language,
|
||
"Type a word with ~ at each allowed break (ex~am~ple); without ~ the word is never hyphenated.",
|
||
))
|
||
.size(10.5),
|
||
));
|
||
ui.horizontal(|ui| {
|
||
text_field(ui, d, "word", 200.0);
|
||
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Add"))).clicked() {
|
||
let w = d.s("word");
|
||
if !w.trim().is_empty() {
|
||
match app.run("hyphenation.addException", json!({"word": w.trim()})) {
|
||
Ok(_) => {
|
||
d.fields.insert("word".into(), json!(""));
|
||
}
|
||
Err(e) => app.status(format!("User Dictionary: {e}")),
|
||
}
|
||
}
|
||
}
|
||
});
|
||
let list = app.session.execute("hyphenation.list", &json!({})).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default();
|
||
egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| {
|
||
for w in list {
|
||
let w = w.as_str().unwrap_or("").to_string();
|
||
ui.horizontal(|ui| {
|
||
ui.label(&w);
|
||
if ui.small_button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Remove"))).clicked() {
|
||
let _ = app.run("hyphenation.removeException", json!({"word": w}));
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
fn is_modifier_key(key: egui::Key) -> bool {
|
||
use egui::Key::*;
|
||
matches!(key, ShiftLeft | ShiftRight | ControlLeft | ControlRight | AltLeft | AltRight | SuperLeft | SuperRight)
|
||
}
|
||
|
||
fn keyboard_shortcuts(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||
let mut q = d.s("query");
|
||
ui.add(
|
||
egui::TextEdit::singleline(&mut q)
|
||
.hint_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Search commands")))
|
||
.desired_width(f32::INFINITY),
|
||
);
|
||
d.fields.insert("query".into(), json!(q));
|
||
let recording = d.s("recording");
|
||
// Capture the next key press for the command being recorded. Pressing a modifier
|
||
// reports the modifier itself as a key; skip it and wait for the key it modifies.
|
||
if !recording.is_empty() {
|
||
let pressed = ui.input(|i| {
|
||
i.events.iter().find_map(|e| match e {
|
||
egui::Event::Key { key, pressed: true, modifiers, .. } if !is_modifier_key(*key) => Some((*key, *modifiers)),
|
||
_ => None,
|
||
})
|
||
});
|
||
if let Some((key, m)) = pressed {
|
||
let sc = match key {
|
||
egui::Key::Escape => None,
|
||
egui::Key::Backspace if !m.any() => Some(String::new()),
|
||
_ => Some(crate::menus::shortcut_string(m, key)),
|
||
};
|
||
if let Some(sc) = sc {
|
||
let msg = match app.run("window.setShortcut", json!({"id": recording, "shortcut": sc})) {
|
||
Ok(v) => match v["conflicts"].as_array().filter(|c| !c.is_empty()) {
|
||
Some(c) => format!("Also used by: {}", c.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", ")),
|
||
None => String::new(),
|
||
},
|
||
Err(e) => e,
|
||
};
|
||
d.fields.insert("message".into(), json!(msg));
|
||
}
|
||
d.fields.insert("recording".into(), json!(""));
|
||
}
|
||
}
|
||
let ql = q.to_lowercase();
|
||
let mut rows: Vec<(String, String, String)> = designcraft_engine::command_specs()
|
||
.iter()
|
||
.filter(|c| !c.menu.is_empty() || c.shortcut.is_some())
|
||
.map(|c| (c.id.to_string(), c.label.to_string(), c.menu.join(" › ")))
|
||
.chain(crate::menus::UI_COMMANDS.iter().map(|c| (c.0.to_string(), c.1.to_string(), String::new())))
|
||
.filter(|(id, l, _)| ql.is_empty() || l.to_lowercase().contains(&ql) || id.to_lowercase().contains(&ql))
|
||
.collect();
|
||
rows.sort_by(|a, b| a.1.cmp(&b.1));
|
||
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
|
||
egui::Grid::new("shortcuts").num_columns(3).striped(true).spacing([12.0, 4.0]).show(ui, |ui| {
|
||
for (id, label, menu) in &rows {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label.trim_end_matches('…')))
|
||
.on_hover_text(format!("{id}{}", if menu.is_empty() { String::new() } else { format!(" ({menu})") }));
|
||
let cur = crate::menus::shortcut_of(app, id).map(|s| crate::menus::shortcut_text(&s)).unwrap_or_else(|| "—".into());
|
||
let text = if recording == *id { "Press keys…".to_string() } else { cur };
|
||
if ui.add(egui::Button::new(text).min_size(egui::vec2(110.0, 0.0))).clicked() {
|
||
d.fields.insert("recording".into(), json!(id));
|
||
d.fields.insert("message".into(), json!(""));
|
||
}
|
||
if app.ui.shortcuts.contains_key(id) {
|
||
if ui.small_button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Default"))).clicked() {
|
||
let _ = app.run("window.setShortcut", json!({"id": id, "shortcut": null}));
|
||
}
|
||
} else {
|
||
ui.label("");
|
||
}
|
||
ui.end_row();
|
||
}
|
||
});
|
||
});
|
||
let msg = d.s("message");
|
||
if !msg.is_empty() {
|
||
ui.label(egui::RichText::new(msg).color(egui::Color32::from_rgb(240, 180, 60)));
|
||
}
|
||
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Reset All to Defaults"))).clicked() {
|
||
let _ = app.run("window.resetShortcuts", json!({}));
|
||
}
|
||
}
|
||
|
||
/// File › Print, with the printers the system knows.
|
||
pub fn open_print(app: &mut DesignApp) {
|
||
let p = app.session.execute("file.printers", &json!({})).unwrap_or_default();
|
||
let printer = p["default"].as_str().map(str::to_string).or_else(|| p["printers"][0].as_str().map(str::to_string)).unwrap_or_default();
|
||
app.ui.dialog = Some(Dialog::new(
|
||
"print",
|
||
json!({"printers": p["printers"], "printer": printer, "copies": 1, "range": "all", "pages": "1", "spreads": false, "marks": false, "bleed": false}),
|
||
));
|
||
}
|
||
|
||
/// File › Export PDF…: the "Export PDF" options dialog, seeded from the persisted settings
|
||
/// (`UiState.pdf_export`) and the active document. `pages` is per-job (not persisted); `flatten`
|
||
/// falls back to the global flattener preset when the persisted value is empty.
|
||
pub fn open_pdf_export(app: &mut DesignApp) {
|
||
let mut fields = serde_json::to_value(&app.ui.pdf_export).unwrap_or_else(|_| json!({}));
|
||
if let Value::Object(m) = &mut fields {
|
||
let title = app.session.active().map(|st| st.doc.title.clone()).unwrap_or_default();
|
||
m.insert("title".into(), json!(title));
|
||
m.insert("pages".into(), json!("all"));
|
||
if app.ui.pdf_export.flatten.is_empty() {
|
||
m.insert("flatten".into(), json!(app.ui.flattener));
|
||
}
|
||
}
|
||
app.ui.dialog = Some(Dialog::new("pdfExport", fields));
|
||
}
|
||
|
||
fn print_dialog(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||
let printers: Vec<String> = d
|
||
.fields
|
||
.get("printers")
|
||
.and_then(Value::as_array)
|
||
.map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect())
|
||
.unwrap_or_default();
|
||
egui::Grid::new("print").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Printer:"));
|
||
let cur = d.s("printer");
|
||
if printers.is_empty() {
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "System default")).italics());
|
||
} else {
|
||
egui::ComboBox::from_id_salt("printer").selected_text(&cur).width(220.0).show_ui(ui, |ui| {
|
||
for p in &printers {
|
||
if ui.selectable_label(*p == cur, p).clicked() {
|
||
d.fields.insert("printer".into(), json!(p));
|
||
}
|
||
}
|
||
});
|
||
}
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Copies:"));
|
||
text_field(ui, d, "copies", 50.0);
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Pages:"));
|
||
ui.vertical(|ui| {
|
||
let range = d.s("range");
|
||
if ui.radio(range == "all", "All").clicked() {
|
||
d.fields.insert("range".into(), json!("all"));
|
||
}
|
||
ui.horizontal(|ui| {
|
||
if ui.radio(range == "range", "Range:").clicked() {
|
||
d.fields.insert("range".into(), json!("range"));
|
||
}
|
||
text_field(ui, d, "pages", 120.0);
|
||
});
|
||
});
|
||
ui.end_row();
|
||
ui.label("");
|
||
ui.vertical(|ui| {
|
||
check(ui, d, "spreads", crate::i18n::tr(&app.ui.language, "Spreads"));
|
||
check(ui, d, "marks", crate::i18n::tr(&app.ui.language, "Printer's Marks"));
|
||
check(ui, d, "bleed", crate::i18n::tr(&app.ui.language, "Include Bleed"));
|
||
});
|
||
ui.end_row();
|
||
});
|
||
}
|
||
|
||
/// The presets offered by the Export PDF dialog, in display order. The stored value is the
|
||
/// English name; the caption is translated.
|
||
const PDF_EXPORT_PRESETS: &[&str] = &["Desktop Printing", "Commercial Printing", "Screen and Email", "PDF/X-4", "PDF/A-2b", "Custom"];
|
||
|
||
/// Apply a preset's dependent fields. Flatten is only touched by "Screen and Email"; "Custom"
|
||
/// (and any unknown value) changes nothing.
|
||
fn apply_pdf_preset(d: &mut Dialog, preset: &str) {
|
||
let (standard, compress, crop, bleed, page_info, flatten) = match preset {
|
||
"Desktop Printing" => ("none", false, false, false, false, None),
|
||
"Commercial Printing" => ("none", true, true, true, false, None),
|
||
"Screen and Email" => ("none", true, false, false, false, Some("low")),
|
||
"PDF/X-4" => ("x4", false, true, true, false, None),
|
||
"PDF/A-2b" => ("a2b", false, false, false, false, None),
|
||
_ => return,
|
||
};
|
||
d.fields.insert("standard".into(), json!(standard));
|
||
d.fields.insert("compressImages".into(), json!(compress));
|
||
d.fields.insert("marksCrop".into(), json!(crop));
|
||
d.fields.insert("marksBleed".into(), json!(bleed));
|
||
d.fields.insert("marksPageInfo".into(), json!(page_info));
|
||
if let Some(f) = flatten {
|
||
d.fields.insert("flatten".into(), json!(f));
|
||
}
|
||
}
|
||
|
||
/// File › Export PDF: the "Export PDF" options (General / Compression / Marks and Bleeds /
|
||
/// Advanced). Selecting a preset fills the dependent fields; editing any other control flips the
|
||
/// preset back to "Custom".
|
||
fn pdf_export(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||
let lang = app.ui.language.as_str();
|
||
let head = |ui: &mut egui::Ui, t: &str| {
|
||
ui.add_space(6.0);
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, t)).font(semibold(12.0)));
|
||
};
|
||
// Snapshot the non-preset fields so a manual edit can be detected after drawing.
|
||
let before = d.fields.clone();
|
||
let mut preset_clicked = false;
|
||
|
||
head(ui, "General");
|
||
ui.horizontal(|ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(lang, "Preset:"));
|
||
let cur = d.s("preset");
|
||
egui::ComboBox::from_id_salt("pdf_preset").selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, &cur))).width(200.0).show_ui(
|
||
ui,
|
||
|ui| {
|
||
for p in PDF_EXPORT_PRESETS {
|
||
if ui.selectable_label(cur == *p, crate::rtl::widget(ui, crate::i18n::tr(lang, p))).clicked() {
|
||
d.fields.insert("preset".into(), json!(*p));
|
||
preset_clicked = true;
|
||
}
|
||
}
|
||
},
|
||
);
|
||
});
|
||
if preset_clicked {
|
||
let p = d.s("preset");
|
||
apply_pdf_preset(d, &p);
|
||
}
|
||
ui.horizontal(|ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(lang, "Page Range:"));
|
||
text_field(ui, d, "pages", 160.0);
|
||
});
|
||
check(ui, d, "spreads", crate::i18n::tr(lang, "Spreads"));
|
||
|
||
head(ui, "Compression");
|
||
check(ui, d, "compressImages", crate::i18n::tr(lang, "Compress images"));
|
||
|
||
head(ui, "Marks and Bleeds");
|
||
check(ui, d, "bleed", crate::i18n::tr(lang, "Include document bleed"));
|
||
check(ui, d, "marksCrop", crate::i18n::tr(lang, "Crop Marks"));
|
||
check(ui, d, "marksBleed", crate::i18n::tr(lang, "Bleed Marks"));
|
||
check(ui, d, "marksPageInfo", crate::i18n::tr(lang, "Page Information"));
|
||
ui.horizontal(|ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(lang, "Weight:"));
|
||
text_field(ui, d, "marksWeight", 60.0);
|
||
ui.add_space(8.0);
|
||
crate::rtl::label(ui, crate::i18n::tr(lang, "Offset:"));
|
||
text_field(ui, d, "marksOffset", 60.0);
|
||
});
|
||
|
||
head(ui, "Advanced");
|
||
ui.horizontal(|ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(lang, "Standard:"));
|
||
translated_combo(lang, ui, d, "standard", &[("none", "None"), ("x4", "PDF/X-4"), ("a2b", "PDF/A-2b")]);
|
||
});
|
||
check(ui, d, "tagged", crate::i18n::tr(lang, "Tagged PDF"));
|
||
|
||
// Any edit other than picking a preset means the settings are no longer that preset.
|
||
if !preset_clicked && d.fields != before {
|
||
d.fields.insert("preset".into(), json!("Custom"));
|
||
}
|
||
}
|
||
|
||
/// The options of the selected text frame (the first selected item, or the frame of the caret).
|
||
fn selected_frame_options(app: &DesignApp) -> Option<designcraft_doc::TextFrameOptions> {
|
||
let st = app.session.active()?;
|
||
let fid = st.selection.items.first().copied().or_else(|| st.selection.text.and_then(|t| t.frame))?;
|
||
st.doc.item(fid)?.text_frame().map(|t| t.options.clone())
|
||
}
|
||
|
||
/// Object › Text Frame Options shows the selected frame's options. `_shown` keeps what it showed,
|
||
/// so OK sends only the fields the user changed. Fields given when the dialog opened count as
|
||
/// changed.
|
||
fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) {
|
||
if d.fields.contains_key("_shown") {
|
||
return;
|
||
}
|
||
let o = selected_frame_options(app).unwrap_or_default();
|
||
let units = app.session.active().map(|s| s.doc.settings.horizontal_units).unwrap_or(Unit::Picas);
|
||
let fm = |v: f64| json!(format_measure(v, units));
|
||
let shown = json!({
|
||
"columns": o.columns, "gutter": fm(o.gutter),
|
||
"insetTop": fm(o.inset[0]), "insetLeft": fm(o.inset[1]), "insetBottom": fm(o.inset[2]), "insetRight": fm(o.inset[3]),
|
||
"verticalJustification": serde_json::to_value(o.vertical_justification).unwrap_or(Value::Null),
|
||
"columnRule": o.column_rule, "columnRuleWeight": format_measure(o.column_rule_weight, Unit::Points), "columnRuleColor": o.column_rule_color,
|
||
"columnRuleTint": percent(o.column_rule_tint), "columnRuleTopInset": fm(o.column_rule_top_inset),
|
||
"columnRuleBottomInset": fm(o.column_rule_bottom_inset), "columnRuleOffset": fm(o.column_rule_offset),
|
||
});
|
||
if let Value::Object(m) = &shown {
|
||
for (k, v) in m {
|
||
d.fields.entry(k.clone()).or_insert_with(|| v.clone());
|
||
}
|
||
}
|
||
d.fields.insert("_shown".into(), shown);
|
||
}
|
||
|
||
/// A 0..1 tint as a percentage ("40", "12.5").
|
||
fn percent(t: f32) -> String {
|
||
let s = format!("{:.2}", f64::from(t) * 100.0);
|
||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||
}
|
||
|
||
fn text_frame_options_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||
let lang = app.ui.language.clone();
|
||
let tr = |s: &'static str| crate::i18n::tr(&lang, s);
|
||
egui::Grid::new("tfo").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
crate::rtl::label(ui, tr("Number of columns"));
|
||
text_field(ui, d, "columns", 80.0);
|
||
ui.end_row();
|
||
crate::rtl::label(ui, tr("Gutter"));
|
||
text_field(ui, d, "gutter", 80.0);
|
||
ui.end_row();
|
||
});
|
||
ui.add_space(6.0);
|
||
crate::rtl::label(ui, egui::RichText::new(tr("Inset spacing")).font(semibold(12.0)));
|
||
egui::Grid::new("tfo_inset").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
for (label, key) in [("Top", "insetTop"), ("Bottom", "insetBottom"), ("Left", "insetLeft"), ("Right", "insetRight")] {
|
||
crate::rtl::label(ui, tr(label));
|
||
text_field(ui, d, key, 80.0);
|
||
ui.end_row();
|
||
}
|
||
});
|
||
ui.add_space(6.0);
|
||
egui::Grid::new("tfo_vj").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
crate::rtl::label(ui, tr("Vertical justification"));
|
||
let cur = d.s("verticalJustification");
|
||
egui::ComboBox::from_id_salt("vj").selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, &cur))).show_ui(ui, |ui| {
|
||
for v in ["top", "center", "bottom", "justify"] {
|
||
if ui.selectable_label(cur == v, crate::rtl::widget(ui, crate::i18n::tr(&lang, v))).clicked() {
|
||
d.fields.insert("verticalJustification".into(), json!(v));
|
||
}
|
||
}
|
||
});
|
||
ui.end_row();
|
||
});
|
||
ui.add_space(6.0);
|
||
crate::rtl::label(ui, egui::RichText::new(tr("Column Rules")).font(semibold(12.0)));
|
||
check(ui, d, "columnRule", tr("Insert Column Rule"));
|
||
let swatches: Vec<String> = app
|
||
.session
|
||
.active()
|
||
.map(|s| {
|
||
s.doc
|
||
.swatches
|
||
.iter()
|
||
.filter(|w| !matches!(w.value, designcraft_color::swatch::SwatchValue::Gradient { .. }))
|
||
.map(|w| w.name.clone())
|
||
.collect()
|
||
})
|
||
.unwrap_or_default();
|
||
let opts: Vec<(&str, &str)> = swatches.iter().map(|n| (n.as_str(), n.as_str())).collect();
|
||
egui::Grid::new("tfo_rule").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
crate::rtl::label(ui, tr("Weight"));
|
||
text_field(ui, d, "columnRuleWeight", 70.0);
|
||
ui.end_row();
|
||
crate::rtl::label(ui, tr("Color"));
|
||
combo(ui, d, "columnRuleColor", &opts);
|
||
ui.end_row();
|
||
for (label, key) in [
|
||
("Tint %", "columnRuleTint"),
|
||
("Top Inset", "columnRuleTopInset"),
|
||
("Bottom Inset", "columnRuleBottomInset"),
|
||
("Horizontal Offset", "columnRuleOffset"),
|
||
] {
|
||
crate::rtl::label(ui, tr(label));
|
||
text_field(ui, d, key, 70.0);
|
||
ui.end_row();
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Whether the user changed `key` from what the dialog showed.
|
||
fn edited(d: &Dialog, key: &str) -> bool {
|
||
let shown = match d.fields.get("_shown").and_then(|s| s.get(key)) {
|
||
Some(Value::String(s)) => s.clone(),
|
||
Some(v) => v.to_string(),
|
||
None => return d.fields.contains_key(key),
|
||
};
|
||
d.fields.contains_key(key) && d.s(key).trim() != shown.trim()
|
||
}
|
||
|
||
fn confirm_text_frame_options(app: &mut DesignApp, d: &mut Dialog) -> Result<Value, String> {
|
||
seed_text_frame_options(app, d);
|
||
let d = &*d;
|
||
let mut p = Map::new();
|
||
let bad = |what: &str| format!("Text Frame Options: {what}");
|
||
if edited(d, "columns") {
|
||
p.insert("columns".into(), json!(d.n("columns").ok_or_else(|| bad("the number of columns is not a number"))?.max(1.0) as u64));
|
||
}
|
||
if edited(d, "gutter") {
|
||
p.insert("gutter".into(), json!(d.m("gutter").ok_or_else(|| bad("the gutter is not a measure"))?));
|
||
}
|
||
let sides = ["insetTop", "insetLeft", "insetBottom", "insetRight"];
|
||
if edited(d, "inset") {
|
||
p.insert("inset".into(), json!(d.m("inset").ok_or_else(|| bad("the inset is not a measure"))?));
|
||
} else if sides.iter().any(|k| edited(d, k)) {
|
||
// Sides left as shown are null: each selected frame keeps its own.
|
||
let mut inset = [None; 4];
|
||
for (v, k) in inset.iter_mut().zip(sides) {
|
||
if edited(d, k) {
|
||
*v = Some(d.m(k).ok_or_else(|| bad("an inset is not a measure"))?);
|
||
}
|
||
}
|
||
p.insert("inset".into(), json!(inset));
|
||
}
|
||
if edited(d, "verticalJustification") {
|
||
p.insert("verticalJustification".into(), json!(d.s("verticalJustification")));
|
||
}
|
||
if edited(d, "columnRule") {
|
||
p.insert("columnRule".into(), json!(d.b("columnRule")));
|
||
}
|
||
if edited(d, "columnRuleWeight") {
|
||
p.insert("columnRuleWeight".into(), json!(d.pt("columnRuleWeight").ok_or_else(|| bad("the column rule weight is not a measure"))?));
|
||
}
|
||
if edited(d, "columnRuleColor") {
|
||
p.insert("columnRuleColor".into(), json!(d.s("columnRuleColor")));
|
||
}
|
||
if edited(d, "columnRuleTint") {
|
||
p.insert("columnRuleTint".into(), json!(d.n("columnRuleTint").ok_or_else(|| bad("the column rule tint is not a number"))? / 100.0));
|
||
}
|
||
for (key, what) in [
|
||
("columnRuleTopInset", "the column rule top inset is not a measure"),
|
||
("columnRuleBottomInset", "the column rule bottom inset is not a measure"),
|
||
("columnRuleOffset", "the column rule offset is not a measure"),
|
||
] {
|
||
if edited(d, key) {
|
||
p.insert(key.into(), json!(d.m(key).ok_or_else(|| bad(what))?));
|
||
}
|
||
}
|
||
if p.is_empty() {
|
||
return Ok(Value::Null);
|
||
}
|
||
// Typing in a frame: the options apply to that frame.
|
||
if let Some(st) = app.session.active()
|
||
&& st.selection.items.is_empty()
|
||
&& let Some(f) = st.selection.text.and_then(|t| t.frame)
|
||
{
|
||
p.insert("ids".into(), json!([f.0]));
|
||
}
|
||
app.run("object.textFrameOptions", Value::Object(p))
|
||
}
|
||
|
||
/// File › Document Setup, filled from the document.
|
||
pub fn open_document_setup(app: &mut DesignApp) {
|
||
let Ok(cur) = app.session.execute("layout.documentSetup", &json!({})) else { return };
|
||
let units = app.session.active().map(|s| s.doc.settings.horizontal_units).unwrap_or(Unit::Picas);
|
||
let fm = |v: &Value| json!(format_measure(v.as_f64().unwrap_or(0.0), units));
|
||
let mut f = json!({"intent": cur["intent"], "pages": cur["pages"], "startPage": cur["startPage"], "facingPages": cur["facingPages"],
|
||
"binding": cur["binding"], "width": fm(&cur["width"]), "height": fm(&cur["height"])});
|
||
for k in ["bleed", "slug"] {
|
||
for (i, e) in ["Top", "Bottom", "Inside", "Outside"].iter().enumerate() {
|
||
f[format!("{k}{e}")] = fm(&cur[k][i]);
|
||
}
|
||
}
|
||
app.ui.dialog = Some(Dialog::new("documentSetup", f));
|
||
}
|
||
|
||
fn document_setup(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||
egui::Grid::new("ds").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Intent:"));
|
||
let cur = d.s("intent");
|
||
egui::ComboBox::from_id_salt("ds_intent")
|
||
.selected_text(match cur.as_str() {
|
||
"web" => crate::i18n::tr(&app.ui.language, "Web"),
|
||
"mobile" => crate::i18n::tr(&app.ui.language, "Mobile"),
|
||
_ => crate::i18n::tr(&app.ui.language, "Print"),
|
||
})
|
||
.width(110.0)
|
||
.show_ui(ui, |ui| {
|
||
for (v, l) in [("print", "Print"), ("web", "Web"), ("mobile", "Mobile")] {
|
||
if ui.selectable_label(cur == v, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, l))).clicked() {
|
||
d.fields.insert("intent".into(), json!(v));
|
||
}
|
||
}
|
||
});
|
||
ui.label("");
|
||
ui.label("");
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Number of Pages:"));
|
||
text_field(ui, d, "pages", 60.0);
|
||
check(ui, d, "facingPages", crate::i18n::tr(&app.ui.language, "Facing Pages"));
|
||
ui.label("");
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Start Page #:"));
|
||
text_field(ui, d, "startPage", 60.0);
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Binding:"));
|
||
let rtl = d.s("binding") == "rightToLeft";
|
||
egui::ComboBox::from_id_salt("ds_binding")
|
||
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, if rtl { "Right to Left" } else { "Left to Right" })))
|
||
.width(110.0)
|
||
.show_ui(ui, |ui| {
|
||
for (v, l) in [("leftToRight", "Left to Right"), ("rightToLeft", "Right to Left")] {
|
||
if ui.selectable_label((v == "rightToLeft") == rtl, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, l))).clicked() {
|
||
d.fields.insert("binding".into(), json!(v));
|
||
}
|
||
}
|
||
});
|
||
ui.end_row();
|
||
});
|
||
ui.add_space(8.0);
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Page Size")).font(semibold(12.0)));
|
||
let (w, h) = (d.m("width").unwrap_or(612.0), d.m("height").unwrap_or(792.0));
|
||
let preset =
|
||
designcraft_doc::build::PRESETS.iter().find(|p| (p.width - w).abs() < 0.5 && (p.height - h).abs() < 0.5).map_or("Custom", |p| p.name);
|
||
egui::Grid::new("ds_size").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Size:"));
|
||
egui::ComboBox::from_id_salt("ds_preset").selected_text(preset).width(140.0).show_ui(ui, |ui| {
|
||
for p in designcraft_doc::build::PRESETS {
|
||
if ui.selectable_label(p.name == preset, p.name).clicked() {
|
||
d.fields.insert("width".into(), json!(format_measure(p.width, p.units)));
|
||
d.fields.insert("height".into(), json!(format_measure(p.height, p.units)));
|
||
}
|
||
}
|
||
});
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Orientation:"));
|
||
ui.horizontal(|ui| {
|
||
for (portrait, l) in [(true, "Portrait"), (false, "Landscape")] {
|
||
if ui.selectable_label((h >= w) == portrait, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, l))).clicked()
|
||
&& (h >= w) != portrait
|
||
{
|
||
let (fw, fh) = (d.s("width"), d.s("height"));
|
||
d.fields.insert("width".into(), json!(fh));
|
||
d.fields.insert("height".into(), json!(fw));
|
||
}
|
||
}
|
||
});
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Width:"));
|
||
text_field(ui, d, "width", 80.0);
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Height:"));
|
||
text_field(ui, d, "height", 80.0);
|
||
ui.end_row();
|
||
});
|
||
ui.add_space(8.0);
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Bleed and Slug")).font(semibold(12.0)));
|
||
egui::Grid::new("ds_bleed").num_columns(5).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
ui.label("");
|
||
for e in ["Top", "Bottom", "Inside", "Outside"] {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, e));
|
||
}
|
||
ui.end_row();
|
||
for (k, l) in [("bleed", "Bleed:"), ("slug", "Slug:")] {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, l));
|
||
for e in ["Top", "Bottom", "Inside", "Outside"] {
|
||
text_field(ui, d, &format!("{k}{e}"), 60.0);
|
||
}
|
||
ui.end_row();
|
||
}
|
||
});
|
||
check(ui, d, "adjustLayout", crate::i18n::tr(&app.ui.language, "Adjust Layout (objects follow the new page size)"));
|
||
}
|
||
|
||
/// Preferences: a section list and the section's options. Application options always; units and
|
||
/// increments when a document is open (InDesign keeps those with the document).
|
||
fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_height: f32) {
|
||
let has_doc = d.fields.contains_key("horizontalUnits");
|
||
let sections: &[(&str, &str)] = if has_doc {
|
||
&[
|
||
("general", "General"),
|
||
("interface", "Interface"),
|
||
("type", "Type"),
|
||
("advancedType", "Advanced Type"),
|
||
("composition", "Composition"),
|
||
("units", "Units & Increments"),
|
||
("grids", "Grids"),
|
||
("guides", "Guides & Pasteboard"),
|
||
("dictionary", "Dictionary"),
|
||
("spelling", "Spelling"),
|
||
("autocorrect", "Autocorrect"),
|
||
("notes", "Notes"),
|
||
("trackChanges", "Track Changes"),
|
||
("storyEditor", "Story Editor Display"),
|
||
("display", "Display Performance"),
|
||
("black", "Appearance of Black"),
|
||
("files", "File Handling"),
|
||
]
|
||
} else {
|
||
&[
|
||
("general", "General"),
|
||
("interface", "Interface"),
|
||
("type", "Type"),
|
||
("composition", "Composition"),
|
||
("spelling", "Spelling"),
|
||
("autocorrect", "Autocorrect"),
|
||
("notes", "Notes"),
|
||
("trackChanges", "Track Changes"),
|
||
("storyEditor", "Story Editor Display"),
|
||
("display", "Display Performance"),
|
||
("files", "File Handling"),
|
||
]
|
||
};
|
||
let cur = d.s("section");
|
||
ui.horizontal_top(|ui| {
|
||
// Bound both panes independently so selecting a lower section keeps its options visible.
|
||
let height = max_height.min(440.0);
|
||
ui.set_min_height(240.0_f32.min(height));
|
||
ui.set_max_height(height);
|
||
ui.vertical(|ui| {
|
||
ui.set_width(150.0);
|
||
egui::ScrollArea::vertical().id_salt("preferences_sections").min_scrolled_height(1.0).max_height(height).show(ui, |ui| {
|
||
for (id, label) in sections {
|
||
if ui.selectable_label(cur == *id, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() {
|
||
d.fields.insert("section".into(), json!(id));
|
||
}
|
||
}
|
||
});
|
||
});
|
||
ui.separator();
|
||
ui.vertical(|ui| {
|
||
ui.set_min_width(300.0);
|
||
egui::ScrollArea::vertical().id_salt(("preferences_options", &cur)).min_scrolled_height(1.0).max_height(height).show(ui, |ui| match cur
|
||
.as_str()
|
||
{
|
||
"dictionary" => {
|
||
ui.label(crate::rtl::widget(
|
||
ui,
|
||
egui::RichText::new(crate::i18n::tr(&app.ui.language, "User Dictionary (this document)")).font(semibold(12.0)),
|
||
));
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Words, one per line:"));
|
||
let mut t = d.s("userWords");
|
||
if egui::ScrollArea::vertical()
|
||
.max_height(170.0)
|
||
.show(ui, |ui| ui.add(egui::TextEdit::multiline(&mut t).desired_rows(8).desired_width(280.0)))
|
||
.inner
|
||
.changed()
|
||
{
|
||
d.fields.insert("userWords".into(), json!(t));
|
||
}
|
||
}
|
||
"spelling" => {
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Dynamic Spelling")).font(semibold(12.0)));
|
||
check(ui, d, "dynamicSpelling", crate::i18n::tr(&app.ui.language, "Enable Dynamic Spelling (underline misspelled words)"));
|
||
}
|
||
"autocorrect" => {
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Options")).font(semibold(12.0)));
|
||
check(ui, d, "autocorrect", crate::i18n::tr(&app.ui.language, "Enable Autocorrect"));
|
||
ui.add_space(6.0);
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Misspelled word → Correction (one per line):"));
|
||
let mut t = d.s("autocorrectText");
|
||
if egui::ScrollArea::vertical()
|
||
.max_height(150.0)
|
||
.show(ui, |ui| ui.add(egui::TextEdit::multiline(&mut t).desired_rows(8).desired_width(280.0)))
|
||
.inner
|
||
.changed()
|
||
{
|
||
d.fields.insert("autocorrectText".into(), json!(t));
|
||
}
|
||
}
|
||
"notes" => {
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Options")).font(semibold(12.0)));
|
||
check(ui, d, "showNoteAnchors", crate::i18n::tr(&app.ui.language, "Show Note Anchors in Layout View"));
|
||
}
|
||
"trackChanges" => {
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Show")).font(semibold(12.0)));
|
||
check(ui, d, "showAddedText", crate::i18n::tr(&app.ui.language, "Added Text (highlighted)"));
|
||
}
|
||
"storyEditor" => {
|
||
ui.label(crate::rtl::widget(
|
||
ui,
|
||
egui::RichText::new(crate::i18n::tr(&app.ui.language, "Text Display Options")).font(semibold(12.0)),
|
||
));
|
||
ui.horizontal(|ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Font Size:"));
|
||
let mut v = d.n("storyEditorSize").unwrap_or(14.0);
|
||
if ui.add(egui::Slider::new(&mut v, 8.0..=36.0).step_by(1.0).suffix(" pt")).changed() {
|
||
d.fields.insert("storyEditorSize".into(), json!(v));
|
||
}
|
||
});
|
||
}
|
||
"files" => {
|
||
ui.label(crate::rtl::widget(
|
||
ui,
|
||
egui::RichText::new(crate::i18n::tr(&app.ui.language, "Document Recovery Data")).font(semibold(12.0)),
|
||
));
|
||
ui.horizontal(|ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Save recovery data every:"));
|
||
let mut v = d.n("recoveryMinutes").unwrap_or(0.5);
|
||
if ui.add(egui::DragValue::new(&mut v).range(0.1..=60.0).speed(0.1).suffix(" min")).changed() {
|
||
d.fields.insert("recoveryMinutes".into(), json!(v));
|
||
}
|
||
});
|
||
}
|
||
"type" => {
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Type Options")).font(semibold(12.0)));
|
||
check(ui, d, "typographersQuotes", crate::i18n::tr(&app.ui.language, "Use Typographer's Quotes"));
|
||
check(ui, d, "showFontNamesInEnglish", crate::i18n::tr(&app.ui.language, "Show Font Names in English"));
|
||
ui.add_space(6.0);
|
||
ui.label(crate::rtl::widget(
|
||
ui,
|
||
egui::RichText::new(crate::i18n::tr(&app.ui.language, "Smart Text Reflow")).font(semibold(12.0)),
|
||
));
|
||
check(
|
||
ui,
|
||
d,
|
||
"smartTextReflow",
|
||
crate::i18n::tr(&app.ui.language, "Add and remove pages as the primary text frame's story grows and shrinks"),
|
||
);
|
||
}
|
||
"composition" => {
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Highlight")).font(semibold(12.0)));
|
||
check(ui, d, "highlightKeeps", crate::i18n::tr(&app.ui.language, "Keep Violations"));
|
||
check(ui, d, "highlightHj", crate::i18n::tr(&app.ui.language, "H&J Violations"));
|
||
check(ui, d, "highlightCustomTracking", crate::i18n::tr(&app.ui.language, "Custom Tracking/Kerning"));
|
||
check(ui, d, "highlightSubstitutedFonts", crate::i18n::tr(&app.ui.language, "Substituted Fonts"));
|
||
if d.fields.contains_key("glyphFallback") {
|
||
ui.add_space(6.0);
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Missing Glyphs")).font(semibold(12.0)));
|
||
check(ui, d, "glyphFallback", crate::i18n::tr(&app.ui.language, "Draw Missing Glyphs from Fallback Fonts"));
|
||
}
|
||
}
|
||
"advancedType" => {
|
||
ui.label(crate::rtl::widget(
|
||
ui,
|
||
egui::RichText::new(crate::i18n::tr(&app.ui.language, "Character Settings")).font(semibold(12.0)),
|
||
));
|
||
egui::Grid::new("pref_adv").num_columns(3).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
ui.label("");
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Size"));
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Position"));
|
||
ui.end_row();
|
||
for (k, l) in [("superscript", "Superscript:"), ("subscript", "Subscript:")] {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, l));
|
||
for f in ["Size", "Position"] {
|
||
ui.horizontal(|ui| {
|
||
text_field(ui, d, &format!("adv.{k}{f}"), 50.0);
|
||
ui.label("%");
|
||
});
|
||
}
|
||
ui.end_row();
|
||
}
|
||
});
|
||
}
|
||
"black" => {
|
||
ui.label(crate::rtl::widget(
|
||
ui,
|
||
egui::RichText::new(crate::i18n::tr(&app.ui.language, "Options for Black on RGB and Grayscale Devices")).font(semibold(12.0)),
|
||
));
|
||
ui.horizontal(|ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "On Screen:"));
|
||
let rich = d.b("richBlack");
|
||
egui::ComboBox::from_id_salt("pref_black")
|
||
.selected_text(crate::rtl::widget(
|
||
ui,
|
||
crate::i18n::tr(
|
||
&app.ui.language,
|
||
if rich { "Display All Blacks as Rich Black" } else { "Display All Blacks Accurately" },
|
||
),
|
||
))
|
||
.width(240.0)
|
||
.show_ui(ui, |ui| {
|
||
for (v, l) in [(false, "Display All Blacks Accurately"), (true, "Display All Blacks as Rich Black")] {
|
||
if ui.selectable_label(v == rich, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, l))).clicked() {
|
||
d.fields.insert("richBlack".into(), json!(v));
|
||
}
|
||
}
|
||
});
|
||
});
|
||
ui.horizontal(|ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Printing / Exporting:"));
|
||
let rich = d.b("richBlackOutput");
|
||
egui::ComboBox::from_id_salt("pref_black_out")
|
||
.selected_text(crate::rtl::widget(
|
||
ui,
|
||
crate::i18n::tr(
|
||
&app.ui.language,
|
||
if rich { "Output All Blacks as Rich Black" } else { "Output All Blacks Accurately" },
|
||
),
|
||
))
|
||
.width(240.0)
|
||
.show_ui(ui, |ui| {
|
||
for (v, l) in [(false, "Output All Blacks Accurately"), (true, "Output All Blacks as Rich Black")] {
|
||
if ui.selectable_label(v == rich, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, l))).clicked() {
|
||
d.fields.insert("richBlackOutput".into(), json!(v));
|
||
}
|
||
}
|
||
});
|
||
});
|
||
if d.fields.contains_key("overprintBlack") {
|
||
ui.add_space(6.0);
|
||
ui.label(crate::rtl::widget(
|
||
ui,
|
||
egui::RichText::new(crate::i18n::tr(&app.ui.language, "[Black] Overprint")).font(semibold(12.0)),
|
||
));
|
||
check(ui, d, "overprintBlack", crate::i18n::tr(&app.ui.language, "Overprint [Black] Swatch at 100%"));
|
||
}
|
||
}
|
||
"interface" => {
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "UI Scaling")).font(semibold(12.0)));
|
||
ui.horizontal(|ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "UI Size:"));
|
||
let mut v = d.n("uiScale").unwrap_or(100.0);
|
||
if ui.add(egui::Slider::new(&mut v, 50.0..=200.0).step_by(5.0).suffix("%")).changed() {
|
||
d.fields.insert("uiScale".into(), json!(v));
|
||
}
|
||
});
|
||
}
|
||
"grids" => {
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Baseline Grid")).font(semibold(12.0)));
|
||
egui::Grid::new("pref_bg").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Color:"));
|
||
color_field(ui, d, "bg.color");
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Start:"));
|
||
text_field(ui, d, "bg.start", 80.0);
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Relative To:"));
|
||
let cur = d.s("bg.relativeTo");
|
||
egui::ComboBox::from_id_salt("bg_rel")
|
||
.selected_text(crate::rtl::widget(
|
||
ui,
|
||
crate::i18n::tr(&app.ui.language, if cur == "topMargin" { "Top Margin" } else { "Top of Page" }),
|
||
))
|
||
.show_ui(ui, |ui| {
|
||
for (v, l) in [("topOfPage", "Top of Page"), ("topMargin", "Top Margin")] {
|
||
if ui.selectable_label(cur == v, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, l))).clicked() {
|
||
d.fields.insert("bg.relativeTo".into(), json!(v));
|
||
}
|
||
}
|
||
});
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Increment Every:"));
|
||
text_field(ui, d, "bg.increment", 80.0);
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "View Threshold:"));
|
||
ui.horizontal(|ui| {
|
||
text_field(ui, d, "bg.viewThreshold", 50.0);
|
||
ui.label("%");
|
||
});
|
||
ui.end_row();
|
||
});
|
||
ui.add_space(6.0);
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Document Grid")).font(semibold(12.0)));
|
||
egui::Grid::new("pref_grid").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Color:"));
|
||
color_field(ui, d, "grid.color");
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Horizontal Gridline Every:"));
|
||
text_field(ui, d, "grid.horizontal", 80.0);
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Vertical Gridline Every:"));
|
||
text_field(ui, d, "grid.vertical", 80.0);
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Subdivisions:"));
|
||
text_field(ui, d, "grid.subdivisions", 50.0);
|
||
ui.end_row();
|
||
});
|
||
check(ui, d, "grid.inBack", crate::i18n::tr(&app.ui.language, "Grids in Back"));
|
||
}
|
||
"guides" => {
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Color")).font(semibold(12.0)));
|
||
egui::Grid::new("pref_guides").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
for (k, l) in [("marginColor", "Margins:"), ("columnColor", "Columns:"), ("bleedColor", "Bleed:"), ("slugColor", "Slug:")] {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, l));
|
||
color_field(ui, d, k);
|
||
ui.end_row();
|
||
}
|
||
});
|
||
ui.add_space(6.0);
|
||
ui.label(crate::rtl::widget(
|
||
ui,
|
||
egui::RichText::new(crate::i18n::tr(&app.ui.language, "Pasteboard Options")).font(semibold(12.0)),
|
||
));
|
||
egui::Grid::new("pref_pb").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Horizontal Margins:"));
|
||
text_field(ui, d, "pasteboard.h", 80.0);
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Vertical Margins:"));
|
||
text_field(ui, d, "pasteboard.v", 80.0);
|
||
ui.end_row();
|
||
});
|
||
ui.add_space(6.0);
|
||
check(ui, d, "snap.alignEdges", "Align to Object Edges");
|
||
check(ui, d, "snap.alignCenters", "Align to Object Centers");
|
||
check(ui, d, "snap.dimensions", "Smart Dimensions");
|
||
check(ui, d, "snap.spacing", "Smart Spacing");
|
||
ui.horizontal(|ui| {
|
||
ui.label("Snap to Zone");
|
||
let mut zone = d.n("snap.zone").filter(|z| z.is_finite()).unwrap_or(4.0);
|
||
if ui.add(egui::DragValue::new(&mut zone).speed(0.1)).changed() {
|
||
d.fields.insert("snap.zone".into(), json!(zone));
|
||
}
|
||
});
|
||
}
|
||
"display" => {
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Options")).font(semibold(12.0)));
|
||
let cur = d.s("displayQuality");
|
||
ui.horizontal(|ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Default View:"));
|
||
egui::ComboBox::from_id_salt("pref_dq")
|
||
.selected_text(match cur.as_str() {
|
||
"fast" => crate::i18n::tr(&app.ui.language, "Fast"),
|
||
"typical" => crate::i18n::tr(&app.ui.language, "Typical"),
|
||
_ => crate::i18n::tr(&app.ui.language, "High Quality"),
|
||
})
|
||
.show_ui(ui, |ui| {
|
||
for (v, l) in [("fast", "Fast"), ("typical", "Typical"), ("high", "High Quality")] {
|
||
if ui.selectable_label(cur == v, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, l))).clicked() {
|
||
d.fields.insert("displayQuality".into(), json!(v));
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
"units" => {
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Ruler Units")).font(semibold(12.0)));
|
||
egui::Grid::new("pref_units").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
for (key, label) in [("horizontalUnits", "Horizontal:"), ("verticalUnits", "Vertical:")] {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label));
|
||
let cur: Unit = d.fields.get(key).and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
|
||
egui::ComboBox::from_id_salt(key)
|
||
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, cur.label())))
|
||
.width(140.0)
|
||
.show_ui(ui, |ui| {
|
||
for u in Unit::ALL {
|
||
if ui
|
||
.selectable_label(u == cur, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, u.label())))
|
||
.clicked()
|
||
{
|
||
d.fields.insert(key.into(), json!(u));
|
||
}
|
||
}
|
||
});
|
||
ui.end_row();
|
||
}
|
||
});
|
||
ui.add_space(6.0);
|
||
ui.label(crate::rtl::widget(
|
||
ui,
|
||
egui::RichText::new(crate::i18n::tr(&app.ui.language, "Keyboard Increments")).font(semibold(12.0)),
|
||
));
|
||
ui.horizontal(|ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Cursor Key:"));
|
||
text_field(ui, d, "keyboardIncrement", 80.0);
|
||
});
|
||
}
|
||
_ => {
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "When Scaling")).font(semibold(12.0)));
|
||
check(ui, d, "scaleStrokes", crate::i18n::tr(&app.ui.language, "Include Stroke Weight"));
|
||
ui.add_space(6.0);
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Transform")).font(semibold(12.0)));
|
||
check(ui, d, "dimensionsIncludeStroke", crate::i18n::tr(&app.ui.language, "Dimensions Include Stroke Weight"));
|
||
check(ui, d, "transformationsAreTotals", crate::i18n::tr(&app.ui.language, "Transformations are Totals"));
|
||
ui.add_space(6.0);
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Page Numbering")).font(semibold(12.0)));
|
||
check(ui, d, "absolutePageNumbers", crate::i18n::tr(&app.ui.language, "Absolute Numbering (instead of Section Numbering)"));
|
||
}
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
/// An `[r, g, b]` field edited with a colour button.
|
||
fn color_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str) {
|
||
let v: [u8; 3] = d.fields.get(key).and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or([128, 128, 128]);
|
||
let mut c = v;
|
||
if egui::color_picker::color_edit_button_srgb(ui, &mut c).changed() {
|
||
d.fields.insert(key.into(), json!(c));
|
||
}
|
||
}
|
||
|
||
fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
|
||
let mut b = d.b(key);
|
||
if ui.checkbox(&mut b, crate::rtl::widget(ui, label)).changed() {
|
||
d.fields.insert(key.into(), Value::Bool(b));
|
||
}
|
||
}
|
||
|
||
pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||
let Some(mut d) = app.ui.dialog.clone() else { return };
|
||
let mut result: Option<bool> = None;
|
||
let alert_title = d.s("title");
|
||
let title = match d.id.as_str() {
|
||
"alert" => crate::i18n::tr(&app.ui.language, &alert_title),
|
||
"ruby" => crate::i18n::tr(&app.ui.language, "Ruby"),
|
||
"newDocument" => crate::i18n::tr(&app.ui.language, "New Document"),
|
||
"frameSize" => crate::i18n::tr(&app.ui.language, "Rectangle"),
|
||
"goToPage" => crate::i18n::tr(&app.ui.language, "Go to Page"),
|
||
"insertTable" => crate::i18n::tr(&app.ui.language, "Create Table"),
|
||
"textFrameOptions" => crate::i18n::tr(&app.ui.language, "Text Frame Options"),
|
||
"documentSetup" => crate::i18n::tr(&app.ui.language, "Document Setup"),
|
||
"findChange" => crate::i18n::tr(&app.ui.language, "Find/Change"),
|
||
"paragraphStyleOptions" => crate::i18n::tr(&app.ui.language, "Paragraph Style Options"),
|
||
"deleteParagraphStyle" => crate::i18n::tr(&app.ui.language, "Delete Paragraph Style"),
|
||
"characterStyleOptions" => crate::i18n::tr(&app.ui.language, "Character Style Options"),
|
||
"deleteCharacterStyle" => crate::i18n::tr(&app.ui.language, "Delete Character Style"),
|
||
"footnoteOptions" => crate::i18n::tr(&app.ui.language, "Footnote Options"),
|
||
"paragraphRules" => crate::i18n::tr(&app.ui.language, "Paragraph Rules"),
|
||
"insertXref" => crate::i18n::tr(&app.ui.language, "New Cross-Reference"),
|
||
"findFont" => crate::i18n::tr(&app.ui.language, "Find/Replace Font"),
|
||
"polygonSettings" => crate::i18n::tr(&app.ui.language, "Polygon Settings"),
|
||
"userDictionary" => crate::i18n::tr(&app.ui.language, "User Dictionary"),
|
||
"newWorkspace" => crate::i18n::tr(&app.ui.language, "New Workspace"),
|
||
"closeDocument" => crate::i18n::tr(&app.ui.language, "Unsaved Changes"),
|
||
"menus" => crate::i18n::tr(&app.ui.language, "Menu Customization"),
|
||
"importOptions" => crate::i18n::tr(&app.ui.language, "Import Options"),
|
||
"fittingOptions" => crate::i18n::tr(&app.ui.language, "Frame Fitting Options"),
|
||
"qrCode" => crate::i18n::tr(&app.ui.language, "Generate QR Code"),
|
||
"preferences" => crate::i18n::tr(&app.ui.language, "Preferences"),
|
||
"print" => crate::i18n::tr(&app.ui.language, "Print"),
|
||
"pdfImport" => crate::i18n::tr(&app.ui.language, "Place PDF"),
|
||
"pdfExport" => crate::i18n::tr(&app.ui.language, "Export PDF"),
|
||
"colorSettings" => crate::i18n::tr(&app.ui.language, "Color Settings"),
|
||
"layerOptions" => crate::i18n::tr(&app.ui.language, "Object Layer Options"),
|
||
"keyboardShortcuts" => crate::i18n::tr(&app.ui.language, "Keyboard Shortcuts"),
|
||
"colorPicker" => crate::i18n::tr(&app.ui.language, "Color Picker"),
|
||
id => match id.strip_prefix("cmd:").and_then(designcraft_engine::find_command) {
|
||
Some(c) => crate::i18n::tr(&app.ui.language, c.label.trim_end_matches('…')),
|
||
None => crate::i18n::tr(&app.ui.language, "Dialog"),
|
||
},
|
||
};
|
||
egui::Modal::new(egui::Id::new("dialog")).show(ctx, |ui| {
|
||
let frame_margin = egui::Frame::popup(ui.style()).total_margin().sum();
|
||
let available = (ctx.content_rect().size() - frame_margin - egui::vec2(16.0, 16.0)).max(egui::Vec2::splat(1.0));
|
||
let preferred_width: f32 = match d.id.as_str() {
|
||
"alert" => 440.0,
|
||
"closeDocument" => 380.0,
|
||
"newDocument" if crate::i18n::is_rtl(&app.ui.language) => 380.0,
|
||
_ => 640.0,
|
||
};
|
||
let width = preferred_width.min(available.x);
|
||
ui.set_min_width(380.0_f32.min(width));
|
||
ui.set_max_width(width);
|
||
if crate::i18n::is_rtl(&app.ui.language) {
|
||
ui.horizontal(|ui| {
|
||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||
crate::rtl::label(ui, egui::RichText::new(title).font(semibold(16.0)));
|
||
});
|
||
});
|
||
} else {
|
||
crate::rtl::label(ui, egui::RichText::new(title).font(semibold(16.0)));
|
||
}
|
||
ui.add_space(10.0);
|
||
// Keep the title and action buttons outside the scrollable body. Oversized
|
||
// forms remain reachable at large UI scales, without shrinking their text.
|
||
let footer_height = ui.spacing().interact_size.y.max(ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y);
|
||
let body_height = (available.y - ui.min_size().y - footer_height - 12.0 - 2.0 * ui.spacing().item_spacing.y).max(1.0);
|
||
egui::ScrollArea::both().id_salt(("dialog_body", &d.id)).min_scrolled_width(1.0).min_scrolled_height(1.0).max_width(width).max_height(body_height).show(ui, |ui| {
|
||
match d.id.as_str() {
|
||
"newDocument" => {
|
||
ui.horizontal(|ui| {
|
||
ui.with_layout(if crate::i18n::is_rtl(&app.ui.language) { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) }, |ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Preset"));
|
||
let cur = d.s("preset");
|
||
egui::ComboBox::from_id_salt("preset").selected_text(&cur).width(180.0).show_ui(ui, |ui| {
|
||
for p in designcraft_doc::build::PRESETS {
|
||
if ui.selectable_label(cur == p.name, p.name).clicked() {
|
||
d.fields.insert("preset".into(), json!(p.name));
|
||
d.fields.insert("width".into(), json!(format_measure(p.width, p.units)));
|
||
d.fields.insert("height".into(), json!(format_measure(p.height, p.units)));
|
||
d.fields.insert("facingPages".into(), json!(p.intent == designcraft_doc::Intent::Print));
|
||
}
|
||
}
|
||
});
|
||
});
|
||
});
|
||
if crate::i18n::is_rtl(&app.ui.language) {
|
||
// egui Grid currently supports LTR only. Use explicit RTL rows;
|
||
// the numeric editors retain LTR alignment and logical values.
|
||
for (first, key, second, other, toggle) in [
|
||
("Width", "width", "Height", "height", false),
|
||
("Pages", "pages", "Facing Pages", "facingPages", true),
|
||
("Columns", "columns", "Gutter", "gutter", false),
|
||
("Top", "marginTop", "Bottom", "marginBottom", false),
|
||
("Inside", "marginInside", "Outside", "marginOutside", false),
|
||
("Bleed", "bleed", "Primary Text Frame", "primaryTextFrame", true),
|
||
] {
|
||
ui.horizontal(|ui| {
|
||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, first));
|
||
text_field(ui, &mut d, key, 80.0);
|
||
if toggle {
|
||
check(ui, &mut d, other, crate::i18n::tr(&app.ui.language, second));
|
||
} else {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, second));
|
||
text_field(ui, &mut d, other, 80.0);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
} else {
|
||
egui::Grid::new("nd").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Width"));
|
||
text_field(ui, &mut d, "width", 80.0);
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Height"));
|
||
text_field(ui, &mut d, "height", 80.0);
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Pages"));
|
||
text_field(ui, &mut d, "pages", 80.0);
|
||
ui.label("");
|
||
check(ui, &mut d, "facingPages", crate::i18n::tr(&app.ui.language, "Facing Pages"));
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Columns"));
|
||
text_field(ui, &mut d, "columns", 80.0);
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Gutter"));
|
||
text_field(ui, &mut d, "gutter", 80.0);
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Top"));
|
||
text_field(ui, &mut d, "marginTop", 80.0);
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Bottom"));
|
||
text_field(ui, &mut d, "marginBottom", 80.0);
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Inside"));
|
||
text_field(ui, &mut d, "marginInside", 80.0);
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Outside"));
|
||
text_field(ui, &mut d, "marginOutside", 80.0);
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Bleed"));
|
||
text_field(ui, &mut d, "bleed", 80.0);
|
||
ui.label("");
|
||
check(ui, &mut d, "primaryTextFrame", crate::i18n::tr(&app.ui.language, "Primary Text Frame"));
|
||
ui.end_row();
|
||
});
|
||
}
|
||
}
|
||
"frameSize" => {
|
||
egui::Grid::new("fs").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Width"));
|
||
text_field(ui, &mut d, "width", 90.0);
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Height"));
|
||
text_field(ui, &mut d, "height", 90.0);
|
||
ui.end_row();
|
||
});
|
||
}
|
||
"findChange" => {
|
||
ui.horizontal(|ui| {
|
||
for (label, grep) in [("Text", false), ("GREP", true)] {
|
||
if ui.selectable_label(!d.b("objectMode") && d.b("grep") == grep, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() {
|
||
d.fields.insert("grep".into(), json!(grep));
|
||
d.fields.insert("objectMode".into(), json!(false));
|
||
}
|
||
}
|
||
if ui.selectable_label(d.b("objectMode"),crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Object"))).clicked() {
|
||
d.fields.insert("objectMode".into(), json!(true));
|
||
}
|
||
});
|
||
if d.b("objectMode") {
|
||
// Find/Change › Object: objects by fill and kind; change their fill or opacity.
|
||
let swatches: Vec<String> = app.session.active().map(|st| st.doc.swatches.iter().filter(|w| !w.hidden).map(|w| w.name.clone()).collect()).unwrap_or_default();
|
||
egui::Grid::new("fco").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
for (key, label, any) in [("objFill", "Find fill:", "(any)"), ("objChangeFill", "Change fill to:", "(unchanged)")] {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label));
|
||
let cur = d.s(key);
|
||
egui::ComboBox::from_id_salt(key).selected_text(if cur.is_empty() { any } else { cur.as_str() }).width(180.0).show_ui(ui, |ui| {
|
||
if ui.selectable_label(cur.is_empty(), crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, any))).clicked() {
|
||
d.fields.insert(key.into(), json!(""));
|
||
}
|
||
for w in &swatches {
|
||
if ui.selectable_label(*w == cur, w).clicked() {
|
||
d.fields.insert(key.into(), json!(w));
|
||
}
|
||
}
|
||
});
|
||
ui.end_row();
|
||
}
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Kind:"));
|
||
let cur = d.s("objKind");
|
||
egui::ComboBox::from_id_salt("objKind").selected_text(if cur.is_empty() { "(any)" } else { cur.as_str() }).show_ui(ui, |ui| {
|
||
for v in ["", "text", "graphic", "shape", "line", "group"] {
|
||
if ui.selectable_label(cur == v, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, if v.is_empty() { "(any)" } else { v }))).clicked() {
|
||
d.fields.insert("objKind".into(), json!(v));
|
||
}
|
||
}
|
||
});
|
||
ui.end_row();
|
||
});
|
||
let mut crit = json!({});
|
||
if !d.s("objFill").is_empty() {
|
||
crit["fill"] = json!(d.s("objFill"));
|
||
}
|
||
if !d.s("objKind").is_empty() {
|
||
crit["kind"] = json!(d.s("objKind"));
|
||
}
|
||
ui.horizontal(|ui| {
|
||
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Find All"))).clicked() {
|
||
let n = app.run("find.objects", crit.clone()).ok().and_then(|r| r["ids"].as_array().map(Vec::len)).unwrap_or(0);
|
||
d.fields.insert("status".into(), json!(format!("{n} found")));
|
||
}
|
||
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Change All"))).clicked() && !d.s("objChangeFill").is_empty() {
|
||
let mut p = crit.clone();
|
||
p["change"] = json!({"fill": d.s("objChangeFill")});
|
||
let n = app.run("find.changeObjects", p).ok().and_then(|r| r["changed"].as_u64()).unwrap_or(0);
|
||
d.fields.insert("status".into(), json!(format!("{n} changed")));
|
||
}
|
||
});
|
||
ui.label(d.s("status"));
|
||
} else {
|
||
egui::Grid::new("fc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Find what:"));
|
||
text_field(ui, &mut d, "find", 260.0);
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Change to:"));
|
||
text_field(ui, &mut d, "change", 260.0);
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Search:"));
|
||
let cur = d.s("scope");
|
||
egui::ComboBox::from_id_salt("fcscope").selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &cur))).show_ui(ui, |ui| {
|
||
for v in ["document", "story", "selection"] {
|
||
if ui.selectable_label(cur == v, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, v))).clicked() {
|
||
d.fields.insert("scope".into(), json!(v));
|
||
}
|
||
}
|
||
});
|
||
ui.end_row();
|
||
});
|
||
ui.horizontal(|ui| {
|
||
check(ui, &mut d, "caseSensitive", crate::i18n::tr(&app.ui.language, "Case sensitive"));
|
||
check(ui, &mut d, "wholeWord", crate::i18n::tr(&app.ui.language, "Whole word"));
|
||
});
|
||
ui.horizontal(|ui| {
|
||
let params0 = json!({"find": d.s("find"), "change": d.s("change"), "grep": d.b("grep"), "caseSensitive": d.b("caseSensitive"), "wholeWord": d.b("wholeWord"), "scope": d.s("scope")});
|
||
let params = || params0.clone();
|
||
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Find Next"))).clicked() {
|
||
let r = app.run("find.next", params());
|
||
let msg = match r {
|
||
Ok(Value::Null) => "No matches".to_string(),
|
||
Ok(_) => "Found".to_string(),
|
||
Err(e) => e,
|
||
};
|
||
d.fields.insert("status".into(), json!(msg));
|
||
}
|
||
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Change All"))).clicked() {
|
||
let msg = match app.run("find.change", params()) {
|
||
Ok(v) => format!("{} replacement(s) made", v["count"]),
|
||
Err(e) => e,
|
||
};
|
||
d.fields.insert("status".into(), json!(msg));
|
||
}
|
||
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Count"))).clicked() {
|
||
let msg = match app.run("find.find", params()) {
|
||
Ok(v) => format!("{} match(es)", v.as_array().map(|a| a.len()).unwrap_or(0)),
|
||
Err(e) => e,
|
||
};
|
||
d.fields.insert("status".into(), json!(msg));
|
||
}
|
||
});
|
||
if let Some(st) = d.fields.get("status").and_then(Value::as_str) {
|
||
ui.label(egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim));
|
||
}
|
||
}
|
||
}
|
||
"paragraphStyleOptions" => paragraph_style_options(app, ui, &mut d),
|
||
"deleteParagraphStyle" => delete_paragraph_style(app, ui, &mut d),
|
||
"characterStyleOptions" => character_style_options(app, ui, &mut d),
|
||
"deleteCharacterStyle" => delete_character_style(app, ui, &mut d),
|
||
"footnoteOptions" => footnote_options(app, ui, &mut d),
|
||
"paragraphRules" => {
|
||
let current = d.fields.get("current").cloned().unwrap_or_default();
|
||
paragraph_rules(app, ui, &mut d, "", ¤t);
|
||
}
|
||
"insertXref" => insert_xref(app, ui, &mut d),
|
||
"findFont" => find_font(app, ui, &mut d),
|
||
"colorPicker" => color_picker(ui, &mut d),
|
||
"preferences" => preferences(app, ui, &mut d, body_height),
|
||
"print" => print_dialog(app, ui, &mut d),
|
||
"pdfImport" => {
|
||
ui.label(egui::RichText::new(if d.b("async") { d.s("name") } else { d.s("path") }).size(11.0));
|
||
ui.horizontal(|ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Page (1–{count}):").replace("{count}", &d.n("pages").unwrap_or(1.0).to_string()));
|
||
text_field(ui, &mut d, "page", 60.0);
|
||
});
|
||
ui.horizontal(|ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Crop to:"));
|
||
let cur = d.s("crop");
|
||
let label = |v: &str| match v {
|
||
"trim" => crate::i18n::tr(&app.ui.language, "Trim"),
|
||
"bleed" => crate::i18n::tr(&app.ui.language, "Bleed"),
|
||
"art" => crate::i18n::tr(&app.ui.language, "Art"),
|
||
"media" => crate::i18n::tr(&app.ui.language, "Media"),
|
||
_ => crate::i18n::tr(&app.ui.language, "Crop"),
|
||
};
|
||
egui::ComboBox::from_id_salt("pdf_crop").selected_text(crate::rtl::widget(ui, label(&cur))).show_ui(ui, |ui| {
|
||
for v in ["crop", "trim", "bleed", "art", "media"] {
|
||
if ui.selectable_label(label(&cur) == label(v), label(v)).clicked() {
|
||
d.fields.insert("crop".into(), json!(v));
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
"layerOptions" => {
|
||
let mut layers = d.fields.get("layers").and_then(Value::as_array).cloned().unwrap_or_default();
|
||
if layers.is_empty() {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "This PDF has no layers."));
|
||
}
|
||
for l in &mut layers {
|
||
let mut on = l["visible"].as_bool().unwrap_or(true);
|
||
if ui.checkbox(&mut on, l["name"].as_str().unwrap_or("")).changed() {
|
||
l["visible"] = json!(on);
|
||
}
|
||
}
|
||
d.fields.insert("layers".into(), json!(layers));
|
||
}
|
||
"colorSettings" => {
|
||
let profiles = d.fields.get("profiles").and_then(Value::as_array).cloned().unwrap_or_default();
|
||
egui::Grid::new("color_settings").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
for (key, label, kind) in [("rgb", "RGB:", "rgb"), ("cmyk", "CMYK:", "cmyk")] {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label));
|
||
let cur = d.s(key);
|
||
egui::ComboBox::from_id_salt(("ws", key)).selected_text(&cur).width(260.0).show_ui(ui, |ui| {
|
||
for pr in profiles.iter().filter(|p| p["kind"] == kind) {
|
||
let name = pr["name"].as_str().unwrap_or("");
|
||
if ui.selectable_label(name == cur, name).clicked() {
|
||
d.fields.insert(key.into(), json!(name));
|
||
}
|
||
}
|
||
});
|
||
ui.end_row();
|
||
}
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Intent:"));
|
||
let cur = d.s("intent");
|
||
egui::ComboBox::from_id_salt("ws_intent")
|
||
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, designcraft_color::cms::Intent::parse(&cur).map_or("", |i| i.label()))))
|
||
.show_ui(ui, |ui| {
|
||
for i in designcraft_color::cms::Intent::ALL {
|
||
if ui.selectable_label(i.id() == cur, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, i.label()))).clicked() {
|
||
d.fields.insert("intent".into(), json!(i.id()));
|
||
}
|
||
}
|
||
});
|
||
ui.end_row();
|
||
});
|
||
check(ui, &mut d, "bpc", crate::i18n::tr(&app.ui.language, "Use Black Point Compensation"));
|
||
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Load Profile…"))).clicked()
|
||
&& let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("icc"))
|
||
{
|
||
match app.session.execute("color.loadProfile", &json!({"path": path})) {
|
||
Ok(_) => {
|
||
let f = app.session.execute("color.settings", &json!({})).unwrap_or_default();
|
||
d.fields.insert("profiles".into(), f["profiles"].clone());
|
||
}
|
||
Err(e) => app.status(format!("Color Settings: {e}")),
|
||
}
|
||
}
|
||
}
|
||
"keyboardShortcuts" => keyboard_shortcuts(app, ui, &mut d),
|
||
"userDictionary" => user_dictionary(app, ui, &mut d),
|
||
"menus" => {
|
||
text_field(ui, &mut d, "query", 260.0);
|
||
let q = d.s("query").to_lowercase();
|
||
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
|
||
for (menu, label) in crate::menus::all_menu_items().into_iter().filter(|(m, l)| q.is_empty() || m.to_lowercase().contains(&q) || l.to_lowercase().contains(&q)) {
|
||
let key = crate::menus::menu_key(&menu, &label);
|
||
let mut visible = !app.ui.hidden_menu_items.contains(&key);
|
||
if ui.checkbox(&mut visible, crate::rtl::widget(ui, format!("{} › {}", crate::i18n::tr(&app.ui.language, &menu), crate::i18n::tr(&app.ui.language, &label)))).changed() {
|
||
let _ = app.run("window.hideMenuItem", json!({"item": key, "hidden": !visible}));
|
||
}
|
||
}
|
||
});
|
||
}
|
||
"importOptions" => {
|
||
ui.label(egui::RichText::new(d.s("path")).size(10.5));
|
||
check(ui, &mut d, "removeStyles", crate::i18n::tr(&app.ui.language, "Remove Styles and Formatting from Text and Tables"));
|
||
if !d.b("removeStyles") {
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Style Name Conflicts")).font(semibold(12.0)));
|
||
let conflicts: Vec<String> = d.fields.get("conflicts").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default();
|
||
ui.label(if conflicts.is_empty() { crate::i18n::tr(&app.ui.language, "No conflicts").to_string() } else { format!("{} conflict(s): {}", conflicts.len(), conflicts.join(", ")) });
|
||
let cur = d.s("styleConflicts");
|
||
ui.horizontal(|ui| {
|
||
for (v, l) in [("useExisting", "Use Document Style Definition"), ("redefine", "Redefine Document Style"), ("autoRename", "Auto Rename")] {
|
||
if ui.radio(cur == v, l).clicked() {
|
||
d.fields.insert("styleConflicts".into(), json!(v));
|
||
}
|
||
}
|
||
});
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Style Mapping")).font(semibold(12.0)));
|
||
let names: Vec<String> = app.session.active().map(|s| s.doc.styles.paragraph.iter().map(|p| p.name.clone()).collect()).unwrap_or_default();
|
||
let imported: Vec<String> = d.fields.get("styles").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default();
|
||
egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| {
|
||
egui::Grid::new("imp_map").num_columns(2).spacing([8.0, 4.0]).show(ui, |ui| {
|
||
for w in &imported {
|
||
ui.label(w);
|
||
let mapped = d.fields.get("map").and_then(|m| m.get(w)).and_then(Value::as_str).unwrap_or("").to_string();
|
||
let shown = if mapped.is_empty() { "(import)".to_string() } else { mapped.clone() };
|
||
egui::ComboBox::from_id_salt(("imp_map", w)).selected_text(crate::rtl::widget(ui, &shown)).width(170.0).show_ui(ui, |ui| {
|
||
if ui.selectable_label(mapped.is_empty(),crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "(import)"))).clicked()
|
||
&& let Some(m) = d.fields.get_mut("map").and_then(Value::as_object_mut)
|
||
{
|
||
m.remove(w);
|
||
}
|
||
for n in &names {
|
||
if ui.selectable_label(*n == mapped, n).clicked()
|
||
&& let Some(m) = d.fields.get_mut("map").and_then(Value::as_object_mut)
|
||
{
|
||
m.insert(w.clone(), json!(n));
|
||
}
|
||
}
|
||
});
|
||
ui.end_row();
|
||
}
|
||
});
|
||
});
|
||
}
|
||
}
|
||
"closeDocument" => {
|
||
let text = crate::i18n::tr(&app.ui.language, "“{}” has changes that are not saved. Save them before closing?").replace("{}", &d.s("title"));
|
||
crate::rtl::label(ui, text);
|
||
}
|
||
"newWorkspace" => {
|
||
ui.horizontal(|ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Name:"));
|
||
text_field(ui, &mut d, "name", 220.0);
|
||
});
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Saves which bars show and where the panels are.")).size(10.5));
|
||
}
|
||
"fittingOptions" => {
|
||
check(ui, &mut d, "autoFit", crate::i18n::tr(&app.ui.language, "Auto-Fit"));
|
||
ui.add_space(4.0);
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Content Fitting")).font(semibold(12.0)));
|
||
let cur = d.s("fitting");
|
||
let label = |v: &str| match v {
|
||
"fillProportionally" => crate::i18n::tr(&app.ui.language, "Fill Frame Proportionally"),
|
||
"fitProportionally" => crate::i18n::tr(&app.ui.language, "Fit Content Proportionally"),
|
||
"fitContentToFrame" => crate::i18n::tr(&app.ui.language, "Fit Content to Frame"),
|
||
"centerContent" => crate::i18n::tr(&app.ui.language, "Center Content"),
|
||
_ => crate::i18n::tr(&app.ui.language, "None"),
|
||
};
|
||
ui.horizontal(|ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Fitting:"));
|
||
egui::ComboBox::from_id_salt("ff_fit").selected_text(crate::rtl::widget(ui, label(&cur))).width(200.0).show_ui(ui, |ui| {
|
||
for v in ["none", "fillProportionally", "fitProportionally", "fitContentToFrame", "centerContent"] {
|
||
if ui.selectable_label(cur == v, crate::rtl::widget(ui, label(v))).clicked() {
|
||
d.fields.insert("fitting".into(), json!(v));
|
||
}
|
||
}
|
||
});
|
||
});
|
||
ui.horizontal(|ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Align From:"));
|
||
let a = d.n("align").unwrap_or(4.0) as u8;
|
||
egui::Grid::new("ff_align").spacing([2.0, 2.0]).show(ui, |ui| {
|
||
for row in 0..3u8 {
|
||
for col in 0..3u8 {
|
||
let i = row * 3 + col;
|
||
let (r, resp) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::click());
|
||
let c = ui.visuals().text_color();
|
||
if a == i {
|
||
ui.painter().rect_filled(r.shrink(2.0), 1.0, c);
|
||
} else {
|
||
ui.painter().rect_stroke(r.shrink(3.0), 1.0, egui::Stroke::new(1.0, c), egui::StrokeKind::Inside);
|
||
}
|
||
if resp.clicked() {
|
||
d.fields.insert("align".into(), json!(i));
|
||
}
|
||
}
|
||
ui.end_row();
|
||
}
|
||
});
|
||
});
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Crop Amount")).font(semibold(12.0)));
|
||
egui::Grid::new("ff_crop").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
for (k, l) in [("cropTop", "Top:"), ("cropLeft", "Left:")] {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, l));
|
||
text_field(ui, &mut d, k, 70.0);
|
||
}
|
||
ui.end_row();
|
||
for (k, l) in [("cropBottom", "Bottom:"), ("cropRight", "Right:")] {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, l));
|
||
text_field(ui, &mut d, k, 70.0);
|
||
}
|
||
ui.end_row();
|
||
});
|
||
}
|
||
"qrCode" => {
|
||
let cur = d.s("type");
|
||
ui.horizontal(|ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Type:"));
|
||
let label = |v: &str| match v {
|
||
"text" => crate::i18n::tr(&app.ui.language, "Plain Text"),
|
||
"sms" => crate::i18n::tr(&app.ui.language, "Text Message"),
|
||
"email" => crate::i18n::tr(&app.ui.language, "Email"),
|
||
"vcard" => crate::i18n::tr(&app.ui.language, "Business Card"),
|
||
_ => crate::i18n::tr(&app.ui.language, "Web Hyperlink"),
|
||
};
|
||
egui::ComboBox::from_id_salt("qr_type").selected_text(crate::rtl::widget(ui, label(&cur))).width(160.0).show_ui(ui, |ui| {
|
||
for v in ["url", "text", "sms", "email", "vcard"] {
|
||
if ui.selectable_label(cur == v, crate::rtl::widget(ui, label(v))).clicked() {
|
||
d.fields.insert("type".into(), json!(v));
|
||
}
|
||
}
|
||
});
|
||
});
|
||
let fields: &[(&str, &str)] = match cur.as_str() {
|
||
"sms" => &[("number", "Cell Number:"), ("message", "Message:")],
|
||
"email" => &[("to", "Email Address:"), ("subject", "Subject:"), ("body", "Message:")],
|
||
"vcard" => &[("name", "Name:"), ("org", "Organization:"), ("phone", "Phone:"), ("email", "Email:"), ("url", "URL:")],
|
||
"text" => &[("content", "Text:")],
|
||
_ => &[("content", "URL:")],
|
||
};
|
||
egui::Grid::new("qr_fields").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
for (k, l) in fields {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, l));
|
||
text_field(ui, &mut d, k, 260.0);
|
||
ui.end_row();
|
||
}
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Color:"));
|
||
text_field(ui, &mut d, "color", 160.0);
|
||
ui.end_row();
|
||
});
|
||
}
|
||
"polygonSettings" => {
|
||
egui::Grid::new("poly").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Number of Sides:"));
|
||
text_field(ui, &mut d, "sides", 60.0);
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Star Inset:"));
|
||
ui.horizontal(|ui| {
|
||
text_field(ui, &mut d, "starInset", 60.0);
|
||
ui.label("%");
|
||
});
|
||
ui.end_row();
|
||
});
|
||
}
|
||
id if id.starts_with("cmd:") => command_form(app, ui, &mut d),
|
||
"goToPage" => {
|
||
ui.horizontal(|ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Page"));
|
||
text_field(ui, &mut d, "page", 80.0);
|
||
});
|
||
}
|
||
"ruby" => {
|
||
ui.horizontal(|ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Ruby:"));
|
||
text_field(ui, &mut d, "text", 200.0);
|
||
});
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Set over the selected text; empty removes it.")).size(11.0));
|
||
}
|
||
"insertTable" => {
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Table Dimensions")).font(semibold(12.0)));
|
||
egui::Grid::new("ins_table").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Body Rows"));
|
||
text_field(ui, &mut d, "bodyRows", 60.0);
|
||
crate::rtl::label(ui, crate::i18n::tr_context(&app.ui.language, "Columns", "table"));
|
||
text_field(ui, &mut d, "columns", 60.0);
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Header Rows"));
|
||
text_field(ui, &mut d, "headerRows", 60.0);
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Footer Rows"));
|
||
text_field(ui, &mut d, "footerRows", 60.0);
|
||
ui.end_row();
|
||
});
|
||
}
|
||
"textFrameOptions" => {
|
||
seed_text_frame_options(app, &mut d);
|
||
text_frame_options_dialog(app, ui, &mut d);
|
||
}
|
||
"documentSetup" => document_setup(app, ui, &mut d),
|
||
"pdfExport" => pdf_export(app, ui, &mut d),
|
||
"alert" => {
|
||
let file = d.s("file");
|
||
if !file.is_empty() {
|
||
crate::rtl::label(ui, egui::RichText::new(crate::rtl::isolate(&file)).font(semibold(13.0)));
|
||
ui.add_space(4.0);
|
||
}
|
||
crate::rtl::label(ui, d.s("message"));
|
||
}
|
||
_ => {}
|
||
}
|
||
});
|
||
ui.add_space(12.0);
|
||
let ok_label = match d.id.as_str() {
|
||
"closeDocument" => " Save ",
|
||
"findFont" => "Replace All",
|
||
_ => " OK ",
|
||
};
|
||
ui.horizontal(|ui| {
|
||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||
if ui
|
||
.add(
|
||
egui::Button::new(crate::rtl::widget(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, ok_label)).color(egui::Color32::WHITE)))
|
||
.fill(crate::theme::Tokens::get(ui.ctx()).accent_strong),
|
||
)
|
||
.clicked()
|
||
|| ui.input(|i| i.key_pressed(egui::Key::Enter))
|
||
{
|
||
result = Some(true);
|
||
}
|
||
// An alert only has OK.
|
||
let cancel = d.id != "alert" && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, if d.id == "findFont" { "Done" } else { "Cancel" }))).clicked();
|
||
if cancel || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||
result = Some(false);
|
||
}
|
||
if d.id == "closeDocument" && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Don't Save"))).clicked() {
|
||
d.fields.insert("discard".into(), json!(true));
|
||
result = Some(true);
|
||
}
|
||
});
|
||
});
|
||
});
|
||
app.ui.dialog = Some(d);
|
||
match result {
|
||
Some(true) => {
|
||
let _ = confirm(app);
|
||
}
|
||
Some(false) => {
|
||
app.ui.dialog = None;
|
||
app.cancel_pdf_import();
|
||
}
|
||
None => {}
|
||
}
|
||
}
|
||
|
||
/// Apply the open dialog.
|
||
pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
|
||
let Some(d) = app.ui.dialog.take() else { return Err("no dialog open".into()) };
|
||
if d.id != "pdfImport" || !d.b("async") {
|
||
app.cancel_pdf_import();
|
||
}
|
||
match d.id.as_str() {
|
||
"newDocument" => {
|
||
let margins = json!({"top": d.m("marginTop").unwrap_or(36.0), "bottom": d.m("marginBottom").unwrap_or(36.0), "inside": d.m("marginInside").unwrap_or(36.0), "outside": d.m("marginOutside").unwrap_or(36.0)});
|
||
app.run(
|
||
"file.new",
|
||
json!({"width": d.m("width"), "height": d.m("height"), "pages": d.n("pages").unwrap_or(1.0) as u64, "facingPages": d.b("facingPages"),
|
||
"columns": d.n("columns").unwrap_or(1.0) as u64, "gutter": d.m("gutter").unwrap_or(12.0), "margins": margins, "bleed": d.m("bleed").unwrap_or(0.0),
|
||
"primaryTextFrame": d.b("primaryTextFrame")}),
|
||
)
|
||
}
|
||
"frameSize" => {
|
||
let x = d.fields.get("x").and_then(Value::as_f64).unwrap_or(0.0);
|
||
let y = d.fields.get("y").and_then(Value::as_f64).unwrap_or(0.0);
|
||
let w = d.m("width").unwrap_or(72.0);
|
||
let h = d.m("height").unwrap_or(72.0);
|
||
app.run(
|
||
"frame.create",
|
||
json!({"spread": d.fields.get("spread").cloned().unwrap_or(json!(0)), "shape": d.s("shape"), "content": d.s("content"), "rect": [x, y, x + w, y + h]}),
|
||
)
|
||
}
|
||
"ruby" => app.run("type.ruby", json!({"text": d.s("text")})),
|
||
"goToPage" => {
|
||
// A page name ("iv", "A-3"), a number, or "+n" for an absolute position.
|
||
let typed = match d.fields.get("page") {
|
||
Some(Value::String(s)) => s.clone(),
|
||
Some(v) => v.to_string(),
|
||
None => "1".into(),
|
||
};
|
||
match app.session.resolve_page(&typed) {
|
||
Some(abs) => {
|
||
crate::canvas::go_to_page(app, abs);
|
||
Ok(Value::Null)
|
||
}
|
||
None => Err(format!("no page \"{typed}\"")),
|
||
}
|
||
}
|
||
"insertTable" => app.run(
|
||
"table.insert",
|
||
json!({"rows": d.n("bodyRows").unwrap_or(4.0).max(1.0) as u64, "cols": d.n("columns").unwrap_or(4.0).max(1.0) as u64,
|
||
"headerRows": d.n("headerRows").unwrap_or(0.0).max(0.0) as u64, "footerRows": d.n("footerRows").unwrap_or(0.0).max(0.0) as u64}),
|
||
),
|
||
"textFrameOptions" => {
|
||
// On an error the dialog stays open to be corrected.
|
||
let mut d = d;
|
||
let r = confirm_text_frame_options(app, &mut d);
|
||
if let Err(e) = &r {
|
||
app.status(e.clone());
|
||
app.ui.dialog = Some(d);
|
||
}
|
||
r
|
||
}
|
||
"documentSetup" => {
|
||
let edges = |k: &str| json!(["Top", "Bottom", "Inside", "Outside"].map(|e| d.m(&format!("{k}{e}")).unwrap_or(0.0)));
|
||
app.run(
|
||
"layout.documentSetup",
|
||
json!({"intent": d.s("intent"), "pages": d.n("pages").unwrap_or(1.0).max(1.0) as u64, "startPage": d.n("startPage").unwrap_or(1.0).max(1.0) as u64,
|
||
"facingPages": d.b("facingPages"), "binding": d.s("binding"), "width": d.m("width"), "height": d.m("height"), "bleed": edges("bleed"), "slug": edges("slug"), "adjustLayout": d.b("adjustLayout")}),
|
||
)
|
||
}
|
||
"paragraphStyleOptions" => {
|
||
let name = d.s("name");
|
||
let mut para = serde_json::Map::new();
|
||
let mut chars = serde_json::Map::new();
|
||
for (k, v) in &d.fields {
|
||
if let Some(a) = k.strip_prefix("p.") {
|
||
// `p.ruleAbove.weight`: one field of a rule (the command keeps the others).
|
||
match a.split_once('.') {
|
||
Some((attr, field)) => {
|
||
if let Value::Object(m) = para.entry(attr).or_insert_with(|| json!({})) {
|
||
m.insert(field.into(), v.clone());
|
||
}
|
||
}
|
||
None => {
|
||
para.insert(a.into(), v.clone());
|
||
}
|
||
}
|
||
} else if let Some(a) = k.strip_prefix("c.") {
|
||
chars.insert(a.into(), v.clone());
|
||
}
|
||
}
|
||
let based = d.s("basedOn");
|
||
let rename = d.s("rename");
|
||
let (r, style) = if d.b("new") {
|
||
let mut params = json!({"name": if rename.trim().is_empty() { "Paragraph Style 1" } else { rename.trim() }, "para": para, "chars": chars});
|
||
if !based.is_empty() && based != designcraft_doc::NO_PARA_STYLE {
|
||
params["basedOn"] = json!(based);
|
||
}
|
||
let r = app.run("style.paragraph.create", params)?;
|
||
let style = r["name"].as_str().unwrap_or_default().to_string();
|
||
(r, style)
|
||
} else {
|
||
let mut params = json!({"name": name, "para": para, "chars": chars});
|
||
if !based.is_empty() {
|
||
params["basedOn"] = if based == designcraft_doc::NO_PARA_STYLE { Value::Null } else { json!(based) };
|
||
}
|
||
if !rename.is_empty() && rename != name {
|
||
params["rename"] = json!(rename);
|
||
}
|
||
let style = if !rename.is_empty() && rename != name { rename } else { name };
|
||
(app.run("style.paragraph.edit", params)?, style)
|
||
};
|
||
if d.fields.contains_key("x.tag") {
|
||
let tag = d.s("x.tag");
|
||
let tag = if tag == "[Automatic]" { String::new() } else { tag };
|
||
app.run("style.exportTag", json!({"style": style, "tag": tag, "class": d.s("x.class")}))?;
|
||
}
|
||
Ok(r)
|
||
}
|
||
"deleteParagraphStyle" => {
|
||
let name = d.s("name");
|
||
let replace = d.s("replaceWith");
|
||
app.run(
|
||
"style.paragraph.delete",
|
||
json!({"name": name, "replaceWith": if replace.is_empty() { json!(designcraft_doc::BASIC_PARAGRAPH) } else { json!(replace) }}),
|
||
)
|
||
}
|
||
"paragraphRules" => {
|
||
let attrs = rule_edits(&d, "");
|
||
if attrs.is_empty() {
|
||
return Ok(Value::Null);
|
||
}
|
||
app.run("type.para", json!({"attrs": attrs}))
|
||
}
|
||
"characterStyleOptions" => {
|
||
let name = d.s("name");
|
||
let mut chars = Map::new();
|
||
for (k, v) in &d.fields {
|
||
if let Some(a) = k.strip_prefix("c.") {
|
||
chars.insert(a.into(), v.clone());
|
||
}
|
||
}
|
||
let rename = d.s("rename").trim().to_string();
|
||
let based = d.fields.get("basedOn").and_then(Value::as_str).map(str::to_string);
|
||
let r = if d.b("new") {
|
||
// A new style sets only what was filled in.
|
||
chars.retain(|_, v| !v.is_null());
|
||
let mut params = json!({"name": if rename.is_empty() { "Character Style 1" } else { rename.as_str() }, "chars": chars});
|
||
if let Some(b) = based.filter(|b| b != designcraft_doc::NO_CHAR_STYLE) {
|
||
params["basedOn"] = json!(b);
|
||
}
|
||
app.run("style.character.create", params)
|
||
} else {
|
||
let mut params = json!({"name": name, "chars": chars});
|
||
if let Some(b) = based {
|
||
params["basedOn"] = if b == designcraft_doc::NO_CHAR_STYLE { Value::Null } else { json!(b) };
|
||
}
|
||
if !rename.is_empty() && rename != name {
|
||
params["rename"] = json!(rename);
|
||
}
|
||
app.run("style.character.edit", params)
|
||
};
|
||
if let Err(e) = &r {
|
||
// Keep the dialog open with the reason (a taken name, a based-on loop).
|
||
let mut d = d.clone();
|
||
d.fields.insert("status".into(), json!(e));
|
||
d.fields.insert("section".into(), json!("general"));
|
||
app.ui.dialog = Some(d);
|
||
}
|
||
r
|
||
}
|
||
"deleteCharacterStyle" => {
|
||
let name = d.s("name");
|
||
let replace = d.s("replaceWith");
|
||
app.run(
|
||
"style.character.delete",
|
||
json!({"name": name, "replaceWith": if replace.is_empty() { json!(designcraft_doc::NO_CHAR_STYLE) } else { json!(replace) }}),
|
||
)
|
||
}
|
||
"colorPicker" => {
|
||
let hex = d.s("hex");
|
||
app.run("object.color", json!({"color": hex, "target": d.s("target"), "text": d.b("text")}))
|
||
}
|
||
"layerOptions" => {
|
||
let hidden: Vec<Value> = d.fields.get("layers").and_then(Value::as_array).map(|a| a.iter().filter(|l| l["visible"] == false).map(|l| l["name"].clone()).collect()).unwrap_or_default();
|
||
app.run("object.layerOptions", json!({"hidden": hidden}))
|
||
}
|
||
"colorSettings" => {
|
||
let r = app.run("color.settings", json!({"rgb": d.s("rgb"), "cmyk": d.s("cmyk"), "intent": d.s("intent"), "bpc": d.b("bpc")}))?;
|
||
app.ui.color_settings = Some(designcraft_color::cms::active_settings());
|
||
app.canvas.shown = None;
|
||
Ok(r)
|
||
}
|
||
"pdfImport" => {
|
||
let crop = d.s("crop");
|
||
if d.b("async") {
|
||
return app.confirm_pdf_import(d.n("page").unwrap_or(1.0).max(1.0) as u64, if crop.is_empty() { "crop".into() } else { crop });
|
||
}
|
||
if let Some(target) = d.fields.get("target").and_then(Value::as_u64) {
|
||
app.activate_import_target(&crate::ImportRequest { purpose: "place".into(), target: Some(target) })?;
|
||
}
|
||
app.open_file("file.place", json!({"path": d.s("path"), "pdfPage": d.n("page").unwrap_or(1.0).max(1.0) as u64, "pdfCrop": if crop.is_empty() { "crop".to_string() } else { crop }}))
|
||
}
|
||
"print" => {
|
||
let pages = if d.s("range") == "all" { Value::Null } else { json!(d.s("pages")) };
|
||
let printer = d.s("printer");
|
||
let r = app.run(
|
||
"file.print",
|
||
json!({"printer": if printer.is_empty() { Value::Null } else { json!(printer) }, "copies": d.n("copies").unwrap_or(1.0).max(1.0) as u64,
|
||
"pages": pages, "spreads": d.b("spreads"), "marks": d.b("marks"), "bleed": d.b("bleed")}),
|
||
)?;
|
||
app.status(format!("Sent {} page(s) to {}", r["pages"], r["printer"].as_str().unwrap_or("the default printer")));
|
||
Ok(r)
|
||
}
|
||
"pdfExport" => {
|
||
// Remember the last-used options; `pages` and `title` are per-job and not persisted.
|
||
app.ui.pdf_export.preset = d.s("preset");
|
||
app.ui.pdf_export.standard = d.s("standard");
|
||
app.ui.pdf_export.compress_images = d.b("compressImages");
|
||
app.ui.pdf_export.flatten = d.s("flatten");
|
||
app.ui.pdf_export.spreads = d.b("spreads");
|
||
app.ui.pdf_export.bleed = d.b("bleed");
|
||
app.ui.pdf_export.marks_crop = d.b("marksCrop");
|
||
app.ui.pdf_export.marks_bleed = d.b("marksBleed");
|
||
app.ui.pdf_export.marks_page_info = d.b("marksPageInfo");
|
||
app.ui.pdf_export.marks_weight = d.s("marksWeight");
|
||
app.ui.pdf_export.marks_offset = d.s("marksOffset");
|
||
app.ui.pdf_export.tagged = d.b("tagged");
|
||
|
||
let mut params = json!({
|
||
"standard": d.s("standard"),
|
||
"compressImages": d.b("compressImages"),
|
||
"spreads": d.b("spreads"),
|
||
"bleed": d.b("bleed"),
|
||
"tagged": d.b("tagged"),
|
||
"marks": {
|
||
"crop": d.b("marksCrop"),
|
||
"bleed": d.b("marksBleed"),
|
||
"pageInfo": d.b("marksPageInfo"),
|
||
"weight": d.pt("marksWeight").unwrap_or(0.25),
|
||
"offset": d.pt("marksOffset").unwrap_or(6.0),
|
||
},
|
||
});
|
||
let flatten = d.s("flatten");
|
||
if !flatten.is_empty() {
|
||
params["flatten"] = json!(flatten);
|
||
}
|
||
let pages = d.s("pages");
|
||
if !pages.trim().is_empty() && pages != "all" {
|
||
params["pages"] = json!(pages);
|
||
}
|
||
let title = d.s("title");
|
||
if !title.is_empty() {
|
||
params["title"] = json!(title);
|
||
}
|
||
let name = if title.is_empty() { "Export.pdf".to_string() } else { format!("{title}.pdf") };
|
||
match app.services.pick_save.as_mut().and_then(|f| f(&name)) {
|
||
Some(path) => {
|
||
params["path"] = json!(path);
|
||
crate::menus::export_pdf(app, ¶ms)
|
||
}
|
||
// The user cancelled the save dialog: export nothing.
|
||
None => Ok(Value::Null),
|
||
}
|
||
}
|
||
"preferences" => {
|
||
app.run(
|
||
"prefs.set",
|
||
json!({"scaleStrokes": d.b("scaleStrokes"), "dimensionsIncludeStroke": d.b("dimensionsIncludeStroke"), "transformationsAreTotals": d.b("transformationsAreTotals"), "absolutePageNumbers": d.b("absolutePageNumbers"), "highlightHj": d.b("highlightHj"), "highlightKeeps": d.b("highlightKeeps"), "highlightCustomTracking": d.b("highlightCustomTracking"), "highlightSubstitutedFonts": d.b("highlightSubstitutedFonts"), "richBlackOutput": d.b("richBlackOutput"), "typographersQuotes": d.b("typographersQuotes"), "showFontNamesInEnglish": d.b("showFontNamesInEnglish"), "smartTextReflow": d.b("smartTextReflow"),
|
||
"autocorrect": d.b("autocorrect"), "showAddedText": d.b("showAddedText"), "showNoteAnchors": d.b("showNoteAnchors"),
|
||
"recoveryMinutes": d.n("recoveryMinutes").unwrap_or(0.5),
|
||
"autocorrectList": d.s("autocorrectText").lines().filter_map(|l| {
|
||
let (a, b) = l.split_once('→').or_else(|| l.split_once("->"))?;
|
||
let (a, b) = (a.trim().to_lowercase(), b.trim().to_string());
|
||
(!a.is_empty() && !b.is_empty()).then(|| json!([a, b]))
|
||
}).collect::<Vec<_>>()}),
|
||
)?;
|
||
app.ui.dynamic_spelling = d.b("dynamicSpelling");
|
||
if d.fields.contains_key("userWords") {
|
||
let words: Vec<String> = d.s("userWords").lines().map(str::to_string).collect();
|
||
app.run("spelling.setWords", json!({"words": words}))?;
|
||
}
|
||
app.ui.story_editor_size = d.n("storyEditorSize").unwrap_or(14.0) as f32;
|
||
// Highlight options change the screen view only.
|
||
app.canvas.shown = None;
|
||
let view = match d.s("displayQuality").as_str() {
|
||
"fast" => "view.fastDisplay",
|
||
"typical" => "view.typicalDisplay",
|
||
_ => "view.highQualityDisplay",
|
||
};
|
||
app.run(view, json!({}))?;
|
||
app.run("window.richBlack", json!({"on": d.b("richBlack")}))?;
|
||
if let Some(v) = d.n("uiScale") {
|
||
app.run("window.uiScale", json!({"scale": v / 100.0}))?;
|
||
}
|
||
if d.fields.contains_key("snap.alignEdges") {
|
||
let mut snap = json!({
|
||
"alignEdges": d.b("snap.alignEdges"),
|
||
"alignCenters": d.b("snap.alignCenters"),
|
||
"smartDimensions": d.b("snap.dimensions"),
|
||
"smartSpacing": d.b("snap.spacing"),
|
||
});
|
||
if let Some(zone) = d.n("snap.zone") {
|
||
snap["zone"] = json!(zone);
|
||
}
|
||
app.run("view.snapPreferences", snap)?;
|
||
}
|
||
if !d.fields.contains_key("horizontalUnits") {
|
||
return Ok(Value::Null);
|
||
}
|
||
let mut doc = json!({"horizontalUnits": d.fields["horizontalUnits"], "verticalUnits": d.fields["verticalUnits"]});
|
||
let mut bg = json!({"relativeTo": d.s("bg.relativeTo"), "color": d.fields.get("bg.color").cloned().unwrap_or(json!([140, 205, 230]))});
|
||
if let Some(v) = d.m("bg.start") {
|
||
bg["start"] = json!(v.max(0.0));
|
||
}
|
||
if let Some(v) = d.m("bg.increment").filter(|v| *v > 0.0) {
|
||
bg["increment"] = json!(v);
|
||
}
|
||
if let Some(v) = d.n("bg.viewThreshold") {
|
||
bg["viewThreshold"] = json!((v / 100.0).clamp(0.05, 40.0));
|
||
}
|
||
doc["baselineGrid"] = bg;
|
||
let mut grid = json!({"inBack": d.b("grid.inBack"), "color": d.fields.get("grid.color").cloned().unwrap_or(json!([200, 200, 200]))});
|
||
for k in ["horizontal", "vertical"] {
|
||
if let Some(v) = d.m(&format!("grid.{k}")).filter(|v| *v > 0.0) {
|
||
grid[k] = json!(v);
|
||
}
|
||
}
|
||
if let Some(v) = d.n("grid.subdivisions") {
|
||
grid["subdivisions"] = json!(v.clamp(1.0, 100.0) as u32);
|
||
}
|
||
doc["grid"] = grid;
|
||
for k in ["marginColor", "columnColor", "bleedColor", "slugColor"] {
|
||
if let Some(v) = d.fields.get(k) {
|
||
doc[k] = v.clone();
|
||
}
|
||
}
|
||
if let (Some(h), Some(v)) = (d.m("pasteboard.h"), d.m("pasteboard.v")) {
|
||
doc["pasteboard"] = json!([h.max(0.0), v.max(0.0)]);
|
||
}
|
||
if let Some(v) = d.fields.get("keyboardIncrement").and_then(Value::as_str).and_then(|s| parse_measure(s, Unit::Points).ok()) {
|
||
doc["keyboardIncrement"] = json!(v.max(0.001));
|
||
}
|
||
let mut adv = json!({});
|
||
for k in ["superscriptSize", "superscriptPosition", "subscriptSize", "subscriptPosition"] {
|
||
if let Some(v) = d.n(&format!("adv.{k}")) {
|
||
adv[k] = json!(if k.ends_with("Size") { v.clamp(1.0, 200.0) } else { v.clamp(-500.0, 500.0) });
|
||
}
|
||
}
|
||
doc["advancedType"] = adv;
|
||
doc["overprintBlack"] = json!(d.b("overprintBlack"));
|
||
doc["glyphFallback"] = json!(d.b("glyphFallback"));
|
||
app.run("document.preferences", doc)
|
||
}
|
||
"newWorkspace" => app.run("window.newWorkspace", json!({"name": d.s("name")})),
|
||
"closeDocument" => {
|
||
// The document may have moved in the tab row (or gone) while the dialog was open.
|
||
let uid = d.fields.get("uid").and_then(Value::as_u64);
|
||
let index = |app: &DesignApp| app.session.documents().iter().position(|doc| Some(doc.uid) == uid);
|
||
let Some(i) = index(app) else { return Ok(Value::Null) };
|
||
if !d.b("discard") {
|
||
app.run("file.activate", json!({"index": i}))?;
|
||
app.run("app.save", json!({}))?;
|
||
// Still unsaved: the save was cancelled (no file chosen). Keep the document open.
|
||
if app.session.documents().get(i).is_none_or(|doc| doc.is_dirty()) {
|
||
return Ok(Value::Null);
|
||
}
|
||
}
|
||
match index(app) {
|
||
Some(i) => app.run("file.close", json!({"index": i})),
|
||
None => Ok(Value::Null),
|
||
}
|
||
}
|
||
"importOptions" => app.open_file(
|
||
"file.place",
|
||
json!({"path": d.s("path"), "removeStyles": d.b("removeStyles"), "styleConflicts": d.s("styleConflicts"), "styleMap": d.fields.get("map").cloned().unwrap_or(json!({}))}),
|
||
),
|
||
"fittingOptions" => {
|
||
let m = |k: &str| d.fields.get(k).and_then(Value::as_str).and_then(|s| parse_measure(s, Unit::Points).ok()).unwrap_or(0.0);
|
||
app.run(
|
||
"object.fittingOptions",
|
||
json!({"autoFit": d.b("autoFit"), "fitting": d.s("fitting"), "align": d.n("align").unwrap_or(4.0) as u64,
|
||
"crop": [m("cropTop"), m("cropLeft"), m("cropBottom"), m("cropRight")]}),
|
||
)
|
||
}
|
||
"qrCode" => {
|
||
let mut p = Value::Object(d.fields.clone());
|
||
// Nothing selected: a 2-inch code at the top left of the page in view.
|
||
if let Some(st) = app.session.active().filter(|s| s.selection.items.is_empty()) {
|
||
let abs = crate::canvas::current_page(app).unwrap_or(0);
|
||
let (si, pi) = st.doc.page_loc(abs).unwrap_or((0, 0));
|
||
let x = st.doc.spreads.get(si).and_then(|sp| sp.pages.get(pi)).map_or(0.0, |pg| pg.x);
|
||
p["rect"] = json!([x + 36.0, 36.0, x + 180.0, 180.0]);
|
||
p["spread"] = json!(si);
|
||
}
|
||
app.run("object.qrCode", p)
|
||
}
|
||
"polygonSettings" => app.run("tool.polygonSettings", json!({"sides": d.n("sides").unwrap_or(6.0) as u64, "starInset": d.n("starInset").unwrap_or(0.0)})),
|
||
"findFont" => {
|
||
let result = app.run("font.replace", json!({"family": d.s("family"), "style": d.s("style"), "toFamily": d.s("toFamily"), "toStyle": d.s("toStyle")}));
|
||
let mut d = d;
|
||
match &result {
|
||
Ok(_) => {
|
||
d.fields.remove("_fonts");
|
||
d.fields.remove("family");
|
||
d.fields.remove("style");
|
||
d.fields.remove("error");
|
||
}
|
||
Err(error) => { d.fields.insert("error".into(), json!(error)); }
|
||
}
|
||
app.ui.dialog = Some(d);
|
||
result
|
||
}
|
||
"insertXref" => {
|
||
let format = d.s("format");
|
||
let target = d.s("target");
|
||
let params = match target.split_once(':') {
|
||
Some(("a", id)) => json!({"anchor": id.parse::<u64>().unwrap_or(0), "format": format}),
|
||
Some((sid, pi)) => json!({"story": sid.parse::<u64>().unwrap_or(0), "para": pi.parse::<u64>().unwrap_or(0), "format": format}),
|
||
None => {
|
||
let mut d = d.clone();
|
||
d.fields.insert("status".into(), json!("Choose a destination paragraph or text anchor."));
|
||
app.ui.dialog = Some(d);
|
||
return Err("no destination chosen".into());
|
||
}
|
||
};
|
||
app.run("xref.insert", params)
|
||
}
|
||
"footnoteOptions" => {
|
||
let pt = |k: &str| d.pt(k).map_or(Value::Null, |v| json!(v));
|
||
let mut p = json!({
|
||
"style": d.s("style"), "startAt": d.n("startAt").unwrap_or(1.0).max(0.0) as u32, "restart": d.s("restart"),
|
||
"prefix": d.s("prefix"), "suffix": d.s("suffix"), "affixIn": d.s("affixIn"), "refPosition": d.s("refPosition"),
|
||
"refCharStyle": d.s("refCharStyle"), "paraStyle": d.s("paraStyle"),
|
||
"separator": d.s("separator").replace("^t", "\t").replace("^m", "\u{2003}").replace("^>", "\u{2002}"),
|
||
"spaceBefore": pt("spaceBefore"), "spaceBetween": pt("spaceBetween"), "firstBaseline": d.s("firstBaseline"),
|
||
"firstBaselineMin": pt("firstBaselineMin"), "spanColumns": d.b("spanColumns"),
|
||
"rule": {"on": d.b("rule.on"), "weight": pt("rule.weight"), "color": d.s("rule.color"), "width": pt("rule.width"),
|
||
"offset": pt("rule.offset"), "leftIndent": pt("rule.leftIndent")},
|
||
});
|
||
// Unparsable measures keep their current values.
|
||
if let Some(o) = p.as_object_mut() {
|
||
o.retain(|_, v| !v.is_null());
|
||
}
|
||
if let Some(r) = p.get_mut("rule").and_then(Value::as_object_mut) {
|
||
r.retain(|_, v| !v.is_null());
|
||
}
|
||
app.run("footnote.options", p)
|
||
}
|
||
"alert" => Ok(Value::Null),
|
||
"findChange" if d.b("objectMode") => Ok(Value::Null),
|
||
"findChange" => app.run("find.change", json!({"find": d.s("find"), "change": d.s("change"), "grep": d.b("grep"), "caseSensitive": d.b("caseSensitive"), "wholeWord": d.b("wholeWord"), "scope": d.s("scope")})),
|
||
id if id.starts_with("cmd:") => {
|
||
let cid = &id[4..];
|
||
let doc = designcraft_engine::find_command(cid).map_or("", |c| c.params);
|
||
let mut p = Map::new();
|
||
for f in command_fields(doc) {
|
||
match d.fields.get(&f.key) {
|
||
Some(Value::String(v)) if !v.trim().is_empty() => {
|
||
let v = v.trim();
|
||
// Numbers, booleans, arrays and objects as JSON; anything else is a string.
|
||
let parsed = serde_json::from_str::<Value>(v).ok().filter(|x| !x.is_string());
|
||
p.insert(f.key, parsed.unwrap_or_else(|| json!(v)));
|
||
}
|
||
Some(Value::String(_)) | None => {}
|
||
Some(value) => {
|
||
// Control/MCP callers can supply JSON directly. Preserve its type,
|
||
// including explicit null, for the command to interpret.
|
||
p.insert(f.key, value.clone());
|
||
}
|
||
}
|
||
}
|
||
let r = app.run(cid, Value::Object(p));
|
||
if let Err(e) = &r {
|
||
// Keep the dialog open with the error.
|
||
let mut d = d.clone();
|
||
d.fields.insert("status".into(), json!(e));
|
||
app.ui.dialog = Some(d);
|
||
}
|
||
r
|
||
}
|
||
other => Err(format!("unknown dialog {other}")),
|
||
}
|
||
}
|
||
|
||
/// Paragraph Style Options: sections in a left list (like InDesign), fields on the right.
|
||
/// Edited values are stored as `p.<attr>` / `c.<attr>` fields and applied on OK.
|
||
/// Is `p` a pattern the GREP styles can use? (Checked as the user types.)
|
||
fn regex_ok(p: &str) -> Result<(), ()> {
|
||
if p.is_empty() {
|
||
return Ok(());
|
||
}
|
||
designcraft_compose::grep::Grep::new(p, Default::default()).map(|_| ()).map_err(|_| ())
|
||
}
|
||
|
||
fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||
let name = d.s("name");
|
||
let new = d.b("new");
|
||
let Some(doc) = app.session.active().map(|s| s.doc.clone()) else { return };
|
||
let style = match doc.styles.para(&name) {
|
||
_ if new => designcraft_doc::ParagraphStyle {
|
||
name: String::new(),
|
||
based_on: None,
|
||
next_style: None,
|
||
para: Default::default(),
|
||
chars: Default::default(),
|
||
shortcut: String::new(),
|
||
},
|
||
Some(s) => s.clone(),
|
||
None => {
|
||
ui.label(format!("No style named {name}"));
|
||
return;
|
||
}
|
||
};
|
||
if new && !d.fields.contains_key("rename") {
|
||
d.fields.insert("rename".into(), json!(next_style_name(&doc.styles, true)));
|
||
}
|
||
let names: Vec<String> = doc.styles.paragraph.iter().map(|p| p.name.clone()).filter(|n| new || *n != name).collect();
|
||
// A new style shows what it would inherit from its Based On.
|
||
let (pp, cp) = doc.styles.resolve_para_style(if new { d.fields.get("basedOn").and_then(Value::as_str).unwrap_or("") } else { &name });
|
||
let units = doc.settings.horizontal_units;
|
||
let pv = serde_json::to_value(&pp).unwrap_or_default();
|
||
let cv = serde_json::to_value(&cp).unwrap_or_default();
|
||
let cur = |d: &Dialog, k: &str, base: &Value| d.fields.get(k).cloned().unwrap_or_else(|| base.clone());
|
||
ui.set_min_width(560.0);
|
||
ui.horizontal_top(|ui| {
|
||
// A fixed height: the separator would otherwise stretch the dialog to the window.
|
||
ui.set_min_height(380.0);
|
||
ui.set_max_height(380.0);
|
||
section_list(
|
||
&app.ui.language,
|
||
ui,
|
||
d,
|
||
&[
|
||
("general", "General"),
|
||
("chars", "Basic Character Formats"),
|
||
("indents", "Indents and Spacing"),
|
||
("rules", "Paragraph Rules"),
|
||
("hyph", "Hyphenation"),
|
||
("justify", "Justification"),
|
||
("nested", "Drop Caps and Nested Styles"),
|
||
("grep", "GREP Style"),
|
||
("color", "Character Color"),
|
||
("export", "Export Tagging"),
|
||
],
|
||
);
|
||
ui.separator();
|
||
let cnames: Vec<String> = app.session.active().map(|s| s.doc.styles.character.iter().map(|c| c.name.clone()).collect()).unwrap_or_default();
|
||
ui.vertical(|ui| match d.s("section").as_str() {
|
||
"export" => {
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "EPUB and HTML")).font(semibold(12.0)));
|
||
let et = app.session.active().and_then(|s| s.doc.styles.export_tag(&name, false).cloned()).unwrap_or_default();
|
||
if !d.fields.contains_key("x.tag") {
|
||
d.fields.insert("x.tag".into(), json!(if et.tag.is_empty() { "[Automatic]" } else { et.tag.as_str() }));
|
||
d.fields.insert("x.class".into(), json!(et.class));
|
||
}
|
||
ui.horizontal(|ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Tag:"));
|
||
let cur = d.s("x.tag");
|
||
egui::ComboBox::from_id_salt("export_tag")
|
||
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &cur)))
|
||
.show_ui(ui, |ui| {
|
||
for t in ["[Automatic]", "p", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "pre", "li", "figcaption", "aside", "div"]
|
||
{
|
||
if ui.selectable_label(cur == t, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, t))).clicked() {
|
||
d.fields.insert("x.tag".into(), json!(t));
|
||
}
|
||
}
|
||
});
|
||
});
|
||
ui.horizontal(|ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Class:"));
|
||
text_field(ui, d, "x.class", 160.0);
|
||
});
|
||
}
|
||
"nested" => {
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Drop Caps")).font(semibold(12.0)));
|
||
egui::Grid::new("psdc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
for (label, key, max) in [
|
||
("Lines:", "dropCapLines", designcraft_compose::MAX_DROP_CAP_LINES),
|
||
("Characters:", "dropCapChars", designcraft_compose::MAX_DROP_CAP_CHARS),
|
||
] {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label));
|
||
let v = cur(d, &format!("p.{key}"), &pv[key]).as_f64();
|
||
if let Some(n) = crate::widgets::number(ui, &format!("ps{key}"), v, "", 60.0, 0) {
|
||
d.fields.insert(format!("p.{key}"), json!((n.max(0.0) as u64).min(max as u64)));
|
||
}
|
||
ui.end_row();
|
||
}
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Character Style:"));
|
||
let style = cur(d, "p.dropCapStyle", &pv["dropCapStyle"]).as_str().unwrap_or("").to_string();
|
||
let style = if style.is_empty() { designcraft_doc::NO_CHAR_STYLE.to_string() } else { style };
|
||
egui::ComboBox::from_id_salt("ps_dropcap_style")
|
||
.selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &style)))
|
||
.width(160.0)
|
||
.show_ui(ui, |ui| {
|
||
for c in &cnames {
|
||
if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, c))).clicked() {
|
||
d.fields.insert("p.dropCapStyle".into(), json!(c));
|
||
}
|
||
}
|
||
});
|
||
ui.end_row();
|
||
});
|
||
for (label, key) in [("Align Left Edge", "dropCapAlignLeft"), ("Scale for Descenders", "dropCapScaleDescenders")] {
|
||
let mut on = cur(d, &format!("p.{key}"), &pv[key]).as_bool().unwrap_or(false);
|
||
if ui.checkbox(&mut on, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).changed() {
|
||
d.fields.insert(format!("p.{key}"), json!(on));
|
||
}
|
||
}
|
||
ui.add_space(8.0);
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Nested Styles")).font(semibold(12.0)));
|
||
let mut list: Vec<Value> = cur(d, "p.nestedStyles", &pv["nestedStyles"]).as_array().cloned().unwrap_or_default();
|
||
let mut changed = false;
|
||
let mut remove = None;
|
||
for (i, ns) in list.iter_mut().enumerate() {
|
||
ui.horizontal(|ui| {
|
||
let style = ns["style"].as_str().unwrap_or("[None]").to_string();
|
||
egui::ComboBox::from_id_salt(("ns_style", i))
|
||
.selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &style)))
|
||
.width(110.0)
|
||
.show_ui(ui, |ui| {
|
||
for c in &cnames {
|
||
if ui
|
||
.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, c)))
|
||
.clicked()
|
||
{
|
||
ns["style"] = json!(c);
|
||
changed = true;
|
||
}
|
||
}
|
||
});
|
||
let through = ns["through"].as_bool().unwrap_or(true);
|
||
egui::ComboBox::from_id_salt(("ns_thr", i))
|
||
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, if through { "through" } else { "up to" })))
|
||
.width(70.0)
|
||
.show_ui(ui, |ui| {
|
||
for (v, l) in [(true, "through"), (false, "up to")] {
|
||
if ui.selectable_label(v == through, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, l))).clicked() {
|
||
ns["through"] = json!(v);
|
||
changed = true;
|
||
}
|
||
}
|
||
});
|
||
let mut n = ns["count"].as_u64().unwrap_or(1) as u32;
|
||
let mut count_value = |ui: &mut egui::Ui, ns: &mut Value, changed: &mut bool| {
|
||
if ui.add(egui::DragValue::new(&mut n).range(1..=999)).changed() {
|
||
ns["count"] = json!(n);
|
||
*changed = true;
|
||
}
|
||
};
|
||
if app.ui.language != "uk" {
|
||
count_value(ui, ns, &mut changed);
|
||
}
|
||
let kind = ns["until"]["kind"].as_str().unwrap_or("words").to_string();
|
||
egui::ComboBox::from_id_salt(("ns_until", i))
|
||
.selected_text(crate::rtl::widget(
|
||
ui,
|
||
if app.ui.language == "uk" {
|
||
format!("{}:", crate::i18n::tr("uk", &kind))
|
||
} else {
|
||
crate::i18n::tr(&app.ui.language, &kind).to_owned()
|
||
},
|
||
))
|
||
.width(100.0)
|
||
.show_ui(ui, |ui| {
|
||
for k in [
|
||
"sentences",
|
||
"words",
|
||
"characters",
|
||
"letters",
|
||
"digits",
|
||
"tab",
|
||
"forcedLineBreak",
|
||
"emSpace",
|
||
"enSpace",
|
||
"dropcap",
|
||
"chars",
|
||
] {
|
||
if ui.selectable_label(k == kind, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, k))).clicked() {
|
||
ns["until"] = if k == "chars" { json!({"kind": "chars", "chars": ":"}) } else { json!({"kind": k}) };
|
||
changed = true;
|
||
}
|
||
}
|
||
});
|
||
if app.ui.language == "uk" {
|
||
count_value(ui, ns, &mut changed);
|
||
}
|
||
if kind == "chars" {
|
||
let mut c = ns["until"]["chars"].as_str().unwrap_or("").to_string();
|
||
if ui.add(egui::TextEdit::singleline(&mut c).desired_width(40.0)).changed() {
|
||
ns["until"]["chars"] = json!(c);
|
||
changed = true;
|
||
}
|
||
}
|
||
if ui
|
||
.small_button("×")
|
||
.on_hover_ui(|ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Delete"));
|
||
})
|
||
.clicked()
|
||
{
|
||
remove = Some(i);
|
||
}
|
||
});
|
||
}
|
||
if let Some(i) = remove {
|
||
list.remove(i);
|
||
changed = true;
|
||
}
|
||
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Nested Style"))).clicked() {
|
||
list.push(json!({"style": cnames.get(1).cloned().unwrap_or_default(), "through": true, "count": 1, "until": {"kind": "words"}}));
|
||
changed = true;
|
||
}
|
||
if changed {
|
||
d.fields.insert("p.nestedStyles".into(), Value::Array(list));
|
||
}
|
||
ui.add_space(8.0);
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Nested Line Styles")).font(semibold(12.0)));
|
||
let mut lines: Vec<Value> = cur(d, "p.nestedLineStyles", &pv["nestedLineStyles"]).as_array().cloned().unwrap_or_default();
|
||
let mut changed = false;
|
||
let mut remove = None;
|
||
for (i, l) in lines.iter_mut().enumerate() {
|
||
ui.horizontal(|ui| {
|
||
let style = l["style"].as_str().unwrap_or("").to_string();
|
||
egui::ComboBox::from_id_salt(("nls_style", i))
|
||
.selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &style)))
|
||
.width(130.0)
|
||
.show_ui(ui, |ui| {
|
||
for c in &cnames {
|
||
if ui
|
||
.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, c)))
|
||
.clicked()
|
||
{
|
||
l["style"] = json!(c);
|
||
changed = true;
|
||
}
|
||
}
|
||
});
|
||
crate::rtl::label(
|
||
ui,
|
||
if app.ui.language == "uk" {
|
||
format!("{}:", crate::i18n::tr("uk", "lines"))
|
||
} else {
|
||
crate::i18n::tr(&app.ui.language, "for").to_owned()
|
||
},
|
||
);
|
||
let mut n = l["lines"].as_u64().unwrap_or(1) as u32;
|
||
if ui.add(egui::DragValue::new(&mut n).range(1..=999)).changed() {
|
||
l["lines"] = json!(n);
|
||
changed = true;
|
||
}
|
||
if app.ui.language != "uk" {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, if n == 1 { "line" } else { "lines" }));
|
||
}
|
||
if ui
|
||
.small_button("×")
|
||
.on_hover_ui(|ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Delete"));
|
||
})
|
||
.clicked()
|
||
{
|
||
remove = Some(i);
|
||
}
|
||
});
|
||
}
|
||
if let Some(i) = remove {
|
||
lines.remove(i);
|
||
changed = true;
|
||
}
|
||
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Line Style"))).clicked() {
|
||
lines.push(json!({"style": cnames.get(1).cloned().unwrap_or_default(), "lines": 1}));
|
||
changed = true;
|
||
}
|
||
if changed {
|
||
d.fields.insert("p.nestedLineStyles".into(), Value::Array(lines));
|
||
}
|
||
}
|
||
"grep" => {
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "GREP Styles")).font(semibold(12.0)));
|
||
let mut list: Vec<Value> = cur(d, "p.grepStyles", &pv["grepStyles"]).as_array().cloned().unwrap_or_default();
|
||
let mut changed = false;
|
||
let mut remove = None;
|
||
for (i, g) in list.iter_mut().enumerate() {
|
||
ui.horizontal(|ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Apply Style:"));
|
||
let style = g["style"].as_str().unwrap_or("").to_string();
|
||
egui::ComboBox::from_id_salt(("gs_style", i))
|
||
.selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &style)))
|
||
.width(110.0)
|
||
.show_ui(ui, |ui| {
|
||
for c in &cnames {
|
||
if ui
|
||
.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, c)))
|
||
.clicked()
|
||
{
|
||
g["style"] = json!(c);
|
||
changed = true;
|
||
}
|
||
}
|
||
});
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "To Text:"));
|
||
let mut pat = g["pattern"].as_str().unwrap_or("").to_string();
|
||
let bad = regex_ok(&pat).is_err();
|
||
let r = ui.add(
|
||
egui::TextEdit::singleline(&mut pat)
|
||
.desired_width(150.0)
|
||
.text_color_opt(bad.then_some(egui::Color32::from_rgb(240, 90, 90))),
|
||
);
|
||
if r.changed() {
|
||
g["pattern"] = json!(pat);
|
||
changed = true;
|
||
}
|
||
if ui
|
||
.small_button("×")
|
||
.on_hover_ui(|ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Delete"));
|
||
})
|
||
.clicked()
|
||
{
|
||
remove = Some(i);
|
||
}
|
||
});
|
||
}
|
||
if let Some(i) = remove {
|
||
list.remove(i);
|
||
changed = true;
|
||
}
|
||
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New GREP Style"))).clicked() {
|
||
list.push(json!({"style": cnames.get(1).cloned().unwrap_or_default(), "pattern": "\\d+"}));
|
||
changed = true;
|
||
}
|
||
if changed {
|
||
d.fields.insert("p.grepStyles".into(), Value::Array(list));
|
||
}
|
||
}
|
||
"chars" => basic_character_formats(app, ui, d, &CharFields { base: &cv, sparse: false }),
|
||
"indents" => {
|
||
egui::Grid::new("psi").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Alignment:"));
|
||
let a: designcraft_doc::Align = serde_json::from_value(cur(d, "p.align", &pv["align"])).unwrap_or_default();
|
||
egui::ComboBox::from_id_salt("psalign")
|
||
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, a.label())))
|
||
.width(240.0)
|
||
.show_ui(ui, |ui| {
|
||
for al in designcraft_doc::Align::ALL {
|
||
if ui.selectable_label(al == a, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, al.label()))).clicked() {
|
||
d.fields.insert("p.align".into(), serde_json::to_value(al).unwrap_or_default());
|
||
}
|
||
}
|
||
});
|
||
ui.end_row();
|
||
for (label, key) in [
|
||
("Left Indent:", "leftIndent"),
|
||
("First Line Indent:", "firstLineIndent"),
|
||
("Right Indent:", "rightIndent"),
|
||
("Space Before:", "spaceBefore"),
|
||
("Space After:", "spaceAfter"),
|
||
] {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label));
|
||
let v = cur(d, &format!("p.{key}"), &pv[key]).as_f64();
|
||
if let Some(n) = crate::widgets::measure(ui, &format!("ps{key}"), v, units, 80.0) {
|
||
d.fields.insert(format!("p.{key}"), json!(n));
|
||
}
|
||
ui.end_row();
|
||
}
|
||
});
|
||
}
|
||
"rules" => paragraph_rules(app, ui, d, "p.", &pv),
|
||
"hyph" => {
|
||
let mut h = cur(d, "p.hyphenate", &pv["hyphenate"]).as_bool().unwrap_or(true);
|
||
if ui.checkbox(&mut h, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Hyphenate"))).changed() {
|
||
d.fields.insert("p.hyphenate".into(), json!(h));
|
||
}
|
||
egui::Grid::new("psh").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
for (label, key) in [
|
||
("Words with at Least:", "hyphMinWord"),
|
||
("After First:", "hyphAfterFirst"),
|
||
("Before Last:", "hyphBeforeLast"),
|
||
("Hyphen Limit:", "hyphLimit"),
|
||
] {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label));
|
||
let v = cur(d, &format!("p.{key}"), &pv[key]).as_f64();
|
||
if let Some(n) = crate::widgets::number(ui, &format!("ps{key}"), v, "", 60.0, 0) {
|
||
d.fields.insert(format!("p.{key}"), json!(n.max(0.0) as u64));
|
||
}
|
||
ui.end_row();
|
||
}
|
||
});
|
||
}
|
||
"justify" => {
|
||
egui::Grid::new("psj").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
ui.label("");
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Minimum"));
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Desired"));
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Maximum"));
|
||
ui.end_row();
|
||
for (label, base) in [("Word Spacing:", "wordSpace"), ("Letter Spacing:", "letterSpace"), ("Glyph Scaling:", "glyphScale")] {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label));
|
||
for suffix in ["Min", "Desired", "Max"] {
|
||
let key = format!("{base}{suffix}");
|
||
let v = cur(d, &format!("p.{key}"), &pv[key.as_str()]).as_f64().map(|x| x * 100.0);
|
||
if let Some(n) = ui.scope(|ui| crate::widgets::number(ui, &format!("ps{key}"), v, "%", 60.0, 0)).inner {
|
||
d.fields.insert(format!("p.{key}"), json!(n / 100.0));
|
||
}
|
||
}
|
||
ui.end_row();
|
||
}
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Composer:"));
|
||
let (single, [paragraph, single_line]) =
|
||
crate::panels::properties::composer_choice(cur(d, "p.composer", &pv["composer"]).as_str());
|
||
if ui.selectable_label(!single, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Paragraph Composer"))).clicked() {
|
||
d.fields.insert("p.composer".into(), json!(paragraph));
|
||
}
|
||
if ui.selectable_label(single, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Single-line Composer"))).clicked() {
|
||
d.fields.insert("p.composer".into(), json!(single_line));
|
||
}
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Insert Kashidas:"));
|
||
let mut k = cur(d, "p.kashidas", &pv["kashidas"]).as_bool().unwrap_or(true);
|
||
if ui.checkbox(&mut k, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "In justified Arabic text"))).changed() {
|
||
d.fields.insert("p.kashidas".into(), json!(k));
|
||
}
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Korean Line Breaks:"));
|
||
let mut kb = cur(d, "p.koreanCharBreaks", &pv["koreanCharBreaks"]).as_bool().unwrap_or(false);
|
||
if ui
|
||
.checkbox(&mut kb, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Between syllables (not only at spaces)")))
|
||
.changed()
|
||
{
|
||
d.fields.insert("p.koreanCharBreaks".into(), json!(kb));
|
||
}
|
||
ui.end_row();
|
||
});
|
||
}
|
||
"color" => character_color(&app.ui.language, ui, d, &CharFields { base: &cv, sparse: false }, &doc),
|
||
_ => {
|
||
egui::Grid::new("psg").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Style Name:"));
|
||
if !d.fields.contains_key("rename") {
|
||
d.fields.insert("rename".into(), json!(name));
|
||
}
|
||
let mut rn = d.s("rename");
|
||
if ui.add(egui::TextEdit::singleline(&mut rn).desired_width(220.0)).changed() {
|
||
d.fields.insert("rename".into(), json!(rn));
|
||
}
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Based On:"));
|
||
let based = d
|
||
.fields
|
||
.get("basedOn")
|
||
.and_then(Value::as_str)
|
||
.map(str::to_string)
|
||
.or(style.based_on.clone())
|
||
.unwrap_or_else(|| "[No Paragraph Style]".into());
|
||
egui::ComboBox::from_id_salt("psbased")
|
||
.selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &based)))
|
||
.width(220.0)
|
||
.show_ui(ui, |ui| {
|
||
for n in &names {
|
||
if ui.selectable_label(*n == based, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n))).clicked() {
|
||
d.fields.insert("basedOn".into(), json!(n));
|
||
}
|
||
}
|
||
});
|
||
ui.end_row();
|
||
});
|
||
ui.add_space(8.0);
|
||
crate::rtl::label(
|
||
ui,
|
||
egui::RichText::new(format!(
|
||
"{} {} {:.1} pt · {}",
|
||
cp.font_family,
|
||
cp.font_style,
|
||
cp.size,
|
||
crate::i18n::tr(&app.ui.language, pp.align.label())
|
||
))
|
||
.color(crate::theme::Tokens::get(ui.ctx()).text_dim),
|
||
);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
fn delete_paragraph_style(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||
let name = d.s("name");
|
||
ui.set_min_width(380.0);
|
||
crate::rtl::label(
|
||
ui,
|
||
egui::RichText::new(
|
||
crate::i18n::tr(&app.ui.language, "Delete the paragraph style \"{name}\"?")
|
||
.replace("{name}", crate::i18n::style_name(&app.ui.language, &name)),
|
||
)
|
||
.size(12.5),
|
||
);
|
||
ui.add_space(8.0);
|
||
let Some(st) = app.session.active() else { return };
|
||
let in_use = st.doc.stories.values().any(|s| s.paras.iter().any(|f| f.style == name));
|
||
let names: Vec<String> = st.doc.styles.paragraph.iter().map(|p| p.name.clone()).filter(|n| *n != name).collect();
|
||
if in_use {
|
||
crate::rtl::label(
|
||
ui,
|
||
egui::RichText::new(crate::i18n::tr(&app.ui.language, "This style is in use. Text using it will be reassigned to:"))
|
||
.size(11.0)
|
||
.color(crate::theme::Tokens::get(ui.ctx()).text_dim),
|
||
);
|
||
} else {
|
||
crate::rtl::label(
|
||
ui,
|
||
egui::RichText::new(crate::i18n::tr(&app.ui.language, "Replace with:")).size(11.0).color(crate::theme::Tokens::get(ui.ctx()).text_dim),
|
||
);
|
||
}
|
||
ui.add_space(4.0);
|
||
if !d.fields.contains_key("replaceWith") {
|
||
d.fields.insert("replaceWith".into(), json!(designcraft_doc::BASIC_PARAGRAPH));
|
||
}
|
||
let replace = d.s("replaceWith");
|
||
egui::ComboBox::from_id_salt("del_para_replace")
|
||
.selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &replace)))
|
||
.width(260.0)
|
||
.show_ui(ui, |ui| {
|
||
for n in &names {
|
||
if ui.selectable_label(*n == replace, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n))).clicked() {
|
||
d.fields.insert("replaceWith".into(), json!(n));
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
fn section_list(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, sections: &[(&str, &str)]) {
|
||
ui.vertical(|ui| {
|
||
ui.set_width(170.0);
|
||
for (id, label) in sections {
|
||
if ui.selectable_label(d.s("section") == *id, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).clicked() {
|
||
d.fields.insert("section".into(), json!(id));
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/// "Paragraph Style n" / "Character Style n": the first name no style has.
|
||
fn next_style_name(styles: &designcraft_doc::Styles, para: bool) -> String {
|
||
let (base, count) = if para { ("Paragraph Style", styles.paragraph.len()) } else { ("Character Style", styles.character.len()) };
|
||
let taken = |n: &str| if para { styles.para(n).is_some() } else { styles.char_style(n).is_some() };
|
||
// Among count + 1 numbers at least one is free.
|
||
(1..=count + 1).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| format!("{base} {}", count + 1))
|
||
}
|
||
|
||
/// Paragraph or Character Style Options for an existing style (a double-click on it in the styles
|
||
/// panel, or Edit in its menu). [None] and [No Paragraph Style] have no options.
|
||
pub fn open_style_options(app: &mut DesignApp, para: bool, name: &str) {
|
||
if name == if para { designcraft_doc::NO_PARA_STYLE } else { designcraft_doc::NO_CHAR_STYLE } {
|
||
return;
|
||
}
|
||
let id = if para { "paragraphStyleOptions" } else { "characterStyleOptions" };
|
||
app.ui.dialog = Some(Dialog::new(id, json!({"name": name})));
|
||
}
|
||
|
||
/// New Paragraph Style… / New Character Style…: the style options for a style made on OK, named
|
||
/// with the next free "Paragraph Style n" / "Character Style n".
|
||
pub fn open_new_style(app: &mut DesignApp, para: bool) {
|
||
let Some(st) = app.session.active() else { return };
|
||
let name = next_style_name(&st.doc.styles, para);
|
||
let id = if para { "paragraphStyleOptions" } else { "characterStyleOptions" };
|
||
app.ui.dialog = Some(Dialog::new(id, json!({"new": true, "rename": name})));
|
||
}
|
||
|
||
/// Character Style Options: General, then the character sections it shares with Paragraph Style
|
||
/// Options. A character style sets only some attributes: the fields show those it and its Based
|
||
/// On set and leave the rest blank. Edits are `c.<attr>` fields (`null` = no longer set), applied
|
||
/// on OK; `new` makes the style instead of editing `name`.
|
||
fn character_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||
let name = d.s("name");
|
||
let new = d.b("new");
|
||
let Some(doc) = app.session.active().map(|st| st.doc.clone()) else { return };
|
||
let styles = &doc.styles;
|
||
let style = match styles.char_style(&name) {
|
||
_ if new => designcraft_doc::CharacterStyle { name: String::new(), based_on: None, chars: Default::default(), shortcut: String::new() },
|
||
Some(s) if name != designcraft_doc::NO_CHAR_STYLE => s.clone(),
|
||
_ => {
|
||
crate::rtl::label(ui, format!("No style named {name}"));
|
||
return;
|
||
}
|
||
};
|
||
if !d.fields.contains_key("rename") {
|
||
d.fields.insert("rename".into(), json!(if new { next_style_name(styles, false) } else { name.clone() }));
|
||
}
|
||
let based = d
|
||
.fields
|
||
.get("basedOn")
|
||
.and_then(Value::as_str)
|
||
.map(str::to_string)
|
||
.or(style.based_on.clone())
|
||
.unwrap_or_else(|| designcraft_doc::NO_CHAR_STYLE.into());
|
||
// The fields show the style's own attributes over those its Based On gives it.
|
||
let mut shown = styles.char_style_attrs(&based);
|
||
shown.merge(&style.chars);
|
||
let base = serde_json::to_value(&shown).unwrap_or_default();
|
||
let cf = CharFields { base: &base, sparse: true };
|
||
let lang = app.ui.language.clone();
|
||
ui.set_min_width(560.0);
|
||
ui.horizontal_top(|ui| {
|
||
// A fixed height: the separator would otherwise stretch the dialog to the window.
|
||
ui.set_min_height(380.0);
|
||
ui.set_max_height(380.0);
|
||
section_list(
|
||
&lang,
|
||
ui,
|
||
d,
|
||
&[
|
||
("general", "General"),
|
||
("chars", "Basic Character Formats"),
|
||
("advanced", "Advanced Character Formats"),
|
||
("color", "Character Color"),
|
||
("openType", "OpenType Features"),
|
||
("underline", "Underline Options"),
|
||
("strikethrough", "Strikethrough Options"),
|
||
],
|
||
);
|
||
ui.separator();
|
||
ui.vertical(|ui| match d.s("section").as_str() {
|
||
"chars" => basic_character_formats(app, ui, d, &cf),
|
||
"advanced" => advanced_character_formats(&lang, ui, d, &cf),
|
||
"color" => character_color(&lang, ui, d, &cf, &doc),
|
||
"openType" => open_type_features(&lang, ui, d, &cf),
|
||
"underline" => line_options(&lang, ui, d, &cf, &doc, "underline"),
|
||
"strikethrough" => line_options(&lang, ui, d, &cf, &doc, "strikethrough"),
|
||
_ => {
|
||
egui::Grid::new("csg").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&lang, "Style Name:"));
|
||
text_field(ui, d, "rename", 220.0);
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&lang, "Based On:"));
|
||
egui::ComboBox::from_id_salt("csbased")
|
||
.selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&lang, &based)))
|
||
.width(220.0)
|
||
.show_ui(ui, |ui| {
|
||
// Not itself, nor a style based on it.
|
||
for s in styles.character.iter().filter(|s| new || !styles.char_based_on_cycles(&name, &s.name)) {
|
||
if ui.selectable_label(s.name == based, crate::rtl::widget(ui, crate::i18n::style_name(&lang, &s.name))).clicked() {
|
||
d.fields.insert("basedOn".into(), json!(s.name));
|
||
}
|
||
}
|
||
});
|
||
ui.end_row();
|
||
});
|
||
let status = d.s("status");
|
||
if !status.is_empty() {
|
||
crate::rtl::label(ui, egui::RichText::new(status).color(crate::theme::Tokens::get(ui.ctx()).text_strong));
|
||
}
|
||
ui.add_space(8.0);
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Style Settings:")).font(semibold(12.0)));
|
||
// What OK would leave the style setting, after its Based On.
|
||
let mut own = serde_json::to_value(&style.chars).unwrap_or_default();
|
||
if let Some(o) = own.as_object_mut() {
|
||
for (k, v) in d.fields.iter().filter_map(|(k, v)| Some((k.strip_prefix("c.")?, v))) {
|
||
if v.is_null() {
|
||
o.remove(k);
|
||
} else {
|
||
o.insert(k.to_string(), v.clone());
|
||
}
|
||
}
|
||
}
|
||
let mut parts = vec![crate::i18n::style_name(&lang, &based).to_string()];
|
||
parts.extend(own.as_object().into_iter().flatten().map(|(k, v)| format!("{}: {}", setting_label(&lang, k), setting_text(v))));
|
||
ui.add(egui::Label::new(egui::RichText::new(parts.join(" + ")).color(crate::theme::Tokens::get(ui.ctx()).text_dim)).wrap());
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
/// An attribute's name as Style Settings shows it: the field's label where the name reads poorly.
|
||
fn setting_label(lang: &str, key: &str) -> String {
|
||
match key {
|
||
"hScale" => crate::i18n::tr(lang, "Horizontal Scale:").trim_end_matches([':', ':']).to_string(),
|
||
"vScale" => crate::i18n::tr(lang, "Vertical Scale:").trim_end_matches([':', ':']).to_string(),
|
||
"capitalization" => crate::i18n::tr(lang, "Case:").trim_end_matches([':', ':']).to_string(),
|
||
"otfFeatures" => crate::i18n::tr(lang, "OpenType Features").to_string(),
|
||
_ => crate::i18n::tr(lang, &humanize(key)).to_string(),
|
||
}
|
||
}
|
||
|
||
/// An attribute value as Style Settings shows it.
|
||
fn setting_text(v: &Value) -> String {
|
||
match v {
|
||
Value::String(s) => s.clone(),
|
||
Value::Number(n) => n.as_f64().map_or_else(|| n.to_string(), |x| format!("{}", (x * 1000.0).round() / 1000.0)),
|
||
Value::Bool(b) => if *b { "on" } else { "off" }.into(),
|
||
Value::Array(a) => a.iter().map(setting_text).collect::<Vec<_>>().join(", "),
|
||
Value::Object(o) => o.get("value").or_else(|| o.get("kind")).map(setting_text).unwrap_or_default(),
|
||
Value::Null => String::new(),
|
||
}
|
||
}
|
||
|
||
/// Where the character sections of the style options dialogs get the values they show: `base`
|
||
/// holds them by attribute name (resolved values for a paragraph style; for a character style the
|
||
/// attributes it sets, the others absent). `sparse` (a character style) lets a field be blank:
|
||
/// emptying it, or picking the blank entry, unsets the attribute.
|
||
struct CharFields<'a> {
|
||
base: &'a Value,
|
||
sparse: bool,
|
||
}
|
||
|
||
impl CharFields<'_> {
|
||
/// The value shown for attribute `key`: the dialog's edit, else the base value.
|
||
fn get(&self, d: &Dialog, key: &str) -> Value {
|
||
d.fields.get(&format!("c.{key}")).cloned().unwrap_or_else(|| self.base.get(key).cloned().unwrap_or(Value::Null))
|
||
}
|
||
fn set(&self, d: &mut Dialog, key: &str, v: Value) {
|
||
d.fields.insert(format!("c.{key}"), v);
|
||
}
|
||
}
|
||
|
||
/// A number attribute shown multiplied by `scale` (100 for a fraction shown as a percentage).
|
||
fn char_number(ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, suffix: &str, scale: f64, range: (f64, f64)) {
|
||
let v = cf.get(d, key).as_f64().map(|x| x * scale);
|
||
let field = crate::widgets::NumField::number(key, v, suffix, 2).width(80.0).range(range.0, range.1);
|
||
let edit = if cf.sparse { field.show_or_clear(ui) } else { field.show(ui).map(Some) };
|
||
if let Some(n) = edit {
|
||
cf.set(d, key, n.map_or(Value::Null, |n| json!(n / scale)));
|
||
}
|
||
}
|
||
|
||
/// An attribute chosen from `opts` (value, label); a character style also has a blank entry.
|
||
fn char_choice(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, opts: &[(Value, &str)]) {
|
||
let cur = cf.get(d, key);
|
||
let shown = opts.iter().find(|o| o.0 == cur).map_or("", |o| crate::i18n::tr(lang, o.1));
|
||
egui::ComboBox::from_id_salt(("cs", key)).selected_text(crate::rtl::widget(ui, shown)).width(200.0).show_ui(ui, |ui| {
|
||
if cf.sparse && ui.selectable_label(cur.is_null(), " ").clicked() {
|
||
cf.set(d, key, Value::Null);
|
||
}
|
||
for (v, label) in opts {
|
||
if ui.selectable_label(*v == cur, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).clicked() {
|
||
cf.set(d, key, v.clone());
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/// An on/off attribute; for a character style a third, mixed state leaves it unset (clicks go
|
||
/// unset → on → off → unset).
|
||
fn char_check(ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, label: &str) {
|
||
let cur = cf.get(d, key);
|
||
let mut on = cur.as_bool().unwrap_or(false);
|
||
let unset = cf.sparse && cur.is_null();
|
||
if ui.add(egui::Checkbox::new(&mut on, crate::rtl::widget(ui, label)).indeterminate(unset)).clicked() {
|
||
let next = match cur.as_bool() {
|
||
_ if !cf.sparse => json!(on),
|
||
None => json!(true),
|
||
Some(true) => json!(false),
|
||
Some(false) => Value::Null,
|
||
};
|
||
cf.set(d, key, next);
|
||
}
|
||
}
|
||
|
||
/// A swatch attribute from a menu; `text_color` names the "" entry (the text's own colour).
|
||
fn char_swatch(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, doc: &designcraft_doc::Document, key: &str, text_color: Option<&str>) {
|
||
let mut opts: Vec<(Value, &str)> = text_color.map(|l| (json!(""), l)).into_iter().collect();
|
||
opts.extend(doc.swatches.iter().map(|w| (json!(w.name), w.name.as_str())));
|
||
char_choice(lang, ui, d, cf, key, &opts);
|
||
}
|
||
|
||
/// Basic Character Formats: font, size, leading, kerning, tracking, case, position and the
|
||
/// underline, strikethrough, ligatures and no-break switches.
|
||
fn basic_character_formats(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields) {
|
||
let lang = app.ui.language.clone();
|
||
let lang = lang.as_str();
|
||
let fonts = crate::panels::fonts(app);
|
||
let menu = crate::panels::font_menu(app);
|
||
egui::Grid::new("psc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(lang, "Font Family:"));
|
||
let fam = cf.get(d, "fontFamily").as_str().unwrap_or("").to_string();
|
||
let shown = if fam.is_empty() { "—".to_string() } else { crate::panels::font_label(app, &menu, &fam) };
|
||
egui::ComboBox::from_id_salt("psfam")
|
||
.selected_text(shown)
|
||
.width(200.0)
|
||
.height(440.0)
|
||
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
|
||
.show_ui(ui, |ui| {
|
||
if cf.sparse && ui.selectable_label(fam.is_empty(), " ").clicked() {
|
||
cf.set(d, "fontFamily", Value::Null);
|
||
}
|
||
if let Some(f) = crate::panels::font_menu_body(app, ui, &menu, &fam, 200.0) {
|
||
cf.set(d, "fontFamily", json!(f));
|
||
}
|
||
});
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(lang, "Font Style:"));
|
||
let sty = cf.get(d, "fontStyle").as_str().unwrap_or("").to_string();
|
||
if cf.sparse && fam.is_empty() {
|
||
// No family to list styles of: type the style ("Italic" in whatever font the text has).
|
||
let mut s = sty.clone();
|
||
if ui.add(egui::TextEdit::singleline(&mut s).desired_width(200.0)).changed() {
|
||
cf.set(d, "fontStyle", if s.trim().is_empty() { Value::Null } else { json!(s) });
|
||
}
|
||
} else {
|
||
egui::ComboBox::from_id_salt("pssty").selected_text(&sty).width(200.0).show_ui(ui, |ui| {
|
||
if cf.sparse && ui.selectable_label(sty.is_empty(), " ").clicked() {
|
||
cf.set(d, "fontStyle", Value::Null);
|
||
}
|
||
for s in fonts.styles(&fam) {
|
||
if ui.selectable_label(s == sty, &s).clicked() {
|
||
cf.set(d, "fontStyle", json!(s));
|
||
}
|
||
}
|
||
});
|
||
}
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(lang, "Size:"));
|
||
char_number(ui, d, cf, "size", " pt", 1.0, (0.1, 1296.0));
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(lang, "Leading:"));
|
||
let lv = match cf.get(d, "leading") {
|
||
v if v["kind"] == "points" => v["value"].as_f64(),
|
||
_ => None,
|
||
};
|
||
let field = crate::widgets::NumField::number("pslead", lv, " pt", 2).width(80.0).range(0.0, 5000.0);
|
||
let edit = if cf.sparse { field.show_or_clear(ui) } else { field.show(ui).map(Some) };
|
||
if let Some(n) = edit {
|
||
cf.set(d, "leading", n.map_or(Value::Null, |n| json!({"kind": "points", "value": n})));
|
||
}
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(lang, "Kerning:"));
|
||
char_choice(
|
||
lang,
|
||
ui,
|
||
d,
|
||
cf,
|
||
"kerning",
|
||
&[(json!({"kind": "metrics"}), "Metrics"), (json!({"kind": "optical"}), "Optical"), (json!({"kind": "none"}), "0")],
|
||
);
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(lang, "Tracking:"));
|
||
char_number(ui, d, cf, "tracking", "", 1.0, (-1000.0, 10000.0));
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(lang, "Case:"));
|
||
char_choice(
|
||
lang,
|
||
ui,
|
||
d,
|
||
cf,
|
||
"capitalization",
|
||
&[
|
||
(json!("normal"), "Normal"),
|
||
(json!("allCaps"), "All Caps"),
|
||
(json!("smallCaps"), "Small Caps"),
|
||
(json!("openTypeAllSmallCaps"), "OpenType All Small Caps"),
|
||
],
|
||
);
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(lang, "Position:"));
|
||
char_choice(
|
||
lang,
|
||
ui,
|
||
d,
|
||
cf,
|
||
"position",
|
||
&[
|
||
(json!("normal"), "Normal"),
|
||
(json!("superscript"), "Superscript"),
|
||
(json!("subscript"), "Subscript"),
|
||
(json!("otSuperscript"), "OpenType Superscript"),
|
||
(json!("otSubscript"), "OpenType Subscript"),
|
||
(json!("otNumerator"), "OpenType Numerator"),
|
||
(json!("otDenominator"), "OpenType Denominator"),
|
||
],
|
||
);
|
||
ui.end_row();
|
||
});
|
||
ui.add_space(4.0);
|
||
egui::Grid::new("pscb").num_columns(2).spacing([16.0, 4.0]).show(ui, |ui| {
|
||
char_check(ui, d, cf, "underline", crate::i18n::tr(lang, "Underline"));
|
||
char_check(ui, d, cf, "ligatures", crate::i18n::tr(lang, "Ligatures"));
|
||
ui.end_row();
|
||
char_check(ui, d, cf, "strikethrough", crate::i18n::tr(lang, "Strikethrough"));
|
||
char_check(ui, d, cf, "noBreak", crate::i18n::tr(lang, "No Break"));
|
||
ui.end_row();
|
||
});
|
||
}
|
||
|
||
/// Advanced Character Formats: scaling, baseline shift, skew and language.
|
||
fn advanced_character_formats(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields) {
|
||
egui::Grid::new("csa").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(lang, "Horizontal Scale:"));
|
||
char_number(ui, d, cf, "hScale", "%", 100.0, (1.0, 1000.0));
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(lang, "Vertical Scale:"));
|
||
char_number(ui, d, cf, "vScale", "%", 100.0, (1.0, 1000.0));
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(lang, "Baseline Shift:"));
|
||
char_number(ui, d, cf, "baselineShift", " pt", 1.0, (-5000.0, 5000.0));
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(lang, "Skew:"));
|
||
char_number(ui, d, cf, "skew", "°", 1.0, (-85.0, 85.0));
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(lang, "Language:"));
|
||
let opts: Vec<(Value, &str)> = crate::panels::properties::LANGUAGES.iter().map(|l| (json!(l), *l)).collect();
|
||
char_choice(lang, ui, d, cf, "language", &opts);
|
||
ui.end_row();
|
||
});
|
||
}
|
||
|
||
/// Character Color: the fill or stroke swatch, its tint and the stroke weight.
|
||
fn character_color(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, doc: &designcraft_doc::Document) {
|
||
let stroke = d.s("colorTarget") == "stroke";
|
||
ui.horizontal(|ui| {
|
||
for (target, label) in [("fill", "Fill"), ("stroke", "Stroke")] {
|
||
if ui.selectable_label((target == "stroke") == stroke, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).clicked() {
|
||
d.fields.insert("colorTarget".into(), json!(target));
|
||
}
|
||
}
|
||
});
|
||
let key = if stroke { "stroke" } else { "fill" };
|
||
let cur = cf.get(d, key).as_str().unwrap_or("").to_string();
|
||
egui::ScrollArea::vertical().id_salt(("cscolor", key)).max_height(220.0).show(ui, |ui| {
|
||
for sw in &doc.swatches {
|
||
let (c, g) = crate::widgets::swatch_colors(doc, &sw.name, 1.0);
|
||
ui.horizontal(|ui| {
|
||
let (r, _) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::hover());
|
||
crate::widgets::paint_chip(ui.painter(), r, c, g);
|
||
if ui.selectable_label(sw.name == cur, &sw.name).clicked() {
|
||
// A character style's chosen swatch, clicked again, is no longer set.
|
||
cf.set(d, key, if cf.sparse && sw.name == cur { Value::Null } else { json!(sw.name) });
|
||
}
|
||
});
|
||
}
|
||
});
|
||
egui::Grid::new("cscg").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(lang, "Tint:"));
|
||
char_number(ui, d, cf, if stroke { "strokeTint" } else { "fillTint" }, "%", 100.0, (0.0, 100.0));
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(lang, "Weight:"));
|
||
char_number(ui, d, cf, "strokeWeight", " pt", 1.0, (0.0, 800.0));
|
||
ui.end_row();
|
||
});
|
||
}
|
||
|
||
/// OpenType Features: the feature switches, figure style and stylistic sets (one `otfFeatures`
|
||
/// list: changing any feature sets the whole list).
|
||
fn open_type_features(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields) {
|
||
use designcraft_doc::otf;
|
||
let mut list: Vec<String> =
|
||
cf.get(d, "otfFeatures").as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default();
|
||
let mut changed = false;
|
||
egui::Grid::new("csot").num_columns(2).spacing([16.0, 4.0]).show(ui, |ui| {
|
||
for (i, (tag, label, _)) in otf::TOGGLES.iter().enumerate() {
|
||
let mut on = otf::is_on(&list, tag);
|
||
if ui.checkbox(&mut on, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).changed() {
|
||
otf::set(&mut list, tag, on);
|
||
changed = true;
|
||
}
|
||
if i % 2 == 1 {
|
||
ui.end_row();
|
||
}
|
||
}
|
||
});
|
||
ui.add_space(6.0);
|
||
ui.horizontal(|ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(lang, "Figure Style:"));
|
||
let fig = otf::figures(&list);
|
||
let shown = otf::FIGURES.iter().find(|f| f.0 == fig).map_or("", |f| f.1);
|
||
egui::ComboBox::from_id_salt("csfig").selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, shown))).width(180.0).show_ui(ui, |ui| {
|
||
for (id, label, ..) in otf::FIGURES {
|
||
if ui.selectable_label(fig == *id, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).clicked() && otf::set_figures(&mut list, id)
|
||
{
|
||
changed = true;
|
||
}
|
||
}
|
||
});
|
||
});
|
||
ui.add_space(4.0);
|
||
crate::rtl::label(ui, crate::i18n::tr(lang, "Stylistic Sets"));
|
||
let mask = otf::stylistic_sets(&list);
|
||
egui::Grid::new("csss").num_columns(10).spacing([2.0, 2.0]).show(ui, |ui| {
|
||
for n in 1..=20u32 {
|
||
let bit = 1u32 << (n - 1);
|
||
if ui.add_sized([28.0, 20.0], egui::Button::selectable(mask & bit != 0, format!("{n}"))).clicked() {
|
||
otf::set_stylistic_sets(&mut list, mask ^ bit);
|
||
changed = true;
|
||
}
|
||
if n % 10 == 0 {
|
||
ui.end_row();
|
||
}
|
||
}
|
||
});
|
||
if changed {
|
||
cf.set(d, "otfFeatures", json!(list));
|
||
}
|
||
}
|
||
|
||
/// Underline Options / Strikethrough Options (`key` is `underline` or `strikethrough`).
|
||
fn line_options(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, doc: &designcraft_doc::Document, key: &str) {
|
||
char_check(ui, d, cf, key, crate::i18n::tr(lang, if key == "underline" { "Underline On" } else { "Strikethrough On" }));
|
||
ui.add_space(4.0);
|
||
egui::Grid::new(("csline", key)).num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(lang, "Weight:"));
|
||
char_number(ui, d, cf, &format!("{key}Weight"), " pt", 1.0, (0.0, 800.0));
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(lang, "Offset:"));
|
||
char_number(ui, d, cf, &format!("{key}Offset"), " pt", 1.0, (-800.0, 800.0));
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(lang, "Color:"));
|
||
char_swatch(lang, ui, d, cf, doc, &format!("{key}Color"), Some("(Text Color)"));
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(lang, "Tint:"));
|
||
char_number(ui, d, cf, &format!("{key}Tint"), "%", 100.0, (0.0, 100.0));
|
||
ui.end_row();
|
||
});
|
||
}
|
||
|
||
fn delete_character_style(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||
let name = d.s("name");
|
||
ui.set_min_width(380.0);
|
||
crate::rtl::label(
|
||
ui,
|
||
egui::RichText::new(
|
||
crate::i18n::tr(&app.ui.language, "Delete the character style \"{name}\"?")
|
||
.replace("{name}", crate::i18n::style_name(&app.ui.language, &name)),
|
||
)
|
||
.size(12.5),
|
||
);
|
||
ui.add_space(8.0);
|
||
let Some(st) = app.session.active() else { return };
|
||
let in_use = st.doc.stories.values().any(|s| s.chars.iter().any(|r| r.format.style == name));
|
||
let names: Vec<String> = st.doc.styles.character.iter().map(|c| c.name.clone()).filter(|n| *n != name).collect();
|
||
if in_use {
|
||
crate::rtl::label(
|
||
ui,
|
||
egui::RichText::new(crate::i18n::tr(&app.ui.language, "This style is in use. Text using it will be reassigned to:"))
|
||
.size(11.0)
|
||
.color(crate::theme::Tokens::get(ui.ctx()).text_dim),
|
||
);
|
||
} else {
|
||
crate::rtl::label(
|
||
ui,
|
||
egui::RichText::new(crate::i18n::tr(&app.ui.language, "Replace with:")).size(11.0).color(crate::theme::Tokens::get(ui.ctx()).text_dim),
|
||
);
|
||
}
|
||
ui.add_space(4.0);
|
||
if !d.fields.contains_key("replaceWith") {
|
||
d.fields.insert("replaceWith".into(), json!(designcraft_doc::NO_CHAR_STYLE));
|
||
}
|
||
let replace = d.s("replaceWith");
|
||
egui::ComboBox::from_id_salt("del_char_replace")
|
||
.selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &replace)))
|
||
.width(260.0)
|
||
.show_ui(ui, |ui| {
|
||
for n in [designcraft_doc::NO_CHAR_STYLE].into_iter().chain(names.iter().map(String::as_str)) {
|
||
if ui.selectable_label(n == replace, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n))).clicked() {
|
||
d.fields.insert("replaceWith".into(), json!(n));
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
fn combo(ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) {
|
||
let cur = d.s(key);
|
||
let shown = opts.iter().find(|o| o.0 == cur).map_or(cur.as_str(), |o| o.1).to_string();
|
||
egui::ComboBox::from_id_salt(key).selected_text(crate::rtl::widget(ui, &shown)).width(170.0).show_ui(ui, |ui| {
|
||
for (v, label) in opts {
|
||
if ui.selectable_label(cur == *v, crate::rtl::widget(ui, *label)).clicked() {
|
||
d.fields.insert(key.into(), json!(v));
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Translate enum captions without changing serialized values or user-defined names.
|
||
fn translated_combo(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) {
|
||
let opts: Vec<_> = opts.iter().map(|(value, label)| (*value, crate::i18n::tr(lang, label))).collect();
|
||
combo(ui, d, key, &opts);
|
||
}
|
||
|
||
/// Style name picker (paragraph or character styles of the active document).
|
||
fn style_combo(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, key: &str, character: bool) {
|
||
let names: Vec<String> = app
|
||
.session
|
||
.active()
|
||
.map(|st| {
|
||
if character {
|
||
st.doc.styles.character.iter().map(|s| s.name.clone()).collect()
|
||
} else {
|
||
st.doc.styles.paragraph.iter().map(|s| s.name.clone()).collect()
|
||
}
|
||
})
|
||
.unwrap_or_default();
|
||
let mut opts: Vec<(&str, &str)> = names.iter().map(|n| (n.as_str(), crate::i18n::style_name(&app.ui.language, n))).collect();
|
||
if character && !names.iter().any(|n| n == designcraft_doc::NO_CHAR_STYLE) {
|
||
opts.insert(0, (designcraft_doc::NO_CHAR_STYLE, crate::i18n::style_name(&app.ui.language, designcraft_doc::NO_CHAR_STYLE)));
|
||
}
|
||
combo(ui, d, key, &opts);
|
||
}
|
||
|
||
const RULES: [(&str, &str); 2] = [("ruleAbove", "Rule Above"), ("ruleBelow", "Rule Below")];
|
||
|
||
/// A rule as the dialog shows it: `current[which]` (the resolved rule, inherited values included)
|
||
/// with the fields edited in the dialog (`{prefix}{which}.{field}`) on top.
|
||
fn shown_rule(d: &Dialog, prefix: &str, which: &str, current: &Value) -> Value {
|
||
let mut r = match current.get(which) {
|
||
Some(Value::Object(m)) => m.clone(),
|
||
_ => Map::new(),
|
||
};
|
||
let edited = format!("{prefix}{which}.");
|
||
for (k, v) in &d.fields {
|
||
if let Some(field) = k.strip_prefix(&edited) {
|
||
r.insert(field.into(), v.clone());
|
||
}
|
||
}
|
||
Value::Object(r)
|
||
}
|
||
|
||
/// The rule fields edited in the dialog, as `type.para` attrs: `{ruleAbove?: {…}, ruleBelow?: {…}}`.
|
||
fn rule_edits(d: &Dialog, prefix: &str) -> Map<String, Value> {
|
||
let mut out = Map::new();
|
||
for (which, _) in RULES {
|
||
let edited = format!("{prefix}{which}.");
|
||
let fields: Map<String, Value> = d.fields.iter().filter_map(|(k, v)| k.strip_prefix(&edited).map(|f| (f.to_string(), v.clone()))).collect();
|
||
if !fields.is_empty() {
|
||
out.insert(which.into(), Value::Object(fields));
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Paragraph Rules (the dialog, and the Paragraph Style Options section): Rule Above or Rule Below,
|
||
/// chosen at the top. `current` holds the resolved `ruleAbove` / `ruleBelow`; each edit is stored as
|
||
/// `{prefix}{rule}.{field}`, so OK changes only the fields edited.
|
||
fn paragraph_rules(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, prefix: &str, current: &Value) {
|
||
let lang = app.ui.language.as_str();
|
||
let (swatches, h_units, v_units) = app
|
||
.session
|
||
.active()
|
||
.map(|st| {
|
||
let sw: Vec<String> = st.doc.swatches.iter().filter(|w| !w.hidden).map(|w| w.name.clone()).collect();
|
||
(sw, st.doc.settings.horizontal_units, st.doc.settings.vertical_units)
|
||
})
|
||
.unwrap_or((Vec::new(), Unit::Points, Unit::Points));
|
||
let which = if d.s("rule") == "ruleBelow" { "ruleBelow" } else { "ruleAbove" };
|
||
let rule = shown_rule(d, prefix, which, current);
|
||
let key = |field: &str| format!("{prefix}{which}.{field}");
|
||
// A number field takes the rest of the row; keep its grid column to the field's width.
|
||
let sized = |ui: &mut egui::Ui, add: &mut dyn FnMut(&mut egui::Ui) -> Option<f64>| {
|
||
ui.allocate_ui_with_layout(egui::vec2(80.0, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| add(ui)).inner
|
||
};
|
||
ui.horizontal(|ui| {
|
||
let shown = RULES.iter().find(|r| r.0 == which).map_or(which, |r| r.1);
|
||
egui::ComboBox::from_id_salt("rule_which").selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, shown))).width(130.0).show_ui(
|
||
ui,
|
||
|ui| {
|
||
for (v, label) in RULES {
|
||
if ui.selectable_label(v == which, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).clicked() {
|
||
d.fields.insert("rule".into(), json!(v));
|
||
}
|
||
}
|
||
},
|
||
);
|
||
ui.add_space(12.0);
|
||
let mut on = rule["on"].as_bool().unwrap_or(false);
|
||
if ui.checkbox(&mut on, crate::rtl::widget(ui, crate::i18n::tr(lang, "Rule On"))).changed() {
|
||
d.fields.insert(key("on"), json!(on));
|
||
}
|
||
});
|
||
ui.add_space(6.0);
|
||
let on = rule["on"].as_bool().unwrap_or(false);
|
||
ui.add_enabled_ui(on, |ui| {
|
||
egui::Grid::new(("rules", which)).num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(lang, "Weight:"));
|
||
if let Some(v) = sized(ui, &mut |ui| crate::widgets::number(ui, &key("weight"), rule["weight"].as_f64(), " pt", 80.0, 2)) {
|
||
d.fields.insert(key("weight"), json!(v.clamp(0.0, 1000.0)));
|
||
}
|
||
crate::rtl::label(ui, crate::i18n::tr(lang, "Color:"));
|
||
let color = rule["color"].as_str().unwrap_or("").to_string();
|
||
egui::ComboBox::from_id_salt(("rule_color", which)).selected_text(&color).width(130.0).show_ui(ui, |ui| {
|
||
for w in &swatches {
|
||
let (c, g) = app.session.active().map_or((None, None), |st| crate::widgets::swatch_colors(&st.doc, w, 1.0));
|
||
ui.horizontal(|ui| {
|
||
let (r, _) = ui.allocate_exact_size(egui::vec2(12.0, 12.0), egui::Sense::hover());
|
||
crate::widgets::paint_chip(ui.painter(), r, c, g);
|
||
if ui.selectable_label(*w == color, w).clicked() {
|
||
d.fields.insert(key("color"), json!(w));
|
||
}
|
||
});
|
||
}
|
||
});
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(lang, "Tint:"));
|
||
if let Some(v) = sized(ui, &mut |ui| crate::widgets::number(ui, &key("tint"), rule["tint"].as_f64().map(|t| t * 100.0), "%", 80.0, 0)) {
|
||
d.fields.insert(key("tint"), json!((v / 100.0).clamp(0.0, 1.0)));
|
||
}
|
||
crate::rtl::label(ui, crate::i18n::tr(lang, "Width:"));
|
||
let column = rule["columnWidth"].as_bool().unwrap_or(true);
|
||
egui::ComboBox::from_id_salt(("rule_width", which))
|
||
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, if column { "Column" } else { "Text" })))
|
||
.width(130.0)
|
||
.show_ui(ui, |ui| {
|
||
for (v, label) in [(true, "Column"), (false, "Text")] {
|
||
if ui.selectable_label(v == column, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).clicked() {
|
||
d.fields.insert(key("columnWidth"), json!(v));
|
||
}
|
||
}
|
||
});
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(lang, "Offset:"));
|
||
if let Some(v) = sized(ui, &mut |ui| crate::widgets::measure(ui, &key("offset"), rule["offset"].as_f64(), v_units, 80.0)) {
|
||
d.fields.insert(key("offset"), json!(v));
|
||
}
|
||
ui.end_row();
|
||
for (label, field) in [("Left Indent:", "leftIndent"), ("Right Indent:", "rightIndent")] {
|
||
crate::rtl::label(ui, crate::i18n::tr(lang, label));
|
||
if let Some(v) = sized(ui, &mut |ui| crate::widgets::measure(ui, &key(field), rule[field].as_f64(), h_units, 80.0)) {
|
||
d.fields.insert(key(field), json!(v));
|
||
}
|
||
}
|
||
ui.end_row();
|
||
});
|
||
});
|
||
}
|
||
|
||
/// Document Footnote Options: Numbering and Formatting / Layout tabs.
|
||
fn footnote_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||
ui.horizontal(|ui| {
|
||
for (tab, label) in [("numbering", "Numbering and Formatting"), ("layout", "Layout")] {
|
||
if ui.selectable_label(d.s("tab") == tab, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() {
|
||
d.fields.insert("tab".into(), json!(tab));
|
||
}
|
||
}
|
||
});
|
||
ui.separator();
|
||
let head = |ui: &mut egui::Ui, t: &str| {
|
||
ui.add_space(4.0);
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, t)).font(semibold(12.0)));
|
||
};
|
||
if d.s("tab") == "layout" {
|
||
head(ui, "Spacing Options");
|
||
egui::Grid::new("fn_sp").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Minimum Space Before First Footnote:"));
|
||
text_field(ui, d, "spaceBefore", 70.0);
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Space Between Footnotes:"));
|
||
text_field(ui, d, "spaceBetween", 70.0);
|
||
ui.end_row();
|
||
});
|
||
head(ui, "First Baseline");
|
||
egui::Grid::new("fn_fb").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Offset:"));
|
||
translated_combo(
|
||
&app.ui.language,
|
||
ui,
|
||
d,
|
||
"firstBaseline",
|
||
&[("ascent", "Ascent"), ("capHeight", "Cap Height"), ("leading", "Leading"), ("xHeight", "x Height"), ("fixed", "Fixed")],
|
||
);
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Min:"));
|
||
text_field(ui, d, "firstBaselineMin", 70.0);
|
||
ui.end_row();
|
||
});
|
||
check(ui, d, "spanColumns", crate::i18n::tr(&app.ui.language, "Span Footnotes Across Columns"));
|
||
head(ui, "Rule Above");
|
||
check(ui, d, "rule.on", crate::i18n::tr(&app.ui.language, "Rule On"));
|
||
egui::Grid::new("fn_rule").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Weight:"));
|
||
text_field(ui, d, "rule.weight", 60.0);
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Color:"));
|
||
let swatches: Vec<String> = app.session.active().map(|st| st.doc.swatches.iter().map(|s| s.name.clone()).collect()).unwrap_or_default();
|
||
let opts: Vec<(&str, &str)> = swatches.iter().map(|n| (n.as_str(), n.as_str())).collect();
|
||
combo(ui, d, "rule.color", &opts);
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Width:"));
|
||
text_field(ui, d, "rule.width", 60.0);
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Offset:"));
|
||
text_field(ui, d, "rule.offset", 60.0);
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Left Indent:"));
|
||
text_field(ui, d, "rule.leftIndent", 60.0);
|
||
ui.end_row();
|
||
});
|
||
return;
|
||
}
|
||
head(ui, "Numbering");
|
||
egui::Grid::new("fn_num").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Style:"));
|
||
translated_combo(
|
||
&app.ui.language,
|
||
ui,
|
||
d,
|
||
"style",
|
||
&[
|
||
("arabic", "1, 2, 3, 4..."),
|
||
("upperRoman", "I, II, III, IV..."),
|
||
("lowerRoman", "i, ii, iii, iv..."),
|
||
("upperLetters", "A, B, C, D..."),
|
||
("lowerLetters", "a, b, c, d..."),
|
||
("arabicLeadingZero", "01, 02, 03..."),
|
||
("symbols", "*, †, ‡, §..."),
|
||
],
|
||
);
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Start at:"));
|
||
text_field(ui, d, "startAt", 60.0);
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Restart Numbering Every:"));
|
||
translated_combo(
|
||
&app.ui.language,
|
||
ui,
|
||
d,
|
||
"restart",
|
||
&[("never", "Never (continuous)"), ("page", "Page"), ("spread", "Spread"), ("section", "Section")],
|
||
);
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Show Prefix/Suffix in:"));
|
||
translated_combo(
|
||
&app.ui.language,
|
||
ui,
|
||
d,
|
||
"affixIn",
|
||
&[("none", "None"), ("reference", "Footnote Reference"), ("text", "Footnote Text"), ("both", "Both")],
|
||
);
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Prefix:"));
|
||
text_field(ui, d, "prefix", 60.0);
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Suffix:"));
|
||
text_field(ui, d, "suffix", 60.0);
|
||
ui.end_row();
|
||
});
|
||
head(ui, "Footnote Reference Number in Text");
|
||
egui::Grid::new("fn_ref").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Position:"));
|
||
translated_combo(
|
||
&app.ui.language,
|
||
ui,
|
||
d,
|
||
"refPosition",
|
||
&[
|
||
("superscript", "Apply Superscript"),
|
||
("subscript", "Apply Subscript"),
|
||
("normal", "Apply Normal"),
|
||
("otSuperscript", "OpenType Superscript"),
|
||
],
|
||
);
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Character Style:"));
|
||
style_combo(app, ui, d, "refCharStyle", true);
|
||
ui.end_row();
|
||
});
|
||
head(ui, "Footnote Formatting");
|
||
egui::Grid::new("fn_fmt").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Paragraph Style:"));
|
||
style_combo(app, ui, d, "paraStyle", false);
|
||
ui.end_row();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Separator:"));
|
||
text_field(ui, d, "separator", 60.0);
|
||
ui.end_row();
|
||
});
|
||
}
|
||
|
||
/// One parameter of a command, parsed from its params documentation
|
||
/// (`{name, type?: a|b|c, count?, flag?: bool, …}`).
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct CommandField {
|
||
pub key: String,
|
||
pub optional: bool,
|
||
/// Enumerated values (`a|b|c`).
|
||
pub choices: Vec<String>,
|
||
pub boolean: bool,
|
||
/// The documented value spec (shown as a hint).
|
||
pub hint: String,
|
||
}
|
||
|
||
/// Parameters of a command from its documentation string: the top-level keys of the first `{…}`.
|
||
pub fn command_fields(doc: &str) -> Vec<CommandField> {
|
||
let Some(start) = doc.find('{') else { return vec![] };
|
||
let mut depth = 0i32;
|
||
let mut end = doc.len();
|
||
for (i, c) in doc[start..].char_indices() {
|
||
match c {
|
||
'{' | '[' | '(' => depth += 1,
|
||
'}' | ']' | ')' => {
|
||
depth -= 1;
|
||
if depth == 0 {
|
||
end = start + i;
|
||
break;
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
let body = &doc[start + 1..end];
|
||
let mut parts = Vec::new();
|
||
let (mut depth, mut from) = (0i32, 0usize);
|
||
for (i, c) in body.char_indices() {
|
||
match c {
|
||
'{' | '[' | '(' => depth += 1,
|
||
'}' | ']' | ')' => depth -= 1,
|
||
',' if depth == 0 => {
|
||
parts.push(&body[from..i]);
|
||
from = i + 1;
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
parts.push(&body[from..]);
|
||
let ident = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') && !s.starts_with(|c: char| c.is_ascii_digit());
|
||
let mut out = Vec::new();
|
||
for part in parts {
|
||
// `key`, `key?`, `key: spec` or `key (spec)`: the key is the leading word.
|
||
let part = part.trim();
|
||
let k_end = part.find(|c: char| c.is_whitespace() || c == ':' || c == '(').unwrap_or(part.len());
|
||
let (k, rest) = part.split_at(k_end);
|
||
let rest = rest.trim_start();
|
||
let spec = match rest.strip_prefix(':') {
|
||
Some(v) => v.trim(),
|
||
None => rest.strip_prefix('(').and_then(|r| r.strip_suffix(')')).unwrap_or(rest).trim(),
|
||
};
|
||
let optional = k.ends_with('?');
|
||
let key = k.trim_end_matches('?');
|
||
if !ident(key) || out.iter().any(|f: &CommandField| f.key == key) {
|
||
continue;
|
||
}
|
||
let word = spec.split_whitespace().next().unwrap_or("");
|
||
let choices: Vec<String> =
|
||
if word.contains('|') && word.split('|').all(ident) { word.split('|').map(str::to_string).collect() } else { vec![] };
|
||
let boolean = spec.starts_with("bool") || spec.starts_with("true|false") || spec.starts_with("true") && spec.len() <= 5;
|
||
out.push(CommandField { key: key.into(), optional, choices: if boolean { vec![] } else { choices }, boolean, hint: spec.into() });
|
||
}
|
||
out
|
||
}
|
||
|
||
fn humanize(key: &str) -> String {
|
||
let mut s = String::new();
|
||
for (i, c) in key.chars().enumerate() {
|
||
if i == 0 {
|
||
s.extend(c.to_uppercase());
|
||
} else if c.is_uppercase() {
|
||
s.push(' ');
|
||
s.extend(c.to_lowercase());
|
||
} else {
|
||
s.push(c);
|
||
}
|
||
}
|
||
s
|
||
}
|
||
|
||
/// A form for any command, from its parameter documentation.
|
||
fn command_form(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||
let doc = designcraft_engine::find_command(&d.id[4..]).map_or("", |c| c.params);
|
||
let fields = command_fields(doc);
|
||
let dim = crate::theme::Tokens::get(ui.ctx()).text_dim;
|
||
egui::Grid::new("cmdform").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
for f in &fields {
|
||
ui.label(crate::rtl::widget(
|
||
ui,
|
||
format!("{}{}", crate::i18n::tr(&app.ui.language, &humanize(&f.key)), if f.optional { "" } else { " *" }),
|
||
));
|
||
if f.boolean {
|
||
check(ui, d, &f.key, "");
|
||
} else if !f.choices.is_empty() {
|
||
let mut opts: Vec<(&str, &str)> = vec![("", "—")];
|
||
let labels: Vec<_> = f.choices.iter().map(|c| humanize(c)).collect();
|
||
opts.extend(f.choices.iter().zip(&labels).map(|(value, label)| (value.as_str(), crate::i18n::tr(&app.ui.language, label))));
|
||
combo(ui, d, &f.key, &opts);
|
||
} else {
|
||
let mut s = d.s(&f.key);
|
||
let hint = if f.hint.is_empty() { String::new() } else { f.hint.clone() };
|
||
if ui.add(egui::TextEdit::singleline(&mut s).hint_text(egui::RichText::new(hint).color(dim)).desired_width(240.0)).changed() {
|
||
d.fields.insert(f.key.clone(), Value::String(s));
|
||
}
|
||
}
|
||
ui.end_row();
|
||
}
|
||
});
|
||
if let Some(st) = d.fields.get("status").and_then(Value::as_str) {
|
||
ui.label(egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim));
|
||
}
|
||
}
|
||
|
||
/// New Cross-Reference: link to a paragraph (paragraph styles on the left, their paragraphs on
|
||
/// the right) or a text anchor, with a cross-reference format.
|
||
fn insert_xref(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||
let Some(st) = app.session.active() else { return };
|
||
let doc = &st.doc;
|
||
let all = "[All Paragraphs]";
|
||
ui.horizontal(|ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Link To:"));
|
||
combo(ui, d, "linkTo", &[("paragraph", "Paragraph"), ("anchor", "Text Anchor")]);
|
||
});
|
||
ui.add_space(6.0);
|
||
let dim = crate::theme::Tokens::get(ui.ctx()).text_dim;
|
||
if d.s("linkTo") == "anchor" {
|
||
egui::ScrollArea::vertical().id_salt("xr_anchors").max_height(220.0).max_width(460.0).show(ui, |ui| {
|
||
let mut any = false;
|
||
for st in doc.stories.values() {
|
||
for a in &st.anchors {
|
||
any = true;
|
||
let key = format!("a:{}", a.id);
|
||
if ui.selectable_label(d.s("target") == key, &a.name).clicked() {
|
||
d.fields.insert("target".into(), json!(key));
|
||
}
|
||
}
|
||
}
|
||
if !any {
|
||
ui.label(crate::rtl::widget(
|
||
ui,
|
||
egui::RichText::new(crate::i18n::tr(&app.ui.language, "No text anchors in this document.")).color(dim),
|
||
));
|
||
}
|
||
});
|
||
} else {
|
||
let mut styles: Vec<String> = vec![all.to_string()];
|
||
styles.extend(doc.styles.paragraph.iter().map(|s| s.name.clone()));
|
||
let cur_style = if d.s("style").is_empty() { all.to_string() } else { d.s("style") };
|
||
ui.horizontal_top(|ui| {
|
||
let col = |w: f32| (egui::vec2(w, 240.0), egui::Layout::top_down(egui::Align::Min));
|
||
let (sz, lay) = col(180.0);
|
||
ui.allocate_ui_with_layout(sz, lay, |ui| {
|
||
egui::ScrollArea::vertical().id_salt("xr_styles").auto_shrink([false, false]).show(ui, |ui| {
|
||
for name in &styles {
|
||
if ui.selectable_label(*name == cur_style, name).clicked() {
|
||
d.fields.insert("style".into(), json!(name));
|
||
}
|
||
}
|
||
})
|
||
});
|
||
ui.add_space(8.0);
|
||
let (sz, lay) = col(330.0);
|
||
ui.allocate_ui_with_layout(sz, lay, |ui| {
|
||
egui::ScrollArea::vertical().id_salt("xr_paras").auto_shrink([false, false]).show(ui, |ui| {
|
||
for st in doc.stories.values().filter(|s| !s.frames.is_empty()) {
|
||
for (pi, r) in st.para_ranges().into_iter().enumerate() {
|
||
if cur_style != all && st.paras[pi].style != cur_style {
|
||
continue;
|
||
}
|
||
let text = designcraft_doc::xref::clean(&st.text[r]);
|
||
if text.is_empty() {
|
||
continue;
|
||
}
|
||
let short: String = text.chars().take(52).collect();
|
||
let key = format!("{}:{pi}", st.id.0);
|
||
if ui.selectable_label(d.s("target") == key, short).clicked() {
|
||
d.fields.insert("target".into(), json!(key));
|
||
}
|
||
}
|
||
}
|
||
})
|
||
});
|
||
});
|
||
}
|
||
ui.add_space(8.0);
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Cross-Reference Format")).font(semibold(12.0)));
|
||
let names: Vec<String> = doc.xref_formats.iter().map(|f| f.name.clone()).collect();
|
||
if d.s("format").is_empty() {
|
||
d.fields.insert("format".into(), json!(names.first().cloned().unwrap_or_default()));
|
||
}
|
||
let opts: Vec<(&str, &str)> = names.iter().map(|n| (n.as_str(), n.as_str())).collect();
|
||
ui.horizontal(|ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Format:"));
|
||
combo(ui, d, "format", &opts);
|
||
});
|
||
if let Some(m) = d.fields.get("status").and_then(Value::as_str) {
|
||
ui.label(egui::RichText::new(m).color(dim));
|
||
}
|
||
}
|
||
|
||
/// Color Picker: saturation/brightness field and hue slider, with RGB, CMYK and hex fields.
|
||
fn color_picker(ui: &mut egui::Ui, d: &mut Dialog) {
|
||
let hex = d.s("hex");
|
||
let mut c = designcraft_color::Color::from_hex(&hex).map(|c| c.to_rgb()).map_or(egui::Color32::BLACK, |[r, g, b]| {
|
||
egui::Color32::from_rgb((r * 255.0).round() as u8, (g * 255.0).round() as u8, (b * 255.0).round() as u8)
|
||
});
|
||
let before = c;
|
||
ui.horizontal_top(|ui| {
|
||
egui::color_picker::color_picker_color32(ui, &mut c, egui::color_picker::Alpha::Opaque);
|
||
ui.vertical(|ui| {
|
||
let mut rgb = [c.r() as f32, c.g() as f32, c.b() as f32];
|
||
egui::Grid::new("cp_rgb").num_columns(2).spacing([6.0, 4.0]).show(ui, |ui| {
|
||
for (k, l) in ["R", "G", "B"].iter().enumerate() {
|
||
ui.label(*l);
|
||
ui.add(egui::DragValue::new(&mut rgb[k]).range(0.0..=255.0));
|
||
ui.end_row();
|
||
}
|
||
});
|
||
c = egui::Color32::from_rgb(rgb[0] as u8, rgb[1] as u8, rgb[2] as u8);
|
||
let col = designcraft_color::Color::rgb8(c.r(), c.g(), c.b());
|
||
let [cc, m, y, k] = col.to_cmyk();
|
||
ui.add_space(6.0);
|
||
ui.label(format!("C {:.0}% M {:.0}% Y {:.0}% K {:.0}%", cc * 100.0, m * 100.0, y * 100.0, k * 100.0));
|
||
ui.add_space(6.0);
|
||
ui.horizontal(|ui| {
|
||
ui.label("#");
|
||
let mut h = format!("{:02x}{:02x}{:02x}", c.r(), c.g(), c.b());
|
||
if ui.add(egui::TextEdit::singleline(&mut h).desired_width(70.0)).changed()
|
||
&& let Some(n) = designcraft_color::Color::from_hex(&h)
|
||
{
|
||
let [r, g, b] = n.to_rgb();
|
||
c = egui::Color32::from_rgb((r * 255.0).round() as u8, (g * 255.0).round() as u8, (b * 255.0).round() as u8);
|
||
}
|
||
});
|
||
});
|
||
});
|
||
if c != before {
|
||
d.fields.insert("hex".into(), json!(format!("#{:02x}{:02x}{:02x}", c.r(), c.g(), c.b())));
|
||
}
|
||
}
|
||
|
||
/// Find/Replace Font: the document's fonts (missing ones first, flagged) and a replacement.
|
||
fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||
// Listed once when the dialog opens (it scans every story).
|
||
if !d.fields.contains_key("_fonts") {
|
||
let list = app.session.execute("font.list", &json!({})).unwrap_or_default();
|
||
d.fields.insert("_fonts".into(), list);
|
||
}
|
||
let fonts = d.fields.get("_fonts").and_then(Value::as_array).cloned().unwrap_or_default();
|
||
if !fonts.iter().any(|font| font["family"] == d.s("family") && font["style"] == d.s("style"))
|
||
&& let Some(font) = fonts.first()
|
||
{
|
||
d.fields.insert("family".into(), font["family"].clone());
|
||
d.fields.insert("style".into(), font["style"].clone());
|
||
}
|
||
if d.b("onOpen") && fonts.iter().any(|f| f["missing"] == true || f["styleMissing"] == true) {
|
||
crate::rtl::label(
|
||
ui,
|
||
crate::i18n::tr(
|
||
&app.ui.language,
|
||
"This document uses unavailable fonts. Choose replacements, or select Done to continue with fallback fonts.",
|
||
),
|
||
);
|
||
}
|
||
if !d.s("error").is_empty() {
|
||
crate::rtl::label(ui, d.s("error"));
|
||
}
|
||
let missing = fonts.iter().filter(|f| f["missing"] == true || f["styleMissing"] == true).count();
|
||
crate::rtl::label(
|
||
ui,
|
||
crate::i18n::tr(&app.ui.language, "Fonts in Document: {count} Missing: {missing}")
|
||
.replace("{count}", &fonts.len().to_string())
|
||
.replace("{missing}", &missing.to_string()),
|
||
);
|
||
egui::ScrollArea::vertical().id_salt("ff_list").max_height(180.0).show(ui, |ui| {
|
||
for f in &fonts {
|
||
let (fam, st) = (f["family"].as_str().unwrap_or(""), f["style"].as_str().unwrap_or(""));
|
||
let warn = f["missing"] == true || f["styleMissing"] == true;
|
||
let label = format!("{}{fam} {st}", if warn { "\u{26A0} " } else { "" });
|
||
let on = d.s("family") == fam && d.s("style") == st;
|
||
let mut text = egui::RichText::new(label);
|
||
if warn && !on {
|
||
text = text.color(crate::theme::Tokens::get(ui.ctx()).text_strong);
|
||
}
|
||
if ui.selectable_label(on, text).clicked() {
|
||
d.fields.insert("family".into(), json!(fam));
|
||
d.fields.insert("style".into(), json!(st));
|
||
}
|
||
}
|
||
});
|
||
let key = format!("{}\n{}", d.s("family"), d.s("style"));
|
||
if let Some(font) = fonts.iter().find(|f| f["family"] == d.s("family") && f["style"] == d.s("style")) {
|
||
if d.s("_fontKey") != key {
|
||
d.fields.insert("_fontKey".into(), json!(key));
|
||
d.fields.insert("toFamily".into(), font["replacementFamily"].clone());
|
||
d.fields.insert("toStyle".into(), font["replacementStyle"].clone());
|
||
}
|
||
crate::rtl::label(
|
||
ui,
|
||
format!(
|
||
"{}: {} {}",
|
||
crate::i18n::tr(&app.ui.language, "Currently Rendered With"),
|
||
font["resolvedFamily"].as_str().unwrap_or(""),
|
||
font["resolvedStyle"].as_str().unwrap_or("")
|
||
),
|
||
);
|
||
}
|
||
ui.add_space(8.0);
|
||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Replace With")).font(semibold(12.0)));
|
||
let db = crate::panels::fonts(app);
|
||
if d.s("toFamily").is_empty() {
|
||
d.fields.insert("toFamily".into(), json!(designcraft_fonts::FALLBACK_FAMILY));
|
||
}
|
||
egui::Grid::new("ff_to").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Font Family:"));
|
||
if let Some(f) = crate::panels::font_combo(app, ui, "toFamily", &d.s("toFamily"), 170.0) {
|
||
d.fields.insert("toFamily".into(), json!(f));
|
||
}
|
||
ui.end_row();
|
||
let styles = db.styles(&d.s("toFamily"));
|
||
if !styles.iter().any(|style| style == &d.s("toStyle")) {
|
||
let preferred = d.s("style");
|
||
let style = styles
|
||
.iter()
|
||
.find(|s| s.eq_ignore_ascii_case(&preferred))
|
||
.or_else(|| styles.iter().find(|s| s.eq_ignore_ascii_case("Regular")))
|
||
.or_else(|| styles.first());
|
||
if let Some(style) = style {
|
||
d.fields.insert("toStyle".into(), json!(style));
|
||
}
|
||
}
|
||
let st_opts: Vec<(&str, &str)> = styles.iter().map(|s| (s.as_str(), s.as_str())).collect();
|
||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Font Style:"));
|
||
combo(ui, d, "toStyle", &st_opts);
|
||
ui.end_row();
|
||
});
|
||
ui.label(crate::rtl::widget(
|
||
ui,
|
||
egui::RichText::new(crate::i18n::tr(
|
||
&app.ui.language,
|
||
"Replace All updates this font throughout text and styles. Done keeps remaining fallback fonts.",
|
||
))
|
||
.color(crate::theme::Tokens::get(ui.ctx()).text_dim)
|
||
.size(11.0),
|
||
));
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn draw(app: &mut DesignApp, ctx: &egui::Context) {
|
||
let input =
|
||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), ..Default::default() };
|
||
let mut out = ctx.run_ui(input, |ui| {
|
||
app.logic(&ui.ctx().clone());
|
||
app.ui(ui);
|
||
});
|
||
out.textures_delta.clear();
|
||
}
|
||
|
||
/// A paragraph styled "Ruled Child", whose Rule Below comes from its parent "Ruled".
|
||
fn ruled_paragraph() -> (DesignApp, Value) {
|
||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||
app.run("file.new", json!({})).unwrap();
|
||
let rule = json!({"on": true, "weight": 3.0, "color": "[Black]", "tint": 0.5, "columnWidth": false, "offset": 4.0, "leftIndent": 6.0, "rightIndent": 2.0});
|
||
app.run("style.paragraph.create", json!({"name": "Ruled", "para": {"ruleBelow": rule}})).unwrap();
|
||
app.run("style.paragraph.create", json!({"name": "Ruled Child", "basedOn": "Ruled"})).unwrap();
|
||
let r = app.run("frame.create", json!({"rect": [72, 72, 300, 400], "content": "text", "text": "One"})).unwrap();
|
||
app.run("text.select", json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap();
|
||
app.run("style.paragraph.apply", json!({"name": "Ruled Child"})).unwrap();
|
||
(app, rule)
|
||
}
|
||
|
||
fn undo_steps(app: &DesignApp) -> usize {
|
||
app.session.doc().map_or(0, |st| st.history.undo.len())
|
||
}
|
||
|
||
#[test]
|
||
fn paragraph_rules_dialog_shows_the_rule_and_applies_one_field() {
|
||
let (mut app, rule) = ruled_paragraph();
|
||
let ctx = egui::Context::default();
|
||
app.run("app.paragraphRulesDialog", json!({"rule": "ruleBelow"})).unwrap();
|
||
draw(&mut app, &ctx);
|
||
let d = app.ui.dialog.clone().unwrap();
|
||
assert_eq!(d.id, "paragraphRules");
|
||
let current = d.fields["current"].clone();
|
||
assert_eq!(shown_rule(&d, "", "ruleBelow", ¤t), rule, "the inherited rule");
|
||
assert_eq!(shown_rule(&d, "", "ruleAbove", ¤t)["on"], false);
|
||
// Pick a colour, as the Color menu does.
|
||
app.ui.dialog.as_mut().unwrap().fields.insert("ruleBelow.color".into(), json!("[Registration]"));
|
||
draw(&mut app, &ctx);
|
||
let before = undo_steps(&app);
|
||
confirm(&mut app).unwrap();
|
||
assert_eq!(undo_steps(&app), before + 1, "one undo step");
|
||
let mut want = rule.clone();
|
||
want["color"] = json!("[Registration]");
|
||
assert_eq!(app.run("type.selectionAttrs", json!({})).unwrap()["para"]["ruleBelow"], want);
|
||
app.run("edit.undo", json!({})).unwrap();
|
||
assert_eq!(app.run("type.selectionAttrs", json!({})).unwrap()["para"]["ruleBelow"], rule);
|
||
}
|
||
|
||
#[test]
|
||
fn paragraph_style_options_edit_one_rule_field() {
|
||
let (mut app, rule) = ruled_paragraph();
|
||
let ctx = egui::Context::default();
|
||
app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "rules", "rule": "ruleBelow"})));
|
||
draw(&mut app, &ctx);
|
||
let resolved = |app: &DesignApp| serde_json::to_value(app.session.doc().unwrap().doc.styles.resolve_para_style("Ruled Child").0).unwrap();
|
||
let d = app.ui.dialog.clone().unwrap();
|
||
assert_eq!(shown_rule(&d, "p.", "ruleBelow", &resolved(&app)), rule, "inherited from the parent style");
|
||
app.ui.dialog.as_mut().unwrap().fields.insert("p.ruleBelow.weight".into(), json!(1.5));
|
||
draw(&mut app, &ctx);
|
||
let before = undo_steps(&app);
|
||
confirm(&mut app).unwrap();
|
||
assert_eq!(undo_steps(&app), before + 1, "one undo step");
|
||
let mut want = rule.clone();
|
||
want["weight"] = json!(1.5);
|
||
assert_eq!(resolved(&app)["ruleBelow"], want);
|
||
assert_eq!(app.run("type.selectionAttrs", json!({})).unwrap()["para"]["ruleBelow"], want, "the paragraph follows its style");
|
||
app.run("edit.undo", json!({})).unwrap();
|
||
assert_eq!(resolved(&app)["ruleBelow"], rule);
|
||
}
|
||
|
||
fn command_app(id: &str, fields: Value) -> DesignApp {
|
||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||
app.ui.dialog = Some(Dialog::new(&format!("cmd:{id}"), fields));
|
||
app
|
||
}
|
||
|
||
#[test]
|
||
fn generic_new_document_preserves_typed_values_and_text_entry() {
|
||
for pages in [json!(2), json!(" 2 ")] {
|
||
let mut app = command_app(
|
||
"file.new",
|
||
json!({
|
||
"preset": "A4", "pages": pages, "facingPages": false, "gutter": 0,
|
||
"margins": {"top": 10, "bottom": 20, "inside": 30, "outside": 40},
|
||
"title": " ", "status": "internal", "primaryTextFrame": true
|
||
}),
|
||
);
|
||
confirm(&mut app).unwrap();
|
||
let doc = &app.session.active().unwrap().doc;
|
||
assert_eq!(doc.page_count(), 2);
|
||
assert!(!doc.settings.facing_pages);
|
||
assert!(doc.title.starts_with("Untitled-"));
|
||
for page in doc.spreads.iter().flat_map(|s| &s.pages) {
|
||
assert!((page.width - 595.2755905511812).abs() < 0.01);
|
||
assert!((page.height - 841.8897637795277).abs() < 0.01);
|
||
assert_eq!(page.columns.gutter, 0.0);
|
||
assert_eq!(page.margins.top, 10.0);
|
||
assert_eq!(page.margins.outside, 40.0);
|
||
}
|
||
assert!(doc.spreads.iter().all(|s| s.items.is_empty()), "undocumented fields stay excluded");
|
||
assert!(app.ui.dialog.is_none());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn generic_command_preserves_array_geometry() {
|
||
let mut app = command_app("file.new", json!({}));
|
||
confirm(&mut app).unwrap();
|
||
app.ui.dialog = Some(Dialog::new("cmd:frame.create", json!({"rect": [10, 20, 110, 220]})));
|
||
confirm(&mut app).unwrap();
|
||
let doc = &app.session.active().unwrap().doc;
|
||
let item = doc.spreads.iter().flat_map(|s| &s.items).next().unwrap();
|
||
assert_eq!(item.bounds(), designcraft_geom::Rect::new(10.0, 20.0, 110.0, 220.0));
|
||
}
|
||
|
||
#[test]
|
||
fn generic_explicit_null_uses_the_commands_existing_semantics() {
|
||
// file.new accepts null width by using the selected preset's width.
|
||
for width in [Value::Null, json!("null")] {
|
||
let mut app = command_app("file.new", json!({"preset": "A4", "width": width, "title": " Sample layout "}));
|
||
confirm(&mut app).unwrap();
|
||
let doc = &app.session.active().unwrap().doc;
|
||
assert_eq!(doc.title, "Sample layout");
|
||
assert!((doc.settings.page_width - 595.2755905511812).abs() < 0.01);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn generic_invalid_number_retains_dialog_without_creating_document() {
|
||
let mut app = command_app("file.new", json!({"width": 0}));
|
||
assert!(confirm(&mut app).is_err());
|
||
assert!(app.session.active().is_none());
|
||
let dialog = app.ui.dialog.as_ref().unwrap();
|
||
assert_eq!(dialog.fields["width"], 0);
|
||
assert!(dialog.fields["status"].as_str().unwrap().contains("page size out of range"));
|
||
}
|
||
|
||
#[test]
|
||
fn text_frame_options_change_only_what_was_edited() {
|
||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||
app.run("file.new", json!({})).unwrap();
|
||
let r = app.run("frame.create", json!({"rect": [36, 36, 336, 236], "content": "text", "text": "Hello"})).unwrap();
|
||
let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap());
|
||
app.run("object.textFrameOptions", json!({"ids": [id.0], "columns": 2, "inset": [1, 2, 3, 4]})).unwrap();
|
||
let opts = |app: &DesignApp| app.session.doc().unwrap().doc.item(id).unwrap().text_frame().unwrap().options.clone();
|
||
let before = opts(&app);
|
||
let open = |app: &mut DesignApp, fields: &[(&str, Value)]| {
|
||
app.ui.dialog = Some(Dialog::new("textFrameOptions", json!({})));
|
||
let mut d = app.ui.dialog.take().unwrap();
|
||
seed_text_frame_options(app, &mut d);
|
||
for (k, v) in fields {
|
||
d.fields.insert((*k).into(), v.clone());
|
||
}
|
||
app.ui.dialog = Some(d);
|
||
confirm(app)
|
||
};
|
||
// OK without edits changes nothing (no undo step either).
|
||
assert_eq!(open(&mut app, &[]).unwrap(), Value::Null);
|
||
assert_eq!(opts(&app), before);
|
||
// The rule's weight is in points; unequal insets the user didn't touch stay.
|
||
open(&mut app, &[("columnRule", json!(true)), ("columnRuleWeight", json!("6"))]).unwrap();
|
||
let o = opts(&app);
|
||
assert!(o.column_rule && o.column_rule_weight == 6.0, "{o:?}");
|
||
assert_eq!((o.inset, o.columns, o.gutter), (before.inset, before.columns, before.gutter));
|
||
// One of the rule's insets edited: the other rule settings stay.
|
||
open(&mut app, &[("columnRuleBottomInset", json!("0p9")), ("columnRuleTint", json!("40"))]).unwrap();
|
||
let o = opts(&app);
|
||
assert_eq!((o.column_rule_bottom_inset, o.column_rule_top_inset, o.column_rule_offset, o.column_rule_weight), (9.0, 0.0, 0.0, 6.0));
|
||
assert!((o.column_rule_tint - 0.4).abs() < 1e-6 && o.column_rule);
|
||
// One inset side edited: the others keep their values.
|
||
open(&mut app, &[("insetLeft", json!("1p0"))]).unwrap();
|
||
assert_eq!(opts(&app).inset, [1.0, 12.0, 3.0, 4.0]);
|
||
// An unknown swatch is an error and the dialog stays open.
|
||
let e = open(&mut app, &[("columnRuleColor", json!("Mauve"))]).unwrap_err();
|
||
assert!(e.contains("Mauve") && app.ui.dialog.is_some(), "{e}");
|
||
assert_eq!(opts(&app).column_rule_color, designcraft_color::swatch::BLACK);
|
||
// Each OK is one undo step.
|
||
for _ in 0..3 {
|
||
app.run("edit.undo", json!({})).unwrap();
|
||
}
|
||
assert_eq!(opts(&app), before);
|
||
}
|
||
|
||
fn dialog_context(language: &str) -> egui::Context {
|
||
let ctx = egui::Context::default();
|
||
crate::theme::install_fonts(&ctx, language);
|
||
crate::theme::apply(&ctx, &crate::theme::Tokens::for_brightness(crate::theme::Brightness::default()));
|
||
ctx
|
||
}
|
||
|
||
fn dialog_frame(app: &mut DesignApp, ctx: &egui::Context, screen: egui::Rect, events: Vec<egui::Event>) -> Vec<(String, egui::Rect, egui::Rect)> {
|
||
let time = ctx.input(|i| i.time) + 1.0 / 60.0;
|
||
let mut output = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), events, ..Default::default() }, |ui| {
|
||
show(app, ui.ctx());
|
||
});
|
||
output.textures_delta.clear();
|
||
fn collect(shape: &egui::Shape, clip: egui::Rect, labels: &mut Vec<(String, egui::Rect, egui::Rect)>) {
|
||
match shape {
|
||
egui::Shape::Text(t) => labels.push((t.galley.job.text.clone(), t.visual_bounding_rect(), clip)),
|
||
egui::Shape::Vec(shapes) => {
|
||
for s in shapes {
|
||
collect(s, clip, labels);
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
let mut labels = Vec::new();
|
||
for shape in output.shapes {
|
||
collect(&shape.shape, shape.clip_rect, &mut labels);
|
||
}
|
||
labels
|
||
}
|
||
|
||
fn visible_label(labels: &[(String, egui::Rect, egui::Rect)], screen: egui::Rect, expected: &str) -> egui::Rect {
|
||
let (_, rect, clip) = labels.iter().find(|(text, _, _)| text.trim() == expected).unwrap_or_else(|| panic!("{expected} is painted"));
|
||
assert!(screen.contains_rect(*rect), "{expected} must stay on screen: {rect:?}");
|
||
assert!(clip.contains_rect(*rect), "{expected} must not be clipped: {rect:?}, {clip:?}");
|
||
*rect
|
||
}
|
||
|
||
fn click_dialog(app: &mut DesignApp, ctx: &egui::Context, screen: egui::Rect, pos: egui::Pos2) {
|
||
for pressed in [true, false] {
|
||
dialog_frame(
|
||
app,
|
||
ctx,
|
||
screen,
|
||
vec![
|
||
egui::Event::PointerMoved(pos),
|
||
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE },
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
fn scroll_dialog(app: &mut DesignApp, ctx: &egui::Context, screen: egui::Rect, pos: egui::Pos2, delta: egui::Vec2) {
|
||
dialog_frame(
|
||
app,
|
||
ctx,
|
||
screen,
|
||
vec![
|
||
egui::Event::PointerMoved(pos),
|
||
egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta, phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE },
|
||
],
|
||
);
|
||
// Let smooth wheel scrolling and scrollbar visibility settle before clicking.
|
||
for _ in 0..30 {
|
||
dialog_frame(app, ctx, screen, vec![]);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn dialog_title_and_actions_fit_scaled_viewports() {
|
||
for language in ["en", "ar"] {
|
||
for size in [egui::vec2(1178.0, 814.0), egui::vec2(589.0, 407.0), egui::vec2(392.0, 271.0), egui::vec2(450.0, 280.0)] {
|
||
for (command, title) in [("app.preferences", "Preferences"), ("app.newDocumentDialog", "New Document")] {
|
||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||
app.ui.language = language.into();
|
||
app.run("file.new", json!({})).unwrap();
|
||
app.run(command, json!({})).unwrap();
|
||
let ctx = dialog_context(language);
|
||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size);
|
||
for _ in 0..3 {
|
||
dialog_frame(&mut app, &ctx, screen, vec![]);
|
||
}
|
||
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
|
||
for expected in [title, "OK", "Cancel"] {
|
||
visible_label(&labels, screen, crate::i18n::tr(language, expected));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn preferences_sidebar_scroll_keeps_selected_options_visible() {
|
||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||
app.run("file.new", json!({})).unwrap();
|
||
app.run("app.preferences", json!({})).unwrap();
|
||
let ctx = dialog_context("en");
|
||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(589.0, 407.0));
|
||
for _ in 0..3 {
|
||
dialog_frame(&mut app, &ctx, screen, vec![]);
|
||
}
|
||
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
|
||
let sidebar = visible_label(&labels, screen, "General").center();
|
||
assert!(
|
||
!labels.iter().any(|(text, rect, clip)| text == "File Handling" && clip.contains_rect(*rect)),
|
||
"last section starts below the sidebar"
|
||
);
|
||
scroll_dialog(&mut app, &ctx, screen, sidebar, egui::vec2(0.0, -600.0));
|
||
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
|
||
let files = visible_label(&labels, screen, "File Handling").center();
|
||
click_dialog(&mut app, &ctx, screen, files);
|
||
assert_eq!(app.ui.dialog.as_ref().unwrap().s("section"), "files");
|
||
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
|
||
for expected in ["Document Recovery Data", "Save recovery data every:", "OK", "Cancel"] {
|
||
visible_label(&labels, screen, expected);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn preferences_horizontal_scroll_reaches_clipped_options() {
|
||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||
app.run("app.preferences", json!({})).unwrap();
|
||
let ctx = dialog_context("en");
|
||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(450.0, 280.0));
|
||
for _ in 0..3 {
|
||
dialog_frame(&mut app, &ctx, screen, vec![]);
|
||
}
|
||
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
|
||
let body = visible_label(&labels, screen, "When Scaling").center();
|
||
let option = "Absolute Numbering (instead of Section Numbering)";
|
||
let (_, rect, clip) = labels.iter().find(|(text, _, _)| text == option).unwrap();
|
||
assert!(rect.right() > clip.right(), "rightmost option starts horizontally clipped: {rect:?}, {clip:?}");
|
||
scroll_dialog(&mut app, &ctx, screen, body, egui::vec2(-600.0, 0.0));
|
||
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
|
||
for expected in [option, "Preferences", "OK", "Cancel"] {
|
||
visible_label(&labels, screen, expected);
|
||
}
|
||
click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, option).center());
|
||
assert!(app.ui.dialog.as_ref().unwrap().b("absolutePageNumbers"));
|
||
}
|
||
|
||
#[test]
|
||
fn new_document_vertical_scroll_reaches_last_field() {
|
||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||
app.run("app.newDocumentDialog", json!({})).unwrap();
|
||
let ctx = dialog_context("en");
|
||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(392.0, 271.0));
|
||
for _ in 0..3 {
|
||
dialog_frame(&mut app, &ctx, screen, vec![]);
|
||
}
|
||
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
|
||
let body = visible_label(&labels, screen, "Width").center();
|
||
let (_, rect, clip) = labels.iter().find(|(text, _, _)| text == "Primary Text Frame").unwrap();
|
||
assert!(rect.bottom() > clip.bottom(), "last field starts vertically clipped");
|
||
scroll_dialog(&mut app, &ctx, screen, body, egui::vec2(0.0, -600.0));
|
||
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
|
||
for expected in ["Primary Text Frame", "New Document", "OK", "Cancel"] {
|
||
visible_label(&labels, screen, expected);
|
||
}
|
||
click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "Primary Text Frame").center());
|
||
assert!(app.ui.dialog.as_ref().unwrap().b("primaryTextFrame"));
|
||
}
|
||
|
||
#[test]
|
||
fn arabic_new_document_leftmost_fields_remain_reachable() {
|
||
for size in [egui::vec2(392.0, 271.0), egui::vec2(450.0, 280.0)] {
|
||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||
app.ui.language = "ar".into();
|
||
app.run("app.newDocumentDialog", json!({})).unwrap();
|
||
let fields = [("height", "66p1"), ("gutter", "1p2"), ("marginBottom", "3p3"), ("marginOutside", "3p4")];
|
||
for (field, value) in fields {
|
||
app.ui.dialog.as_mut().unwrap().fields.insert(field.into(), json!(value));
|
||
}
|
||
let ctx = dialog_context("ar");
|
||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size);
|
||
for _ in 0..3 {
|
||
dialog_frame(&mut app, &ctx, screen, vec![]);
|
||
}
|
||
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
|
||
for (_, value) in fields {
|
||
visible_label(&labels, screen, value);
|
||
}
|
||
let body = visible_label(&labels, screen, "66p1").center();
|
||
for x in [-600.0, 600.0] {
|
||
scroll_dialog(&mut app, &ctx, screen, body, egui::vec2(x, 0.0));
|
||
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
|
||
for (_, value) in fields {
|
||
visible_label(&labels, screen, value);
|
||
}
|
||
}
|
||
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
|
||
click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "66p1").center());
|
||
dialog_frame(
|
||
&mut app,
|
||
&ctx,
|
||
screen,
|
||
vec![
|
||
egui::Event::Key {
|
||
key: egui::Key::A,
|
||
physical_key: None,
|
||
pressed: true,
|
||
repeat: false,
|
||
modifiers: egui::Modifiers { ctrl: true, command: true, ..Default::default() },
|
||
},
|
||
egui::Event::Text("88p8".into()),
|
||
],
|
||
);
|
||
assert_eq!(app.ui.dialog.as_ref().unwrap().s("height"), "88p8", "leftmost numeric field accepts edits");
|
||
scroll_dialog(&mut app, &ctx, screen, body, egui::vec2(0.0, -600.0));
|
||
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
|
||
let label = crate::i18n::tr("ar", "Primary Text Frame");
|
||
click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, label).center());
|
||
assert!(app.ui.dialog.as_ref().unwrap().b("primaryTextFrame"), "leftmost checkbox remains clickable after scrolling");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn resized_dialogs_cancel_and_reopen_without_applying_edits() {
|
||
for (command, title, field) in [("app.preferences", "Preferences", "recoveryMinutes"), ("app.newDocumentDialog", "New Document", "pages")] {
|
||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||
app.run(command, json!({})).unwrap();
|
||
let original = app.ui.dialog.as_ref().unwrap().fields[field].clone();
|
||
app.ui.dialog.as_mut().unwrap().fields.insert(field.into(), json!(42));
|
||
let ctx = dialog_context("en");
|
||
for size in [egui::vec2(1178.0, 814.0), egui::vec2(392.0, 271.0), egui::vec2(589.0, 407.0)] {
|
||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size);
|
||
for _ in 0..3 {
|
||
dialog_frame(&mut app, &ctx, screen, vec![]);
|
||
}
|
||
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
|
||
for expected in [title, "OK", "Cancel"] {
|
||
visible_label(&labels, screen, expected);
|
||
}
|
||
}
|
||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(589.0, 407.0));
|
||
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
|
||
click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "Cancel").center());
|
||
assert!(app.ui.dialog.is_none());
|
||
assert!(app.session.active().is_none(), "cancelling must not create a document");
|
||
app.run(command, json!({})).unwrap();
|
||
assert_eq!(app.ui.dialog.as_ref().unwrap().fields[field], original, "cancelled edits must not survive reopening");
|
||
for _ in 0..3 {
|
||
dialog_frame(&mut app, &ctx, screen, vec![]);
|
||
}
|
||
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
|
||
visible_label(&labels, screen, title);
|
||
dialog_frame(
|
||
&mut app,
|
||
&ctx,
|
||
screen,
|
||
vec![egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }],
|
||
);
|
||
assert!(app.ui.dialog.is_none(), "Escape must also close the reopened dialog");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn parses_command_params() {
|
||
let f = command_fields("{name, type: custom|lastPageNumber|chapterNumber, text?, rule?: {on, weight}, flag?: bool} — creates");
|
||
let keys: Vec<&str> = f.iter().map(|f| f.key.as_str()).collect();
|
||
assert_eq!(keys, ["name", "type", "text", "rule", "flag"]);
|
||
assert_eq!(f[1].choices, ["custom", "lastPageNumber", "chapterNumber"]);
|
||
assert!(!f[0].optional && f[2].optional);
|
||
assert!(f[4].boolean);
|
||
assert!(command_fields("{}").is_empty());
|
||
assert!(command_fields("no params").is_empty());
|
||
// A parenthetical after the key describes the value.
|
||
let f = command_fields("{angle (degrees, CCW), ids?}");
|
||
let keys: Vec<&str> = f.iter().map(|f| f.key.as_str()).collect();
|
||
assert_eq!(keys, ["angle", "ids"]);
|
||
assert!(!f[0].optional && f[1].optional);
|
||
assert_eq!(f[0].hint, "degrees, CCW");
|
||
let f = command_fields("{scaleX? (%), ref?: 0..8, ids?: parent items (default: all)}");
|
||
let keys: Vec<&str> = f.iter().map(|f| f.key.as_str()).collect();
|
||
assert_eq!(keys, ["scaleX", "ref", "ids"]);
|
||
assert!(f.iter().all(|f| f.optional));
|
||
assert_eq!(f[0].hint, "%");
|
||
assert_eq!(f[2].hint, "parent items (default: all)");
|
||
// Every command with a "…" label parses without panicking.
|
||
for c in designcraft_engine::command_specs() {
|
||
let _ = command_fields(c.params);
|
||
}
|
||
}
|
||
|
||
fn test_app() -> crate::DesignApp {
|
||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||
app.session.execute("file.new", &json!({"pages": 1})).unwrap();
|
||
app
|
||
}
|
||
|
||
#[test]
|
||
fn paragraph_style_options_dialog_saves_edits() {
|
||
let mut app = test_app();
|
||
let name = app.session.execute("style.paragraph.create", &json!({"name": "Body Copy"})).unwrap()["name"].as_str().unwrap().to_string();
|
||
app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": name, "p.align": "center"})));
|
||
confirm(&mut app).expect("OK saves the style edit");
|
||
let st = app.session.active().unwrap();
|
||
assert_eq!(st.doc.styles.para(&name).unwrap().para.align, Some(designcraft_doc::Align::Center));
|
||
}
|
||
|
||
#[test]
|
||
fn delete_paragraph_style_dialog_removes_the_style_and_reassigns_text() {
|
||
let mut app = test_app();
|
||
let name = app.session.execute("style.paragraph.create", &json!({"name": "Caption"})).unwrap()["name"].as_str().unwrap().to_string();
|
||
let sid = app.session.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap()["story"].as_u64().unwrap();
|
||
app.session.execute("text.insert", &json!({"text": "Hello"})).unwrap();
|
||
app.session.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap();
|
||
app.session.execute("style.paragraph.apply", &json!({"name": name})).unwrap();
|
||
app.ui.dialog = Some(Dialog::new("deleteParagraphStyle", json!({"name": name, "replaceWith": designcraft_doc::BASIC_PARAGRAPH})));
|
||
confirm(&mut app).expect("OK deletes the style");
|
||
let st = app.session.active().unwrap();
|
||
assert!(st.doc.styles.para(&name).is_none(), "style removed");
|
||
let story = st.doc.stories.get(&designcraft_doc::StoryId(sid)).unwrap();
|
||
assert_eq!(story.paras[0].style, designcraft_doc::BASIC_PARAGRAPH, "text reassigned to the replacement style");
|
||
}
|
||
|
||
#[test]
|
||
fn delete_paragraph_style_dialog_defaults_replacement_to_basic_paragraph() {
|
||
let mut app = test_app();
|
||
let name = app.session.execute("style.paragraph.create", &json!({"name": "Caption"})).unwrap()["name"].as_str().unwrap().to_string();
|
||
// No `replaceWith` set — the dialog's default field.
|
||
app.ui.dialog = Some(Dialog::new("deleteParagraphStyle", json!({"name": name})));
|
||
confirm(&mut app).expect("OK deletes the style with the default replacement");
|
||
let st = app.session.active().unwrap();
|
||
assert!(st.doc.styles.para(&name).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn character_style_options_dialog_saves_edits() {
|
||
let mut app = test_app();
|
||
let name = app.session.execute("style.character.create", &json!({"name": "Key Term"})).unwrap()["name"].as_str().unwrap().to_string();
|
||
app.ui.dialog = Some(Dialog::new("characterStyleOptions", json!({"name": name, "c.tracking": 20.0})));
|
||
confirm(&mut app).expect("OK saves the style edit");
|
||
let st = app.session.active().unwrap();
|
||
assert_eq!(st.doc.styles.char_style(&name).unwrap().chars.tracking, Some(20.0));
|
||
}
|
||
|
||
#[test]
|
||
fn delete_character_style_dialog_removes_the_style_and_reassigns_text() {
|
||
let mut app = test_app();
|
||
let name = app.session.execute("style.character.create", &json!({"name": "Key Term"})).unwrap()["name"].as_str().unwrap().to_string();
|
||
let sid = app.session.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap()["story"].as_u64().unwrap();
|
||
app.session.execute("text.insert", &json!({"text": "Hello"})).unwrap();
|
||
app.session.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap();
|
||
app.session.execute("style.character.apply", &json!({"name": name})).unwrap();
|
||
app.ui.dialog = Some(Dialog::new("deleteCharacterStyle", json!({"name": name, "replaceWith": designcraft_doc::NO_CHAR_STYLE})));
|
||
confirm(&mut app).expect("OK deletes the style");
|
||
let st = app.session.active().unwrap();
|
||
assert!(st.doc.styles.char_style(&name).is_none(), "style removed");
|
||
let story = st.doc.stories.get(&designcraft_doc::StoryId(sid)).unwrap();
|
||
assert_eq!(story.chars[0].format.style, designcraft_doc::NO_CHAR_STYLE, "text reassigned to the replacement style");
|
||
}
|
||
|
||
#[test]
|
||
fn delete_character_style_dialog_defaults_replacement_to_none() {
|
||
let mut app = test_app();
|
||
let name = app.session.execute("style.character.create", &json!({"name": "Key Term"})).unwrap()["name"].as_str().unwrap().to_string();
|
||
// No `replaceWith` set — the dialog's default field.
|
||
app.ui.dialog = Some(Dialog::new("deleteCharacterStyle", json!({"name": name})));
|
||
confirm(&mut app).expect("OK deletes the style with the default replacement");
|
||
let st = app.session.active().unwrap();
|
||
assert!(st.doc.styles.char_style(&name).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn pdf_export_defaults() {
|
||
let d = Dialog::new("pdfExport", json!({}));
|
||
let keys: Vec<&str> = d.fields.iter().map(|(k, _)| k.as_str()).collect();
|
||
// serde_json::Map keeps keys sorted.
|
||
let expected = [
|
||
"bleed",
|
||
"compressImages",
|
||
"flatten",
|
||
"marksBleed",
|
||
"marksCrop",
|
||
"marksOffset",
|
||
"marksPageInfo",
|
||
"marksWeight",
|
||
"preset",
|
||
"spreads",
|
||
"standard",
|
||
"tagged",
|
||
];
|
||
assert_eq!(keys, expected);
|
||
assert_eq!(d.fields["preset"].as_str(), Some("Desktop Printing"));
|
||
assert_eq!(d.fields["standard"].as_str(), Some("none"));
|
||
assert_eq!(d.fields["compressImages"].as_bool(), Some(false));
|
||
assert_eq!(d.fields["flatten"].as_str(), Some(""));
|
||
assert_eq!(d.fields["spreads"].as_bool(), Some(false));
|
||
assert_eq!(d.fields["bleed"].as_bool(), Some(true));
|
||
assert_eq!(d.fields["marksCrop"].as_bool(), Some(false));
|
||
assert_eq!(d.fields["marksBleed"].as_bool(), Some(false));
|
||
assert_eq!(d.fields["marksPageInfo"].as_bool(), Some(false));
|
||
assert_eq!(d.fields["marksWeight"].as_str(), Some("0.25"));
|
||
assert_eq!(d.fields["marksOffset"].as_str(), Some("6"));
|
||
assert_eq!(d.fields["tagged"].as_bool(), Some(true));
|
||
}
|
||
|
||
#[test]
|
||
fn pdf_export_override_keeps_override() {
|
||
let d = Dialog::new("pdfExport", json!({"bleed": false}));
|
||
assert_eq!(d.fields["bleed"].as_bool(), Some(false));
|
||
assert_eq!(d.fields["preset"].as_str(), Some("Desktop Printing"));
|
||
assert_eq!(d.fields["tagged"].as_bool(), Some(true));
|
||
}
|
||
|
||
#[test]
|
||
fn pdf_export_preset_high_quality_print_keeps_flatten() {
|
||
let mut d = Dialog::new("pdfExport", json!({}));
|
||
d.fields.insert("flatten".into(), json!("medium"));
|
||
apply_pdf_preset(&mut d, "Desktop Printing");
|
||
assert_eq!(d.s("standard"), "none");
|
||
assert!(!d.b("compressImages"));
|
||
assert!(!d.b("marksCrop"));
|
||
assert!(!d.b("marksBleed"));
|
||
assert!(!d.b("marksPageInfo"));
|
||
assert_eq!(d.s("flatten"), "medium", "a leave-unchanged preset must not touch flatten");
|
||
}
|
||
|
||
#[test]
|
||
fn pdf_export_preset_press_quality_keeps_flatten() {
|
||
let mut d = Dialog::new("pdfExport", json!({}));
|
||
d.fields.insert("flatten".into(), json!("high"));
|
||
apply_pdf_preset(&mut d, "Commercial Printing");
|
||
assert_eq!(d.s("standard"), "none");
|
||
assert!(d.b("compressImages"));
|
||
assert!(d.b("marksCrop"));
|
||
assert!(d.b("marksBleed"));
|
||
assert!(!d.b("marksPageInfo"));
|
||
assert_eq!(d.s("flatten"), "high");
|
||
}
|
||
|
||
#[test]
|
||
fn pdf_export_preset_smallest_file_size_sets_flatten_low() {
|
||
let mut d = Dialog::new("pdfExport", json!({}));
|
||
apply_pdf_preset(&mut d, "Screen and Email");
|
||
assert_eq!(d.s("standard"), "none");
|
||
assert!(d.b("compressImages"));
|
||
assert!(!d.b("marksCrop"));
|
||
assert!(!d.b("marksBleed"));
|
||
assert!(!d.b("marksPageInfo"));
|
||
assert_eq!(d.s("flatten"), "low");
|
||
}
|
||
|
||
#[test]
|
||
fn pdf_export_preset_pdf_x4_keeps_flatten() {
|
||
let mut d = Dialog::new("pdfExport", json!({}));
|
||
d.fields.insert("flatten".into(), json!("medium"));
|
||
apply_pdf_preset(&mut d, "PDF/X-4");
|
||
assert_eq!(d.s("standard"), "x4");
|
||
assert!(!d.b("compressImages"));
|
||
assert!(d.b("marksCrop"));
|
||
assert!(d.b("marksBleed"));
|
||
assert!(!d.b("marksPageInfo"));
|
||
assert_eq!(d.s("flatten"), "medium");
|
||
}
|
||
|
||
#[test]
|
||
fn pdf_export_preset_pdf_a2b_keeps_flatten() {
|
||
let mut d = Dialog::new("pdfExport", json!({}));
|
||
d.fields.insert("flatten".into(), json!("low"));
|
||
apply_pdf_preset(&mut d, "PDF/A-2b");
|
||
assert_eq!(d.s("standard"), "a2b");
|
||
assert!(!d.b("compressImages"));
|
||
assert!(!d.b("marksCrop"));
|
||
assert!(!d.b("marksBleed"));
|
||
assert!(!d.b("marksPageInfo"));
|
||
assert_eq!(d.s("flatten"), "low");
|
||
}
|
||
|
||
#[test]
|
||
fn open_pdf_export_seeds_fields_from_settings() {
|
||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||
app.ui.pdf_export.standard = "x4".into();
|
||
app.ui.pdf_export.tagged = false;
|
||
app.ui.pdf_export.marks_crop = true;
|
||
app.ui.pdf_export.flatten = String::new();
|
||
app.ui.flattener = "medium".into();
|
||
open_pdf_export(&mut app);
|
||
let d = app.ui.dialog.as_ref().unwrap();
|
||
assert_eq!(d.id, "pdfExport");
|
||
assert_eq!(d.s("standard"), "x4");
|
||
assert!(!d.b("tagged"));
|
||
assert!(d.b("marksCrop"));
|
||
assert_eq!(d.s("pages"), "all");
|
||
assert_eq!(d.s("flatten"), "medium", "empty flatten falls back to the global flattener");
|
||
assert_eq!(d.s("title"), "", "no document → empty title");
|
||
|
||
// A non-empty persisted flatten is used verbatim, not the global fallback.
|
||
app.ui.pdf_export.flatten = "low".into();
|
||
app.ui.flattener = "medium".into();
|
||
open_pdf_export(&mut app);
|
||
let d = app.ui.dialog.as_ref().unwrap();
|
||
assert_eq!(d.s("flatten"), "low");
|
||
}
|
||
|
||
#[test]
|
||
fn pdf_export_preset_custom_changes_nothing() {
|
||
let mut d = Dialog::new("pdfExport", json!({}));
|
||
d.fields.insert("standard".into(), json!("x4"));
|
||
d.fields.insert("compressImages".into(), json!(true));
|
||
d.fields.insert("marksCrop".into(), json!(true));
|
||
d.fields.insert("marksBleed".into(), json!(true));
|
||
d.fields.insert("marksPageInfo".into(), json!(true));
|
||
d.fields.insert("flatten".into(), json!("high"));
|
||
let before = d.fields.clone();
|
||
apply_pdf_preset(&mut d, "Custom");
|
||
assert_eq!(d.fields, before);
|
||
}
|
||
|
||
#[test]
|
||
fn confirm_pdf_export_writes_file_and_persists_settings() {
|
||
use std::sync::{Arc, Mutex};
|
||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||
app.session.execute("file.new", &json!({})).unwrap();
|
||
let writes: Arc<Mutex<Vec<(String, Vec<u8>)>>> = Arc::new(Mutex::new(Vec::new()));
|
||
let sink = writes.clone();
|
||
app.services.write = Some(Box::new(move |path: &str, bytes: &[u8]| {
|
||
sink.lock().unwrap().push((path.to_string(), bytes.to_vec()));
|
||
Ok(())
|
||
}));
|
||
app.services.pick_save = Some(Box::new(|_name: &str| Some("/tmp/designcraft-test.pdf".to_string())));
|
||
|
||
app.ui.dialog = Some(Dialog::new("pdfExport", json!({})));
|
||
{
|
||
let d = app.ui.dialog.as_mut().unwrap();
|
||
d.fields.insert("standard".into(), json!("x4"));
|
||
d.fields.insert("bleed".into(), json!(false));
|
||
d.fields.insert("marksCrop".into(), json!(true));
|
||
d.fields.insert("tagged".into(), json!(false));
|
||
d.fields.insert("title".into(), json!("Report"));
|
||
}
|
||
confirm(&mut app).unwrap();
|
||
|
||
let got = writes.lock().unwrap();
|
||
assert_eq!(got.len(), 1, "confirming the dialog writes exactly one file");
|
||
assert_eq!(got[0].0, "/tmp/designcraft-test.pdf");
|
||
assert!(got[0].1.starts_with(b"%PDF"), "the written bytes are a PDF");
|
||
drop(got);
|
||
|
||
assert_eq!(app.ui.pdf_export.standard, "x4");
|
||
assert!(!app.ui.pdf_export.bleed);
|
||
assert!(app.ui.pdf_export.marks_crop);
|
||
assert!(!app.ui.pdf_export.tagged);
|
||
}
|
||
|
||
#[test]
|
||
fn confirm_pdf_export_cancelled_save_writes_nothing() {
|
||
use std::sync::{Arc, Mutex};
|
||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||
app.session.execute("file.new", &json!({})).unwrap();
|
||
let writes: Arc<Mutex<Vec<(String, Vec<u8>)>>> = Arc::new(Mutex::new(Vec::new()));
|
||
let sink = writes.clone();
|
||
app.services.write = Some(Box::new(move |path: &str, bytes: &[u8]| {
|
||
sink.lock().unwrap().push((path.to_string(), bytes.to_vec()));
|
||
Ok(())
|
||
}));
|
||
app.services.pick_save = Some(Box::new(|_name: &str| None));
|
||
|
||
app.ui.dialog = Some(Dialog::new("pdfExport", json!({})));
|
||
let r = confirm(&mut app).unwrap();
|
||
assert_eq!(r, Value::Null);
|
||
assert!(writes.lock().unwrap().is_empty(), "cancelling the save picker must not write");
|
||
}
|
||
|
||
use designcraft_doc::{CharAttrs, CharacterStyle};
|
||
|
||
/// A document with character styles Base (bold) and Emphasis (italic) on the first two letters.
|
||
fn styled_app() -> DesignApp {
|
||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||
app.run("file.new", json!({})).unwrap();
|
||
app.run("style.character.create", json!({"name": "Base", "chars": {"fontStyle": "Bold"}})).unwrap();
|
||
app.run("style.character.create", json!({"name": "Emphasis", "chars": {"fontStyle": "Italic"}})).unwrap();
|
||
let r = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hi there"})).unwrap();
|
||
app.run("text.select", json!({"story": r["story"], "anchor": 0, "focus": 2})).unwrap();
|
||
app.run("style.character.apply", json!({"name": "Emphasis"})).unwrap();
|
||
app
|
||
}
|
||
|
||
/// One control-channel request, as the control port or MCP would send it.
|
||
fn ctl(app: &mut DesignApp, method: &str, params: Value) -> Value {
|
||
let (req, _reply) = crate::control::ControlRequest::new(method, params);
|
||
match crate::control::handle(app, &egui::Context::default(), &req) {
|
||
crate::control::Outcome::Done(v) => v,
|
||
crate::control::Outcome::Screenshot { .. } => Value::Null,
|
||
}
|
||
}
|
||
|
||
fn char_style(app: &DesignApp, name: &str) -> Option<CharacterStyle> {
|
||
app.session.active().and_then(|st| st.doc.styles.char_style(name).cloned())
|
||
}
|
||
|
||
#[test]
|
||
fn character_style_options_set_exactly_the_edited_attributes() {
|
||
let mut app = styled_app();
|
||
assert_eq!(ctl(&mut app, "ui.dialog.open", json!({"id": "characterStyleOptions", "fields": {"name": "Emphasis"}}))["ok"], true);
|
||
ctl(&mut app, "ui.dialog.set", json!({"field": "c.size", "value": 14}));
|
||
ctl(&mut app, "ui.dialog.set", json!({"field": "c.fill", "value": "[Paper]"}));
|
||
ctl(&mut app, "ui.dialog.set", json!({"field": "basedOn", "value": "Base"}));
|
||
assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true);
|
||
assert!(app.ui.dialog.is_none());
|
||
let st = char_style(&app, "Emphasis").unwrap();
|
||
assert_eq!(st.based_on.as_deref(), Some("Base"));
|
||
assert_eq!(st.chars, CharAttrs { font_style: Some("Italic".into()), size: Some(14.0), fill: Some("[Paper]".into()), ..Default::default() });
|
||
}
|
||
|
||
#[test]
|
||
fn character_style_options_unset_cleared_fields_and_rename_every_use() {
|
||
let mut app = styled_app();
|
||
ctl(&mut app, "ui.dialog.open", json!({"id": "characterStyleOptions", "fields": {"name": "Emphasis"}}));
|
||
ctl(&mut app, "ui.dialog.set", json!({"field": "c.fontStyle", "value": null}));
|
||
ctl(&mut app, "ui.dialog.set", json!({"field": "rename", "value": "Strong"}));
|
||
assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true);
|
||
assert!(char_style(&app, "Emphasis").is_none());
|
||
assert!(char_style(&app, "Strong").unwrap().chars.is_empty());
|
||
let st = app.session.active().unwrap();
|
||
let story = st.doc.stories.values().next().unwrap();
|
||
assert!(story.chars.iter().any(|r| r.format.style == "Strong"));
|
||
// A taken name keeps the dialog open with the reason.
|
||
ctl(&mut app, "ui.dialog.open", json!({"id": "characterStyleOptions", "fields": {"name": "Strong"}}));
|
||
ctl(&mut app, "ui.dialog.set", json!({"field": "rename", "value": "Base"}));
|
||
assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false);
|
||
assert!(app.ui.dialog.as_ref().is_some_and(|d| !d.s("status").is_empty()));
|
||
assert!(char_style(&app, "Strong").is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn new_character_style_opens_its_options_and_creates_the_style() {
|
||
let mut app = styled_app();
|
||
crate::menus::activate(&mut app, "style.character.create", &Value::Null);
|
||
let d = app.ui.dialog.clone().unwrap();
|
||
assert_eq!((d.id.as_str(), d.b("new"), d.s("rename").as_str()), ("characterStyleOptions", true, "Character Style 1"));
|
||
ctl(&mut app, "ui.dialog.set", json!({"field": "c.size", "value": 9}));
|
||
assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true);
|
||
let st = char_style(&app, "Character Style 1").unwrap();
|
||
assert_eq!((st.based_on, st.chars), (None, CharAttrs { size: Some(9.0), ..Default::default() }));
|
||
crate::menus::activate(&mut app, "style.character.create", &Value::Null);
|
||
assert_eq!(app.ui.dialog.as_ref().unwrap().s("rename"), "Character Style 2");
|
||
}
|
||
|
||
#[test]
|
||
fn new_paragraph_style_opens_its_options_and_creates_the_style() {
|
||
let mut app = styled_app();
|
||
crate::menus::activate(&mut app, "style.paragraph.create", &Value::Null);
|
||
let d = app.ui.dialog.clone().unwrap();
|
||
assert_eq!((d.id.as_str(), d.b("new"), d.s("rename").as_str()), ("paragraphStyleOptions", true, "Paragraph Style 1"));
|
||
ctl(&mut app, "ui.dialog.set", json!({"field": "c.size", "value": 20}));
|
||
ctl(&mut app, "ui.dialog.set", json!({"field": "basedOn", "value": designcraft_doc::BASIC_PARAGRAPH}));
|
||
assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true);
|
||
let st = app.session.active().unwrap().doc.styles.para("Paragraph Style 1").cloned().unwrap();
|
||
assert_eq!(st.based_on.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH));
|
||
assert_eq!(st.chars, CharAttrs { size: Some(20.0), ..Default::default() });
|
||
}
|
||
|
||
#[test]
|
||
fn style_rows_open_their_options_except_none() {
|
||
let mut app = styled_app();
|
||
open_style_options(&mut app, false, "Emphasis");
|
||
let d = app.ui.dialog.take().unwrap();
|
||
assert_eq!((d.id.as_str(), d.s("name").as_str()), ("characterStyleOptions", "Emphasis"));
|
||
open_style_options(&mut app, false, designcraft_doc::NO_CHAR_STYLE);
|
||
assert!(app.ui.dialog.is_none());
|
||
open_style_options(&mut app, true, designcraft_doc::BASIC_PARAGRAPH);
|
||
assert_eq!(app.ui.dialog.take().unwrap().id, "paragraphStyleOptions");
|
||
}
|
||
|
||
#[test]
|
||
fn style_options_sections_draw() {
|
||
let mut app = styled_app();
|
||
let ctx = egui::Context::default();
|
||
let frame = |app: &mut DesignApp| {
|
||
for _ in 0..2 {
|
||
let raw = egui::RawInput {
|
||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0))),
|
||
max_texture_side: Some(8192),
|
||
..Default::default()
|
||
};
|
||
let mut out = ctx.run_ui(raw, |ui| {
|
||
app.logic(&ui.ctx().clone());
|
||
app.ui(ui);
|
||
});
|
||
out.textures_delta.clear();
|
||
}
|
||
};
|
||
for lang in ["", "ar"] {
|
||
app.run("app.language", json!({"lang": lang})).unwrap();
|
||
for fields in [json!({"name": "Emphasis"}), json!({"new": true}), json!({"name": "Emphasis", "c.size": null, "c.underline": false})] {
|
||
for section in ["general", "chars", "advanced", "color", "openType", "underline", "strikethrough"] {
|
||
let mut f = fields.clone();
|
||
f["section"] = json!(section);
|
||
app.ui.dialog = Some(Dialog::new("characterStyleOptions", f));
|
||
frame(&mut app);
|
||
assert!(app.ui.dialog.is_some(), "{section} stays open");
|
||
}
|
||
}
|
||
for section in ["general", "chars", "color"] {
|
||
app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"new": true, "section": section})));
|
||
frame(&mut app);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn shortcut_recorder_waits_for_the_key_after_its_modifiers() {
|
||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||
let mut d = Dialog::new("keyboardShortcuts", json!({"query": "", "recording": "app.palette"}));
|
||
let ctx = egui::Context::default();
|
||
let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
|
||
// Holding Ctrl reports Ctrl itself as a key press, then Ctrl+K arrives.
|
||
for event in [key(egui::Key::ControlLeft, egui::Modifiers::COMMAND), key(egui::Key::K, egui::Modifiers::COMMAND)] {
|
||
let input = egui::RawInput { events: vec![event], ..Default::default() };
|
||
ctx.run_ui(input, |ui| keyboard_shortcuts(&mut app, ui, &mut d)).textures_delta.clear();
|
||
}
|
||
assert_eq!(crate::menus::shortcut_of(&app, "app.palette").as_deref(), Some("Cmd+K"));
|
||
assert_eq!(d.s("recording"), "", "recording ends with the recorded shortcut");
|
||
}
|
||
}
|