diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 8a520b685..aa1cc4a5f 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -105,7 +105,7 @@ pub fn specs() -> Vec { "New Character Style…", [], None, - "{name, basedOn?: style | \"[None]\", chars?: {…}} — the style sets only `chars` → {name} (made unique)", + "{name, basedOn?: style | \"[None]\", chars?: {…}, fromSelection?: bool (default false)} — if true, capture the first selected character's style/local overrides (caret: typing format), then overlay chars (null unsets); only explicit fill names are validated; paragraph formatting stays inherited; no text target: chars only → {name} (made unique)", has_doc, create_char ), @@ -622,8 +622,27 @@ fn delete_para(s: &mut Session, p: &Value) -> Result { fn create_char(s: &mut Session, p: &Value) -> Result { 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 mut chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + // Validate caller-supplied fills; captured formatting may retain a deleted swatch name. super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.create")?; + if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false) + && let Some(target) = super::text::format_targets_pub(s).first() + { + let st = s.doc()?; + let story = st.doc.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?; + // Match paragraph fromSelection's first-character/caret policy, but keep paragraph + // attributes inherited instead of freezing the fully resolved character properties. + let format = if target.range.is_empty() { story.char_format_at(target.range.start) } else { story.format_after(target.range.start) }; + let mut captured = st.doc.styles.char_style_attrs(&format.style); + captured.merge(&format.over); + // Replay the explicit fields so null clears a captured value rather than looking absent. + if let Some(overrides) = p.get("chars").and_then(Value::as_object) { + for (key, value) in overrides { + captured.set_json(key, value).map_err(|e| bad("style", e))?; + } + } + chars = captured; + } // Based on [None] is based on nothing. let based_on = str_param(p, "basedOn").filter(|b| *b != designcraft_doc::NO_CHAR_STYLE).map(str::to_string); s.edit(|d, _| { diff --git a/crates/engine/tests/character_style_from_selection.rs b/crates/engine/tests/character_style_from_selection.rs new file mode 100644 index 000000000..0c9d80831 --- /dev/null +++ b/crates/engine/tests/character_style_from_selection.rs @@ -0,0 +1,274 @@ +//! Character Styles panel's existing `fromSelection` command contract. +use designcraft_doc::{CharAttrs, StoryId}; +use designcraft_engine::Session; +use serde_json::{Value, json}; + +fn story(text: &str) -> (Session, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let frame = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": text})).unwrap(); + (s, frame["story"].as_u64().unwrap()) +} + +fn select(s: &mut Session, sid: u64, anchor: usize, focus: usize) { + s.execute("text.select", &json!({"story": sid, "anchor": anchor, "focus": focus})).unwrap(); +} + +fn chars(s: &Session, name: &str) -> CharAttrs { + s.doc().unwrap().doc.styles.char_style(name).unwrap().chars.clone() +} + +fn document(s: &Session) -> Value { + serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap() +} + +#[test] +fn character_capture_overlays_style_chain_local_and_explicit_attrs_but_inherits_paragraph() { + let (mut s, sid) = story("alpha\nbeta"); + s.execute("style.paragraph.create", &json!({"name": "Source paragraph", "chars": {"size": 18}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Destination", "chars": {"size": 30, "underline": true}})).unwrap(); + s.execute("style.character.create", &json!({"name": "Base", "chars": {"underline": true, "tracking": 10, "strikethrough": true}})).unwrap(); + s.execute("style.character.create", &json!({"name": "Emphasis", "basedOn": "Base", "chars": {"fontStyle": "Italic"}})).unwrap(); + select(&mut s, sid, 0, 5); + s.execute("style.paragraph.apply", &json!({"name": "Source paragraph"})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Emphasis"})).unwrap(); + s.execute("type.char", &json!({"tracking": 20, "underline": false})).unwrap(); + s.execute("style.character.create", &json!({"name": "Captured", "fromSelection": true, "chars": {"tracking": 7}})).unwrap(); + assert_eq!( + chars(&s, "Captured"), + CharAttrs { font_style: Some("Italic".into()), underline: Some(false), tracking: Some(7.0), strikethrough: Some(true), ..Default::default() } + ); + select(&mut s, sid, 6, 10); + s.execute("style.paragraph.apply", &json!({"name": "Destination"})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Captured"})).unwrap(); + let resolved = s.execute("type.selectionAttrs", &json!({})).unwrap(); + assert_eq!(resolved["chars"]["fontStyle"], "Italic"); + assert_eq!(resolved["chars"]["strikethrough"], true); + assert_eq!(resolved["chars"]["underline"], false); + assert_eq!(resolved["chars"]["tracking"], 7.0); + assert_eq!(resolved["chars"]["size"], 30.0); + s.execute("style.paragraph.edit", &json!({"name": "Destination", "chars": {"size": 34}})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], 34.0); + // The captured attributes are a snapshot, independent of future source-style edits. + s.execute("style.character.edit", &json!({"name": "Emphasis", "chars": {"fontStyle": "Bold"}})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontStyle"], "Italic"); +} + +#[test] +fn character_capture_reproducer_is_one_undo_step_and_survives_native_reopen() { + let (mut s, sid) = story("alpha beta"); + select(&mut s, sid, 0, 5); + s.execute("type.char", &json!({"underline": true})).unwrap(); + let before = document(&s); + let selection = s.doc().unwrap().selection.clone(); + let undo_len = s.doc().unwrap().history.undo.len(); + let source = serde_json::to_value(s.doc().unwrap().doc.story(StoryId(sid)).unwrap()).unwrap(); + s.execute("style.character.create", &json!({"name": "Captured", "fromSelection": true})).unwrap(); + assert_eq!(chars(&s, "Captured"), CharAttrs { underline: Some(true), ..Default::default() }); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1); + assert_eq!(s.doc().unwrap().selection, selection); + assert_eq!(serde_json::to_value(s.doc().unwrap().doc.story(StoryId(sid)).unwrap()).unwrap(), source); + let after = document(&s); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(document(&s), before); + assert_eq!(s.doc().unwrap().selection, selection); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(document(&s), after); + assert_eq!(s.doc().unwrap().selection, selection); + + // Both commands use the native ZIP format, without a filesystem fixture. + let saved = s.execute("file.serialize", &json!({})).unwrap(); + let mut reopened = Session::new(); + reopened.execute("file.openBytes", &json!({"name": "capture.designcraft", "base64": saved["base64"]})).unwrap(); + assert_eq!(chars(&reopened, "Captured"), chars(&s, "Captured")); + select(&mut reopened, sid, 6, 10); + assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["underline"], false); + let before_apply = document(&reopened); + reopened.execute("style.character.apply", &json!({"name": "Captured"})).unwrap(); + assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["underline"], true); + assert_eq!(reopened.doc().unwrap().doc.story(StoryId(sid)).unwrap().text, "alpha beta"); + let after_apply = document(&reopened); + reopened.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(document(&reopened), before_apply); + reopened.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(document(&reopened), after_apply); +} + +#[test] +fn character_capture_matches_paragraph_selection_and_caret_boundary_policy() { + let (mut s, sid) = story("éX"); + select(&mut s, sid, 0, 2); + s.execute("type.char", &json!({"underline": true})).unwrap(); + select(&mut s, sid, 2, 3); + s.execute("type.char", &json!({"underline": false})).unwrap(); + // A selected character takes the run on the right; a caret takes the typing run on the left. + // Reversed and mixed selections start at the lowest byte offset, just like paragraph capture. + for (anchor, focus, underlined) in [(3, 2, false), (2, 2, true), (3, 0, true)] { + select(&mut s, sid, anchor, focus); + let p = s.execute("style.paragraph.create", &json!({"name": "Paragraph", "fromSelection": true})).unwrap(); + let c = s.execute("style.character.create", &json!({"name": "Character", "fromSelection": true})).unwrap(); + let p = s.doc().unwrap().doc.styles.para(p["name"].as_str().unwrap()).unwrap(); + let c = chars(&s, c["name"].as_str().unwrap()); + assert_eq!(c.underline, Some(underlined)); + assert_eq!(c.underline, p.chars.underline); + assert_eq!(c.size, None, "unlike paragraph capture, character capture must not freeze paragraph defaults"); + } +} + +#[test] +fn character_capture_is_opt_in_and_no_text_uses_only_explicit_chars() { + let (mut s, sid) = story("alpha"); + select(&mut s, sid, 0, 5); + s.execute("type.char", &json!({"underline": true})).unwrap(); + for options in [json!({"name": "Plain"}), json!({"name": "Plain", "fromSelection": false})] { + let created = s.execute("style.character.create", &options).unwrap(); + assert!(chars(&s, created["name"].as_str().unwrap()).is_empty()); + } + assert!(s.doc().unwrap().doc.styles.char_style("Plain 2").is_some()); + s.execute("selection.set", &json!({"ids": []})).unwrap(); + s.execute("style.character.create", &json!({"name": "No target", "fromSelection": true, "basedOn": "Plain", "chars": {"underline": false}})) + .unwrap(); + assert_eq!(chars(&s, "No target"), CharAttrs { underline: Some(false), ..Default::default() }); + assert_eq!(s.doc().unwrap().doc.styles.char_style("No target").unwrap().based_on.as_deref(), Some("Plain")); + let (mut empty, empty_sid) = story(""); + select(&mut empty, empty_sid, 0, 0); + empty.execute("style.character.create", &json!({"name": "Empty", "fromSelection": true})).unwrap(); + assert!(chars(&empty, "Empty").is_empty()); +} + +#[test] +fn character_capture_retains_deleted_fill_and_reuses_it_after_native_reopen() { + for named_source in [false, true] { + let (mut s, sid) = story("alpha beta gamma"); + s.execute("swatch.create", &json!({"name": "Ink", "color": "#ff0000"})).unwrap(); + select(&mut s, sid, 0, 5); + if named_source { + s.execute("style.character.create", &json!({"name": "Base", "chars": {"fill": "Ink"}})).unwrap(); + s.execute("style.character.create", &json!({"name": "Source", "basedOn": "Base"})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Source"})).unwrap(); + } else { + s.execute("type.char", &json!({"fill": "Ink"})).unwrap(); + } + s.execute("swatch.delete", &json!({"name": "Ink"})).unwrap(); + s.doc().unwrap().doc.check().unwrap(); + let before = document(&s); + let selection = s.doc().unwrap().selection.clone(); + let undo_len = s.doc().unwrap().history.undo.len(); + s.execute("style.character.create", &json!({"name": "Captured", "fromSelection": true})).unwrap(); + assert_eq!(chars(&s, "Captured"), CharAttrs { fill: Some("Ink".into()), ..Default::default() }); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1); + assert_eq!(s.doc().unwrap().selection, selection); + let after = document(&s); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(document(&s), before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(document(&s), after); + // Non-fill overrides do not turn an existing captured reference into explicit input. + s.execute("style.character.create", &json!({"name": "Tweaked", "fromSelection": true, "chars": {"underline": false, "tracking": 0}})) + .unwrap(); + assert_eq!(chars(&s, "Tweaked"), CharAttrs { fill: Some("Ink".into()), underline: Some(false), tracking: Some(0.0), ..Default::default() }); + s.execute("style.character.create", &json!({"name": "Paper", "fromSelection": true, "chars": {"fill": "[Paper]"}})).unwrap(); + assert_eq!(chars(&s, "Paper"), CharAttrs { fill: Some("[Paper]".into()), ..Default::default() }); + s.execute("style.character.create", &json!({"name": "Cleared fill", "fromSelection": true, "chars": {"fill": null}})).unwrap(); + assert!(chars(&s, "Cleared fill").is_empty()); + select(&mut s, sid, 6, 10); + s.execute("style.character.apply", &json!({"name": "Captured"})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "Ink"); + s.doc().unwrap().doc.check().unwrap(); + + let saved = s.execute("file.serialize", &json!({})).unwrap(); + let mut reopened = Session::new(); + reopened.execute("file.openBytes", &json!({"name": "deleted-fill.designcraft", "base64": saved["base64"]})).unwrap(); + reopened.doc().unwrap().doc.check().unwrap(); + for name in ["Captured", "Tweaked", "Paper", "Cleared fill"] { + assert_eq!(chars(&reopened, name), chars(&s, name)); + } + select(&mut reopened, sid, 6, 10); + assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "Ink"); + select(&mut reopened, sid, 11, 16); + assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "[Black]"); + reopened.execute("style.character.apply", &json!({"name": "Captured"})).unwrap(); + assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "Ink"); + reopened.execute("style.character.apply", &json!({"name": "Cleared fill"})).unwrap(); + assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "[Black]"); + assert_eq!(reopened.doc().unwrap().doc.story(StoryId(sid)).unwrap().text, "alpha beta gamma"); + } +} + +#[test] +fn character_capture_rejects_explicit_missing_fills_without_losing_redo() { + let (mut s, sid) = story("alpha"); + s.execute("swatch.create", &json!({"name": "Ink", "color": "#ff0000"})).unwrap(); + select(&mut s, sid, 0, 5); + s.execute("type.char", &json!({"fill": "Ink"})).unwrap(); + s.execute("swatch.delete", &json!({"name": "Ink"})).unwrap(); + s.execute("style.character.create", &json!({"name": "Redo sentinel"})).unwrap(); + let redo_doc = s.doc().unwrap().doc.clone(); + let redo_selection = s.doc().unwrap().selection.clone(); + s.execute("edit.undo", &json!({})).unwrap(); + let before = s.doc().unwrap().clone(); + let journal = s.journal.clone(); + for (capture, fill) in [(true, "Missing"), (true, "Ink"), (false, "Missing")] { + let error = s.execute("style.character.create", &json!({"name": "Invalid", "fromSelection": capture, "chars": {"fill": fill}})).unwrap_err(); + assert!(error.to_string().contains(fill)); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before.doc)); + assert_eq!(s.doc().unwrap().selection, before.selection); + assert_eq!(s.doc().unwrap().revision, before.revision); + assert_eq!(format!("{:?}", s.doc().unwrap().history), format!("{:?}", before.history)); + assert_eq!(s.journal, journal); + } + s.execute("edit.redo", &json!({})).unwrap(); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &redo_doc)); + assert_eq!(s.doc().unwrap().selection, redo_selection); + assert!(s.doc().unwrap().doc.styles.char_style("Redo sentinel").is_some()); +} + +#[test] +fn character_capture_explicit_null_restores_live_paragraph_inheritance() { + for named_source in [false, true] { + let (mut s, sid) = story("alpha\nbeta"); + select(&mut s, sid, 0, 5); + let source = json!({"tracking": 20, "underline": true, "underlineWeight": 1.5, "fontStyle": "Italic"}); + if named_source { + s.execute("style.character.create", &json!({"name": "Base", "chars": source})).unwrap(); + s.execute("style.character.create", &json!({"name": "Source", "basedOn": "Base"})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Source"})).unwrap(); + } else { + s.execute("type.char", &source).unwrap(); + } + // Omitting chars still captures values, including a nullable attribute's numeric value. + s.execute("style.character.create", &json!({"name": "Unchanged", "fromSelection": true})).unwrap(); + assert_eq!(chars(&s, "Unchanged").tracking, Some(20.0)); + assert_eq!(chars(&s, "Unchanged").underline_weight, Some(Some(1.5))); + let before = document(&s); + let undo_len = s.doc().unwrap().history.undo.len(); + s.execute( + "style.character.create", + &json!({"name": "Cleared", "fromSelection": true, + "chars": {"tracking": null, "underlineWeight": null, "underline": false, "baselineShift": 0}}), + ) + .unwrap(); + assert_eq!( + chars(&s, "Cleared"), + CharAttrs { font_style: Some("Italic".into()), underline: Some(false), baseline_shift: Some(0.0), ..Default::default() } + ); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1); + let after = document(&s); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(document(&s), before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(document(&s), after); + s.execute("style.paragraph.create", &json!({"name": "Destination", "chars": {"tracking": 30, "underlineWeight": 2}})).unwrap(); + select(&mut s, sid, 6, 10); + s.execute("style.paragraph.apply", &json!({"name": "Destination"})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Cleared"})).unwrap(); + let effective = s.execute("type.selectionAttrs", &json!({})).unwrap(); + assert_eq!(effective["chars"]["tracking"], 30.0); + assert_eq!(effective["chars"]["underlineWeight"], 2.0); + assert_eq!(effective["chars"]["underline"], false); + s.execute("style.paragraph.edit", &json!({"name": "Destination", "chars": {"tracking": 40, "underlineWeight": 3}})).unwrap(); + let effective = s.execute("type.selectionAttrs", &json!({})).unwrap(); + assert_eq!(effective["chars"]["tracking"], 40.0); + assert_eq!(effective["chars"]["underlineWeight"], 3.0); + } +} diff --git a/crates/ui-egui/src/panels/styles.rs b/crates/ui-egui/src/panels/styles.rs index 25a596644..d64cc1bd5 100644 --- a/crates/ui-egui/src/panels/styles.rs +++ b/crates/ui-egui/src/panels/styles.rs @@ -150,3 +150,44 @@ fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, curren } }); } + +#[cfg(test)] +mod tests { + use egui_kittest::{Harness, kittest::Queryable}; + + use super::*; + + #[test] + fn character_style_plus_captures_selection_for_reuse() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let frame = app.run("frame.create", json!({"rect": [36, 36, 300, 200], "content": "text", "text": "alpha beta"})).unwrap(); + app.run("text.select", json!({"story": frame["story"], "anchor": 0, "focus": 5})).unwrap(); + app.run("type.char", json!({"underline": true})).unwrap(); + let selection = app.session.doc().unwrap().selection.clone(); + let undo_len = app.session.doc().unwrap().history.undo.len(); + let mut h = Harness::builder().with_size(vec2(360.0, 240.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + character(app, ui); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Create New Style from Selection").click(); + h.run_steps(3); + let app = h.state_mut(); + let style = app.session.doc().unwrap().doc.styles.char_style("Character Style 1").unwrap(); + assert_eq!(style.chars, designcraft_doc::CharAttrs { underline: Some(true), ..Default::default() }); + assert_eq!(app.session.doc().unwrap().selection, selection); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo_len + 1); + app.run("text.select", json!({"story": frame["story"], "anchor": 6, "focus": 10})).unwrap(); + app.run("style.character.apply", json!({"name": "Character Style 1"})).unwrap(); + assert_eq!(app.run("type.selectionAttrs", json!({})).unwrap()["chars"]["underline"], true); + } +}