Files
designcraft/crates/engine/src/cmd/style.rs
T
Brandon ThomasandClaude Opus 5.5 91ae2b3cc9 M0: engine, tools, egui UI, desktop app, CLI, sample document, docs
Engine with command registry (~100 commands), tool hosting with one-step
undo per gesture, InDesign-style egui frontend (app bar, Control panel,
Tools panel, rulers, guides, frame edges, ports/threads, Properties/Pages/
Layers dock, panels, dialogs), JSON control channel, headless CLI renderer,
generated sample magazine, README/AGENTS/ROADMAP/ASSETS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:39:15 -04:00

279 lines
11 KiB
Rust

//! Paragraph / character styles and swatches.
use designcraft_color::{Swatch, SwatchValue};
use designcraft_doc::{CharAttrs, CharacterStyle, ParaAttrs, ParagraphStyle, Styles};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, has_text_or_frames, ok, str_param};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("style.paragraph.apply", "Apply Paragraph Style", [], None, "{name, clearOverrides?: bool}", has_text_or_frames, apply_para),
cmd!("style.character.apply", "Apply Character Style", [], None, "{name}", has_text_or_frames, apply_char),
cmd!(
"style.paragraph.create",
"New Paragraph Style…",
[],
None,
"{name, basedOn?, nextStyle?, para?: {…}, chars?: {…}, fromSelection?: bool}",
has_doc,
create_para
),
cmd!(
"style.paragraph.edit",
"Paragraph Style Options…",
[],
None,
"{name, rename?, basedOn?, nextStyle?, para?: {…}, chars?: {…}}",
has_doc,
edit_para
),
cmd!("style.paragraph.delete", "Delete Paragraph Style", [], None, "{name, replaceWith?}", has_doc, delete_para),
cmd!("style.character.create", "New Character Style…", [], None, "{name, basedOn?, chars?: {…}}", has_doc, create_char),
cmd!("style.character.edit", "Character Style Options…", [], None, "{name, rename?, basedOn?, chars?}", has_doc, edit_char),
cmd!(query "style.list", "List Styles", [], None, "{} → paragraph and character style names", has_doc, |s, _| {
let st = s.doc()?;
Ok(json!({
"paragraph": st.doc.styles.paragraph.iter().map(|p| &p.name).collect::<Vec<_>>(),
"character": st.doc.styles.character.iter().map(|p| &p.name).collect::<Vec<_>>(),
"object": st.doc.styles.object.iter().map(|p| &p.name).collect::<Vec<_>>(),
}))
}),
cmd!(
"swatch.create",
"New Color Swatch…",
[],
None,
"{name?, color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b], spot?: bool}",
has_doc,
create_swatch
),
cmd!("swatch.delete", "Delete Swatch", [], None, "{name}", has_doc, |s, p| {
let name = str_param(p, "name").unwrap_or("").to_string();
s.edit(|d, _| {
if d.swatch(&name).is_some_and(|w| w.locked) {
return Err(bad("swatch.delete", "special swatches can't be deleted"));
}
d.swatches.retain(|w| w.name != name);
ok()
})
}),
]
}
fn attrs<T: Default>(v: Option<&Value>, set: impl Fn(&mut T, &str, &Value) -> std::result::Result<(), String>) -> Result<T> {
let mut a = T::default();
if let Some(o) = v.and_then(Value::as_object) {
for (k, v) in o {
set(&mut a, k, v).map_err(|e| bad("style", e))?;
}
}
Ok(a)
}
fn apply_para(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.apply", "missing name"))?.to_string();
let clear = p.get("clearOverrides").and_then(Value::as_bool).unwrap_or(false);
if s.doc()?.doc.styles.para(&name).is_none() {
return Err(bad("style.paragraph.apply", format!("no paragraph style `{name}`")));
}
let targets = super::text::format_targets_pub(s);
s.edit(|d, _| {
for (sid, r) in &targets {
if let Some(st) = d.story_mut(*sid) {
st.format_paras(r.clone(), |f| {
f.style = name.clone();
if clear {
f.para = ParaAttrs::default();
f.chars = CharAttrs::default();
}
});
if clear {
let (a, b) = (st.para_ranges()[st.para_at(r.start)].start, st.para_ranges()[st.para_at(r.end)].end);
st.format_chars(a..b, |f| f.over = CharAttrs::default());
}
}
}
ok()
})
}
fn apply_char(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("style.character.apply", "missing name"))?.to_string();
if s.doc()?.doc.styles.char_style(&name).is_none() {
return Err(bad("style.character.apply", format!("no character style `{name}`")));
}
let targets = super::text::format_targets_pub(s);
s.edit(|d, _| {
for (sid, r) in &targets {
if let Some(st) = d.story_mut(*sid) {
st.format_chars(r.clone(), |f| f.style = name.clone());
}
}
ok()
})
}
fn create_para(s: &mut Session, p: &Value) -> Result<Value> {
let base = str_param(p, "name").unwrap_or("Paragraph Style 1").to_string();
let mut para: ParaAttrs = attrs(p.get("para"), |a: &mut ParaAttrs, k, v| a.set_json(k, v))?;
let mut chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false)
&& let Ok(cur) = s.execute("type.selectionAttrs", &json!({}))
&& !cur.is_null()
{
if let Ok(pp) = serde_json::from_value::<designcraft_doc::ParaProps>(cur["para"].clone()) {
let mut full = ParaProps_to_attrs(&pp);
full.merge(&para);
para = full;
}
if let Ok(cp) = serde_json::from_value::<designcraft_doc::CharProps>(cur["chars"].clone()) {
let mut full = CharProps_to_attrs(&cp);
full.merge(&chars);
chars = full;
}
}
let based_on = str_param(p, "basedOn").map(str::to_string);
let next = str_param(p, "nextStyle").map(str::to_string);
s.edit(|d, _| {
let name = Styles::unique_name(|n| d.styles.para(n).is_some(), &base);
if let Some(b) = &based_on
&& d.styles.para(b).is_none()
{
return Err(bad("style.paragraph.create", format!("no style `{b}`")));
}
d.styles_mut().paragraph.push(ParagraphStyle { name: name.clone(), based_on, next_style: next, para, chars, shortcut: String::new() });
Ok(json!({"name": name}))
})
}
#[allow(non_snake_case)]
fn ParaProps_to_attrs(p: &designcraft_doc::ParaProps) -> ParaAttrs {
designcraft_doc::ParaProps::common([p])
}
#[allow(non_snake_case)]
fn CharProps_to_attrs(p: &designcraft_doc::CharProps) -> CharAttrs {
designcraft_doc::CharProps::common([p])
}
fn edit_para(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.edit", "missing name"))?.to_string();
let para: ParaAttrs = attrs(p.get("para"), |a: &mut ParaAttrs, k, v| a.set_json(k, v))?;
let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
let rename = str_param(p, "rename").map(str::to_string);
let based = p.get("basedOn").cloned();
s.edit(|d, _| {
if let Some(Value::String(b)) = &based
&& d.styles.para_based_on_cycles(&name, b)
{
return Err(bad("style.paragraph.edit", "based-on would create a cycle"));
}
let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?;
st.para.merge(&para);
st.chars.merge(&chars);
match based {
Some(Value::String(b)) => st.based_on = Some(b),
Some(Value::Null) => st.based_on = None,
_ => {}
}
if let Some(n) = rename.clone() {
st.name = n.clone();
for sid in d.stories.keys().copied().collect::<Vec<_>>() {
if let Some(story) = d.story_mut(sid) {
for f in &mut story.paras {
if f.style == name {
f.style = n.clone();
}
}
}
}
}
ok()
})
}
fn delete_para(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").unwrap_or("").to_string();
let repl = str_param(p, "replaceWith").unwrap_or(designcraft_doc::BASIC_PARAGRAPH).to_string();
if name.starts_with('[') {
return Err(bad("style.paragraph.delete", "built-in styles can't be deleted"));
}
s.edit(|d, _| {
d.styles_mut().paragraph.retain(|x| x.name != name);
for sid in d.stories.keys().copied().collect::<Vec<_>>() {
if let Some(story) = d.story_mut(sid) {
for f in &mut story.paras {
if f.style == name {
f.style = repl.clone();
}
}
}
}
ok()
})
}
fn create_char(s: &mut Session, p: &Value) -> Result<Value> {
let base = str_param(p, "name").unwrap_or("Character Style 1").to_string();
let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
let based_on = str_param(p, "basedOn").map(str::to_string);
s.edit(|d, _| {
let name = Styles::unique_name(|n| d.styles.char_style(n).is_some(), &base);
d.styles_mut().character.push(CharacterStyle { name: name.clone(), based_on, chars, shortcut: String::new() });
Ok(json!({"name": name}))
})
}
fn edit_char(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("style.character.edit", "missing name"))?.to_string();
let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
s.edit(|d, _| {
let st = d.styles_mut().char_style_mut(&name).ok_or_else(|| bad("style.character.edit", "no such style"))?;
st.chars.merge(&chars);
ok()
})
}
fn create_swatch(s: &mut Session, p: &Value) -> Result<Value> {
let c = p.get("color").ok_or_else(|| bad("swatch.create", "missing color"))?;
let color = parse_color(c).ok_or_else(|| bad("swatch.create", "bad color"))?;
let spot = p.get("spot").and_then(Value::as_bool).unwrap_or(false);
let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| match color {
designcraft_color::Color::Cmyk { c, m, y, k } => designcraft_color::swatch::cmyk_name(c, m, y, k),
other => {
let [r, g, b] = other.to_rgb();
format!("R={} G={} B={}", (r * 255.0).round(), (g * 255.0).round(), (b * 255.0).round())
}
});
s.edit(|d, _| {
let name = Styles::unique_name(|n| d.swatch(n).is_some(), &name);
d.swatches.push(Swatch {
name: name.clone(),
value: SwatchValue::Color {
color,
color_type: if spot { designcraft_color::ColorType::Spot } else { designcraft_color::ColorType::Process },
},
locked: false,
named: true,
});
Ok(json!({"name": name}))
})
}
pub fn parse_color(v: &Value) -> Option<designcraft_color::Color> {
use designcraft_color::Color;
match v {
Value::String(s) => Color::from_hex(s),
Value::Array(a) if a.len() >= 3 => {
let f = |i: usize| a[i].as_f64().map(|x| if x > 1.0 { x / 255.0 } else { x } as f32);
Some(Color::rgb(f(0)?, f(1)?, f(2)?))
}
Value::Object(o) => {
let g = |k: &str| o.get(k).and_then(Value::as_f64).map(|x| if x > 1.0 { x / 100.0 } else { x } as f32);
Some(Color::cmyk(g("c")?, g("m")?, g("y")?, g("k")?))
}
_ => None,
}
}