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:
parent
b9febf68c1
commit
617e487dff
|
|
@ -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"];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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", "自由连字"],
|
||||
|
|
|
|||
|
|
@ -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", "ترتيبي"),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue