diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 3d39aab9..2d651545 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -37,7 +37,7 @@ pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, Gradient pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask}; pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType}; pub use selection::{AnchorRef, Selection}; -pub use text::{CharStyle, Justify, ParaStyle, TextKind, TextObject, TextRun}; +pub use text::{CharStyle, Justify, ParaStyle, TextKind, TextObject, TextRun, TextStyleDef}; use drawcraft_color::{Swatch, SwatchGroup}; use drawcraft_geom::{Point, Rect}; @@ -268,6 +268,12 @@ pub struct Document { pub swatch_groups: Vec, #[serde(default)] pub graphic_styles: Vec, + /// Character styles (besides the built-in [Normal Character Style], which may be redefined here). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub char_styles: Vec, + /// Paragraph styles (besides the built-in [Normal Paragraph Style]). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub para_styles: Vec, #[serde(default)] pub symbols: Vec, #[serde(default)] @@ -316,6 +322,8 @@ impl Document { swatches, swatch_groups, graphic_styles: default_graphic_styles(), + char_styles: vec![], + para_styles: vec![], symbols: vec![], guides: vec![], grid: GridPrefs::default(), diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 87c719bb..c6733c63 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -52,6 +52,9 @@ pub struct CharStyle { pub strikethrough: bool, #[serde(default)] pub all_caps: bool, + /// Character style (Character Styles panel) these attributes come from; None = Normal. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub style_name: Option, } fn regular() -> String { @@ -80,6 +83,7 @@ impl Default for CharStyle { underline: false, strikethrough: false, all_caps: false, + style_name: None, } } } @@ -107,6 +111,18 @@ pub struct ParaStyle { pub space_after: f64, #[serde(default)] pub hyphenate: bool, + /// Paragraph style (Paragraph Styles panel) these attributes come from; None = Normal. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub style_name: Option, +} + +/// A named character or paragraph style: the attributes it sets (a subset of [`CharStyle`] or +/// [`ParaStyle`] fields, by their serialized names). Text using it records the name. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TextStyleDef { + pub name: String, + #[serde(default)] + pub attrs: serde_json::Map, } /// A run of text sharing one character style. diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index daf691cd..54c74147 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -26,6 +26,7 @@ pub mod prefscmds; mod recolor; mod select; mod textedit; +pub mod textstyles; mod typecmd; mod typemenu; pub mod wand; @@ -150,6 +151,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(colormgmt::specs()); v.extend(typemenu::specs()); v.extend(textedit::specs()); + v.extend(textstyles::specs()); v.extend(docmenu::specs()); v.extend(panelcmds::specs()); v.extend(buildcmds::specs()); diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs new file mode 100644 index 00000000..87952bd8 --- /dev/null +++ b/crates/engine/src/cmd/textstyles.rs @@ -0,0 +1,540 @@ +//! Character Styles and Paragraph Styles panels. +//! +//! A style is a named, partial set of attributes ([`TextStyleDef`]); text using it records the +//! name in `CharStyle::style_name` / `ParaStyle::style_name`. Applying a style sets its attributes +//! (Clear Overrides first resets to the Normal style). Redefining a style updates its users but keeps +//! their local overrides: an attribute changes only where it still had the old style's value. + +use drawcraft_doc::{CharStyle, Document, NodeId, NodeKind, ParaStyle, TextStyleDef}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::{Map, Value, json}; + +use super::typecmd::{refresh_bounds, text_targets}; +use super::*; + +pub const NORMAL_CHAR: &str = "[Normal Character Style]"; +pub const NORMAL_PARA: &str = "[Normal Paragraph Style]"; + +#[derive(Clone, Copy, PartialEq)] +enum Kind { + Char, + Para, +} + +impl Kind { + fn cmd(self, verb: &str) -> String { + format!("{}.{verb}", if self == Kind::Char { "charStyle" } else { "paraStyle" }) + } + fn normal(self) -> &'static str { + if self == Kind::Char { NORMAL_CHAR } else { NORMAL_PARA } + } + fn defs(self, d: &Document) -> &Vec { + if self == Kind::Char { &d.char_styles } else { &d.para_styles } + } + fn defs_mut(self, d: &mut Document) -> &mut Vec { + if self == Kind::Char { &mut d.char_styles } else { &mut d.para_styles } + } + /// Attributes of style `name` (the Normal style is built in: empty unless redefined). + fn attrs(self, d: &Document, name: &str) -> Option> { + match self.defs(d).iter().find(|s| s.name == name) { + Some(s) => Some(s.attrs.clone()), + None if name == self.normal() => Some(Map::new()), + None => None, + } + } +} + +/// One command spec (the `cmd!` macro wants literals; these ids are built with `concat!`). +const fn spec(id: &'static str, label: &'static str, menu: &'static [&'static str], params: &'static str, run: Run, journal: bool) -> CommandSpec { + CommandSpec { id, label, menu, shortcut: None, params, enabled: has_doc, run, journal } +} + +macro_rules! style_cmds { + ($kind:expr, $p:literal, $menu:literal, $what:literal) => { + vec![ + spec(concat!($p, ".list"), concat!($what, " Styles"), &[], "{} → {styles: [{name, attrs, uses}]}", |s, _| list(s, $kind), false), + spec( + concat!($p, ".new"), + concat!("New ", $what, " Style"), + &["Window", "Type", $menu], + "{name?, attrs?: {…}} (default attrs: the selected text's) → {name}", + |s, p| new(s, p, $kind), + true, + ), + spec( + concat!($p, ".apply"), + concat!("Apply ", $what, " Style"), + &[], + "{name, clearOverrides?, ids?|id + start?/end? (a Type tool range)} → {count}", + |s, p| apply(s, p, $kind), + true, + ), + spec( + concat!($p, ".redefine"), + concat!("Redefine ", $what, " Style"), + &["Window", "Type", $menu], + "{name, id?, start?} from the selected text; users keep their overrides", + |s, p| redefine(s, p, $kind), + true, + ), + spec( + concat!($p, ".setAttrs"), + concat!($what, " Style Options"), + &[], + "{name, attrs: {…}} replace the style's attributes; users keep their overrides", + |s, p| set_attrs(s, p, $kind), + true, + ), + spec( + concat!($p, ".duplicate"), + concat!("Duplicate ", $what, " Style"), + &["Window", "Type", $menu], + "{name} → {name}", + |s, p| duplicate(s, p, $kind), + true, + ), + spec(concat!($p, ".rename"), concat!("Rename ", $what, " Style"), &[], "{name, to}", |s, p| rename(s, p, $kind), true), + spec( + concat!($p, ".delete"), + concat!("Delete ", $what, " Style"), + &["Window", "Type", $menu], + "{name} (text keeps its look)", + |s, p| delete(s, p, $kind), + true, + ), + ] + }; +} + +pub fn specs() -> Vec { + let mut v = style_cmds!(Kind::Char, "charStyle", "Character Styles", "Character"); + v.extend(style_cmds!(Kind::Para, "paraStyle", "Paragraph Styles", "Paragraph")); + v +} + +// ---------- attribute plumbing ---------- + +/// A style's attributes as a JSON map, without the style name. +fn to_attrs(v: &T) -> Map { + let mut m = match serde_json::to_value(v) { + Ok(Value::Object(m)) => m, + _ => Map::new(), + }; + m.remove("style_name"); + m +} + +/// `base` with `attrs` written over it (unknown or ill-typed attributes are an error). +fn with_attrs(base: &T, attrs: &Map, cmd: &str) -> Result { + let mut m = to_attrs(base); + if let Ok(Value::Object(full)) = serde_json::to_value(base) { + m.insert("style_name".into(), full.get("style_name").cloned().unwrap_or(Value::Null)); + } + for (k, v) in attrs { + if !m.contains_key(k) { + return Err(bad(cmd, format!("unknown attribute `{k}`"))); + } + m.insert(k.clone(), v.clone()); + } + serde_json::from_value(Value::Object(m)).map_err(|e| bad(cmd, e.to_string())) +} + +/// `cur` after its style changed from `old` to `new`: attributes still equal to the old style's +/// value (or not set by it) take the new value; overrides stay. +fn restyle(cur: &T, old: &Map, new: &Map) -> T { + let mut m = match serde_json::to_value(cur) { + Ok(Value::Object(m)) => m, + _ => return serde_json::from_value(serde_json::to_value(cur).unwrap_or_default()).unwrap_or_else(|_| unreachable!()), + }; + for (k, v) in new { + if old.get(k).is_none_or(|o| m.get(k) == Some(o)) { + m.insert(k.clone(), v.clone()); + } + } + serde_json::from_value(Value::Object(m.clone())) + .unwrap_or_else(|_| serde_json::from_value(serde_json::to_value(cur).unwrap_or_default()).expect("round trip")) +} + +/// 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) + } + }); + v +} + +fn text_mut(d: &mut Document, id: NodeId) -> Option<&mut drawcraft_doc::TextObject> { + match d.node_mut(id).map(|n| &mut n.kind) { + Some(NodeKind::Text(t)) => Some(t), + _ => None, + } +} + +/// How many runs (char) or text objects (para) use `name`. +fn uses(d: &Document, kind: Kind, name: &str) -> usize { + let target = (name != kind.normal()).then_some(name); + let mut n = 0; + d.walk(|node| { + if let NodeKind::Text(t) = &node.kind { + n += match kind { + Kind::Char => t.runs.iter().filter(|r| r.style.style_name.as_deref() == target).count(), + Kind::Para => usize::from(t.para.style_name.as_deref() == target), + }; + } + }); + n +} + +/// 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) { + 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 mut changed = false; + match kind { + Kind::Char => { + for r in &mut t.runs { + if r.style.style_name == target { + r.style = restyle(&r.style, old, new); + changed = true; + } + } + } + Kind::Para => { + if t.para.style_name == target { + t.para = restyle(&t.para, old, new); + changed = true; + } + } + } + if changed { + refresh_bounds(t); + } + } +} + +/// The selected text's attributes: the Type tool range's start (`id`, `start`) or the first +/// selected text object's first run. +fn selection_attrs(s: &Session, p: &Value, kind: Kind) -> Result>> { + let d = &s.doc()?.doc; + let id = match id_param(p, "id") { + Some(id) => Some(id), + None => text_targets(s, &json!({}), "").ok().and_then(|v| v.first().copied()), + }; + let Some(NodeKind::Text(t)) = id.and_then(|i| d.node(i)).map(|n| &n.kind) else { return Ok(None) }; + Ok(Some(match kind { + Kind::Char => { + let at = p.get("start").and_then(Value::as_u64).unwrap_or(0) as usize; + to_attrs(&drawcraft_text::edit::style_at(&t.runs, at)) + } + Kind::Para => to_attrs(&t.para), + })) +} + +fn name_param<'a>(p: &'a Value, kind: Kind, verb: &str) -> Result<&'a str> { + str_param(p, "name").ok_or_else(|| bad(&kind.cmd(verb), "missing `name`")) +} + +fn attrs_param(p: &Value, kind: Kind, verb: &str) -> Result>> { + match p.get("attrs") { + None | Some(Value::Null) => Ok(None), + Some(Value::Object(m)) => { + // Validate against the attribute set, and store values in their canonical serialized + // form (so comparing a style with its text's attributes is exact). + let c = kind.cmd(verb); + let full = match kind { + Kind::Char => to_attrs(&with_attrs(&CharStyle::default(), m, &c)?), + Kind::Para => to_attrs(&with_attrs(&ParaStyle::default(), m, &c)?), + }; + Ok(Some(m.keys().filter_map(|k| full.get(k).map(|v| (k.clone(), v.clone()))).collect())) + } + Some(_) => Err(bad(&kind.cmd(verb), "attrs must be an object")), + } +} + +// ---------- commands ---------- + +fn list(s: &mut Session, kind: Kind) -> Result { + let d = &s.doc()?.doc; + let mut names = vec![kind.normal().to_string()]; + names.extend(kind.defs(d).iter().map(|s| s.name.clone()).filter(|n| n != kind.normal())); + let styles: Vec = names.iter().map(|n| json!({ "name": n, "attrs": kind.attrs(d, n), "uses": uses(d, kind, n) })).collect(); + Ok(json!({ "styles": styles })) +} + +fn new(s: &mut Session, p: &Value, kind: Kind) -> Result { + let c = kind.cmd("new"); + let attrs = match attrs_param(p, kind, "new")? { + Some(a) => a, + None => selection_attrs(s, p, kind)?.unwrap_or_default(), + }; + let d = &s.doc()?.doc; + let base = if kind == Kind::Char { "Character Style" } else { "Paragraph Style" }; + let name = match str_param(p, "name") { + Some(n) => n.to_string(), + None => (1..).map(|i| format!("{base} {i}")).find(|n| kind.attrs(d, n).is_none()).expect("free name"), + }; + if name.trim().is_empty() || kind.attrs(d, &name).is_some() { + return Err(bad(&c, format!("a style named `{name}` already exists"))); + } + let n2 = name.clone(); + s.edit(&format!("New {base}"), |d, _| { + kind.defs_mut(d).push(TextStyleDef { name: n2, attrs }); + Ok(()) + })?; + Ok(json!({ "name": name })) +} + +fn apply(s: &mut Session, p: &Value, kind: Kind) -> Result { + let c = kind.cmd("apply"); + let name = name_param(p, kind, "apply")?.to_string(); + let d = &s.doc()?.doc; + let attrs = kind.attrs(d, &name).ok_or_else(|| bad(&c, format!("no style `{name}`")))?; + let normal = kind.attrs(d, kind.normal()).unwrap_or_default(); + let clear = bool_or(p, "clearOverrides", false); + let style_name = (name != kind.normal()).then(|| name.clone()); + // A Type tool range (character styles only) or whole text objects. + let range = id_param(p, "id").filter(|_| kind == Kind::Char && (p.get("start").is_some() || p.get("end").is_some())); + let ids = match range.or_else(|| id_param(p, "id")) { + Some(id) => vec![id], + None => text_targets(s, p, &c)?, + }; + // Characters: Normal then the style over the defaults (clear) or over the current attributes. + let char_fn = |cur: &CharStyle| -> Result { + let base = if clear { with_attrs(&CharStyle::default(), &normal, &c)? } else { cur.clone() }; + let mut st = with_attrs(&base, &attrs, &c)?; + st.style_name = style_name.clone(); + Ok(st) + }; + let para_fn = |cur: &ParaStyle| -> Result { + let base = if clear { with_attrs(&ParaStyle::default(), &normal, &c)? } else { cur.clone() }; + let mut st = with_attrs(&base, &attrs, &c)?; + st.style_name = style_name.clone(); + Ok(st) + }; + let count = ids.len(); + s.edit(&format!("Apply {name}"), |d, _| { + for id in &ids { + let Some(t) = text_mut(d, *id) else { continue }; + match kind { + Kind::Char => { + let len = drawcraft_text::edit::runs_len(&t.runs); + let (a, b) = match range { + Some(_) => { + let g = |k: &str, def: usize| p.get(k).and_then(Value::as_u64).map_or(def, |v| (v as usize).min(len)); + (g("start", 0), g("end", len)) + } + None => (0, len), + }; + let mut err = None; + drawcraft_text::edit::style_range(&mut t.runs, a.min(b), a.max(b), |st| match char_fn(st) { + Ok(n) => *st = n, + Err(e) => err = Some(e), + }); + if len == 0 { + for r in &mut t.runs { + r.style = char_fn(&r.style)?; + } + } + if let Some(e) = err { + return Err(e); + } + } + Kind::Para => t.para = para_fn(&t.para)?, + } + refresh_bounds(t); + } + Ok(()) + })?; + Ok(json!({ "count": count })) +} + +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}`")))?; + let name = name.to_string(); + s.edit(&format!("Redefine {name}"), |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); + Ok(()) + })?; + ok() +} + +fn redefine(s: &mut Session, p: &Value, kind: Kind) -> Result { + let name = name_param(p, kind, "redefine")?.to_string(); + let attrs = selection_attrs(s, p, kind)?.ok_or_else(|| bad(&kind.cmd("redefine"), "select text to redefine the style from"))?; + redefine_to(s, kind, &name, attrs, "redefine") +} + +fn set_attrs(s: &mut Session, p: &Value, kind: Kind) -> Result { + let name = name_param(p, kind, "setAttrs")?.to_string(); + let attrs = attrs_param(p, kind, "setAttrs")?.ok_or_else(|| bad(&kind.cmd("setAttrs"), "missing `attrs`"))?; + redefine_to(s, kind, &name, attrs, "setAttrs") +} + +fn duplicate(s: &mut Session, p: &Value, kind: Kind) -> Result { + let name = name_param(p, kind, "duplicate")?.to_string(); + let d = &s.doc()?.doc; + let attrs = kind.attrs(d, &name).ok_or_else(|| bad(&kind.cmd("duplicate"), format!("no style `{name}`")))?; + let copy = (1..) + .map(|i| if i == 1 { format!("{name} copy") } else { format!("{name} copy {i}") }) + .find(|n| kind.attrs(d, n).is_none()) + .expect("free name"); + new(s, &json!({ "name": copy, "attrs": attrs }), kind) +} + +fn rename(s: &mut Session, p: &Value, kind: Kind) -> Result { + let c = kind.cmd("rename"); + let name = name_param(p, kind, "rename")?.to_string(); + let to = str_param(p, "to").ok_or_else(|| bad(&c, "missing `to`"))?.trim().to_string(); + let d = &s.doc()?.doc; + if name == kind.normal() || !kind.defs(d).iter().any(|s| s.name == name) { + return Err(bad(&c, format!("`{name}` can't be renamed"))); + } + if to.is_empty() || kind.attrs(d, &to).is_some() { + return Err(bad(&c, format!("`{to}` is empty or already used"))); + } + s.edit("Rename Style", |d, _| { + 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 }; + let fix = |n: &mut Option| { + if n.as_deref() == Some(name.as_str()) { + *n = Some(to.clone()); + } + }; + match kind { + Kind::Char => t.runs.iter_mut().for_each(|r| fix(&mut r.style.style_name)), + Kind::Para => fix(&mut t.para.style_name), + } + } + Ok(()) + })?; + ok() +} + +fn delete(s: &mut Session, p: &Value, kind: Kind) -> Result { + let c = kind.cmd("delete"); + let name = name_param(p, kind, "delete")?.to_string(); + if name == kind.normal() || !kind.defs(&s.doc()?.doc).iter().any(|s| s.name == name) { + return Err(bad(&c, format!("`{name}` can't be deleted"))); + } + s.edit("Delete Style", |d, _| { + kind.defs_mut(d).retain(|s| s.name != name); + for id in all_text(d) { + let Some(t) = text_mut(d, id) else { continue }; + let fix = |n: &mut Option| { + if n.as_deref() == Some(name.as_str()) { + *n = None; + } + }; + match kind { + Kind::Char => t.runs.iter_mut().for_each(|r| fix(&mut r.style.style_name)), + Kind::Para => fix(&mut t.para.style_name), + } + } + Ok(()) + })?; + ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn session_with_text(text: &str) -> (Session, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": text})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + (s, id) + } + + fn runs(s: &Session, id: u64) -> Vec { + match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.clone(), + _ => panic!(), + } + } + + #[test] + fn char_style_new_apply_range_redefine_keeps_overrides() { + let (mut s, id) = session_with_text("Hello world"); + s.execute("charStyle.new", &json!({"name": "Emphasis", "attrs": {"size": 24.0, "underline": true}})).unwrap(); + // Style "world" only (a Type tool range). + s.execute("charStyle.apply", &json!({"name": "Emphasis", "id": id, "start": 6, "end": 11})).unwrap(); + let r = runs(&s, id); + assert_eq!(r.len(), 2); + assert_eq!((r[1].text.as_str(), r[1].style.size, r[1].style.underline), ("world", 24.0, true)); + assert_eq!(r[1].style.style_name.as_deref(), Some("Emphasis")); + assert_eq!(r[0].style.style_name, None); + // A local override on the styled run survives a redefinition; other attributes update. + s.execute("text.setRangeStyle", &json!({"id": id, "start": 6, "end": 11, "size": 30.0})).unwrap(); + s.execute("charStyle.setAttrs", &json!({"name": "Emphasis", "attrs": {"size": 18.0, "underline": false, "tracking": 50.0}})).unwrap(); + let r = runs(&s, id); + assert_eq!((r[1].style.size, r[1].style.underline, r[1].style.tracking), (30.0, false, 50.0)); + // Clear Overrides: back to exactly the style's attributes. + s.execute("charStyle.apply", &json!({"name": "Emphasis", "id": id, "start": 6, "end": 11, "clearOverrides": true})).unwrap(); + assert_eq!(runs(&s, id)[1].style.size, 18.0); + let list = s.execute("charStyle.list", &json!({})).unwrap(); + assert_eq!(list["styles"][0]["name"], NORMAL_CHAR); + assert_eq!(list["styles"][1]["uses"], 1); + } + + #[test] + fn new_from_selection_rename_delete_and_undo() { + let (mut s, id) = session_with_text("Title"); + s.execute("text.setStyle", &json!({"size": 40})).unwrap(); + let name = s.execute("charStyle.new", &json!({})).unwrap()["name"].as_str().unwrap().to_string(); + assert_eq!(name, "Character Style 1"); + assert_eq!(s.doc().unwrap().doc.char_styles[0].attrs["size"], 40.0); + s.execute("charStyle.apply", &json!({"name": name})).unwrap(); + s.execute("charStyle.rename", &json!({"name": name, "to": "Heading"})).unwrap(); + assert_eq!(runs(&s, id)[0].style.style_name.as_deref(), Some("Heading")); + assert!(s.execute("charStyle.rename", &json!({"name": NORMAL_CHAR, "to": "x"})).is_err()); + s.execute("charStyle.duplicate", &json!({"name": "Heading"})).unwrap(); + s.execute("charStyle.delete", &json!({"name": "Heading"})).unwrap(); + assert_eq!(runs(&s, id)[0].style.style_name, None); + assert_eq!(runs(&s, id)[0].style.size, 40.0, "deleting keeps the look"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(runs(&s, id)[0].style.style_name.as_deref(), Some("Heading")); + assert!(s.execute("charStyle.new", &json!({"name": "Bad", "attrs": {"nope": 1}})).is_err()); + // Attributes are stored canonically (a hand-written colour matches the text's afterwards). + s.execute( + "charStyle.new", + &json!({"name": "Pink", "attrs": {"fill": {"type": "solid", "color": {"model": "rgb", "r": 0.9, "g": 0.2, "b": 0.4}}}}), + ) + .unwrap(); + s.execute("charStyle.apply", &json!({"name": "Pink"})).unwrap(); + let fill = serde_json::to_value(&runs(&s, id)[0].style.fill).unwrap(); + assert_eq!(s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == "Pink").unwrap().attrs["fill"], fill); + } + + #[test] + fn paragraph_styles_and_survive_save() { + let (mut s, id) = session_with_text("Body"); + s.execute("paraStyle.new", &json!({"name": "Centered", "attrs": {"justify": "Center", "space_after": 6.0}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Centered"})).unwrap(); + let para = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.para.clone(), + _ => panic!(), + }; + assert_eq!((para(&s).justify, para(&s).space_after), (drawcraft_doc::Justify::Center, 6.0)); + // The Normal style can be redefined too (stored once redefined). + s.execute("paraStyle.redefine", &json!({"name": NORMAL_PARA})).unwrap(); + assert_eq!(s.execute("paraStyle.list", &json!({})).unwrap()["styles"][0]["attrs"]["justify"], "Center"); + let d = s.doc().unwrap().doc.clone(); + let back = drawcraft_format::load(&drawcraft_format::save(&d, false)).unwrap(); + assert_eq!(back.para_styles, d.para_styles); + } +} diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index c4f55fd3..aea08830 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -110,7 +110,7 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) } -fn text_targets(s: &Session, p: &Value, cmd: &str) -> Result> { +pub(crate) fn text_targets(s: &Session, p: &Value, cmd: &str) -> Result> { let ids = targets(s, p)?; let t = text_ids(&s.doc()?.doc, &ids); if t.is_empty() { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index f7d8cd0f..a873b13c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -976,11 +976,11 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { "Type", vec![ panel("Character", "character"), - todo("Character Styles"), + panel("Character Styles", "charStyles"), panel("Glyphs", "glyphs"), todo("OpenType"), panel("Paragraph", "paragraph"), - todo("Paragraph Styles"), + panel("Paragraph Styles", "paraStyles"), todo("Tabs"), ], ), diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index cd7aee23..2a832567 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -28,6 +28,7 @@ pub mod separations; pub mod stroke; pub mod swatches; pub mod symbols; +pub mod text_styles; pub mod transform; pub mod transparency; @@ -77,6 +78,8 @@ pub fn show_icon_panel(app: &mut DrawcraftApp, ui: &mut Ui, id: &str) { "symbols" => symbols::show(app, ui), "patternOptions" => pattern_options::show(app, ui), "imageTrace" => image_trace::show(app, ui), + "charStyles" => text_styles::show(app, ui, text_styles::Kind::Char), + "paraStyles" => text_styles::show(app, ui, text_styles::Kind::Para), "magicWand" => magic_wand::show(app, ui), _ => { dim_label(ui, "This panel is on the roadmap (see the parity plan)."); @@ -110,6 +113,8 @@ pub fn panel_menu_items(app: &mut DrawcraftApp, ui: &mut Ui, id: &str) { "symbols" => symbols::menu(app, ui), "patternOptions" => pattern_options::menu(app, ui), "imageTrace" => image_trace::menu(app, ui), + "charStyles" => text_styles::menu(app, ui, text_styles::Kind::Char), + "paraStyles" => text_styles::menu(app, ui, text_styles::Kind::Para), "magicWand" => magic_wand::menu(app, ui), _ => { ui.add_enabled(false, egui::Button::new("No options").frame(false)); diff --git a/crates/ui-egui/src/panels/text_styles.rs b/crates/ui-egui/src/panels/text_styles.rs new file mode 100644 index 00000000..0f876688 --- /dev/null +++ b/crates/ui-egui/src/panels/text_styles.rs @@ -0,0 +1,191 @@ +//! Character Styles and Paragraph Styles panels (one implementation, two kinds). +//! +//! Click a style to apply it to the selected text (or the Type tool's selected characters); +//! Alt-click applies it and clears overrides. The style of the selection is highlighted, with a +//! "+" when the text overrides some of its attributes. + +use egui::{Sense, Ui, vec2}; +use serde_json::{Map, Value, json}; + +use super::character::{text_editing, text_style}; +use super::{pstate, selection_len, set_pstate}; +use crate::DrawcraftApp; +use crate::theme::Tokens; +use crate::widgets::{self, menu_item}; + +#[derive(Clone, Copy, PartialEq)] +pub enum Kind { + Char, + Para, +} + +impl Kind { + fn prefix(self) -> &'static str { + if self == Kind::Char { "charStyle" } else { "paraStyle" } + } + fn what(self) -> &'static str { + if self == Kind::Char { "Character" } else { "Paragraph" } + } + fn key(self) -> &'static str { + if self == Kind::Char { "char-style-sel" } else { "para-style-sel" } + } +} + +/// (name, attrs, uses) for every style, Normal first. +fn styles(app: &mut DrawcraftApp, kind: Kind) -> Vec<(String, Map, u64)> { + let v = app.session.execute(&format!("{}.list", kind.prefix()), &json!({})).unwrap_or_default(); + v["styles"] + .as_array() + .map(|a| { + a.iter() + .map(|s| { + ( + s["name"].as_str().unwrap_or("").to_string(), + s["attrs"].as_object().cloned().unwrap_or_default(), + s["uses"].as_u64().unwrap_or(0), + ) + }) + .collect() + }) + .unwrap_or_default() +} + +/// The selection's style name (Normal when none) and its attributes, if text is selected. +fn current(app: &DrawcraftApp, kind: Kind) -> Option<(Option, Map)> { + let (c, p) = text_style(app)?; + let v = match kind { + Kind::Char => serde_json::to_value(&c), + Kind::Para => serde_json::to_value(&p), + } + .ok()?; + let mut m = v.as_object()?.clone(); + let name = m.remove("style_name").and_then(|v| v.as_str().map(str::to_string)); + Some((name, m)) +} + +/// Parameters targeting the selection: the Type tool's selected characters, or the selected objects. +fn target(app: &DrawcraftApp, kind: Kind, mut p: Value) -> Value { + if let Some((id, a, b)) = text_editing(app) { + p["id"] = json!(id.0); + if kind == Kind::Char && b > a { + p["start"] = json!(a); + p["end"] = json!(b); + } + } + p +} + +pub fn show(app: &mut DrawcraftApp, ui: &mut Ui, kind: Kind) { + let t = Tokens::get(ui.ctx()); + let all = styles(app, kind); + let cur = current(app, kind); + let normal = all.first().map(|s| s.0.clone()).unwrap_or_default(); + let cur_name = cur.as_ref().map(|(n, _)| n.clone().unwrap_or_else(|| normal.clone())); + let mut clicked: Option<(String, bool)> = None; + let mut sel: Option = pstate(ui.ctx(), kind.key()); + widgets::list_box(ui, |ui| { + for (name, attrs, _) in &all { + let is_cur = cur_name.as_deref() == Some(name.as_str()); + // Overrides: an attribute the style sets that the selection doesn't match. + let overridden = is_cur && cur.as_ref().is_some_and(|(_, m)| attrs.iter().any(|(k, v)| m.get(k) != Some(v))); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); + if is_cur || sel.as_deref() == Some(name.as_str()) { + ui.painter().rect_filled(r, 0.0, if is_cur { t.row_selected } else { t.hover }); + } else if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover); + } + let label = if overridden { format!("{name}+") } else { name.clone() }; + ui.painter().text(r.left_center() + vec2(8.0, 0.0), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(12.5), t.text); + if resp.clicked() { + clicked = Some((name.clone(), ui.input(|i| i.modifiers.alt))); + } + } + }); + let has_text = cur.is_some(); + if let Some((name, clear)) = clicked { + sel = Some(name.clone()); + if has_text { + let p = target(app, kind, json!({ "name": name, "clearOverrides": clear })); + app.run(&format!("{}.apply", kind.prefix()), p).ok(); + } + } + set_pstate(ui.ctx(), kind.key(), sel.clone()); + let deletable = sel.as_ref().is_some_and(|s| *s != normal); + widgets::bottom_bar(ui, |ui| { + ui.add_space((ui.available_width() - 2.0 * 28.0).max(0.0)); + if widgets::icon_button_enabled(ui, "dc-new-item", &format!("Create New {} Style", kind.what()), false, true, 24.0).clicked() + && let Ok(r) = app.run(&format!("{}.new", kind.prefix()), target(app, kind, json!({}))) + { + set_pstate(ui.ctx(), kind.key(), r["name"].as_str().map(str::to_string)); + } + if widgets::icon_button_enabled(ui, "trash-2", &format!("Delete {} Style", kind.what()), false, deletable, 24.0).clicked() + && let Some(n) = &sel + && app.run(&format!("{}.delete", kind.prefix()), json!({ "name": n })).is_ok() + { + set_pstate::>(ui.ctx(), kind.key(), None); + } + }); +} + +pub fn menu(app: &mut DrawcraftApp, ui: &mut Ui, kind: Kind) { + let sel: Option = pstate(ui.ctx(), kind.key()); + let normal = if kind == Kind::Char { "[Normal Character Style]" } else { "[Normal Paragraph Style]" }; + let user = sel.as_ref().is_some_and(|s| s != normal); + let has_text = current(app, kind).is_some() || selection_len(app) > 0; + let p = kind.prefix(); + if menu_item(ui, &format!("New {} Style…", kind.what()), true, false) { + app.run(&format!("{p}.new"), target(app, kind, json!({}))).ok(); + } + if menu_item(ui, &format!("Duplicate {} Style", kind.what()), sel.is_some(), false) + && let Some(n) = &sel + { + app.run(&format!("{p}.duplicate"), json!({ "name": n })).ok(); + } + if menu_item(ui, &format!("Delete {} Style", kind.what()), user, false) + && let Some(n) = &sel + { + app.run(&format!("{p}.delete"), json!({ "name": n })).ok(); + } + ui.separator(); + if menu_item(ui, "Clear Overrides", sel.is_some() && has_text, false) + && let Some(n) = &sel + { + app.run(&format!("{p}.apply"), target(app, kind, json!({ "name": n, "clearOverrides": true }))).ok(); + } + if menu_item(ui, &format!("Redefine {} Style", kind.what()), sel.is_some() && has_text, false) + && let Some(n) = &sel + { + app.run(&format!("{p}.redefine"), target(app, kind, json!({ "name": n }))).ok(); + } + if menu_item(ui, &format!("{} Style Options…", kind.what()), 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(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn panels_draw_and_apply_headless() { + let mut app = DrawcraftApp::new(drawcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let id = app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi"})).unwrap()["id"].clone(); + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + app.session.execute("charStyle.new", &json!({"name": "Big", "attrs": {"size": 30.0}})).unwrap(); + for kind in [Kind::Char, Kind::Para] { + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + show(&mut app, ui, kind); + menu(&mut app, ui, kind); + }); + out.textures_delta.clear(); + } + app.run("charStyle.apply", target(&app, Kind::Char, json!({"name": "Big"}))).unwrap(); + let (name, attrs) = current(&app, Kind::Char).unwrap(); + assert_eq!((name.as_deref(), attrs["size"].as_f64()), (Some("Big"), Some(30.0))); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 6c556250..25623fe9 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -76,6 +76,8 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("character", "Character", "type"), ("paragraph", "Paragraph", "pilcrow"), ("glyphs", "Glyphs", "text-cursor-input"), + ("charStyles", "Character Styles", "type"), + ("paraStyles", "Paragraph Styles", "pilcrow"), ("history", "History", "history"), ("actions", "Actions", "dc-actions"), ("info", "Info", "info"),