Styles: Character Style Options dialog (#144)

* Styles: editing a character style renames it everywhere it is used, changes its Based On and unsets attributes; [None] can't be edited

* UI: Character Style Options edits a character style's name, Based On and character formats, opened by double-clicking the style, Edit in its menu, or New Character Style…

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
aiongg 2026-10-10 02:14:30 -04:00 committed by GitHub
parent b9febf68c1
commit 617e487dff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1034 additions and 100 deletions

View File

@ -336,6 +336,15 @@ impl Styles {
cp
}
/// The attributes character style `name` sets, with those of its Based On chain beneath them.
pub fn char_style_attrs(&self, name: &str) -> CharAttrs {
let mut a = CharAttrs::default();
for s in self.char_chain(name) {
a.merge(&s.chars);
}
a
}
/// Would making `name` based on `parent` create a cycle?
pub fn para_based_on_cycles(&self, name: &str, parent: &str) -> bool {
name == parent || self.para_chain(parent).iter().any(|s| s.name == name)
@ -402,6 +411,24 @@ mod tests {
assert!(!st.para_based_on_cycles(BASIC_PARAGRAPH, NO_PARA_STYLE));
}
#[test]
fn character_style_attrs_include_its_based_on_chain_only() {
let mut st = Styles::default();
let cs = |name: &str, based_on: Option<&str>, chars| CharacterStyle {
name: name.into(),
based_on: based_on.map(Into::into),
chars,
shortcut: String::new(),
};
st.character.push(cs("Base", None, CharAttrs { font_style: Some("Bold".into()), size: Some(9.0), ..Default::default() }));
st.character.push(cs("Strong", Some("Base"), CharAttrs { size: Some(14.0), ..Default::default() }));
st.character.push(cs("Loop", Some("Loop"), CharAttrs { tracking: Some(5.0), ..Default::default() }));
assert_eq!(st.char_style_attrs("Strong"), CharAttrs { font_style: Some("Bold".into()), size: Some(14.0), ..Default::default() });
assert_eq!(st.char_style_attrs("Loop"), CharAttrs { tracking: Some(5.0), ..Default::default() });
assert!(st.char_style_attrs(NO_CHAR_STYLE).is_empty());
assert!(st.char_style_attrs("Missing").is_empty());
}
#[test]
fn unique_names() {
let names = ["Body", "Body 2"];

View File

@ -100,8 +100,24 @@ pub fn specs() -> Vec<CommandSpec> {
edit_para
),
cmd!("style.paragraph.delete", "Delete Paragraph Style", [], None, "{name, replaceWith?}", has_doc, delete_para),
cmd!("style.character.create", "New Character Style…", [], None, "{name, basedOn?, chars?: {…}}", has_doc, create_char),
cmd!("style.character.edit", "Character Style Options…", [], None, "{name, rename?, basedOn?, chars?}", has_doc, edit_char),
cmd!(
"style.character.create",
"New Character Style…",
[],
None,
"{name, basedOn?: style | \"[None]\", chars?: {…}} — the style sets only `chars` → {name} (made unique)",
has_doc,
create_char
),
cmd!(
"style.character.edit",
"Character Style Options…",
[],
None,
"{name, rename?, basedOn?: style | \"[None]\" | null, chars?: {attr: value | null (no longer set)}} — renaming updates every use",
has_doc,
edit_char
),
cmd!(query "style.list", "List Styles", [], None, "{} → paragraph and character style names", has_doc, |s, _| {
let st = s.doc()?;
Ok(json!({
@ -507,6 +523,12 @@ fn rename_style(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &
s.based_on = Some(to.to_string());
}
}
for s in &mut st.paragraph {
rename_char_refs(&mut s.para, from, to);
}
if d.footnote_options.ref_char_style == from {
d.footnote_options.ref_char_style = to.to_string();
}
}
for sid in d.stories.keys().copied().collect::<Vec<_>>() {
let Some(story) = d.story_mut(sid) else { continue };
@ -519,12 +541,26 @@ fn rename_style(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &
for r in st.chars.iter_mut().filter(|r| r.format.style == from) {
r.format.style = to.to_string();
}
for f in &mut st.paras {
rename_char_refs(&mut f.para, from, to);
}
}
st.rev += 1;
});
}
}
/// Point the nested, nested line and GREP styles of paragraph attributes at a renamed character style.
fn rename_char_refs(p: &mut ParaAttrs, from: &str, to: &str) {
let names = p.nested_styles.iter_mut().flatten().map(|n| &mut n.style);
let names = names.chain(p.nested_line_styles.iter_mut().flatten().map(|n| &mut n.style));
for n in names.chain(p.grep_styles.iter_mut().flatten().map(|g| &mut g.style)) {
if n == from {
*n = to.to_string();
}
}
}
fn group_styles(s: &mut Session, p: &Value) -> Result<Value> {
let para = match str_param(p, "kind").unwrap_or("paragraph") {
"paragraph" => true,
@ -583,8 +619,14 @@ fn delete_para(s: &mut Session, p: &Value) -> Result<Value> {
fn create_char(s: &mut Session, p: &Value) -> Result<Value> {
let base = str_param(p, "name").unwrap_or("Character Style 1").to_string();
let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
let based_on = str_param(p, "basedOn").map(str::to_string);
// Based on [None] is based on nothing.
let based_on = str_param(p, "basedOn").filter(|b| *b != designcraft_doc::NO_CHAR_STYLE).map(str::to_string);
s.edit(|d, _| {
if let Some(b) = &based_on
&& d.styles.char_style(b).is_none()
{
return Err(bad("style.character.create", format!("no character style `{b}`")));
}
let name = Styles::unique_name(|n| d.styles.char_style(n).is_some(), &base);
d.styles_mut().character.push(CharacterStyle { name: name.clone(), based_on, chars, shortcut: String::new() });
Ok(json!({"name": name}))
@ -593,10 +635,50 @@ fn create_char(s: &mut Session, p: &Value) -> Result<Value> {
fn edit_char(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("style.character.edit", "missing name"))?.to_string();
if name == designcraft_doc::NO_CHAR_STYLE {
return Err(bad("style.character.edit", "[None] can't be edited"));
}
let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
// `null` unsets an attribute (the style no longer sets it).
let unset: Vec<String> = p
.get("chars")
.and_then(Value::as_object)
.map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect())
.unwrap_or_default();
let rename = str_param(p, "rename").filter(|n| *n != name).map(|n| n.trim().to_string());
let based: Option<Option<String>> = match p.get("basedOn") {
None => None,
Some(Value::Null) => Some(None),
Some(Value::String(b)) if b == designcraft_doc::NO_CHAR_STYLE => Some(None),
Some(Value::String(b)) => Some(Some(b.clone())),
Some(_) => return Err(bad("style.character.edit", "`basedOn` is a style name or null")),
};
s.edit(|d, _| {
let st = d.styles_mut().char_style_mut(&name).ok_or_else(|| bad("style.character.edit", "no such style"))?;
if let Some(Some(b)) = &based {
if d.styles.char_style(b).is_none() {
return Err(bad("style.character.edit", format!("no character style `{b}`")));
}
if d.styles.char_based_on_cycles(&name, b) {
return Err(bad("style.character.edit", "based-on would create a cycle"));
}
}
if let Some(n) = &rename
&& (n.is_empty() || n.starts_with('[') || d.styles.char_style(n).is_some())
{
return Err(bad("style.character.edit", format!("can't rename to `{n}`")));
}
let st = d.styles_mut().char_style_mut(&name).ok_or_else(|| bad("style.character.edit", format!("no character style `{name}`")))?;
st.chars.merge(&chars);
for k in &unset {
st.chars.set_json(k, &Value::Null).map_err(|e| bad("style.character.edit", e))?;
}
if let Some(b) = based.clone() {
st.based_on = b;
}
if let Some(n) = &rename {
// Every use: other styles, stories, table cells, nested and GREP styles, footnotes.
rename_style(d, false, &name, n);
}
ok()
})
}
@ -977,6 +1059,73 @@ mod color_tests {
assert!(s.execute("style.group", &json!({"names": ["[Basic Paragraph]"], "group": "X"})).is_err());
}
#[test]
fn character_style_edit_sets_unsets_rebases_and_renames_every_use() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("style.character.create", &json!({"name": "Base", "chars": {"fontStyle": "Bold"}})).unwrap();
s.execute("style.character.create", &json!({"name": "Emphasis", "chars": {"fontStyle": "Italic", "tracking": 20}})).unwrap();
s.execute(
"style.paragraph.create",
&json!({"name": "Lead", "para": {
"nestedStyles": [{"style": "Emphasis", "through": true, "count": 1, "until": {"kind": "words"}}],
"grepStyles": [{"style": "Emphasis", "pattern": "\\d+"}],
"nestedLineStyles": [{"style": "Emphasis", "lines": 1}]}}),
)
.unwrap();
s.execute("footnote.options", &json!({"refCharStyle": "Emphasis"})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hi there"})).unwrap();
let sid = r["story"].as_u64().unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 2})).unwrap();
s.execute("style.character.apply", &json!({"name": "Emphasis"})).unwrap();
s.execute(
"style.character.edit",
&json!({"name": "Emphasis", "rename": "Strong", "basedOn": "Base", "chars": {"size": 14, "fill": "[Paper]", "fontStyle": null}}),
)
.unwrap();
let d = s.doc().unwrap().doc.clone();
assert!(d.styles.char_style("Emphasis").is_none());
let st = d.styles.char_style("Strong").unwrap();
assert_eq!(st.based_on.as_deref(), Some("Base"));
// Exactly the attributes set: `null` unset the font style, the rest stayed unset.
assert_eq!(st.chars, CharAttrs { size: Some(14.0), fill: Some("[Paper]".into()), tracking: Some(20.0), ..Default::default() });
let story = d.story(designcraft_doc::StoryId(sid)).unwrap();
assert!(story.chars.iter().any(|r| r.format.style == "Strong"));
assert!(story.chars.iter().all(|r| r.format.style != "Emphasis"));
let lead = d.styles.para("Lead").unwrap();
assert_eq!(lead.para.nested_styles.as_ref().unwrap()[0].style, "Strong");
assert_eq!(lead.para.grep_styles.as_ref().unwrap()[0].style, "Strong");
assert_eq!(lead.para.nested_line_styles.as_ref().unwrap()[0].style, "Strong");
assert_eq!(d.footnote_options.ref_char_style, "Strong");
// Based On [None] (null or the name) clears it.
s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": null})).unwrap();
assert_eq!(s.doc().unwrap().doc.styles.char_style("Strong").unwrap().based_on, None);
s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "Base"})).unwrap();
s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "[None]"})).unwrap();
assert_eq!(s.doc().unwrap().doc.styles.char_style("Strong").unwrap().based_on, None);
// Refused: a based-on cycle, an unknown parent, a taken or empty name, editing [None].
s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "Base"})).unwrap();
assert!(s.execute("style.character.edit", &json!({"name": "Base", "basedOn": "Strong"})).is_err());
assert!(s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "Nope"})).is_err());
assert!(s.execute("style.character.edit", &json!({"name": "Strong", "rename": "Base"})).is_err());
assert!(s.execute("style.character.edit", &json!({"name": "Strong", "rename": " "})).is_err());
assert!(s.execute("style.character.edit", &json!({"name": "[None]", "chars": {"size": 9}})).is_err());
assert!(s.doc().unwrap().doc.styles.char_style("[None]").unwrap().chars.is_empty());
}
#[test]
fn new_character_style_checks_its_based_on() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
assert!(s.execute("style.character.create", &json!({"name": "Orphan", "basedOn": "Nope"})).is_err());
s.execute("style.character.create", &json!({"name": "Plain", "basedOn": "[None]", "chars": {"size": 9}})).unwrap();
let st = s.doc().unwrap().doc.styles.char_style("Plain").cloned().unwrap();
assert_eq!((st.based_on, st.chars), (None, CharAttrs { size: Some(9.0), ..Default::default() }));
}
#[test]
fn swatches_save_and_load_ase() {
let mut s = Session::new();

View File

@ -50,7 +50,7 @@ impl Dialog {
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" {
if id == "paragraphStyleOptions" || id == "characterStyleOptions" {
fields.entry("section".to_string()).or_insert(json!("general"));
}
if id == "frameSize" {
@ -821,6 +821,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
"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"),
"characterStyleOptions" => crate::i18n::tr(&app.ui.language, "Character Style Options"),
"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"),
@ -1065,6 +1066,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
}
}
"paragraphStyleOptions" => paragraph_style_options(app, ui, &mut d),
"characterStyleOptions" => character_style_options(app, ui, &mut d),
"footnoteOptions" => footnote_options(app, ui, &mut d),
"insertXref" => insert_xref(app, ui, &mut d),
"findFont" => find_font(app, ui, &mut d),
@ -1492,24 +1494,71 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
chars.insert(a.into(), v.clone());
}
}
let mut params = json!({"name": name, "para": para, "chars": chars});
let based = d.s("basedOn");
if !based.is_empty() {
params["basedOn"] = if based == "[No Paragraph Style]" { Value::Null } else { json!(based) };
}
let rename = d.s("rename");
if !rename.is_empty() && rename != name {
params["rename"] = json!(rename);
}
let r = app.run("style.paragraph.edit", params)?;
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 };
let style = if !rename.is_empty() && rename != name { rename } else { name };
app.run("style.exportTag", json!({"style": style, "tag": tag, "class": d.s("x.class")}))?;
}
Ok(r)
}
"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
}
"colorPicker" => {
let hex = d.s("hex");
app.run("object.color", json!({"color": hex, "target": d.s("target")}))
@ -1751,13 +1800,29 @@ fn regex_ok(p: &str) -> Result<(), ()> {
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 Some(style) = doc.styles.para(&name).cloned() else {
ui.label(format!("No style named {name}"));
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;
}
};
let names: Vec<String> = doc.styles.paragraph.iter().map(|p| p.name.clone()).filter(|n| *n != name).collect();
let (pp, cp) = doc.styles.resolve_para_style(&name);
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();
@ -1767,9 +1832,11 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
// A fixed height: the separator would otherwise stretch the dialog to the window.
ui.set_min_height(380.0);
ui.set_max_height(380.0);
ui.vertical(|ui| {
ui.set_width(170.0);
for (id, label) in [
section_list(
&app.ui.language,
ui,
d,
&[
("general", "General"),
("chars", "Basic Character Formats"),
("indents", "Indents and Spacing"),
@ -1779,12 +1846,8 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
("grep", "GREP Style"),
("color", "Character Color"),
("export", "Export Tagging"),
] {
if ui.selectable_label(d.s("section") == id, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() {
d.fields.insert("section".into(), json!(id));
}
}
});
],
);
ui.separator();
let cnames: Vec<String> = app.session.active().map(|s| s.doc.styles.character.iter().map(|c| c.name.clone()).collect()).unwrap_or_default();
ui.vertical(|ui| match d.s("section").as_str() {
@ -2030,44 +2093,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
d.fields.insert("p.grepStyles".into(), Value::Array(list));
}
}
"chars" => {
let fonts = crate::panels::fonts(app);
egui::Grid::new("psc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Font Family:"));
let fam = cur(d, "c.fontFamily", &cv["fontFamily"]).as_str().unwrap_or("").to_string();
if let Some(f) = crate::panels::font_combo(app, ui, "psfam", &fam, 200.0) {
d.fields.insert("c.fontFamily".into(), json!(f));
}
ui.end_row();
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Font Style:"));
let sty = cur(d, "c.fontStyle", &cv["fontStyle"]).as_str().unwrap_or("").to_string();
egui::ComboBox::from_id_salt("pssty").selected_text(&sty).width(200.0).show_ui(ui, |ui| {
for s in fonts.styles(&fam) {
if ui.selectable_label(s == sty, &s).clicked() {
d.fields.insert("c.fontStyle".into(), json!(s));
}
}
});
ui.end_row();
for (label, key, suffix) in [("Size:", "size", " pt"), ("Tracking:", "tracking", "")] {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label));
let v = cur(d, &format!("c.{key}"), &cv[key]).as_f64();
if let Some(n) = crate::widgets::number(ui, &format!("ps{key}"), v, suffix, 80.0, 2) {
d.fields.insert(format!("c.{key}"), json!(n));
}
ui.end_row();
}
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Leading:"));
let lv = match cur(d, "c.leading", &cv["leading"]) {
v if v["kind"] == "points" => v["value"].as_f64(),
_ => None,
};
if let Some(n) = crate::widgets::number(ui, "pslead", lv, " pt", 80.0, 2) {
d.fields.insert("c.leading".into(), json!({"kind": "points", "value": n}));
}
ui.end_row();
});
}
"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:"));
@ -2164,22 +2190,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
ui.end_row();
});
}
"color" => {
let cur_fill = cur(d, "c.fill", &cv["fill"]).as_str().unwrap_or("").to_string();
let swatches: Vec<String> = doc.swatches.iter().map(|s| s.name.clone()).collect();
egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| {
for sw in swatches {
let (c, g) = crate::widgets::swatch_colors(&doc, &sw, 1.0);
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::hover());
crate::widgets::paint_chip(ui.painter(), r, c, g);
if ui.selectable_label(sw == cur_fill, &sw).clicked() {
d.fields.insert("c.fill".into(), json!(sw));
}
});
}
});
}
"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:"));
@ -2228,6 +2239,489 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
});
}
/// The section list on the left of the style options dialogs.
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 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();
@ -2806,6 +3300,143 @@ mod tests {
}
}
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());

