mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-28 04:06:54 -06:00
2360 lines
116 KiB
Rust
2360 lines
116 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"}),
|
||
"textFrameOptions" => json!({"columns": 1, "gutter": "1p0", "inset": "0p0", "verticalJustification": "top"}),
|
||
"documentSetup" => json!({}),
|
||
_ => 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" {
|
||
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)).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) {
|
||
ui.label(egui::RichText::new("Hyphenation Exceptions").font(semibold(12.0)));
|
||
ui.label(egui::RichText::new("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("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("Remove").clicked() {
|
||
let _ = app.run("hyphenation.removeException", json!({"word": w}));
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
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("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.
|
||
if !recording.is_empty() {
|
||
let pressed = ui.input(|i| {
|
||
i.events.iter().find_map(|e| match e {
|
||
egui::Event::Key { key, pressed: true, modifiers, .. } => 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 {
|
||
ui.label(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("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("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}),
|
||
));
|
||
}
|
||
|
||
fn print_dialog(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| {
|
||
ui.label("Printer:");
|
||
let cur = d.s("printer");
|
||
if printers.is_empty() {
|
||
ui.label(egui::RichText::new("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();
|
||
ui.label("Copies:");
|
||
text_field(ui, d, "copies", 50.0);
|
||
ui.end_row();
|
||
ui.label("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", "Spreads");
|
||
check(ui, d, "marks", "Printer's Marks");
|
||
check(ui, d, "bleed", "Include Bleed");
|
||
});
|
||
ui.end_row();
|
||
});
|
||
}
|
||
|
||
/// 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"],
|
||
"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(ui: &mut egui::Ui, d: &mut Dialog) {
|
||
egui::Grid::new("ds").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
ui.label("Intent:");
|
||
let cur = d.s("intent");
|
||
egui::ComboBox::from_id_salt("ds_intent")
|
||
.selected_text(match cur.as_str() {
|
||
"web" => "Web",
|
||
"mobile" => "Mobile",
|
||
_ => "Print",
|
||
})
|
||
.width(110.0)
|
||
.show_ui(ui, |ui| {
|
||
for (v, l) in [("print", "Print"), ("web", "Web"), ("mobile", "Mobile")] {
|
||
if ui.selectable_label(cur == v, l).clicked() {
|
||
d.fields.insert("intent".into(), json!(v));
|
||
}
|
||
}
|
||
});
|
||
ui.label("");
|
||
ui.label("");
|
||
ui.end_row();
|
||
ui.label("Number of Pages:");
|
||
text_field(ui, d, "pages", 60.0);
|
||
check(ui, d, "facingPages", "Facing Pages");
|
||
ui.label("");
|
||
ui.end_row();
|
||
ui.label("Start Page #:");
|
||
text_field(ui, d, "startPage", 60.0);
|
||
ui.end_row();
|
||
});
|
||
ui.add_space(8.0);
|
||
ui.label(egui::RichText::new("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| {
|
||
ui.label("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)));
|
||
}
|
||
}
|
||
});
|
||
ui.label("Orientation:");
|
||
ui.horizontal(|ui| {
|
||
for (portrait, l) in [(true, "Portrait"), (false, "Landscape")] {
|
||
if ui.selectable_label((h >= w) == portrait, 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();
|
||
ui.label("Width:");
|
||
text_field(ui, d, "width", 80.0);
|
||
ui.label("Height:");
|
||
text_field(ui, d, "height", 80.0);
|
||
ui.end_row();
|
||
});
|
||
ui.add_space(8.0);
|
||
ui.label(egui::RichText::new("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"] {
|
||
ui.label(e);
|
||
}
|
||
ui.end_row();
|
||
for (k, l) in [("bleed", "Bleed:"), ("slug", "Slug:")] {
|
||
ui.label(l);
|
||
for e in ["Top", "Bottom", "Inside", "Outside"] {
|
||
text_field(ui, d, &format!("{k}{e}"), 60.0);
|
||
}
|
||
ui.end_row();
|
||
}
|
||
});
|
||
check(ui, d, "adjustLayout", "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(ui: &mut egui::Ui, d: &mut Dialog) {
|
||
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| {
|
||
// A fixed height: the separator would otherwise take the whole window.
|
||
ui.set_min_height(240.0);
|
||
ui.set_max_height(240.0);
|
||
ui.vertical(|ui| {
|
||
ui.set_width(150.0);
|
||
for (id, label) in sections {
|
||
if ui.selectable_label(cur == *id, *label).clicked() {
|
||
d.fields.insert("section".into(), json!(id));
|
||
}
|
||
}
|
||
});
|
||
ui.separator();
|
||
ui.vertical(|ui| {
|
||
ui.set_min_width(300.0);
|
||
match cur.as_str() {
|
||
"dictionary" => {
|
||
ui.label(egui::RichText::new("User Dictionary (this document)").font(semibold(12.0)));
|
||
ui.label("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" => {
|
||
ui.label(egui::RichText::new("Dynamic Spelling").font(semibold(12.0)));
|
||
check(ui, d, "dynamicSpelling", "Enable Dynamic Spelling (underline misspelled words)");
|
||
}
|
||
"autocorrect" => {
|
||
ui.label(egui::RichText::new("Options").font(semibold(12.0)));
|
||
check(ui, d, "autocorrect", "Enable Autocorrect");
|
||
ui.add_space(6.0);
|
||
ui.label("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" => {
|
||
ui.label(egui::RichText::new("Options").font(semibold(12.0)));
|
||
check(ui, d, "showNoteAnchors", "Show Note Anchors in Layout View");
|
||
}
|
||
"trackChanges" => {
|
||
ui.label(egui::RichText::new("Show").font(semibold(12.0)));
|
||
check(ui, d, "showAddedText", "Added Text (highlighted)");
|
||
}
|
||
"storyEditor" => {
|
||
ui.label(egui::RichText::new("Text Display Options").font(semibold(12.0)));
|
||
ui.horizontal(|ui| {
|
||
ui.label("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(egui::RichText::new("Document Recovery Data").font(semibold(12.0)));
|
||
ui.horizontal(|ui| {
|
||
ui.label("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" => {
|
||
ui.label(egui::RichText::new("Type Options").font(semibold(12.0)));
|
||
check(ui, d, "typographersQuotes", "Use Typographer's Quotes");
|
||
ui.add_space(6.0);
|
||
ui.label(egui::RichText::new("Smart Text Reflow").font(semibold(12.0)));
|
||
check(ui, d, "smartTextReflow", "Add and remove pages as the primary text frame's story grows and shrinks");
|
||
}
|
||
"composition" => {
|
||
ui.label(egui::RichText::new("Highlight").font(semibold(12.0)));
|
||
check(ui, d, "highlightKeeps", "Keep Violations");
|
||
check(ui, d, "highlightHj", "H&J Violations");
|
||
check(ui, d, "highlightCustomTracking", "Custom Tracking/Kerning");
|
||
check(ui, d, "highlightSubstitutedFonts", "Substituted Fonts");
|
||
}
|
||
"advancedType" => {
|
||
ui.label(egui::RichText::new("Character Settings").font(semibold(12.0)));
|
||
egui::Grid::new("pref_adv").num_columns(3).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
ui.label("");
|
||
ui.label("Size");
|
||
ui.label("Position");
|
||
ui.end_row();
|
||
for (k, l) in [("superscript", "Superscript:"), ("subscript", "Subscript:")] {
|
||
ui.label(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(egui::RichText::new("Options for Black on RGB and Grayscale Devices").font(semibold(12.0)));
|
||
ui.horizontal(|ui| {
|
||
ui.label("On Screen:");
|
||
let rich = d.b("richBlack");
|
||
egui::ComboBox::from_id_salt("pref_black")
|
||
.selected_text(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, l).clicked() {
|
||
d.fields.insert("richBlack".into(), json!(v));
|
||
}
|
||
}
|
||
});
|
||
});
|
||
ui.horizontal(|ui| {
|
||
ui.label("Printing / Exporting:");
|
||
let rich = d.b("richBlackOutput");
|
||
egui::ComboBox::from_id_salt("pref_black_out")
|
||
.selected_text(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, l).clicked() {
|
||
d.fields.insert("richBlackOutput".into(), json!(v));
|
||
}
|
||
}
|
||
});
|
||
});
|
||
if d.fields.contains_key("overprintBlack") {
|
||
ui.add_space(6.0);
|
||
ui.label(egui::RichText::new("[Black] Overprint").font(semibold(12.0)));
|
||
check(ui, d, "overprintBlack", "Overprint [Black] Swatch at 100%");
|
||
}
|
||
}
|
||
"interface" => {
|
||
ui.label(egui::RichText::new("UI Scaling").font(semibold(12.0)));
|
||
ui.horizontal(|ui| {
|
||
ui.label("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" => {
|
||
ui.label(egui::RichText::new("Baseline Grid").font(semibold(12.0)));
|
||
egui::Grid::new("pref_bg").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
ui.label("Color:");
|
||
color_field(ui, d, "bg.color");
|
||
ui.end_row();
|
||
ui.label("Start:");
|
||
text_field(ui, d, "bg.start", 80.0);
|
||
ui.end_row();
|
||
ui.label("Relative To:");
|
||
let cur = d.s("bg.relativeTo");
|
||
egui::ComboBox::from_id_salt("bg_rel").selected_text(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, l).clicked() {
|
||
d.fields.insert("bg.relativeTo".into(), json!(v));
|
||
}
|
||
}
|
||
},
|
||
);
|
||
ui.end_row();
|
||
ui.label("Increment Every:");
|
||
text_field(ui, d, "bg.increment", 80.0);
|
||
ui.end_row();
|
||
ui.label("View Threshold:");
|
||
ui.horizontal(|ui| {
|
||
text_field(ui, d, "bg.viewThreshold", 50.0);
|
||
ui.label("%");
|
||
});
|
||
ui.end_row();
|
||
});
|
||
ui.add_space(6.0);
|
||
ui.label(egui::RichText::new("Document Grid").font(semibold(12.0)));
|
||
egui::Grid::new("pref_grid").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
ui.label("Color:");
|
||
color_field(ui, d, "grid.color");
|
||
ui.end_row();
|
||
ui.label("Horizontal Gridline Every:");
|
||
text_field(ui, d, "grid.horizontal", 80.0);
|
||
ui.end_row();
|
||
ui.label("Vertical Gridline Every:");
|
||
text_field(ui, d, "grid.vertical", 80.0);
|
||
ui.end_row();
|
||
ui.label("Subdivisions:");
|
||
text_field(ui, d, "grid.subdivisions", 50.0);
|
||
ui.end_row();
|
||
});
|
||
check(ui, d, "grid.inBack", "Grids in Back");
|
||
}
|
||
"guides" => {
|
||
ui.label(egui::RichText::new("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:")] {
|
||
ui.label(l);
|
||
color_field(ui, d, k);
|
||
ui.end_row();
|
||
}
|
||
});
|
||
ui.add_space(6.0);
|
||
ui.label(egui::RichText::new("Pasteboard Options").font(semibold(12.0)));
|
||
egui::Grid::new("pref_pb").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
ui.label("Horizontal Margins:");
|
||
text_field(ui, d, "pasteboard.h", 80.0);
|
||
ui.end_row();
|
||
ui.label("Vertical Margins:");
|
||
text_field(ui, d, "pasteboard.v", 80.0);
|
||
ui.end_row();
|
||
});
|
||
}
|
||
"display" => {
|
||
ui.label(egui::RichText::new("Options").font(semibold(12.0)));
|
||
let cur = d.s("displayQuality");
|
||
ui.horizontal(|ui| {
|
||
ui.label("Default View:");
|
||
egui::ComboBox::from_id_salt("pref_dq")
|
||
.selected_text(match cur.as_str() {
|
||
"fast" => "Fast",
|
||
"typical" => "Typical",
|
||
_ => "High Quality",
|
||
})
|
||
.show_ui(ui, |ui| {
|
||
for (v, l) in [("fast", "Fast"), ("typical", "Typical"), ("high", "High Quality")] {
|
||
if ui.selectable_label(cur == v, l).clicked() {
|
||
d.fields.insert("displayQuality".into(), json!(v));
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
"units" => {
|
||
ui.label(egui::RichText::new("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:")] {
|
||
ui.label(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(cur.label()).width(140.0).show_ui(ui, |ui| {
|
||
for u in Unit::ALL {
|
||
if ui.selectable_label(u == cur, u.label()).clicked() {
|
||
d.fields.insert(key.into(), json!(u));
|
||
}
|
||
}
|
||
});
|
||
ui.end_row();
|
||
}
|
||
});
|
||
ui.add_space(6.0);
|
||
ui.label(egui::RichText::new("Keyboard Increments").font(semibold(12.0)));
|
||
ui.horizontal(|ui| {
|
||
ui.label("Cursor Key:");
|
||
text_field(ui, d, "keyboardIncrement", 80.0);
|
||
});
|
||
}
|
||
_ => {
|
||
ui.label(egui::RichText::new("When Scaling").font(semibold(12.0)));
|
||
check(ui, d, "scaleStrokes", "Include Stroke Weight");
|
||
ui.add_space(6.0);
|
||
ui.label(egui::RichText::new("Transform").font(semibold(12.0)));
|
||
check(ui, d, "dimensionsIncludeStroke", "Dimensions Include Stroke Weight");
|
||
check(ui, d, "transformationsAreTotals", "Transformations are Totals");
|
||
ui.add_space(6.0);
|
||
ui.label(egui::RichText::new("Page Numbering").font(semibold(12.0)));
|
||
check(ui, d, "absolutePageNumbers", "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, 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 title = match d.id.as_str() {
|
||
"newDocument" => "New Document",
|
||
"frameSize" => "Rectangle",
|
||
"goToPage" => "Go to Page",
|
||
"insertTable" => "Create Table",
|
||
"textFrameOptions" => "Text Frame Options",
|
||
"documentSetup" => "Document Setup",
|
||
"findChange" => "Find/Change",
|
||
"paragraphStyleOptions" => "Paragraph Style Options",
|
||
"footnoteOptions" => "Footnote Options",
|
||
"insertXref" => "New Cross-Reference",
|
||
"findFont" => "Find/Replace Font",
|
||
"polygonSettings" => "Polygon Settings",
|
||
"userDictionary" => "User Dictionary",
|
||
"newWorkspace" => "New Workspace",
|
||
"menus" => "Menu Customization",
|
||
"importOptions" => "Import Options",
|
||
"fittingOptions" => "Frame Fitting Options",
|
||
"qrCode" => "Generate QR Code",
|
||
"preferences" => "Preferences",
|
||
"print" => "Print",
|
||
"pdfImport" => "Place PDF",
|
||
"colorSettings" => "Color Settings",
|
||
"keyboardShortcuts" => "Keyboard Shortcuts",
|
||
"colorPicker" => "Color Picker",
|
||
id => match id.strip_prefix("cmd:").and_then(designcraft_engine::find_command) {
|
||
Some(c) => c.label.trim_end_matches('…'),
|
||
None => "Dialog",
|
||
},
|
||
};
|
||
egui::Modal::new(egui::Id::new("dialog")).show(ctx, |ui| {
|
||
ui.set_min_width(380.0);
|
||
ui.set_max_width(640.0);
|
||
ui.label(egui::RichText::new(title).font(semibold(16.0)));
|
||
ui.add_space(10.0);
|
||
match d.id.as_str() {
|
||
"newDocument" => {
|
||
ui.horizontal(|ui| {
|
||
ui.label("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));
|
||
}
|
||
}
|
||
});
|
||
});
|
||
egui::Grid::new("nd").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
ui.label("Width");
|
||
text_field(ui, &mut d, "width", 80.0);
|
||
ui.label("Height");
|
||
text_field(ui, &mut d, "height", 80.0);
|
||
ui.end_row();
|
||
ui.label("Pages");
|
||
text_field(ui, &mut d, "pages", 80.0);
|
||
ui.label("");
|
||
check(ui, &mut d, "facingPages", "Facing Pages");
|
||
ui.end_row();
|
||
ui.label("Columns");
|
||
text_field(ui, &mut d, "columns", 80.0);
|
||
ui.label("Gutter");
|
||
text_field(ui, &mut d, "gutter", 80.0);
|
||
ui.end_row();
|
||
ui.label("Top");
|
||
text_field(ui, &mut d, "marginTop", 80.0);
|
||
ui.label("Bottom");
|
||
text_field(ui, &mut d, "marginBottom", 80.0);
|
||
ui.end_row();
|
||
ui.label("Inside");
|
||
text_field(ui, &mut d, "marginInside", 80.0);
|
||
ui.label("Outside");
|
||
text_field(ui, &mut d, "marginOutside", 80.0);
|
||
ui.end_row();
|
||
ui.label("Bleed");
|
||
text_field(ui, &mut d, "bleed", 80.0);
|
||
ui.label("");
|
||
check(ui, &mut d, "primaryTextFrame", "Primary Text Frame");
|
||
ui.end_row();
|
||
});
|
||
}
|
||
"frameSize" => {
|
||
egui::Grid::new("fs").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
ui.label("Width");
|
||
text_field(ui, &mut d, "width", 90.0);
|
||
ui.end_row();
|
||
ui.label("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, label).clicked() {
|
||
d.fields.insert("grep".into(), json!(grep));
|
||
d.fields.insert("objectMode".into(), json!(false));
|
||
}
|
||
}
|
||
if ui.selectable_label(d.b("objectMode"), "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)")] {
|
||
ui.label(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(), 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();
|
||
}
|
||
ui.label("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, 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("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("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| {
|
||
ui.label("Find what:");
|
||
text_field(ui, &mut d, "find", 260.0);
|
||
ui.end_row();
|
||
ui.label("Change to:");
|
||
text_field(ui, &mut d, "change", 260.0);
|
||
ui.end_row();
|
||
ui.label("Search:");
|
||
let cur = d.s("scope");
|
||
egui::ComboBox::from_id_salt("fcscope").selected_text(&cur).show_ui(ui, |ui| {
|
||
for v in ["document", "story", "selection"] {
|
||
if ui.selectable_label(cur == v, v).clicked() {
|
||
d.fields.insert("scope".into(), json!(v));
|
||
}
|
||
}
|
||
});
|
||
ui.end_row();
|
||
});
|
||
ui.horizontal(|ui| {
|
||
check(ui, &mut d, "caseSensitive", "Case sensitive");
|
||
check(ui, &mut d, "wholeWord", "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("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("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("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),
|
||
"footnoteOptions" => footnote_options(app, ui, &mut d),
|
||
"insertXref" => insert_xref(app, ui, &mut d),
|
||
"findFont" => find_font(app, ui, &mut d),
|
||
"colorPicker" => color_picker(ui, &mut d),
|
||
"preferences" => preferences(ui, &mut d),
|
||
"print" => print_dialog(ui, &mut d),
|
||
"pdfImport" => {
|
||
ui.label(egui::RichText::new(d.s("path")).size(11.0));
|
||
ui.horizontal(|ui| {
|
||
ui.label(format!("Page (1–{}):", d.n("pages").unwrap_or(1.0)));
|
||
text_field(ui, &mut d, "page", 60.0);
|
||
});
|
||
ui.horizontal(|ui| {
|
||
ui.label("Crop to:");
|
||
let cur = d.s("crop");
|
||
let label = |v: &str| match v {
|
||
"trim" => "Trim",
|
||
"bleed" => "Bleed",
|
||
"art" => "Art",
|
||
"media" => "Media",
|
||
_ => "Crop",
|
||
};
|
||
egui::ComboBox::from_id_salt("pdf_crop").selected_text(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));
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
"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")] {
|
||
ui.label(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();
|
||
}
|
||
ui.label("Intent:");
|
||
let cur = d.s("intent");
|
||
egui::ComboBox::from_id_salt("ws_intent")
|
||
.selected_text(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, i.label()).clicked() {
|
||
d.fields.insert("intent".into(), json!(i.id()));
|
||
}
|
||
}
|
||
});
|
||
ui.end_row();
|
||
});
|
||
check(ui, &mut d, "bpc", "Use Black Point Compensation");
|
||
if ui.button("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, format!("{menu} › {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", "Remove Styles and Formatting from Text and Tables");
|
||
if !d.b("removeStyles") {
|
||
ui.label(egui::RichText::new("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() { "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));
|
||
}
|
||
}
|
||
});
|
||
ui.label(egui::RichText::new("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(shown).width(170.0).show_ui(ui, |ui| {
|
||
if ui.selectable_label(mapped.is_empty(), "(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();
|
||
}
|
||
});
|
||
});
|
||
}
|
||
}
|
||
"newWorkspace" => {
|
||
ui.horizontal(|ui| {
|
||
ui.label("Name:");
|
||
text_field(ui, &mut d, "name", 220.0);
|
||
});
|
||
ui.label(egui::RichText::new("Saves which bars show and where the panels are.").size(10.5));
|
||
}
|
||
"fittingOptions" => {
|
||
check(ui, &mut d, "autoFit", "Auto-Fit");
|
||
ui.add_space(4.0);
|
||
ui.label(egui::RichText::new("Content Fitting").font(semibold(12.0)));
|
||
let cur = d.s("fitting");
|
||
let label = |v: &str| match v {
|
||
"fillProportionally" => "Fill Frame Proportionally",
|
||
"fitProportionally" => "Fit Content Proportionally",
|
||
"fitContentToFrame" => "Fit Content to Frame",
|
||
"centerContent" => "Center Content",
|
||
_ => "None",
|
||
};
|
||
ui.horizontal(|ui| {
|
||
ui.label("Fitting:");
|
||
egui::ComboBox::from_id_salt("ff_fit").selected_text(label(&cur)).width(200.0).show_ui(ui, |ui| {
|
||
for v in ["none", "fillProportionally", "fitProportionally", "fitContentToFrame", "centerContent"] {
|
||
if ui.selectable_label(cur == v, label(v)).clicked() {
|
||
d.fields.insert("fitting".into(), json!(v));
|
||
}
|
||
}
|
||
});
|
||
});
|
||
ui.horizontal(|ui| {
|
||
ui.label("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();
|
||
}
|
||
});
|
||
});
|
||
ui.label(egui::RichText::new("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:")] {
|
||
ui.label(l);
|
||
text_field(ui, &mut d, k, 70.0);
|
||
}
|
||
ui.end_row();
|
||
for (k, l) in [("cropBottom", "Bottom:"), ("cropRight", "Right:")] {
|
||
ui.label(l);
|
||
text_field(ui, &mut d, k, 70.0);
|
||
}
|
||
ui.end_row();
|
||
});
|
||
}
|
||
"qrCode" => {
|
||
let cur = d.s("type");
|
||
ui.horizontal(|ui| {
|
||
ui.label("Type:");
|
||
let label = |v: &str| match v {
|
||
"text" => "Plain Text",
|
||
"sms" => "Text Message",
|
||
"email" => "Email",
|
||
"vcard" => "Business Card",
|
||
_ => "Web Hyperlink",
|
||
};
|
||
egui::ComboBox::from_id_salt("qr_type").selected_text(label(&cur)).width(160.0).show_ui(ui, |ui| {
|
||
for v in ["url", "text", "sms", "email", "vcard"] {
|
||
if ui.selectable_label(cur == v, 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 {
|
||
ui.label(*l);
|
||
text_field(ui, &mut d, k, 260.0);
|
||
ui.end_row();
|
||
}
|
||
ui.label("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| {
|
||
ui.label("Number of Sides:");
|
||
text_field(ui, &mut d, "sides", 60.0);
|
||
ui.end_row();
|
||
ui.label("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(ui, &mut d),
|
||
"goToPage" => {
|
||
ui.horizontal(|ui| {
|
||
ui.label("Page");
|
||
text_field(ui, &mut d, "page", 80.0);
|
||
});
|
||
}
|
||
"insertTable" => {
|
||
ui.label(egui::RichText::new("Table Dimensions").font(semibold(12.0)));
|
||
egui::Grid::new("ins_table").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
ui.label("Body Rows");
|
||
text_field(ui, &mut d, "bodyRows", 60.0);
|
||
ui.label("Columns");
|
||
text_field(ui, &mut d, "columns", 60.0);
|
||
ui.end_row();
|
||
ui.label("Header Rows");
|
||
text_field(ui, &mut d, "headerRows", 60.0);
|
||
ui.label("Footer Rows");
|
||
text_field(ui, &mut d, "footerRows", 60.0);
|
||
ui.end_row();
|
||
});
|
||
}
|
||
"textFrameOptions" => {
|
||
egui::Grid::new("tfo").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
ui.label("Number of columns");
|
||
text_field(ui, &mut d, "columns", 80.0);
|
||
ui.end_row();
|
||
ui.label("Gutter");
|
||
text_field(ui, &mut d, "gutter", 80.0);
|
||
ui.end_row();
|
||
ui.label("Inset spacing");
|
||
text_field(ui, &mut d, "inset", 80.0);
|
||
ui.end_row();
|
||
ui.label("Vertical justification");
|
||
let cur = d.s("verticalJustification");
|
||
egui::ComboBox::from_id_salt("vj").selected_text(&cur).show_ui(ui, |ui| {
|
||
for v in ["top", "center", "bottom", "justify"] {
|
||
if ui.selectable_label(cur == v, v).clicked() {
|
||
d.fields.insert("verticalJustification".into(), json!(v));
|
||
}
|
||
}
|
||
});
|
||
ui.end_row();
|
||
});
|
||
}
|
||
"documentSetup" => document_setup(ui, &mut d),
|
||
_ => {}
|
||
}
|
||
ui.add_space(12.0);
|
||
ui.horizontal(|ui| {
|
||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||
if ui
|
||
.add(
|
||
egui::Button::new(egui::RichText::new(" OK ").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);
|
||
}
|
||
if ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||
result = Some(false);
|
||
}
|
||
});
|
||
});
|
||
});
|
||
app.ui.dialog = Some(d);
|
||
match result {
|
||
Some(true) => {
|
||
let _ = confirm(app);
|
||
}
|
||
Some(false) => app.ui.dialog = None,
|
||
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()) };
|
||
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]}),
|
||
)
|
||
}
|
||
"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" => app.run(
|
||
"object.textFrameOptions",
|
||
json!({"columns": d.n("columns").unwrap_or(1.0) as u64, "gutter": d.m("gutter").unwrap_or(12.0), "inset": d.m("inset").unwrap_or(0.0), "verticalJustification": d.s("verticalJustification")}),
|
||
),
|
||
"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"), "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.") {
|
||
para.insert(a.into(), v.clone());
|
||
} else if let Some(a) = k.strip_prefix("c.") {
|
||
chars.insert(a.into(), v.clone());
|
||
}
|
||
}
|
||
let mut params = json!({"name": name, "para": para, "chars": chars});
|
||
let based = d.s("basedOn");
|
||
if !based.is_empty() {
|
||
params["basedOn"] = if based == "[No Paragraph Style]" { Value::Null } else { json!(based) };
|
||
}
|
||
let rename = d.s("rename");
|
||
if !rename.is_empty() && rename != name {
|
||
params["rename"] = json!(rename);
|
||
}
|
||
app.run("style.paragraph.edit", params)
|
||
}
|
||
"colorPicker" => {
|
||
let hex = d.s("hex");
|
||
app.run("object.color", json!({"color": hex, "target": d.s("target")}))
|
||
}
|
||
"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");
|
||
app.run("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)
|
||
}
|
||
"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"), "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("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"));
|
||
app.run("document.preferences", doc)
|
||
}
|
||
"newWorkspace" => app.run("window.newWorkspace", json!({"name": d.s("name")})),
|
||
"importOptions" => app.run(
|
||
"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 app.session.active().is_some_and(|s| s.selection.items.is_empty()) {
|
||
let st = app.session.active().expect("doc");
|
||
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 (f, st) = (d.s("family"), d.s("style"));
|
||
if f.is_empty() || d.s("toFamily").is_empty() {
|
||
return Ok(Value::Null);
|
||
}
|
||
app.run("font.replace", json!({"family": f, "style": st, "toFamily": d.s("toFamily"), "toStyle": d.s("toStyle")}))
|
||
}
|
||
"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)
|
||
}
|
||
"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::Bool(b)) => {
|
||
p.insert(f.key, json!(b));
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
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(());
|
||
}
|
||
regex::Regex::new(p).map(|_| ()).map_err(|_| ())
|
||
}
|
||
|
||
fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||
let name = d.s("name");
|
||
let Some(st) = app.session.active() else { return };
|
||
let Some(style) = st.doc.styles.para(&name).cloned() else {
|
||
ui.label(format!("No style named {name}"));
|
||
return;
|
||
};
|
||
let names: Vec<String> = st.doc.styles.paragraph.iter().map(|p| p.name.clone()).filter(|n| *n != name).collect();
|
||
let (pp, cp) = st.doc.styles.resolve_para_style(&name);
|
||
let units = st.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);
|
||
ui.vertical(|ui| {
|
||
ui.set_width(170.0);
|
||
for (id, label) in [
|
||
("general", "General"),
|
||
("chars", "Basic Character Formats"),
|
||
("indents", "Indents and Spacing"),
|
||
("hyph", "Hyphenation"),
|
||
("justify", "Justification"),
|
||
("nested", "Drop Caps and Nested Styles"),
|
||
("grep", "GREP Style"),
|
||
("color", "Character Color"),
|
||
] {
|
||
if ui.selectable_label(d.s("section") == id, label).clicked() {
|
||
d.fields.insert("section".into(), json!(id));
|
||
}
|
||
}
|
||
});
|
||
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() {
|
||
"nested" => {
|
||
ui.label(egui::RichText::new("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(&style).width(110.0).show_ui(ui, |ui| {
|
||
for c in &cnames {
|
||
if ui.selectable_label(*c == style, 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(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, l).clicked() {
|
||
ns["through"] = json!(v);
|
||
changed = true;
|
||
}
|
||
}
|
||
},
|
||
);
|
||
let mut n = ns["count"].as_u64().unwrap_or(1) as u32;
|
||
if ui.add(egui::DragValue::new(&mut n).range(1..=999)).changed() {
|
||
ns["count"] = json!(n);
|
||
changed = true;
|
||
}
|
||
let kind = ns["until"]["kind"].as_str().unwrap_or("words").to_string();
|
||
egui::ComboBox::from_id_salt(("ns_until", i)).selected_text(&kind).width(100.0).show_ui(ui, |ui| {
|
||
for k in
|
||
["sentences", "words", "characters", "letters", "digits", "tab", "forcedLineBreak", "emSpace", "enSpace", "chars"]
|
||
{
|
||
if ui.selectable_label(k == kind, k).clicked() {
|
||
ns["until"] = if k == "chars" { json!({"kind": "chars", "chars": ":"}) } else { json!({"kind": k}) };
|
||
changed = true;
|
||
}
|
||
}
|
||
});
|
||
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_text("Delete").clicked() {
|
||
remove = Some(i);
|
||
}
|
||
});
|
||
}
|
||
if let Some(i) = remove {
|
||
list.remove(i);
|
||
changed = true;
|
||
}
|
||
if ui.button("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);
|
||
ui.label(egui::RichText::new("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(&style).width(130.0).show_ui(ui, |ui| {
|
||
for c in &cnames {
|
||
if ui.selectable_label(*c == style, c).clicked() {
|
||
l["style"] = json!(c);
|
||
changed = true;
|
||
}
|
||
}
|
||
});
|
||
ui.label("for");
|
||
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;
|
||
}
|
||
ui.label(if n == 1 { "line" } else { "lines" });
|
||
if ui.small_button("×").on_hover_text("Delete").clicked() {
|
||
remove = Some(i);
|
||
}
|
||
});
|
||
}
|
||
if let Some(i) = remove {
|
||
lines.remove(i);
|
||
changed = true;
|
||
}
|
||
if ui.button("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" => {
|
||
ui.label(egui::RichText::new("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| {
|
||
ui.label("Apply Style:");
|
||
let style = g["style"].as_str().unwrap_or("").to_string();
|
||
egui::ComboBox::from_id_salt(("gs_style", i)).selected_text(&style).width(110.0).show_ui(ui, |ui| {
|
||
for c in &cnames {
|
||
if ui.selectable_label(*c == style, c).clicked() {
|
||
g["style"] = json!(c);
|
||
changed = true;
|
||
}
|
||
}
|
||
});
|
||
ui.label("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_text("Delete").clicked() {
|
||
remove = Some(i);
|
||
}
|
||
});
|
||
}
|
||
if let Some(i) = remove {
|
||
list.remove(i);
|
||
changed = true;
|
||
}
|
||
if ui.button("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" => {
|
||
egui::Grid::new("psc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
ui.label("Font Family:");
|
||
let fam = cur(d, "c.fontFamily", &cv["fontFamily"]).as_str().unwrap_or("").to_string();
|
||
egui::ComboBox::from_id_salt("psfam").selected_text(&fam).width(200.0).show_ui(ui, |ui| {
|
||
for f in designcraft_fonts::FontDb::global().families() {
|
||
if ui.selectable_label(f == fam, &f).clicked() {
|
||
d.fields.insert("c.fontFamily".into(), json!(f));
|
||
}
|
||
}
|
||
});
|
||
ui.end_row();
|
||
ui.label("Font Style:");
|
||
let sty = cur(d, "c.fontStyle", &cv["fontStyle"]).as_str().unwrap_or("").to_string();
|
||
egui::ComboBox::from_id_salt("pssty").selected_text(&sty).width(200.0).show_ui(ui, |ui| {
|
||
for s in designcraft_fonts::FontDb::global().styles(&fam) {
|
||
if ui.selectable_label(s == sty, &s).clicked() {
|
||
d.fields.insert("c.fontStyle".into(), json!(s));
|
||
}
|
||
}
|
||
});
|
||
ui.end_row();
|
||
for (label, key, suffix) in [("Size:", "size", " pt"), ("Tracking:", "tracking", "")] {
|
||
ui.label(label);
|
||
let v = cur(d, &format!("c.{key}"), &cv[key]).as_f64();
|
||
if let Some(n) = crate::widgets::number(ui, &format!("ps{key}"), v, suffix, 80.0, 2) {
|
||
d.fields.insert(format!("c.{key}"), json!(n));
|
||
}
|
||
ui.end_row();
|
||
}
|
||
ui.label("Leading:");
|
||
let lv = match cur(d, "c.leading", &cv["leading"]) {
|
||
v if v["kind"] == "points" => v["value"].as_f64(),
|
||
_ => None,
|
||
};
|
||
if let Some(n) = crate::widgets::number(ui, "pslead", lv, " pt", 80.0, 2) {
|
||
d.fields.insert("c.leading".into(), json!({"kind": "points", "value": n}));
|
||
}
|
||
ui.end_row();
|
||
});
|
||
}
|
||
"indents" => {
|
||
egui::Grid::new("psi").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
ui.label("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(a.label()).width(240.0).show_ui(ui, |ui| {
|
||
for al in designcraft_doc::Align::ALL {
|
||
if ui.selectable_label(al == a, 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"),
|
||
] {
|
||
ui.label(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();
|
||
}
|
||
});
|
||
}
|
||
"hyph" => {
|
||
let mut h = cur(d, "p.hyphenate", &pv["hyphenate"]).as_bool().unwrap_or(true);
|
||
if ui.checkbox(&mut h, "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"),
|
||
] {
|
||
ui.label(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("");
|
||
ui.label("Minimum");
|
||
ui.label("Desired");
|
||
ui.label("Maximum");
|
||
ui.end_row();
|
||
for (label, base) in [("Word Spacing:", "wordSpace"), ("Letter Spacing:", "letterSpace"), ("Glyph Scaling:", "glyphScale")] {
|
||
ui.label(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) = crate::widgets::number(ui, &format!("ps{key}"), v, "%", 60.0, 0) {
|
||
d.fields.insert(format!("p.{key}"), json!(n / 100.0));
|
||
}
|
||
}
|
||
ui.end_row();
|
||
}
|
||
ui.label("Composer:");
|
||
let single = cur(d, "p.composer", &pv["composer"]).as_str() == Some("singleLine");
|
||
if ui.selectable_label(!single, "Paragraph Composer").clicked() {
|
||
d.fields.insert("p.composer".into(), json!("paragraph"));
|
||
}
|
||
if ui.selectable_label(single, "Single-line Composer").clicked() {
|
||
d.fields.insert("p.composer".into(), json!("singleLine"));
|
||
}
|
||
ui.end_row();
|
||
});
|
||
}
|
||
"color" => {
|
||
let cur_fill = cur(d, "c.fill", &cv["fill"]).as_str().unwrap_or("").to_string();
|
||
let swatches: Vec<String> = st.doc.swatches.iter().map(|s| s.name.clone()).collect();
|
||
egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| {
|
||
for sw in swatches {
|
||
let (c, g) = crate::widgets::swatch_colors(&st.doc, &sw, 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 == cur_fill, &sw).clicked() {
|
||
d.fields.insert("c.fill".into(), json!(sw));
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}
|
||
_ => {
|
||
egui::Grid::new("psg").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
ui.label("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();
|
||
ui.label("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(&based).width(220.0).show_ui(ui, |ui| {
|
||
for n in &names {
|
||
if ui.selectable_label(*n == based, n).clicked() {
|
||
d.fields.insert("basedOn".into(), json!(n));
|
||
}
|
||
}
|
||
});
|
||
ui.end_row();
|
||
});
|
||
ui.add_space(8.0);
|
||
ui.label(
|
||
egui::RichText::new(format!("{} {} {:.1} pt · {}", cp.font_family, cp.font_style, cp.size, pp.align.label()))
|
||
.color(crate::theme::Tokens::get(ui.ctx()).text_dim),
|
||
);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
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(shown).width(170.0).show_ui(ui, |ui| {
|
||
for (v, label) in opts {
|
||
if ui.selectable_label(cur == *v, *label).clicked() {
|
||
d.fields.insert(key.into(), json!(v));
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/// 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(), n.as_str())).collect();
|
||
if character && !names.iter().any(|n| n == designcraft_doc::NO_CHAR_STYLE) {
|
||
opts.insert(0, (designcraft_doc::NO_CHAR_STYLE, designcraft_doc::NO_CHAR_STYLE));
|
||
}
|
||
combo(ui, d, key, &opts);
|
||
}
|
||
|
||
/// 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, label).clicked() {
|
||
d.fields.insert("tab".into(), json!(tab));
|
||
}
|
||
}
|
||
});
|
||
ui.separator();
|
||
let head = |ui: &mut egui::Ui, t: &str| {
|
||
ui.add_space(4.0);
|
||
ui.label(egui::RichText::new(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| {
|
||
ui.label("Minimum Space Before First Footnote:");
|
||
text_field(ui, d, "spaceBefore", 70.0);
|
||
ui.end_row();
|
||
ui.label("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| {
|
||
ui.label("Offset:");
|
||
combo(
|
||
ui,
|
||
d,
|
||
"firstBaseline",
|
||
&[("ascent", "Ascent"), ("capHeight", "Cap Height"), ("leading", "Leading"), ("xHeight", "x Height"), ("fixed", "Fixed")],
|
||
);
|
||
ui.end_row();
|
||
ui.label("Min:");
|
||
text_field(ui, d, "firstBaselineMin", 70.0);
|
||
ui.end_row();
|
||
});
|
||
check(ui, d, "spanColumns", "Span Footnotes Across Columns");
|
||
head(ui, "Rule Above");
|
||
check(ui, d, "rule.on", "Rule On");
|
||
egui::Grid::new("fn_rule").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
ui.label("Weight:");
|
||
text_field(ui, d, "rule.weight", 60.0);
|
||
ui.label("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();
|
||
ui.label("Width:");
|
||
text_field(ui, d, "rule.width", 60.0);
|
||
ui.label("Offset:");
|
||
text_field(ui, d, "rule.offset", 60.0);
|
||
ui.end_row();
|
||
ui.label("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| {
|
||
ui.label("Style:");
|
||
combo(
|
||
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();
|
||
ui.label("Start at:");
|
||
text_field(ui, d, "startAt", 60.0);
|
||
ui.end_row();
|
||
ui.label("Restart Numbering Every:");
|
||
combo(ui, d, "restart", &[("never", "Never (continuous)"), ("page", "Page"), ("spread", "Spread"), ("section", "Section")]);
|
||
ui.end_row();
|
||
ui.label("Show Prefix/Suffix in:");
|
||
combo(ui, d, "affixIn", &[("none", "None"), ("reference", "Footnote Reference"), ("text", "Footnote Text"), ("both", "Both")]);
|
||
ui.end_row();
|
||
ui.label("Prefix:");
|
||
text_field(ui, d, "prefix", 60.0);
|
||
ui.end_row();
|
||
ui.label("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| {
|
||
ui.label("Position:");
|
||
combo(
|
||
ui,
|
||
d,
|
||
"refPosition",
|
||
&[
|
||
("superscript", "Apply Superscript"),
|
||
("subscript", "Apply Subscript"),
|
||
("normal", "Apply Normal"),
|
||
("otSuperscript", "OpenType Superscript"),
|
||
],
|
||
);
|
||
ui.end_row();
|
||
ui.label("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| {
|
||
ui.label("Paragraph Style:");
|
||
style_combo(app, ui, d, "paraStyle", false);
|
||
ui.end_row();
|
||
ui.label("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 {
|
||
let part = part.trim();
|
||
let (k, spec) = match part.split_once(':') {
|
||
Some((k, v)) => (k.trim(), v.trim()),
|
||
None => (part, ""),
|
||
};
|
||
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(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(format!("{}{}", 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![("", "—")];
|
||
opts.extend(f.choices.iter().map(|c| (c.as_str(), c.as_str())));
|
||
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| {
|
||
ui.label("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(egui::RichText::new("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);
|
||
ui.label(egui::RichText::new("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| {
|
||
ui.label("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();
|
||
let missing = fonts.iter().filter(|f| f["missing"] == true || f["styleMissing"] == true).count();
|
||
ui.label(format!("Fonts in Document: {} Missing: {missing}", fonts.len()));
|
||
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 {
|
||
text = text.color(egui::Color32::from_rgb(0xe5, 0x4b, 0x4b));
|
||
}
|
||
if ui.selectable_label(on, text).clicked() {
|
||
d.fields.insert("family".into(), json!(fam));
|
||
d.fields.insert("style".into(), json!(st));
|
||
}
|
||
}
|
||
});
|
||
ui.add_space(8.0);
|
||
ui.label(egui::RichText::new("Replace With").font(semibold(12.0)));
|
||
let db = designcraft_fonts::FontDb::global();
|
||
let families = db.families();
|
||
let fam_opts: Vec<(&str, &str)> = families.iter().map(|f| (f.as_str(), f.as_str())).collect();
|
||
egui::Grid::new("ff_to").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||
ui.label("Font Family:");
|
||
combo(ui, d, "toFamily", &fam_opts);
|
||
ui.end_row();
|
||
let styles = db.styles(&d.s("toFamily"));
|
||
let st_opts: Vec<(&str, &str)> = styles.iter().map(|s| (s.as_str(), s.as_str())).collect();
|
||
ui.label("Font Style:");
|
||
combo(ui, d, "toStyle", &st_opts);
|
||
ui.end_row();
|
||
});
|
||
ui.label(
|
||
egui::RichText::new("OK changes all: text and paragraph/character styles.").color(crate::theme::Tokens::get(ui.ctx()).text_dim).size(11.0),
|
||
);
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[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());
|
||
// Every command with a "…" label parses without panicking.
|
||
for c in designcraft_engine::command_specs() {
|
||
let _ = command_fields(c.params);
|
||
}
|
||
}
|
||
}
|