diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index c1312fa9b..30ccbfc7b 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -8,7 +8,7 @@ use serde::Serialize; use serde::de::DeserializeOwned; use serde_json::{Map, Value, json}; -use vectorcraft_doc::{CharStyle, Document, NodeId, NodeKind, ParaStyle, TextStyleDef}; +use vectorcraft_doc::{CharStyle, Document, Node, NodeId, NodeKind, ParaStyle, TextStyleDef}; use super::typecmd::{refresh_bounds, text_targets}; use super::{CommandSpec, Result, Run, Session, bad, bool_or, has_doc, id_param, index, ok, str_param}; @@ -117,6 +117,14 @@ macro_rules! style_cmds { pub fn specs() -> Vec { let mut v = style_cmds!(Kind::Char, "charStyle", "Character Styles", "Character"); + v.push(spec( + "charStyle.edit", + "Edit Character Style", + &[], + "{name, to, attrs: {…}} replace name and sparse attributes in one undo step; users keep local overrides", + |s, p| edit_style(s, p, Kind::Char), + true, + )); v.extend(style_cmds!(Kind::Para, "paraStyle", "Paragraph Styles", "Paragraph")); v } @@ -245,14 +253,16 @@ fn with_attrs(base: &T, attrs: &Map( cur: &T, attrs: fn(&T) -> Map, old: &Map, new: &Map, + base: &Map, ) -> T { let Some(full) = to_map(cur) else { return cur.clone() }; let mut m = attrs(cur); @@ -264,6 +274,14 @@ fn restyle( m.insert(k.clone(), v.clone()); } } + for (k, v) in old { + if !new.contains_key(k) + && m.get(k) == Some(v) + && let Some(base_value) = base.get(k) + { + m.insert(k.clone(), base_value.clone()); + } + } serde_json::from_value(Value::Object(m)).unwrap_or_else(|_| cur.clone()) } @@ -282,15 +300,47 @@ fn font_change(before: &CharStyle, mut st: CharStyle, attrs: &Map st } -/// Every text object id in the document. -fn all_text(d: &Document) -> Vec { - let mut v = vec![]; - d.walk(|n| { - if matches!(n.kind, NodeKind::Text(_)) { - v.push(n.id) +/// Change text in layers, symbols, graph designs, pattern tiles, saved pattern tiles restored by +/// Cancel, and brush art, including opacity masks in those trees. +fn each_text_mut(d: &mut Document, f: &mut impl FnMut(&mut vectorcraft_doc::TextObject) -> Result<()>) -> Result<()> { + fn has_text(n: &Node) -> bool { + matches!(n.kind, NodeKind::Text(_)) + || n.extras().mask.as_ref().is_some_and(|mask| has_text(&mask.art)) + || n.children().is_some_and(|children| children.iter().any(|child| has_text(child))) + } + + fn visit(n: &mut Node, f: &mut impl FnMut(&mut vectorcraft_doc::TextObject) -> Result<()>) -> Result<()> { + if let NodeKind::Text(t) = &mut n.kind { + f(t)?; } - }); - v + if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_mut()) + && has_text(&mask.art) + { + visit(std::sync::Arc::make_mut(&mut mask.art), f)?; + } + for child in n.children_mut().into_iter().flatten() { + if has_text(child) { + visit(std::sync::Arc::make_mut(child), f)?; + } + } + Ok(()) + } + let trees = d.layers.iter_mut().chain(d.symbols.iter_mut().map(|s| &mut s.art)).chain(d.graph_designs.iter_mut().map(|g| &mut g.art)); + let patterns = d.patterns.iter_mut().chain(d.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut())); + for root in trees.chain(patterns.flat_map(|p| &mut p.art)) { + if has_text(root) { + visit(std::sync::Arc::make_mut(root), f)?; + } + } + let mut brushes = vectorcraft_brush::library(d); + let original_brushes = brushes.clone(); + for art in brushes.iter_mut().flat_map(|b| b.art_mut()) { + visit(art, f)?; + } + if brushes != original_brushes { + vectorcraft_brush::store(d, &brushes); + } + Ok(()) } fn text_mut(d: &mut Document, id: NodeId) -> Option<&mut vectorcraft_doc::TextObject> { @@ -328,16 +378,20 @@ fn uses_of(uses: &std::collections::HashMap, usize>, kind: Kind, } /// Update every user of `name` from `old` to `new` attributes (overrides kept). -fn update_users(d: &mut Document, kind: Kind, name: &str, old: &Map, new: &Map) { +fn update_users(d: &mut Document, kind: Kind, name: &str, old: &Map, new: &Map) -> Result<()> { let target = (name != kind.normal()).then_some(name.to_string()); - for id in all_text(d) { - let Some(t) = text_mut(d, id) else { continue }; + let normal = kind.attrs(d, kind.normal()).unwrap_or_default(); + let base = match kind { + Kind::Char => char_attrs(&with_attrs(&CharStyle::default(), &normal, &kind.cmd("edit"))?), + Kind::Para => para_attrs(&with_attrs(&ParaStyle::default(), &normal, &kind.cmd("edit"))?), + }; + each_text_mut(d, &mut |t| { let mut changed = false; match kind { Kind::Char => { for r in &mut t.runs { if r.style.style_name == target { - r.style = font_change(&r.style, restyle(&r.style, char_attrs, old, new), new); + r.style = font_change(&r.style, restyle(&r.style, char_attrs, old, new, &base), new); changed = true; } } @@ -345,7 +399,7 @@ fn update_users(d: &mut Document, kind: Kind, name: &str, old: &Map { for pa in t.para_styles_mut() { if pa.style_name == target { - *pa = restyle(pa, para_attrs, old, new); + *pa = restyle(pa, para_attrs, old, new, &base); changed = true; } } @@ -354,7 +408,8 @@ fn update_users(d: &mut Document, kind: Kind, name: &str, old: &Map Option Map { + new_style_attrs(s, if para { Kind::Para } else { Kind::Char }) +} + /// Whether the document has a character (`para`: paragraph) style `name`, and its attributes. pub(crate) fn style_attrs(d: &Document, para: bool, name: &str) -> Option> { if para { Kind::Para } else { Kind::Char }.attrs(d, name) @@ -567,13 +627,16 @@ fn apply(s: &mut Session, p: &Value, kind: Kind) -> Result { fn redefine_to(s: &mut Session, kind: Kind, name: &str, new_attrs: Map, verb: &str) -> Result { let d = &s.doc()?.doc; let old = kind.attrs(d, name).ok_or_else(|| bad(&kind.cmd(verb), format!("no style `{name}`")))?; + if new_attrs == old { + return ok(); + } let name = name.to_string(); s.edit_named("Redefine {name}", &name, name == kind.normal(), |d, _| { match kind.defs_mut(d).iter_mut().find(|s| s.name == name) { Some(def) => def.attrs = new_attrs.clone(), None => kind.defs_mut(d).push(TextStyleDef { name: name.clone(), attrs: new_attrs.clone() }), } - update_users(d, kind, &name, &old, &new_attrs); + update_users(d, kind, &name, &old, &new_attrs)?; Ok(()) })?; ok() @@ -591,6 +654,50 @@ fn set_attrs(s: &mut Session, p: &Value, kind: Kind) -> Result { redefine_to(s, kind, &name, attrs, "setAttrs") } +fn edit_style(s: &mut Session, p: &Value, kind: Kind) -> Result { + let c = kind.cmd("edit"); + let name = name_param(p, kind, "edit")?.to_string(); + let to = str_param(p, "to").ok_or_else(|| bad(&c, "missing `to`"))?.trim().to_string(); + let attrs = attrs_param(p, kind, "edit")?.ok_or_else(|| bad(&c, "missing `attrs`"))?; + let d = &s.doc()?.doc; + let old = kind.attrs(d, &name).ok_or_else(|| bad(&c, format!("no style `{name}`")))?; + if name == kind.normal() { + return Err(bad(&c, format!("`{name}` can't be edited this way"))); + } + if to.is_empty() { + return Err(bad(&c, "give the style a name")); + } + if to != name && kind.attrs(d, &to).is_some() { + return Err(bad(&c, format!("a style named `{to}` already exists"))); + } + if to == name && attrs == old { + return ok(); + } + s.edit(if kind == Kind::Char { "Character Style Options" } else { "Paragraph Style Options" }, |d, _| { + if let Some(def) = kind.defs_mut(d).iter_mut().find(|def| def.name == name) { + def.name = to.clone(); + def.attrs = attrs.clone(); + } + update_users(d, kind, &name, &old, &attrs)?; + if to != name { + each_text_mut(d, &mut |t| { + let fix = |style_name: &mut Option| { + if style_name.as_deref() == Some(name.as_str()) { + *style_name = Some(to.clone()); + } + }; + match kind { + Kind::Char => t.runs.iter_mut().for_each(|r| fix(&mut r.style.style_name)), + Kind::Para => t.para_styles_mut().for_each(|pa| fix(&mut pa.style_name)), + } + Ok(()) + })?; + } + Ok(()) + })?; + ok() +} + fn duplicate(s: &mut Session, p: &Value, kind: Kind) -> Result { let name = name_param(p, kind, "duplicate")?.to_string(); let d = &s.doc()?.doc; @@ -619,8 +726,7 @@ fn rename(s: &mut Session, p: &Value, kind: Kind) -> Result { if let Some(def) = kind.defs_mut(d).iter_mut().find(|s| s.name == name) { def.name = to.clone(); } - for id in all_text(d) { - let Some(t) = text_mut(d, id) else { continue }; + each_text_mut(d, &mut |t| { let fix = |n: &mut Option| { if n.as_deref() == Some(name.as_str()) { *n = Some(to.clone()); @@ -630,7 +736,8 @@ fn rename(s: &mut Session, p: &Value, kind: Kind) -> Result { Kind::Char => t.runs.iter_mut().for_each(|r| fix(&mut r.style.style_name)), Kind::Para => t.para_styles_mut().for_each(|pa| fix(&mut pa.style_name)), } - } + Ok(()) + })?; Ok(()) })?; ok() @@ -654,8 +761,7 @@ fn delete(s: &mut Session, p: &Value, kind: Kind) -> Result { } s.edit(if names.len() > 1 { "Delete Styles" } else { "Delete Style" }, |d, _| { kind.defs_mut(d).retain(|s| !names.contains(&s.name)); - for id in all_text(d) { - let Some(t) = text_mut(d, id) else { continue }; + each_text_mut(d, &mut |t| { let fix = |n: &mut Option| { if n.as_ref().is_some_and(|n| names.contains(n)) { *n = None; @@ -665,7 +771,8 @@ fn delete(s: &mut Session, p: &Value, kind: Kind) -> Result { Kind::Char => t.runs.iter_mut().for_each(|r| fix(&mut r.style.style_name)), Kind::Para => t.para_styles_mut().for_each(|pa| fix(&mut pa.style_name)), } - } + Ok(()) + })?; Ok(()) })?; ok() diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index 670e3bbb7..46b52346d 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -15,6 +15,233 @@ fn runs(s: &Session, id: u64) -> Vec { } } +fn embedded_style(s: &Session, container: &str) -> (Option, f64, Option) { + let d = &s.doc().unwrap().doc; + let brushes = vectorcraft_brush::library(d); + let roots: Vec<&Node> = match container { + "symbol" => d.symbols.iter().map(|s| &*s.art).collect(), + "pattern" => d.patterns.iter().flat_map(|p| p.art.iter().map(|a| &**a)).collect(), + "graph" => d.graph_designs.iter().map(|g| &*g.art).collect(), + "brush" => brushes.iter().flat_map(|b| b.art()).collect(), + _ => panic!("unknown container"), + }; + let mut found = None; + for root in roots { + vectorcraft_doc::swatches::walk_with_masks(root, &mut |n| { + if let NodeKind::Text(t) = &n.kind { + found = Some((t.runs[0].style.style_name.clone(), t.runs[0].style.size, t.para.style_name.clone())); + } + }); + } + found.unwrap() +} + +fn embedded_style_session(kind: &str, container: &str) -> Session { + let (mut s, id) = session_with_text("Styled"); + s.execute(&format!("{kind}Style.new"), &json!({"name": "Body", "attrs": if kind == "char" { json!({"size": 24}) } else { json!({}) }})).unwrap(); + s.execute(&format!("{kind}Style.apply"), &json!({"name": "Body", "id": id})).unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + match container { + "symbol" => { + let symbol = s.execute("symbol.new", &json!({"name": "Mark"})).unwrap(); + s.execute("edit.clear", &json!({"ids": [symbol["id"]]})).unwrap(); + } + "pattern" => { + s.execute("object.pattern.make", &json!({"edit": false})).unwrap(); + if s.doc().unwrap().doc.node(NodeId(id)).is_some() { + s.execute("edit.clear", &json!({"ids": [id]})).unwrap(); + } + } + "graph" => { + s.execute("graph.design", &json!({"new": "Bar"})).unwrap(); + s.execute("edit.clear", &json!({"ids": [id]})).unwrap(); + } + "brush" => { + s.execute("brush.new", &json!({"type": "art", "name": "Lettering"})).unwrap(); + s.execute("edit.clear", &json!({"ids": [id]})).unwrap(); + } + _ => panic!("unknown container"), + } + s +} + +#[test] +fn character_style_options_update_embedded_art() { + for container in ["symbol", "pattern", "graph", "brush"] { + let mut s = embedded_style_session("char", container); + assert_eq!(embedded_style(&s, container).0.as_deref(), Some("Body"), "{container}"); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("charStyle.edit", &json!({"name": "Body", "to": "Heading", "attrs": {"size": 18}})).unwrap(); + assert_eq!(embedded_style(&s, container).0.as_deref(), Some("Heading"), "{container}"); + assert_eq!(embedded_style(&s, container).1, 18.0, "{container}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{container}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before, "{container}"); + s.execute("edit.redo", &json!({})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("charStyle.delete", &json!({"name": "Heading"})).unwrap(); + assert_eq!(embedded_style(&s, container).0, None, "{container}"); + assert_eq!(embedded_style(&s, container).1, 18.0, "{container}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{container}"); + } +} + +#[test] +fn character_style_edit_survives_canceling_pattern_edit() { + let mut s = embedded_style_session("char", "pattern"); + let name = s.doc().unwrap().doc.patterns[0].name.clone(); + s.execute("object.pattern.edit", &json!({"name": name})).unwrap(); + s.execute("charStyle.edit", &json!({"name": "Body", "to": "Heading", "attrs": {"size": 18}})).unwrap(); + s.execute("object.pattern.cancel", &json!({})).unwrap(); + + assert_eq!(embedded_style(&s, "pattern").0.as_deref(), Some("Heading")); + assert_eq!(embedded_style(&s, "pattern").1, 18.0); +} + +#[test] +fn paragraph_style_rename_updates_symbol_art() { + let mut s = embedded_style_session("para", "symbol"); + assert_eq!(embedded_style(&s, "symbol").2.as_deref(), Some("Body")); + s.execute("paraStyle.rename", &json!({"name": "Body", "to": "Heading"})).unwrap(); + assert_eq!(embedded_style(&s, "symbol").2.as_deref(), Some("Heading")); +} + +#[test] +fn character_style_options_keep_text_free_layer_shared() { + let (mut s, id) = session_with_text("Styled"); + s.execute("charStyle.new", &json!({"name": "Body", "attrs": {"size": 24}})).unwrap(); + s.execute("charStyle.apply", &json!({"name": "Body", "id": id})).unwrap(); + let empty_id = s.execute("layer.new", &json!({"name": "Empty"})).unwrap()["id"].as_u64().unwrap(); + let before = s.doc().unwrap().doc.layers.iter().find(|layer| layer.id == NodeId(empty_id)).unwrap().clone(); + + s.execute("charStyle.edit", &json!({"name": "Body", "to": "Heading", "attrs": {"size": 18}})).unwrap(); + + let d = &s.doc().unwrap().doc; + let after = d.layers.iter().find(|layer| layer.id == NodeId(empty_id)).unwrap(); + assert!(std::sync::Arc::ptr_eq(&before, after)); + assert_eq!(runs(&s, id)[0].style.style_name.as_deref(), Some("Heading")); + assert_eq!(runs(&s, id)[0].style.size, 18.0); +} + +#[test] +fn unchanged_style_options_keep_undo_and_redo_with_and_without_preview() { + for preview in [false, true] { + let (mut s, _) = session_with_text("Text"); + s.execute("charStyle.new", &json!({"name": "Body", "attrs": {"size": 18, "leading": null}})).unwrap(); + s.execute("charStyle.new", &json!({"name": "Other", "attrs": {}})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + let redo = s.doc().unwrap().history.redo.len(); + assert!(redo > 0); + let params = json!({"name": "Body", "to": " Body ", "attrs": {"size": 18.0, "leading": null}}); + if preview { + s.begin_interaction("Character Style Options").unwrap(); + s.preview("charStyle.edit", ¶ms).unwrap(); + s.commit_interaction().unwrap(); + } else { + s.execute("charStyle.edit", ¶ms).unwrap(); + } + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "preview={preview}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "preview={preview}"); + assert_eq!(s.doc().unwrap().history.redo.len(), redo, "preview={preview}"); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.char_styles.iter().any(|style| style.name == "Other")); + } +} + +#[test] +fn unchanged_style_attributes_keep_undo_and_redo_for_both_kinds() { + for (command, para) in [("charStyle", false), ("paraStyle", true)] { + let (mut s, _) = session_with_text("Text"); + let attrs = if para { json!({"space_before": 18}) } else { json!({"size": 18}) }; + s.execute(&format!("{command}.new"), &json!({"name": "Body", "attrs": attrs})).unwrap(); + s.execute(&format!("{command}.new"), &json!({"name": "Other", "attrs": {}})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + let redo = s.doc().unwrap().history.redo.len(); + assert!(redo > 0); + let attrs = if para { json!({"space_before": 18.0}) } else { json!({"size": 18.0}) }; + s.execute(&format!("{command}.setAttrs"), &json!({"name": "Body", "attrs": attrs})).unwrap(); + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "{command}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{command}"); + assert_eq!(s.doc().unwrap().history.redo.len(), redo, "{command}"); + } +} + +#[test] +fn character_style_options_edit_applies_to_users_and_is_one_saved_undo_step() { + let (mut s, id) = session_with_text("First second"); + s.execute("charStyle.new", &json!({"name":"Body", "attrs":{"size":24.0,"tracking":20.0,"baseline_shift":3.0}})).unwrap(); + s.execute("charStyle.apply", &json!({"name":"Body", "id":id, "start":0, "end":5})).unwrap(); + s.execute("charStyle.apply", &json!({"name":"Body", "id":id, "start":6, "end":12})).unwrap(); + s.execute("text.setRangeStyle", &json!({"id":id,"start":6,"end":12,"size":31.0})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("charStyle.edit", &json!({"name":"Body","to":"Text","attrs":{"size":18.0,"tracking":40.0,"baseline_shift":3.0}})).unwrap(); + let changed = s.doc().unwrap().doc.clone(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + assert_eq!( + runs(&s, id).iter().map(|r| (r.style.style_name.as_deref(), r.style.size, r.style.tracking)).collect::>(), + [(Some("Text"), 18.0, 40.0), (None, 12.0, 0.0), (Some("Text"), 31.0, 40.0)] + ); + let future = s.execute("text.create", &json!({"x":20,"y":90,"text":""})).unwrap()["id"].as_u64().unwrap(); + s.execute("charStyle.apply", &json!({"name":"Text","id":future})).unwrap(); + s.execute("text.editRange", &json!({"id":future,"start":0,"end":0,"insert":"Later"})).unwrap(); + assert_eq!((runs(&s, future)[0].style.size, runs(&s, future)[0].style.tracking), (18.0, 40.0)); + let saved = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert_eq!( + saved.char_styles.iter().find(|d| d.name == "Text").unwrap().attrs, + s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == "Text").unwrap().attrs + ); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, changed); +} + +#[test] +fn editing_character_style_clears_removed_size_without_losing_local_override() { + let (mut s, id) = session_with_text("One two"); + s.execute("charStyle.setAttrs", &json!({"name": NORMAL_CHAR, "attrs": {"size": 15.0}})).unwrap(); + s.execute("charStyle.new", &json!({"name": "Body", "attrs": {"size": 24.0}})).unwrap(); + s.execute("charStyle.apply", &json!({"name": "Body", "id": id})).unwrap(); + s.execute("text.setRangeStyle", &json!({"id": id, "start": 4, "end": 7, "size": 31.0})).unwrap(); + s.execute("charStyle.edit", &json!({"name": "Body", "to": "Body", "attrs": {}})).unwrap(); + let r = runs(&s, id); + assert_eq!((r[0].style.size, r[1].style.size), (15.0, 31.0)); +} + +#[test] +fn replacing_character_style_attrs_clears_removed_size() { + let (mut s, id) = session_with_text("Text"); + s.execute("charStyle.new", &json!({"name": "Body", "attrs": {"size": 24.0}})).unwrap(); + s.execute("charStyle.apply", &json!({"name": "Body", "id": id})).unwrap(); + s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {}})).unwrap(); + assert_eq!(runs(&s, id)[0].style.size, 12.0); +} + +#[test] +fn character_style_options_edit_validates_names_and_allows_case_changes() { + let (mut s, _) = session_with_text("Text"); + s.execute("charStyle.new", &json!({"name":"Body","attrs":{"size":24.0}})).unwrap(); + s.execute("charStyle.new", &json!({"name":"Other","attrs":{}})).unwrap(); + for (name, to, message) in + [(super::NORMAL_CHAR, "New", "can't be edited this way"), ("Body", " ", "give the style a name"), ("Body", "Other", "already exists")] + { + let error = s.execute("charStyle.edit", &json!({"name":name,"to":to,"attrs":{}})).unwrap_err().to_string(); + assert!(error.contains(message), "{error}"); + } + s.execute("charStyle.edit", &json!({"name":"Body","to":"body","attrs":{"size":18.0}})).unwrap(); + s.execute("charStyle.edit", &json!({"name":"body","to":" body ","attrs":{"size":20.0}})).unwrap(); + assert_eq!(s.doc().unwrap().doc.char_styles.iter().find(|style| style.name == "body").unwrap().attrs["size"], json!(20.0)); +} + #[test] fn char_style_new_apply_range_redefine_keeps_overrides() { let (mut s, id) = session_with_text("Hello world"); @@ -293,10 +520,10 @@ fn auto_leading_redefine_keeps_overrides_and_history() { s.execute("text.setStyle", &json!({"ids": [linked], "leading": "auto"})).unwrap(); s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"leading": 28}})).unwrap(); assert_eq!(ids.map(|id| runs(&s, id)[0].style.leading), [Some(28.0), None, Some(42.0)]); - // Omitting leading replaces the definition without resetting the text's leading. + // Omitting leading returns linked text to Auto and keeps local overrides. s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"tracking": 20}})).unwrap(); assert!(!s.doc().unwrap().doc.char_styles[0].attrs.contains_key("leading")); - assert_eq!(ids.map(|id| runs(&s, id)[0].style.leading), [Some(28.0), None, Some(42.0)]); + assert_eq!(ids.map(|id| runs(&s, id)[0].style.leading), [None, None, Some(42.0)]); } } @@ -308,7 +535,7 @@ fn settable() -> std::collections::BTreeSet { /// New and Redefine capture every paragraph attribute. A paragraph style redefined from a /// paragraph at the defaults gives its paragraphs the defaults back where they had the old /// definition's values, and `setAttrs` with a default value does too; local overrides, and -/// attributes `setAttrs` leaves out, stay. +/// attributes `setAttrs` leaves out return to the base value. #[test] fn para_style_redefined_to_the_defaults_updates_its_paragraphs() { use vectorcraft_doc::{Composer, HyphenationOptions}; @@ -335,11 +562,11 @@ fn para_style_redefined_to_the_defaults_updates_its_paragraphs() { assert_eq!(defined(&s, 0), settable::(), "Redefine captures every attribute"); s.execute("paraStyle.new", &json!({"name": "Copy", "id": local})).unwrap(); assert_eq!(defined(&s, 1), settable::(), "New captures every attribute"); - // Style options: a default value reaches the users too; an attribute left out stays as it is. + // Style options: a default value reaches users, and an attribute left out returns to base. s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"hyphenation": {"hyphen_limit": 3}, "composer": "singleLine"}})).unwrap(); assert_eq!((limits(&s), composers(&s)), ([3, 3, 4], [Composer::SingleLine; 3])); s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"composer": "everyLine"}})).unwrap(); - assert_eq!((limits(&s), composers(&s)), ([3, 3, 4], [Composer::EveryLine; 3])); + assert_eq!((limits(&s), composers(&s)), ([0, 0, 4], [Composer::EveryLine; 3])); assert_eq!(Value::Object(s.doc().unwrap().doc.para_styles[0].attrs.clone()), json!({"composer": "everyLine"})); } @@ -368,7 +595,7 @@ fn char_style_redefined_to_the_defaults_updates_its_text() { s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"kerning": 10, "charAlign": "emBoxTop"}})).unwrap(); assert_eq!((kerning(&s), aligns(&s)), ([Some(10.0), Some(10.0), Some(60.0)], [CharAlign::EmBoxTop; 3])); s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"charAlign": "romanBaseline"}})).unwrap(); - assert_eq!((kerning(&s), aligns(&s)), ([Some(10.0), Some(10.0), Some(60.0)], [CharAlign::RomanBaseline; 3])); + assert_eq!((kerning(&s), aligns(&s)), ([None, None, Some(60.0)], [CharAlign::RomanBaseline; 3])); assert_eq!(Value::Object(s.doc().unwrap().doc.char_styles[0].attrs.clone()), json!({"charAlign": "romanBaseline"})); } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b2a42af49..a4eac7041 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -14,6 +14,7 @@ pub mod blend_options; mod brush_art_options; pub mod brush_delete; pub mod brush_options; +pub mod char_style_options; pub mod color_balance; pub mod color_guide_options; mod color_picker; @@ -306,6 +307,7 @@ registry! { GradientStop: ["gradientStop"] => gradient_stop::SPEC, ColorPicker: [color_picker::KIND] => color_picker::SPEC, GraphicStyleOptions: [graphic_style_options::KIND] => graphic_style_options::SPEC, + CharStyleOptions: [char_style_options::KIND] => char_style_options::SPEC, NewTextStyle: [new_text_style::KIND] => new_text_style::SPEC, EffectExists: [effect::EXISTS] => effect::EXISTS_SPEC, ColorGuideOptions: [color_guide_options::KIND] => color_guide_options::SPEC, diff --git a/crates/ui-egui/src/dialogs/char_style_options.rs b/crates/ui-egui/src/dialogs/char_style_options.rs new file mode 100644 index 000000000..848151ac5 --- /dev/null +++ b/crates/ui-egui/src/dialogs/char_style_options.rs @@ -0,0 +1,548 @@ +//! Character Style Options fields shared by editing and creating a character style. + +use serde_json::{Map, Value, json}; + +use super::{DialogSpec, form}; +use crate::VectorcraftApp; +use crate::state::Dialog; + +pub const KIND: &str = "charStyleOptions"; +const BASIC: &[&str] = &[ + "font_family", + "font_style", + "size", + "leading", + "kerning", + "tracking", + "all_caps", + "small_caps", + "position", + "underline", + "strikethrough", + "charAlign", +]; +const NUMBERS: &[&str] = &["size", "leading", "kerning", "tracking"]; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("Character Style Options").into(), + body, + confirm, + preview: true, + inputs: |_| { + &[ + "name", + "font_family", + "font_style", + "size", + "leading", + "kerning", + "tracking", + "case", + "position", + "underline", + "strikethrough", + "charAlign", + "section", + ] + }, + min_width: 650.0, + ..DialogSpec::FORM +}; + +pub fn open(app: &mut VectorcraftApp, name: &str) -> Result { + crate::panels::character::end_typing(app); + let doc = &app.session.active().ok_or("no document open")?.doc; + let def = doc.char_styles.iter().find(|s| s.name == name).ok_or_else(|| format!("no character style `{name}`"))?; + let mut fields = fields_from_attrs(&def.attrs); + fields.insert("name".into(), json!(name)); + fields.insert("__style".into(), json!(name)); + fields.insert("__attrs".into(), json!(def.attrs)); + fields.insert("__smallCapsSize".into(), json!(doc.setup.small_caps_size)); + fields.insert("__superscript".into(), json!(doc.setup.superscript)); + fields.insert("__subscript".into(), json!(doc.setup.subscript)); + fields.insert("section".into(), json!("general")); + fields.insert("preview".into(), json!(true)); + app.ui.dialog = Some(Dialog { kind: KIND.into(), fields }); + Ok(json!({"dialog": KIND})) +} + +pub(crate) fn fields_from_attrs(attrs: &Map) -> Map { + let mut fields = Map::new(); + for key in ["font_family", "font_style", "size", "leading", "kerning", "tracking", "underline", "strikethrough", "charAlign"] { + let value = attrs.get(key).cloned().unwrap_or(Value::Null); + let shown = if ["underline", "strikethrough"].contains(&key) { + value + } else if value.is_null() { + if attrs.contains_key(key) && ["leading", "kerning"].contains(&key) { json!("Auto") } else { json!("") } + } else { + value + }; + fields.insert(key.into(), shown); + } + fields.insert("case".into(), case_from_attrs(attrs).map_or(Value::Null, Value::from)); + fields.insert("position".into(), attrs.get("position").and_then(|v| v.get("kind")).cloned().unwrap_or(Value::Null)); + fields +} + +fn case_from_attrs(attrs: &Map) -> Option<&'static str> { + if attrs.get("all_caps") == Some(&json!(true)) { + Some("allCaps") + } else if attrs.get("small_caps").is_some_and(|v| !v.is_null()) { + Some("smallCaps") + } else if attrs.contains_key("all_caps") || attrs.contains_key("small_caps") { + Some("normal") + } else { + None + } +} + +fn optional_number(d: &Dialog, key: &str) -> Result, String> { + let label = match key { + "size" => tl!("Size"), + "leading" => tl!("Leading"), + "kerning" => tl!("Kerning"), + "tracking" => tl!("Tracking"), + _ => return Err("unknown character style number field".into()), + }; + match d.fields.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(s)) if s.trim().is_empty() => Ok(None), + Some(Value::String(s)) if s.trim().eq_ignore_ascii_case("auto") && ["leading", "kerning"].contains(&key) => Ok(Some(Value::Null)), + Some(v) => { + let n = v + .as_f64() + .or_else(|| v.as_str().and_then(|s| s.trim().parse::().ok())) + .filter(|n| n.is_finite()) + .ok_or_else(|| format!("{label} must be a number"))?; + if ["size", "leading"].contains(&key) && n < 0.1 { + return Err(format!("{label} must be a number of at least 0.1")); + } + Ok(Some(json!(n))) + } + } +} + +pub(crate) fn edited_attrs(d: &Dialog) -> Result, String> { + let original = d.fields.get("__attrs").and_then(Value::as_object).cloned().unwrap_or_default(); + let original_fields = fields_from_attrs(&original); + let reset = d.bool("__resetBasic"); + let mut attrs = original.clone(); + for key in BASIC { + attrs.remove(*key); + } + for key in ["font_family", "font_style"] { + if let Some(v) = d.fields.get(key).and_then(Value::as_str).filter(|v| !v.trim().is_empty()) { + attrs.insert(key.into(), json!(v.trim())); + } + } + if reset || ["font_family", "font_style"].iter().any(|key| d.fields.get(*key) != original_fields.get(*key)) { + attrs.remove("font_version"); + attrs.remove("fontAxes"); + } + for key in NUMBERS { + if let Some(v) = optional_number(d, key)? { + attrs.insert((*key).into(), v); + } + } + for key in ["underline", "strikethrough"] { + if let Some(v) = d.fields.get(key).and_then(Value::as_bool) { + attrs.insert(key.into(), json!(v)); + } + } + if reset || d.fields.get("case").and_then(Value::as_str) != case_from_attrs(&original) { + match d.fields.get("case").and_then(Value::as_str) { + Some("allCaps") => { + attrs.insert("all_caps".into(), json!(true)); + attrs.insert("small_caps".into(), Value::Null); + } + Some("smallCaps") => { + attrs.insert("all_caps".into(), json!(false)); + attrs.insert( + "small_caps".into(), + original + .get("small_caps") + .filter(|v| v.is_number()) + .cloned() + .unwrap_or_else(|| d.fields.get("__smallCapsSize").cloned().unwrap_or_else(|| json!(70.0))), + ); + } + Some("normal") => { + attrs.insert("all_caps".into(), json!(false)); + attrs.insert("small_caps".into(), Value::Null); + } + _ => {} + } + } + if let Some(position) = d.fields.get("position").and_then(Value::as_str).filter(|v| !v.is_empty()) { + let value = if position == "normal" { + json!({"kind": "normal"}) + } else { + original.get("position").filter(|v| v.get("kind").and_then(Value::as_str) == Some(position)).cloned().unwrap_or_else(|| { + let metrics = d.fields.get(if position == "superscript" { "__superscript" } else { "__subscript" }); + json!({"kind": position, "size": metrics.and_then(|m| m.get("size")).cloned().unwrap_or(json!(58.3)), + "position": metrics.and_then(|m| m.get("position")).cloned().unwrap_or(json!(33.3))}) + }) + }; + attrs.insert("position".into(), value); + } + if let Some(align) = d.fields.get("charAlign").and_then(Value::as_str).filter(|v| !v.is_empty()) { + attrs.insert("charAlign".into(), json!(align)); + } + if !reset { + for key in BASIC { + let field = if ["all_caps", "small_caps"].contains(key) { "case" } else { key }; + if d.fields.get(field) == original_fields.get(field) { + attrs.remove(*key); + if let Some(value) = original.get(*key) { + attrs.insert((*key).into(), value.clone()); + } + } + } + } + Ok(attrs) +} + +pub(crate) fn reset_basic(d: &mut Dialog) { + d.fields.insert("__resetBasic".into(), json!(true)); + for key in ["font_family", "font_style", "size", "leading", "kerning", "tracking", "case", "position", "underline", "strikethrough", "charAlign"] + { + d.fields.insert( + key.into(), + if ["font_family", "font_style", "size", "leading", "kerning", "tracking"].contains(&key) { json!("") } else { Value::Null }, + ); + } +} + +pub fn reset_panel(app: &mut VectorcraftApp) -> Result { + let d = app.ui.dialog.as_mut().ok_or("no dialog open")?; + let character_new = d.kind == super::new_text_style::KIND && !d.bool("__para"); + if (d.kind != KIND && !character_new) || d.str("section") != "basic" { + return Err("the open dialog has no Reset".into()); + } + reset_basic(d); + Ok(json!({"dialog": d.kind})) +} + +fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, options: &[(&str, &str)]) { + let selected = d.fields.get(key).and_then(Value::as_str).unwrap_or("").to_string(); + let shown = options.iter().find(|(value, _)| *value == selected).map_or("", |(_, label)| *label); + let labels: Vec<&str> = std::iter::once(" ").chain(options.iter().map(|(_, label)| *label)).collect(); + if let Some(i) = crate::widgets::dropdown(ui, (KIND, key), shown, &labels, 190.0) { + d.fields.insert(key.into(), if i == 0 { Value::Null } else { options.get(i - 1).map_or(Value::Null, |(value, _)| json!(value)) }); + } +} + +pub(crate) fn basic_body(ui: &mut egui::Ui, d: &mut Dialog) { + egui::Grid::new("character-style-basic").num_columns(2).spacing([18.0, 8.0]).show(ui, |ui| { + for (key, caption) in [ + ("font_family", tl!("Font Family:")), + ("font_style", tl!("Font Style:")), + ("size", tl!("Size")), + ("leading", tl!("Leading")), + ("kerning", tl!("Kerning")), + ("tracking", tl!("Tracking")), + ] { + ui.label(caption); + if d.fields.get(key).is_some_and(Value::is_null) { + d.fields.insert(key.into(), json!("")); + } + form::text(ui, d, key, 190.0); + ui.end_row(); + } + ui.label(tl!("Case")); + choice(ui, d, "case", &[("normal", tl!("Normal")), ("allCaps", tl!("All Caps")), ("smallCaps", tl!("Small Caps"))]); + ui.end_row(); + ui.label(tl!("Position")); + choice(ui, d, "position", &[("normal", tl!("Normal")), ("superscript", tl!("Superscript")), ("subscript", tl!("Subscript"))]); + ui.end_row(); + for (key, caption) in [("underline", tl!("Underline")), ("strikethrough", tl!("Strikethrough"))] { + ui.label(""); + let value = d.fields.get(key).and_then(Value::as_bool); + if crate::widgets::check3(ui, caption, value, true) { + d.fields.insert( + key.into(), + match value { + None => json!(true), + Some(true) => json!(false), + Some(false) => Value::Null, + }, + ); + } + ui.end_row(); + } + ui.label(tl!("Standard Vertical Roman Alignment")); + choice( + ui, + d, + "charAlign", + &[ + ("romanBaseline", tl!("Roman Baseline")), + ("emBoxTop", tl!("Em Box Top/Right")), + ("emBoxCenter", tl!("Em Box Center")), + ("emBoxBottom", tl!("Em Box Bottom/Left")), + ("icfTop", tl!("ICF Top/Right")), + ("icfBottom", tl!("ICF Bottom/Left")), + ], + ); + ui.end_row(); + }); +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + ui.horizontal(|ui| { + ui.vertical(|ui| { + for (id, label) in [("general", tl!("General")), ("basic", tl!("Basic Character Formats"))] { + if ui.selectable_label(d.str("section") == id, label).clicked() { + d.fields.insert("section".into(), json!(id)); + } + } + }); + ui.separator(); + ui.vertical(|ui| { + if d.str("section") == "basic" { + basic_body(ui, d); + } else { + ui.horizontal(|ui| { + ui.label(tl!("Style Name:")); + form::text(ui, d, "name", 190.0); + }); + ui.separator(); + if let Ok(attrs) = edited_attrs(d) { + for (key, value) in attrs { + ui.label(format!("{}: {}", super::humanize(&key), value)); + } + } + } + }); + }); + ui.add_enabled_ui(d.str("section") == "basic", |ui| { + if ui.button(tl!("Reset Panel")).clicked() { + reset_basic(d); + } + }); + match edited_attrs(d) { + Ok(attrs) => { + let params = json!({"name": d.str("__style"), "to": d.str("name"), "attrs": attrs}); + form::preview(app, ui, d, tl!("Character Style Options"), "charStyle.edit", params); + } + Err(why) => { + ui.add_space(6.0); + ui.colored_label(crate::theme::Tokens::get(ui.ctx()).error, crate::i18n::message(crate::i18n::current(), &why)); + } + } + false +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let attrs = edited_attrs(d)?; + form::commit_preview(app, "charStyle.edit", json!({"name": d.str("__style"), "to": d.str("name"), "attrs": attrs})) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + + fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + app + } + + fn frame(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + } + + #[test] + fn unchanged_basic_fields_preserve_original_keys() { + for attrs in [ + json!({"all_caps": false}), + json!({"all_caps": true}), + json!({"small_caps": 70.0}), + json!({"font_family": "Example", "font_style": "Regular", "font_version": "1", "size": 24, + "leading": null, "kerning": null, "tracking": 3, "underline": null, + "strikethrough": false, "position": {"kind": "normal", "size": 80}, "charAlign": null}), + ] { + let original = attrs.as_object().unwrap().clone(); + let mut fields = fields_from_attrs(&original); + fields.insert("__attrs".into(), attrs); + let d = Dialog { kind: KIND.into(), fields }; + assert_eq!(edited_attrs(&d).unwrap(), original); + } + let original = json!({"all_caps": false}).as_object().unwrap().clone(); + let mut fields = fields_from_attrs(&original); + fields.insert("__attrs".into(), json!(original)); + fields.insert("case".into(), json!("allCaps")); + let d = Dialog { kind: KIND.into(), fields }; + assert_eq!(edited_attrs(&d).unwrap(), json!({"all_caps": true, "small_caps": null}).as_object().unwrap().clone()); + } + + #[test] + fn unchanged_options_keep_local_small_caps_and_history() { + for preview in [false, true] { + let mut app = app(); + app.run("charStyle.new", json!({"name": "Body", "attrs": {"all_caps": false}})).unwrap(); + let id = app.run("text.create", json!({"x": 10, "y": 40, "text": "Text"})).unwrap()["id"].as_u64().unwrap(); + app.run("charStyle.apply", json!({"name": "Body", "id": id})).unwrap(); + app.run("text.setRangeStyle", json!({"id": id, "start": 0, "end": 4, "smallCaps": true})).unwrap(); + let state = app.session.doc().unwrap(); + let before = state.doc.clone(); + let history = (state.history.undo.len(), state.history.redo.len()); + open(&mut app, "Body").unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(preview)); + if preview { + frame(&mut app); + assert_eq!(app.session.doc().unwrap().doc, before); + } + super::super::confirm(&mut app).unwrap(); + let state = app.session.doc().unwrap(); + assert_eq!(state.doc, before); + assert_eq!((state.history.undo.len(), state.history.redo.len()), history); + assert_eq!( + state.doc.node(vectorcraft_doc::NodeId(id)).and_then(|node| match &node.kind { + vectorcraft_doc::NodeKind::Text(text) => text.runs.first().map(|run| run.style.small_caps), + _ => None, + }), + Some(Some(state.doc.setup.small_caps_size)) + ); + } + } + + #[test] + fn options_preview_cancel_reset_and_commit_keep_sparse_definition() { + let mut app = app(); + app.run("charStyle.new", json!({"name":"Body","attrs":{"size":24.0,"baseline_shift":3.0}})).unwrap(); + let id = app.run("text.create", json!({"x":10,"y":40,"text":"Text"})).unwrap()["id"].as_u64().unwrap(); + app.run("charStyle.apply", json!({"name":"Body","id":id})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + let undo = app.session.doc().unwrap().history.undo.len(); + open(&mut app, "Body").unwrap(); + assert!(app.ui.dialog.as_ref().unwrap().bool("preview")); + assert!(app.run("ui.charStyleOptions.resetPanel", json!({})).is_err()); + assert!(app.ui.dialog.as_ref().unwrap().fields["underline"].is_null()); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("kerning"), ""); + { + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("name".into(), json!("Revised")); + d.fields.insert("size".into(), json!(18)); + } + frame(&mut app); + assert_eq!(app.session.doc().unwrap().doc.char_styles[0].name, "Revised"); + super::super::cancel(&mut app); + assert_eq!(app.session.doc().unwrap().doc, before); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo); + open(&mut app, "Body").unwrap(); + { + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("section".into(), json!("basic")); + } + app.run("ui.charStyleOptions.resetPanel", json!({})).unwrap(); + { + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!(edited_attrs(d).unwrap(), serde_json::from_value(json!({"baseline_shift":3.0})).unwrap()); + d.fields.insert("name".into(), json!("Revised")); + } + super::super::confirm(&mut app).unwrap(); + let state = app.session.doc().unwrap(); + assert_eq!(state.history.undo.len(), undo + 1); + assert_eq!(state.doc.char_styles[0].name, "Revised"); + assert_eq!(state.doc.char_styles[0].attrs, serde_json::from_value(json!({"baseline_shift":3.0})).unwrap()); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, before); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.char_styles[0].name, "Revised"); + app.run("select.set", json!({"ids":[id]})).unwrap(); + app.run("ui.newCharStyle", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("size"), "12.0"); + { + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("size".into(), json!(36.0)); + d.fields.insert("name".into(), json!("Future")); + } + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.char_styles.iter().find(|def| def.name == "Future").unwrap().attrs["size"], json!(36.0)); + } + + #[test] + fn invalid_numbers_name_the_visible_field() { + let mut d = Dialog::new(KIND, json!({})); + for (key, label) in [("size", "Size"), ("leading", "Leading"), ("kerning", "Kerning"), ("tracking", "Tracking")] { + d.fields.insert(key.into(), json!("not a number")); + assert_eq!(optional_number(&d, key).unwrap_err(), format!("{label} must be a number")); + } + } + + #[test] + fn size_and_leading_below_minimum_do_not_preview_or_commit() { + for (key, value) in [("size", json!(0)), ("size", json!(-2)), ("leading", json!(0.09)), ("leading", json!(-1))] { + let mut app = app(); + app.run("charStyle.new", json!({"name": "Body", "attrs": {"size": 24.0, "leading": 28.0}})).unwrap(); + let id = app.run("text.create", json!({"x": 10, "y": 40, "text": "Text"})).unwrap()["id"].as_u64().unwrap(); + app.run("charStyle.apply", json!({"name": "Body", "id": id})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + open(&mut app, "Body").unwrap(); + { + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("name".into(), json!("Revised")); + d.fields.insert(key.into(), value); + assert!(edited_attrs(d).unwrap_err().contains("at least 0.1")); + } + frame(&mut app); + assert_eq!(app.session.doc().unwrap().doc, before, "{key} preview"); + assert!(super::super::confirm(&mut app).is_err(), "{key} confirm"); + assert_eq!(app.session.doc().unwrap().doc, before, "{key} confirm"); + } + } + + #[test] + fn invalid_number_keeps_last_valid_preview() { + let mut app = app(); + app.run("charStyle.new", json!({"name": "Body", "attrs": {"size": 24.0}})).unwrap(); + let id = app.run("text.create", json!({"x": 10, "y": 40, "text": "Text"})).unwrap()["id"].as_u64().unwrap(); + app.run("charStyle.apply", json!({"name": "Body", "id": id})).unwrap(); + open(&mut app, "Body").unwrap(); + { + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("name".into(), json!("Revised")); + d.fields.insert("size".into(), json!(18)); + } + frame(&mut app); + let valid = app.session.doc().unwrap().doc.clone(); + assert_eq!(valid.char_styles[0].name, "Revised"); + assert_eq!(valid.char_styles[0].attrs["size"], json!(18.0)); + app.ui.dialog.as_mut().unwrap().fields.insert("size".into(), json!("invalid")); + frame(&mut app); + assert_eq!(app.session.doc().unwrap().doc, valid); + } + + #[test] + fn script_positions_use_document_setup_in_existing_and_new_style_dialogs() { + let mut app = app(); + app.run( + "document.setup", + json!({"superscript": {"size": 67.0, "position": 22.0}, + "subscript": {"size": 72.0, "position": 18.0}}), + ) + .unwrap(); + app.run("charStyle.new", json!({"name": "Body", "attrs": {}})).unwrap(); + open(&mut app, "Body").unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("position".into(), json!("superscript")); + assert_eq!( + edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap()["position"], + json!({"kind": "superscript", "size": 67.0, "position": 22.0}) + ); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.char_styles[0].attrs["position"], json!({"kind": "superscript", "size": 67.0, "position": 22.0})); + app.run("ui.newCharStyle", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("position".into(), json!("subscript")); + assert_eq!(edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap()["position"], json!({"kind": "subscript", "size": 72.0, "position": 18.0})); + super::super::confirm(&mut app).unwrap(); + assert_eq!( + app.session.doc().unwrap().doc.char_styles.last().unwrap().attrs["position"], + json!({"kind": "subscript", "size": 72.0, "position": 18.0}) + ); + } +} diff --git a/crates/ui-egui/src/dialogs/new_text_style.rs b/crates/ui-egui/src/dialogs/new_text_style.rs index f945332e6..16ec9568d 100644 --- a/crates/ui-egui/src/dialogs/new_text_style.rs +++ b/crates/ui-egui/src/dialogs/new_text_style.rs @@ -31,14 +31,60 @@ pub fn open(app: &mut VectorcraftApp, para: bool, target: Value) -> Result bool { + if !para(d) { + ui.horizontal(|ui| { + ui.vertical(|ui| { + for (id, title) in [("general", tl!("General")), ("basic", tl!("Basic Character Formats"))] { + if ui.selectable_label(d.str("section") == id, title).clicked() { + d.fields.insert("section".into(), json!(id)); + } + } + }); + ui.separator(); + if d.str("section") == "basic" { + super::char_style_options::basic_body(ui, d); + } else { + ui.vertical(|ui| { + grid(ui, |ui| { + label(ui, tl!("Style Name:")); + form::text(ui, d, "name", 190.0); + ui.end_row(); + }); + if let Ok(attrs) = super::char_style_options::edited_attrs(d) { + for (key, value) in attrs { + ui.label(format!("{}: {}", super::humanize(&key), value)); + } + } + }); + } + }); + ui.add_enabled_ui(d.str("section") == "basic", |ui| { + if ui.button(tl!("Reset Panel")).clicked() { + super::char_style_options::reset_basic(d); + } + }); + return false; + } grid(ui, |ui| { label(ui, tl!("Style Name:")); form::text(ui, d, "name", 190.0); @@ -52,6 +98,9 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let mut params = d.fields.get("__target").filter(|t| t.is_object()).cloned().unwrap_or_else(|| json!({})); params["name"] = json!(d.str("name")); + if !para(d) { + params["attrs"] = Value::Object(super::char_style_options::edited_attrs(d)?); + } let r = app.run(if para(d) { "paraStyle.new" } else { "charStyle.new" }, params); if let Ok(v) = &r { app.ui.dialog = None; diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 442e051c0..87c2edac4 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5869,6 +5869,12 @@ anchor Points: Punts: # Color Effects add-on: Vector Halftone (Effect › Color Effects). Dot Color from Art Color dels punts de la il·lustració + Basic Character Formats Formats bàsics de caràcters + Case Majúscules i minúscules + Kerning Interlletratge +@msg {label} must be a number {label} ha de ser un nombre +@msg {label} must be a number of at least 0.1 {label} ha de ser un nombre de com a mínim 0,1 +@msg no character style `{name}` No s'ha trobat l'estil de caràcter `{name}` # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). Duotone… Bitò… diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index aa88620f4..3f9c07acd 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5878,6 +5878,12 @@ anchor Points: Body: # Color Effects add-on: Vector Halftone (Effect › Color Effects). Dot Color from Art Barva bodů podle kresby + Basic Character Formats Základní formáty znaků + Case Velikost písmen + Kerning Vyrovnání párů +@msg {label} must be a number {label} musí být číslo +@msg {label} must be a number of at least 0.1 {label} musí být číslo alespoň 0,1 +@msg no character style `{name}` styl znaků `{name}` neexistuje # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). Duotone… Dvoutón… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2d1e72845..f24842175 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5463,6 +5463,12 @@ anchor Points: Punkte: # Color Effects add-on: Vector Halftone (Effect › Color Effects). Dot Color from Art Punktfarbe aus Grafik + Basic Character Formats Grundlegende Zeichenformate + Case Großschreibung + Kerning Unterschneidung +@msg {label} must be a number {label} muss eine Zahl sein +@msg {label} must be a number of at least 0.1 {label} muss eine Zahl von mindestens 0,1 sein +@msg no character style `{name}` Zeichenformat `{name}` nicht gefunden # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). Duotone… Duplex… diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 16fe9435e..eab0f70fa 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5830,6 +5830,12 @@ anchor Points: Σημεία: # Color Effects add-on: Vector Halftone (Effect › Color Effects). Dot Color from Art Χρώμα κουκκίδων από το γραφικό + Basic Character Formats Βασικές μορφές χαρακτήρων + Case Πεζά και κεφαλαία + Kerning Διαγραμμάτωση +@msg {label} must be a number Το {label} πρέπει να είναι αριθμός +@msg {label} must be a number of at least 0.1 Το {label} πρέπει να είναι αριθμός τουλάχιστον 0,1 +@msg no character style `{name}` Δεν βρέθηκε το στυλ χαρακτήρων `{name}` # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). Duotone… Δίτονο… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index d4d2d20eb..5b7d31740 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5821,6 +5821,12 @@ anchor Points: Puntos: # Color Effects add-on: Vector Halftone (Effect › Color Effects). Dot Color from Art Color de puntos de la ilustración + Basic Character Formats Formatos básicos de caracteres + Case Mayúsculas y minúsculas + Kerning Interletraje +@msg {label} must be a number {label} debe ser un número +@msg {label} must be a number of at least 0.1 {label} debe ser un número de al menos 0,1 +@msg no character style `{name}` No existe el estilo de carácter `{name}` # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). Duotone… Duotono… diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index e080c8f98..4f6a802cc 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5909,6 +5909,12 @@ anchor Points: Pisteet: # Color Effects add-on: Vector Halftone (Effect › Color Effects). Dot Color from Art Pisteiden väri kuvituksesta + Basic Character Formats Merkkien perusmuotoilut + Case Kirjainkoko + Kerning Merkkipariväli +@msg {label} must be a number {label} on oltava luku +@msg {label} must be a number of at least 0.1 {label} on oltava vähintään 0,1 +@msg no character style `{name}` Merkkityyliä `{name}` ei löytynyt # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). Duotone… Duotone… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index dd9cc259a..57196f94e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5876,6 +5876,12 @@ anchor Points: Points : # Color Effects add-on: Vector Halftone (Effect › Color Effects). Dot Color from Art Couleur des points d’après l’illustration + Basic Character Formats Formats de caractère de base + Case Casse + Kerning Crénage +@msg {label} must be a number {label} doit être un nombre +@msg {label} must be a number of at least 0.1 {label} doit être un nombre d’au moins 0,1 +@msg no character style `{name}` Style de caractère `{name}` introuvable # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). Duotone… Bichromie… diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 671a569ca..5e9b7a386 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5865,6 +5865,12 @@ anchor Points: Pontok: # Color Effects add-on: Vector Halftone (Effect › Color Effects). Dot Color from Art Pontszín a grafikából + Basic Character Formats Alapvető karakterformátumok + Case Kis- és nagybetűk + Kerning Betűpárköz +@msg {label} must be a number {label}: szám kell +@msg {label} must be a number of at least 0.1 {label} legalább 0,1 értékű szám kell legyen +@msg no character style `{name}` A(z) `{name}` karakterstílus nem található # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). Duotone… Duplex… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 708378ba4..9c41f8daf 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5497,6 +5497,12 @@ anchor Points: Punti: # Color Effects add-on: Vector Halftone (Effect › Color Effects). Dot Color from Art Colore punti dalla grafica + Basic Character Formats Formati carattere di base + Case Maiuscole e minuscole + Kerning Crenatura +@msg {label} must be a number {label} deve essere un numero +@msg {label} must be a number of at least 0.1 {label} deve essere un numero di almeno 0,1 +@msg no character style `{name}` Stile di carattere `{name}` non trovato # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). Duotone… Bicromia… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 628f0ec03..6978094ee 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5563,6 +5563,12 @@ anchor Points: ポイント: # Color Effects add-on: Vector Halftone (Effect › Color Effects). Dot Color from Art ドットの色をアートから取得 + Basic Character Formats 基本文字形式 + Case 大文字と小文字 + Kerning カーニング +@msg {label} must be a number {label} には数値を指定してください +@msg {label} must be a number of at least 0.1 {label} には 0.1 以上の数値を指定してください +@msg no character style `{name}` 文字スタイル `{name}` が見つかりません # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). Duotone… ダブルトーン… diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 4d44093f7..545c5fc1e 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5868,6 +5868,12 @@ anchor Points: წერტილები: # Color Effects add-on: Vector Halftone (Effect › Color Effects). Dot Color from Art წერტილების ფერი ნახატიდან + Basic Character Formats სიმბოლოს ძირითადი ფორმატები + Case ასოების რეგისტრი + Kerning კერნინგი +@msg {label} must be a number {label} უნდა იყოს რიცხვი +@msg {label} must be a number of at least 0.1 {label} უნდა იყოს მინიმუმ 0,1 +@msg no character style `{name}` სიმბოლოს სტილი `{name}` ვერ მოიძებნა # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). Duotone… დუოტონი… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index dea1f8f50..07b703658 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5460,6 +5460,12 @@ anchor Points: 포인트: # Color Effects add-on: Vector Halftone (Effect › Color Effects). Dot Color from Art 아트워크에서 점 색상 가져오기 + Basic Character Formats 기본 문자 형식 + Case 대소문자 + Kerning 커닝 +@msg {label} must be a number {label}은(는) 숫자여야 합니다 +@msg {label} must be a number of at least 0.1 {label}은(는) 0.1 이상의 숫자여야 합니다 +@msg no character style `{name}` 문자 스타일 `{name}`을 찾을 수 없습니다 # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). Duotone… 이중톤… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 9debbef17..8104acb32 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5733,6 +5733,12 @@ anchor Points: Punkty: # Color Effects add-on: Vector Halftone (Effect › Color Effects). Dot Color from Art Kolor kropek z kompozycji + Basic Character Formats Podstawowe formaty znaków + Case Wielkość liter + Kerning Kerning +@msg {label} must be a number {label} musi być liczbą +@msg {label} must be a number of at least 0.1 {label} musi być liczbą co najmniej 0,1 +@msg no character style `{name}` Nie znaleziono stylu znakowego `{name}` # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). Duotone… Dwutonowy… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index e40d32fb1..dbccb87c7 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5796,6 +5796,12 @@ anchor Points: Pontos: # Color Effects add-on: Vector Halftone (Effect › Color Effects). Dot Color from Art Cor dos Pontos da Arte + Basic Character Formats Formatos básicos de caracteres + Case Maiúsculas e minúsculas + Kerning Kerning +@msg {label} must be a number {label} deve ser um número +@msg {label} must be a number of at least 0.1 {label} deve ser um número de pelo menos 0,1 +@msg no character style `{name}` Estilo de caractere `{name}` não encontrado # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). Duotone… Duotom… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 7bfca518c..9419aeb42 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5856,6 +5856,12 @@ anchor Points: Точки: # Color Effects add-on: Vector Halftone (Effect › Color Effects). Dot Color from Art Цвет точек из рисунка + Basic Character Formats Основные форматы символов + Case Регистр + Kerning Кернинг +@msg {label} must be a number {label} должно быть числом +@msg {label} must be a number of at least 0.1 {label} должно быть числом не меньше 0,1 +@msg no character style `{name}` Стиль символов `{name}` не найден # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). Duotone… Дуплекс… diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 1bd22f672..616c5f319 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5931,6 +5931,12 @@ shape Open Öppen # Color Effects add-on: Vector Halftone (Effect › Color Effects). Dot Color from Art Punktfärg från objekt + Basic Character Formats Grundläggande teckenformat + Case Skiftläge + Kerning Kerning +@msg {label} must be a number {label} måste vara ett tal +@msg {label} must be a number of at least 0.1 {label} måste vara ett tal på minst 0,1 +@msg no character style `{name}` Teckenformatet `{name}` hittades inte # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). Duotone… Duoton… diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index deb3e95d7..7d84df263 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5836,6 +5836,12 @@ anchor Points: Noktalar: # Color Effects add-on: Vector Halftone (Effect › Color Effects). Dot Color from Art Nokta Rengini Sanattan Al + Basic Character Formats Temel karakter biçimleri + Case Büyük küçük harf + Kerning Karakter aralığı +@msg {label} must be a number {label} bir sayı olmalıdır +@msg {label} must be a number of at least 0.1 {label} en az 0,1 olan bir sayı olmalıdır +@msg no character style `{name}` `{name}` karakter stili bulunamadı # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). Duotone… Çift Ton… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index d78f48b33..aeaffaaac 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5477,6 +5477,12 @@ anchor Points: Точки: # Color Effects add-on: Vector Halftone (Effect › Color Effects). Dot Color from Art Колір точок із малюнка + Basic Character Formats Основні формати символів + Case Регістр + Kerning Кернінг +@msg {label} must be a number {label} має бути числом +@msg {label} must be a number of at least 0.1 {label} має бути числом не менше 0,1 +@msg no character style `{name}` Стиль символів `{name}` не знайдено # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). Duotone… Дуплекс… diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index ade0a07aa..3f3292be3 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5479,6 +5479,12 @@ anchor Points: Điểm: # Color Effects add-on: Vector Halftone (Effect › Color Effects). Dot Color from Art Màu chấm theo tác phẩm + Basic Character Formats Định dạng ký tự cơ bản + Case Chữ hoa và chữ thường + Kerning Điều chỉnh khoảng cách cặp chữ +@msg {label} must be a number {label} phải là một số +@msg {label} must be a number of at least 0.1 {label} phải là một số ít nhất bằng 0,1 +@msg no character style `{name}` Không tìm thấy kiểu ký tự `{name}` # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). Duotone… Hai tông… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index acffa1e4e..cf2ae14bf 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5720,6 +5720,12 @@ anchor Points: 点: # Color Effects add-on: Vector Halftone (Effect › Color Effects). Dot Color from Art 网点颜色取自图稿 + Basic Character Formats 基本字符格式 + Case 大小写 + Kerning 字偶间距 +@msg {label} must be a number {label} 必须是数字 +@msg {label} must be a number of at least 0.1 {label} 必须是至少 0.1 的数字 +@msg no character style `{name}` 找不到字符样式 `{name}` # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). Duotone… 双色调… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 3b560be95..f8c302079 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5516,6 +5516,12 @@ anchor Points: 點: # Color Effects add-on: Vector Halftone (Effect › Color Effects). Dot Color from Art 網點顏色取自圖稿 + Basic Character Formats 基本字元格式 + Case 大小寫 + Kerning 字偶間距 +@msg {label} must be a number {label} 必須是數字 +@msg {label} must be a number of at least 0.1 {label} 必須是至少 0.1 的數字 +@msg no character style `{name}` 找不到字元樣式 `{name}` # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). Duotone… 雙色調… diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index bb20fbbca..5b83dc25b 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -594,6 +594,11 @@ fn run_ui_part7(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::open_color_picker(app, p), "ui.sampleScreenColor" => crate::screen_color::start(app, p), "ui.graphicStyleOptions" => crate::dialogs::graphic_style_options::open(app, s("name").as_deref()), + "ui.charStyleOptions" => match s("name") { + Some(name) => crate::dialogs::char_style_options::open(app, &name), + None => Err("missing `name`".into()), + }, + "ui.charStyleOptions.resetPanel" => crate::dialogs::char_style_options::reset_panel(app), "ui.newCharStyle" => crate::panels::text_styles::open_new(app, crate::panels::text_styles::Kind::Char), "ui.newParaStyle" => crate::panels::text_styles::open_new(app, crate::panels::text_styles::Kind::Para), "ui.symbolOptions" => { diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index c1cdf4f59..ad7f88c5c 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -225,7 +225,11 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "ui.missingFontsDialog" => !cfg!(target_arch = "wasm32") && app.session.active().is_some(), "ui.findFontsInFolder" => crate::picks::can(app, &crate::picks::PickRequest::Folder) && app.session.active().is_some(), "ui.swatchOptions" | "ui.newSwatch" | "ui.newColorGroup" => app.session.active().is_some(), - "ui.graphicStyleOptions" | "ui.newCharStyle" | "ui.newParaStyle" => app.session.active().is_some(), + "ui.graphicStyleOptions" | "ui.charStyleOptions" | "ui.newCharStyle" | "ui.newParaStyle" => app.session.active().is_some(), + "ui.charStyleOptions.resetPanel" => app.ui.dialog.as_ref().is_some_and(|d| { + (d.kind == crate::dialogs::char_style_options::KIND || (d.kind == crate::dialogs::new_text_style::KIND && !d.bool("__para"))) + && d.str("section") == "basic" + }), "ui.newArtboard" => app.session.active().is_some(), "ui.sampleScreenColor" => crate::screen_color::available(app), "ui.newBrush" => app.session.active().is_some(), diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index ba3e700c6..bd633da60 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -657,6 +657,18 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{name?} open Graphic Style Options (dialog `graphicStyleOptions`, field `name`): for style `name` OK renames it (graphicStyle.rename); without, OK makes a new style of that name from the selection (graphicStyle.new)", ), + ( + "ui.charStyleOptions", + "Character Style Options", + "", + "{name} open Character Style Options (dialog `charStyleOptions`); OK runs charStyle.edit with name, to and sparse attrs in one undo step; Cancel restores a live preview", + ), + ( + "ui.charStyleOptions.resetPanel", + "Reset Panel", + "", + "{} clear the Basic Character Formats settings in the open Character Style Options or New Character Style dialog; available only on that section", + ), ( "ui.newCharStyle", "New Character Style…", diff --git a/crates/ui-egui/src/panels/text_styles.rs b/crates/ui-egui/src/panels/text_styles.rs index 8eebeae19..1aca6039a 100644 --- a/crates/ui-egui/src/panels/text_styles.rs +++ b/crates/ui-egui/src/panels/text_styles.rs @@ -375,8 +375,12 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { if menu_item(ui, &crate::i18n::fmt(tl!("{kind} Style Options…"), &[("kind", kind.what_label())]), user, false) && let Some(n) = &sel { - let label = format!("{} Style Options", kind.what()); - app.run("ui.paramDialog", json!({ "command": format!("{p}.rename"), "label": label, "params": { "name": n, "to": n } })).ok(); + if kind == Kind::Char { + app.run("ui.charStyleOptions", json!({ "name": n })).ok(); + } else { + let label = format!("{} Style Options", kind.what()); + app.run("ui.paramDialog", json!({ "command": format!("{p}.rename"), "label": label, "params": { "name": n, "to": n } })).ok(); + } } } diff --git a/crates/ui-egui/src/panels/text_styles/tests.rs b/crates/ui-egui/src/panels/text_styles/tests.rs index d20962461..144e2d525 100644 --- a/crates/ui-egui/src/panels/text_styles/tests.rs +++ b/crates/ui-egui/src/panels/text_styles/tests.rs @@ -291,6 +291,22 @@ fn panel_menu_duplicate_and_delete_update_the_highlighted_style() { } } +#[test] +fn character_style_options_menu_opens_format_sections() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("charStyle.new", json!({"name": "Body", "attrs": {"size": 24.0}})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + click_label(&ctx, &mut app, "Body", |app, ui| show(app, ui, Kind::Char)); + assert_eq!(click_label(&ctx, &mut app, " Character Style Options…", |app, ui| menu(app, ui, Kind::Char)), 1); + let dialog = app.ui.dialog.as_ref().unwrap(); + assert_eq!(dialog.kind, crate::dialogs::char_style_options::KIND); + assert_eq!(dialog.str("section"), "general"); + assert_eq!(dialog.str("size"), "24.0"); + assert!(dialog.bool("preview")); +} + /// In a panel scrolled to the top of a long list, the menu's Duplicate scrolls the copy's row into /// view, and so does the New button's `highlight_made` for the new style. #[test] @@ -866,6 +882,40 @@ fn alt_clicking_new_character_style_asks_for_its_name() { assert_eq!(text_editing(&app), Some((id, 3, 5)), "the Type selection stays"); } +#[test] +fn new_character_style_without_selection_uses_japanese_defaults() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.prefs.interface_language = "ja".into(); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + assert!(app.session.japanese_interface()); + + for (name, size) in [("Default", None), ("Edited", Some(18))] { + crate::dialogs::new_text_style::open(&mut app, false, json!({})).unwrap(); + let dialog = app.ui.dialog.as_mut().unwrap(); + assert_eq!(dialog.fields["__attrs"]["charAlign"], "emBoxCenter"); + dialog.fields.insert("name".into(), json!(name)); + if let Some(size) = size { + dialog.fields.insert("size".into(), json!(size)); + } + crate::dialogs::confirm(&mut app).unwrap(); + let style = app.session.doc().unwrap().doc.char_styles.iter().find(|style| style.name == name).unwrap(); + assert_eq!(style.attrs.get("charAlign"), Some(&json!("emBoxCenter"))); + assert_eq!(style.attrs.get("size").cloned(), size.map(|value| json!(f64::from(value)))); + } + crate::dialogs::new_text_style::open(&mut app, false, json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!("basic")); + app.run("ui.charStyleOptions.resetPanel", json!({})).unwrap(); + let dialog = app.ui.dialog.as_ref().unwrap(); + assert!(!crate::dialogs::char_style_options::edited_attrs(dialog).unwrap().contains_key("charAlign")); + crate::dialogs::cancel(&mut app); + + crate::dialogs::new_text_style::open(&mut app, true, json!({})).unwrap(); + let dialog = app.ui.dialog.as_ref().unwrap(); + assert!(dialog.fields["__target"].get("attrs").is_none()); + assert!(dialog.fields.get("__attrs").is_none()); + crate::dialogs::cancel(&mut app); +} + /// The dialog highlights its new style and scrolls the matching panel to its row. #[test] fn a_style_made_in_the_new_style_dialog_is_highlighted() { diff --git a/docs/mcp.md b/docs/mcp.md index 129d7346c..155755523 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5109,6 +5109,22 @@ name and the Type tool's selection as it was when the dialog opened (else the se has, or an empty one, keeps the dialog open, and Cancel adds no style. A plain click on New still makes the style at once. +`ui.charStyleOptions {name}` opens Character Style Options for an existing named style. +Character Style Options uses `charStyle.edit {name, to, attrs}` to replace the name and complete sparse +attribute map in one undo step. `to` is trimmed and must be unique. The command updates text using the style +while preserving local overrides; Undo/Redo restores the definition and its users. The `charStyleOptions` +dialog exposes `name`, `font_family`, `font_style`, `size`, `leading`, `kerning`, `tracking`, `case`, +`position`, `underline`, `strikethrough`, `charAlign`, `section`, and `preview`. An empty field leaves its +attribute unset. `leading` and `kerning` also accept `Auto` to store an explicit automatic value. +The New Character Style dialog accepts the same character fields and starts with the selected text's +captured attributes. Without selected text, it starts with the character defaults for the interface language +(including `charAlign: "emBoxCenter"` in Japanese); changing a Basic field keeps other seeded attributes. +Without selected text, the New Paragraph Style dialog sends no `attrs`, and the engine gives the new style the +paragraph defaults for the interface language. Reset Panel in Basic Character +Formats removes those fields from the definition. +`ui.charStyleOptions.resetPanel {}` performs that reset when Basic Character Formats is selected in +either character-style dialog; it is disabled on General. + In the native file a text object's `para` holds the first paragraph's attributes and `paras` (left out when every paragraph is alike) one entry per paragraph, so older readers keep the first paragraph's. SVG export anchors each centred or right-aligned line on its own (``), and SVG import gives each line's paragraph its diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e6645bbbc..caa6dbfb3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -500,7 +500,8 @@ since 2026-10-01. - **COLR colour fonts:** v0 and v1 glyphs draw as vector layers (solid colours, linear and radial gradients, clips, blend modes, the foreground colour from the run's fill) on the canvas, as paths in SVG, EPS, legacy `.ai` and PDF with outlined text, as Type 3 text in PDF, and as editable groups with Create Outlines; sweep gradients and Porter-Duff compositing draw the glyph as an image. Measured by `tests_colr`, `render::tests_colrglyphs`, `pdf::tests::colr_glyphs_export_as_vector_art`, `svg/tests/it/colour_glyphs.rs`, `eps` `colr_glyphs_*`, `tests_pathops::create_outlines_turns_colr_glyphs_into_layer_groups`. Scores unchanged (estimated). - **OpenType SVG colour fonts:** each glyph's SVG document is read by the SVG importer (registered by the engine) into vector art in the run's colours, then drawn, exported and outlined as COLR glyphs are; PDF real text writes them as text that draws nothing and draws their art where they sit. Measured by `tests_svg_glyphs`, `svg/tests/it/svg_glyphs.rs`, `tests_colourfonts::create_outlines_turns_svg_glyphs_into_groups`, `tests_colourfonts::pdf_real_text_draws_svg_glyphs_as_art_and_keeps_their_characters`. Scores unchanged (estimated). - **Character-style Auto leading:** capture and explicit style attributes retain Auto as a reset, distinct from omitted leading. Redefine and later numeric updates preserve local leading overrides; the Character Styles marker recognizes Auto correctly. Regression assertions cover partial definitions, Undo/Redo and native save/load serialization. Public references: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html) and [Leading](https://helpx.adobe.com/illustrator/using/line-character-spacing.html). -- **Text styles redefined to the defaults:** New and Redefine Character Style and Paragraph Style capture every attribute of the selected text, including those at their defaults (Auto kerning, Roman baseline alignment, the default Hyphenation options, the Every-line Composer, no tab stops), and `charStyle.setAttrs` and `paraStyle.setAttrs` keep an attribute set to its default value. After a redefinition, text whose value still equals the old definition's takes the new value, a default included; local overrides and attributes the new definition leaves out stay. The style panels show "+" only where the text differs from its style, including type with Auto alignment. Public reference: [Character and paragraph styles](https://web.archive.org/web/20260825113735/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). +- **Character Style Options, General and Basic Character Formats:** the dialog edits a style's name and stored character formats together. Empty fields remain unset, checkboxes show an indeterminate unset state, Basic's Reset Panel clears its explicit settings, and Preview can be canceled without changing history. OK saves one undo step; New Character Style offers the same fields from captured text or the interface language's defaults without a selection. Public reference: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). +- **Text styles redefined to the defaults:** New and Redefine Character Style and Paragraph Style capture every attribute of the selected text, including those at their defaults (Auto kerning, Roman baseline alignment, the default Hyphenation options, the Every-line Composer, no tab stops), and `charStyle.setAttrs` and `paraStyle.setAttrs` keep an attribute set to its default value. After a redefinition, text whose value still equals the old definition's takes the new value, a default included; local overrides stay, and text that still has the old value of an attribute the new definition leaves out goes back to the Normal style's value. The style panels show "+" only where the text differs from its style, including type with Auto alignment. Public reference: [Character and paragraph styles](https://web.archive.org/web/20260825113735/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Text styles dragged onto the panel's buttons:** a style dragged from the Character Styles or Paragraph Styles panel onto Create New Style is copied (`charStyle.duplicate`, `paraStyle.duplicate`, which name the copy ` copy`) and the copy is highlighted; dragged onto Delete it is deleted (`charStyle.delete`, `paraStyle.delete`); the Normal style is not deleted. Each drop is one undo step. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Auto Leading outside the layout:** a paragraph's Justification… › Auto Leading () also sets the Leading the Character panel shows for Auto type (blank where the selected paragraphs differ), the leading the Alt+↑/↓ nudge (`type.step`) steps from, each paragraph's own, and the lines Fill with Placeholder Text counts in area type. Evidence: `ParaStyle::effective_leading` (`crates/doc/src/text.rs` test `paragraph_effective_leading_follows_auto_leading`), `tests_prefs::auto_leading_steps_and_fills_from_the_paragraph_auto_leading`, `panels::character` test `leading_field_shows_the_paragraph_auto_leading`. - **The text style panel menus' Duplicate and Delete:** in the Character Styles and Paragraph Styles panel menus, Duplicate highlights the copy, as Create New Style highlights the new style, and Delete leaves no style highlighted, as the Delete button does, so Duplicate, Clear Overrides, Redefine and Style Options are disabled until another style is clicked. A highlighted style that is renamed in Style Options or deleted by a command is no longer highlighted either. The tests `panels::text_styles::tests::panel_menu_duplicate_and_delete_update_the_highlighted_style` and `a_highlighted_style_that_is_no_longer_listed_is_not_highlighted` cover this. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html).