View File

@ -2262,6 +2262,38 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Justify", ["Justify", "Justify", "Justify", "Justify", "两端对齐"]),
("Underline Options", ["Underline Options", "Underline Options", "Underline Options", "Underline Options", "下划线选项"]),
("Strikethrough Options", ["Strikethrough Options", "Strikethrough Options", "Strikethrough Options", "Strikethrough Options", "删除线选项"]),
(
"Character Style Options",
["Character Style Options", "Character Style Options", "Character Style Options", "Character Style Options", "字符样式选项"],
),
(
"Advanced Character Formats",
["Advanced Character Formats", "Advanced Character Formats", "Advanced Character Formats", "Advanced Character Formats", "高级字符格式"],
),
("OpenType Features", ["OpenType Features", "OpenType Features", "OpenType Features", "OpenType Features", "OpenType 功能"]),
("Style Settings:", ["Style Settings:", "Style Settings:", "Style Settings:", "Style Settings:", "样式设置:"]),
("Kerning:", ["Kerning:", "Kerning:", "Kerning:", "Kerning:", "字偶间距:"]),
("Case:", ["Case:", "Case:", "Case:", "Case:", "大小写:"]),
(
"OpenType All Small Caps",
["OpenType All Small Caps", "OpenType All Small Caps", "OpenType All Small Caps", "OpenType All Small Caps", "OpenType 全部小型大写字母"],
),
("Subscript", ["Subscript", "Subscript", "Subscript", "Subscript", "下标"]),
("OpenType Subscript", ["OpenType Subscript", "OpenType Subscript", "OpenType Subscript", "OpenType Subscript", "OpenType 下标"]),
("OpenType Numerator", ["OpenType Numerator", "OpenType Numerator", "OpenType Numerator", "OpenType Numerator", "OpenType 分子"]),
("OpenType Denominator", ["OpenType Denominator", "OpenType Denominator", "OpenType Denominator", "OpenType Denominator", "OpenType 分母"]),
("Ligatures", ["Ligatures", "Ligatures", "Ligatures", "Ligatures", "连字"]),
("No Break", ["No Break", "No Break", "No Break", "No Break", "不换行"]),
("Horizontal Scale:", ["Horizontal Scale:", "Horizontal Scale:", "Horizontal Scale:", "Horizontal Scale:", "水平缩放:"]),
("Vertical Scale:", ["Vertical Scale:", "Vertical Scale:", "Vertical Scale:", "Vertical Scale:", "垂直缩放:"]),
("Baseline Shift:", ["Baseline Shift:", "Baseline Shift:", "Baseline Shift:", "Baseline Shift:", "基线偏移:"]),
("Skew:", ["Skew:", "Skew:", "Skew:", "Skew:", "倾斜:"]),
("Language:", ["Language:", "Language:", "Language:", "Language:", "语言:"]),
("Tint:", ["Tint:", "Tint:", "Tint:", "Tint:", "色调:"]),
("Figure Style:", ["Figure Style:", "Figure Style:", "Figure Style:", "Figure Style:", "数字样式:"]),
("Underline On", ["Underline On", "Underline On", "Underline On", "Underline On", "启用下划线"]),
("Strikethrough On", ["Strikethrough On", "Strikethrough On", "Strikethrough On", "Strikethrough On", "启用删除线"]),
("Edit \"{name}\"…", ["„{name}“ bearbeiten…", "Modifier « {name} »…", "Editar \"{name}\"…", "\"{name}\" を編集…", "编辑\"{name}\"…"]),
(
"Discretionary Ligatures",
["Discretionary Ligatures", "Discretionary Ligatures", "Discretionary Ligatures", "Discretionary Ligatures", "自由连字"],

View File

@ -570,6 +570,29 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Justify", "ضبط"),
("Underline Options", "خيارات التسطير"),
("Strikethrough Options", "خيارات الخط المتوسط"),
("Character Style Options", "خيارات نمط الأحرف"),
("Advanced Character Formats", "تنسيقات الأحرف المتقدمة"),
("OpenType Features", "ميزات OpenType"),
("Style Settings:", "إعدادات النمط:"),
("Kerning:", "تقنين الأزواج:"),
("Case:", "حالة الأحرف:"),
("OpenType All Small Caps", "كل الأحرف الكبيرة المصغّرة OpenType"),
("Subscript", "النص المنخفض"),
("OpenType Subscript", "منخفض OpenType"),
("OpenType Numerator", "بسط OpenType"),
("OpenType Denominator", "مقام OpenType"),
("Ligatures", "الحروف المركبة"),
("No Break", "بدون فصل"),
("Horizontal Scale:", "المقياس الأفقي:"),
("Vertical Scale:", "المقياس الرأسي:"),
("Baseline Shift:", "إزاحة خط الأساس:"),
("Skew:", "الإمالة:"),
("Language:", "اللغة:"),
("Tint:", "الصبغة:"),
("Figure Style:", "نمط الأرقام:"),
("Underline On", "تشغيل التسطير"),
("Strikethrough On", "تشغيل الخط المتوسط"),
("Edit \"{name}\"…", "تحرير \"{name}\"…"),
("Discretionary Ligatures", "روابط اختيارية"),
("Fractions", "كسور"),
("Ordinal", "ترتيبي"),

View File

@ -569,6 +569,29 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Justify", "Justificar"),
("Underline Options", "Opções de sublinhado"),
("Strikethrough Options", "Opções de tachado"),
("Character Style Options", "Opções de estilo de caractere"),
("Advanced Character Formats", "Formatos avançados de caracteres"),
("OpenType Features", "Recursos OpenType"),
("Style Settings:", "Configurações do estilo:"),
("Kerning:", "Kerning:"),
("Case:", "Caixa:"),
("OpenType All Small Caps", "Todas em versalete OpenType"),
("Subscript", "Subscrito"),
("OpenType Subscript", "Subscrito OpenType"),
("OpenType Numerator", "Numerador OpenType"),
("OpenType Denominator", "Denominador OpenType"),
("Ligatures", "Ligaturas"),
("No Break", "Sem quebra"),
("Horizontal Scale:", "Escala horizontal:"),
("Vertical Scale:", "Escala vertical:"),
("Baseline Shift:", "Deslocamento da linha de base:"),
("Skew:", "Inclinação:"),
("Language:", "Idioma:"),
("Tint:", "Matiz:"),
("Figure Style:", "Estilo de algarismos:"),
("Underline On", "Sublinhado ativado"),
("Strikethrough On", "Tachado ativado"),
("Edit \"{name}\"…", "Editar \"{name}\"…"),
("Discretionary Ligatures", "Ligaduras discricionárias"),
("Fractions", "Frações"),
("Ordinal", "Ordinal"),

View File

@ -1773,6 +1773,20 @@ pub fn activate(app: &mut DesignApp, id: &str, params: &Value) {
app.ui.dialog = Some(crate::dialogs::Dialog::new("textFrameOptions", json!({})));
return;
}
// New Paragraph/Character Style…: the style options for the new style.
if params.is_null() && matches!(id, "style.paragraph.create" | "style.character.create") {
crate::dialogs::open_new_style(app, id == "style.paragraph.create");
return;
}
// Paragraph/Character Style Options…: those of the selected text's style.
if params.is_null() && matches!(id, "style.paragraph.edit" | "style.character.edit") {
let para = id == "style.paragraph.edit";
let cur = crate::panels::text_attrs(app).and_then(|a| a[if para { "paragraphStyle" } else { "characterStyle" }].as_str().map(str::to_string));
if let Some(name) = cur {
crate::dialogs::open_style_options(app, para, &name);
return;
}
}
if params.is_null()
&& ui_label(id).is_none()
&& let Some(c) = designcraft_engine::find_command(id)

View File

@ -1178,7 +1178,7 @@ fn text_frame_section(app: &mut DesignApp, ui: &mut Ui) {
}
/// Languages offered for text (InDesign-style names).
const LANGUAGES: &[&str] = &[
pub(crate) const LANGUAGES: &[&str] = &[
"[No Language]",
"English: USA",
"English: UK",

View File

@ -78,8 +78,8 @@ pub fn character(app: &mut DesignApp, ui: &mut egui::Ui) {
list(app, ui, false);
}
/// One style in the list: click applies (⌥ clears overrides), double-click edits (paragraph),
/// right-click: apply, break link, move to group.
/// One style in the list: click applies (⌥ clears overrides), double-click edits,
/// right-click: edit, apply, break link, move to group.
fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, current: Option<&str>, groups: &[String], t: &Tokens) {
let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
if current == Some(n) {
@ -96,8 +96,9 @@ fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, curren
egui::FontId::proportional(12.5),
t.text,
);
if resp.double_clicked() && para {
app.ui.dialog = Some(crate::dialogs::Dialog::new("paragraphStyleOptions", json!({"name": n})));
let editable = n != if para { designcraft_doc::NO_PARA_STYLE } else { designcraft_doc::NO_CHAR_STYLE };
if resp.double_clicked() && editable {
crate::dialogs::open_style_options(app, para, n);
} else if resp.clicked() {
let cmd = if para { "style.paragraph.apply" } else { "style.character.apply" };
let clear = ui.input(|i| i.modifiers.alt);
@ -105,17 +106,12 @@ fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, curren
}
let kind = if para { "paragraph" } else { "character" };
resp.context_menu(|ui| {
if ui
.button(crate::rtl::widget(
ui,
format!(
"{} \"{}\"",
crate::i18n::tr(&app.ui.language, "Apply"),
if n.contains('/') { shown } else { crate::i18n::style_name(&app.ui.language, shown) }
),
))
.clicked()
{
let label = if n.contains('/') { shown } else { crate::i18n::style_name(&app.ui.language, shown) };
if editable && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Edit \"{name}\"…").replace("{name}", label))).clicked() {
crate::dialogs::open_style_options(app, para, n);
ui.close();
}
if ui.button(crate::rtl::widget(ui, format!("{} \"{}\"", crate::i18n::tr(&app.ui.language, "Apply"), label))).clicked() {
let cmd = if para { "style.paragraph.apply" } else { "style.character.apply" };
let _ = app.run(cmd, json!({"name": n}));
ui.close();

View File

@ -134,12 +134,19 @@ impl<'a> NumField<'a> {
/// Draw the field; returns the new value when the user commits an edit (Enter, focus loss,
/// a spinner click, ↑/↓ while focused (Shift = ×10) or a preset).
pub fn show(self, ui: &mut Ui) -> Option<f64> {
self.show_or_clear(ui).flatten()
}
/// Like [`NumField::show`]; a field emptied while it had a value commits `Some(None)` (for
/// values that may be unset, like a character style's attributes).
pub fn show_or_clear(self, ui: &mut Ui) -> Option<Option<f64>> {
let t = Tokens::get(ui.ctx());
let key = ui.id().with(self.id);
let (rect, _) = ui.allocate_exact_size(vec2(self.width, FIELD_H), Sense::hover());
let step = self.step.unwrap_or_else(|| self.kind.step());
let clamp = |v: f64| v.clamp(self.range.0, self.range.1);
let mut out: Option<f64> = None;
let mut cleared = false;
let mut text_rect = rect;
let focused_id = key.with("te");
let focused = ui.memory(|m| m.has_focus(focused_id));
@ -218,6 +225,8 @@ impl<'a> NumField<'a> {
&& self.value.is_none_or(|old| (old - v).abs() > 1e-9)
{
out = Some(v);
} else if buf.trim().is_empty() && self.value.is_some() {
cleared = true;
}
ui.data_mut(|d| d.remove::<String>(key));
} else if !r.has_focus() {
@ -246,7 +255,7 @@ impl<'a> NumField<'a> {
}
});
}
out
if cleared { Some(None) } else { out.map(Some) }
}
}
@ -662,6 +671,36 @@ mod tests {
assert_eq!(got, vec![12.0]);
}
#[test]
fn emptying_a_clearable_field_clears_it() {
let ctx = egui::Context::default();
let field = |ui: &mut Ui| NumField::number("c", Some(12.0), " pt", 2).width(80.0).show_or_clear(ui);
let mut rect = Rect::NOTHING;
run(&ctx, vec![], egui::Modifiers::NONE, &mut |ui| {
rect = ui.available_rect_before_wrap();
field(ui);
});
let key = |k, m| egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m };
let mut frames: Vec<Vec<egui::Event>> = click(rect.min + vec2(40.0, 10.0), egui::Modifiers::NONE);
frames.push(vec![key(egui::Key::A, egui::Modifiers::COMMAND)]);
frames.push(vec![key(egui::Key::Backspace, egui::Modifiers::NONE)]);
frames.push(vec![key(egui::Key::Enter, egui::Modifiers::NONE)]);
let mut got = vec![];
for ev in frames {
let m = if ev.iter().any(|e| matches!(e, egui::Event::Key { modifiers, .. } if modifiers.command)) {
egui::Modifiers::COMMAND
} else {
egui::Modifiers::NONE
};
run(&ctx, ev, m, &mut |ui| {
if let Some(v) = field(ui) {
got.push(v);
}
});
}
assert_eq!(got, vec![None]);
}
#[test]
fn number_kind_formats_and_parses() {
let k = NumKind::Number { suffix: "%", decimals: 0 };

View File

@ -30,7 +30,7 @@ so only enable it while you use it. Transport: `apps/designcraft/src/control_ser
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Synthetic keyboard input (typing into a text frame) |
| `ui.move` / `ui.click` / `ui.drag` | screen points, `button?: left\|right\|middle` | Real egui pointer input — reaches every widget, menu and panel |
| `ui.set` | `{brightness?, panel?, rulers?, guides?, frameEdges?, baselineGrid?, textThreads?, screenMode?, zoom?, page?, fit?}` | UI state |
| `ui.dialog.open` | `{id, fields?}` | Open a dialog by id (e.g. `paragraphStyleOptions` with `{name, section}`) |
| `ui.dialog.open` | `{id, fields?}` | Open a dialog by id (e.g. `paragraphStyleOptions` or `characterStyleOptions` with `{name, section}`, or `{new: true}` for a new style) |
| `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{field, value}` | Fill and confirm the open dialog |
| `ui.resize`, `ui.focus` | | Window control |
| `ui.screenshot` | `{path?}` | PNG of the whole window |