Merge branch 'pr/667' into batch1

This commit is contained in:
Brandon Thomas committed 2026-10-13 02:21:39 -04:00
commit 8255d58c2e
3 files changed
+336 -2

No files matched your search

+21 -2
View File
@@ -105,7 +105,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
fn create_char(s: &mut Session, p: &Value) -> Result<Value> {
let base = str_param(p, "name").unwrap_or("Character Style 1").to_string();
let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
let 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, _| {
@@ -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);
}
}
+41
View File
@@ -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::<bool>(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);
}
}