Files
designcraft/crates/ui-egui/src/dialogs.rs
T
zydvc26s67-web 85e3fc879c Merge remote-tracking branch 'origin/main' into zydvc26s67-web-paragraph-styles-save-delete
# Conflicts:
#	crates/doc/src/styles.rs
#	crates/engine/src/cmd/style.rs
#	crates/ui-egui/src/dialogs.rs
#	crates/ui-egui/src/panels/styles.rs
2026-10-09 23:28:38 -07:00

3647 lines
193 KiB
Rust
Raw Blame History

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