Merge main into #2268; import both layers_or and popup_options, drop main's now-unreachable post-duplicate end_edit (the PR ends the edit first), adapt tests to main's kept empty Type-tool layer (f262e39c6)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-30 10:23:03 +07:00
co-authored by Claude Opus 5.5
542 changed files with 37155 additions and 3140 deletions
+39 -26
View File
@@ -200,6 +200,20 @@ fn typing_deleting_and_caret_movement() {
assert!(s.state.text_edit.is_none());
}
/// Duplicating during a text edit moves the selection to the copy: the edit on the original ends
/// (#2226), as in After Effects — typing must not keep editing an unselected layer.
#[test]
fn duplicate_during_a_text_edit_ends_the_edit() {
let (mut s, t) = session("Hello");
s.execute("text.edit", json!({"layer": t, "caret": 5})).unwrap();
let copy = s.execute("edit.duplicate", json!({"layers": [t]})).unwrap()[0].as_u64().unwrap();
assert_ne!(copy, t, "the duplicate is created");
assert_eq!(s.state.selected_layers, vec![effectcraft_project::LayerId(copy)], "the copy is selected");
assert!(s.state.text_edit.is_none(), "the edit ended with the selection move (#2226)");
assert_eq!(doc(&s, t).text, "Hello", "the original keeps its text");
assert_eq!(doc(&s, copy).text, "Hello", "and the copy carries it");
}
#[test]
fn caret_style_applies_to_the_next_typed_text() {
let (mut s, t) = session("ab");
@@ -261,25 +275,25 @@ fn clipboard_and_paste_formatting() {
}
#[test]
fn type_tool_layers_point_box_and_empty_cleanup() {
fn type_tool_layers_point_box_and_empty_layer_is_kept() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 4})).unwrap();
let t = s.execute("layer.newText", json!({"text": "", "position": [100, 200], "justify": "left", "edit": true})).unwrap()["layer"].as_u64().unwrap();
assert_eq!(s.state.text_edit.as_ref().map(|e| e.layer.0), Some(t));
// Leaving an untouched Type-tool layer deletes it.
// Leaving an untouched Type-tool layer keeps it, as After Effects does.
s.execute("text.endEdit", json!({})).unwrap();
assert!(s.active_comp().unwrap().layers.is_empty());
assert_eq!(s.active_comp().unwrap().layers.len(), 1, "an empty Type-tool layer is kept, as in AE");
let b = s.execute("layer.newText", json!({"text": "", "box": [40, 50, 300, 120], "edit": true})).unwrap()["layer"].as_u64().unwrap();
s.execute("text.insert", json!({"text": "Boxed"})).unwrap();
let d = doc(&s, b);
assert_eq!(d.box_size, Some([300.0, 120.0]));
assert_eq!(d.box_pos, [-150.0, -60.0]);
let l = s.active_comp().unwrap().layers[0].clone();
let l = s.active_comp().unwrap().layers.iter().find(|l| l.id.0 == b).expect("the boxed layer").clone();
assert_eq!(l.name, "Boxed", "named after its text");
let pos = l.props.prop("transform/position").unwrap().value.as_vec3();
assert_eq!([pos[0], pos[1]], [190.0, 110.0]);
s.execute("text.endEdit", json!({})).unwrap();
assert_eq!(layer_count(&s), 1);
assert_eq!(s.active_comp().unwrap().layers.len(), 2, "both text layers stay, the empty one included");
// Resizing the box (layer space) is an attribute too.
s.execute("layer.setText", json!({"layer": b, "box": [-100, -60, 200, 120]})).unwrap();
assert_eq!(doc(&s, b).box_size, Some([200.0, 120.0]));
@@ -929,11 +943,19 @@ fn fonts_lists_bundled_and_installed_families() {
let r = s.execute("text.fonts", json!({"query": "noto ser", "rescan": true})).unwrap();
assert_eq!(r["families"][0]["family"], "Noto Serif");
// The bundled face is Regular; the OS can also provide Bold, Italic, or other faces.
// Compare against the independent directory inventory rather than assuming an empty OS.
let mut expected_styles = std::collections::BTreeSet::from(["Regular".to_string()]);
expected_styles.extend(installed.iter().filter(|face| face.family.eq_ignore_ascii_case("Noto Serif")).map(|face| face.style.clone()));
// Model `add_source()` on the independent directory inventory: a system face whose family
// and primary style duplicate an earlier face is skipped, with its named instances.
let mut expected_styles = std::collections::BTreeSet::from(["regular".to_string()]);
let mut retained = std::collections::BTreeSet::from([("noto serif".to_string(), "regular".to_string())]);
for face in installed.iter().filter(|face| face.family.eq_ignore_ascii_case("Noto Serif")) {
if retained.insert((face.family.to_ascii_lowercase(), face.style.to_ascii_lowercase())) {
expected_styles.insert(face.style.to_ascii_lowercase());
expected_styles.extend(face.instances.iter().map(|i| i.style.to_ascii_lowercase()));
}
}
let styles: Vec<String> = r["families"][0]["styles"].as_array().unwrap().iter().map(|style| style.as_str().unwrap().to_string()).collect();
assert_eq!(styles.iter().cloned().collect::<std::collections::BTreeSet<_>>(), expected_styles);
// `families()` keeps the first spelling of a style that differs only in case.
assert_eq!(styles.iter().map(|style| style.to_ascii_lowercase()).collect::<std::collections::BTreeSet<_>>(), expected_styles);
assert_eq!(styles.len(), expected_styles.len(), "style menu has no duplicates");
assert!(r["families"].as_array().unwrap().iter().all(|f| f["family"].as_str().unwrap().to_lowercase().contains("noto ser")));
// The style menus offer a family's own styles.
@@ -1203,7 +1225,7 @@ fn deselect_all_ends_the_open_edit() {
}
#[test]
fn deselect_all_discards_an_empty_type_tool_layer() {
fn deselect_all_keeps_an_empty_type_tool_layer() {
let (mut s, _keep) = session("Hello");
let new = s.execute("layer.newText", json!({"text": "", "position": [80.0, 80.0], "edit": true})).unwrap()["layer"].as_u64().unwrap();
assert!(
@@ -1214,9 +1236,7 @@ fn deselect_all_discards_an_empty_type_tool_layer() {
s.execute("edit.deselectAll", json!({})).unwrap();
assert!(s.state.text_edit.is_none());
assert!(s.state.selected_layers.is_empty());
assert_eq!(layer_count(&s), before - 1, "a Type-tool layer left empty is removed, as text.endEdit does");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(layer_count(&s), before, "that removal is one undo step of its own, as on text.endEdit");
assert_eq!(layer_count(&s), before, "an empty Type-tool layer is kept, as in After Effects");
}
#[test]
@@ -1386,24 +1406,17 @@ fn duplicate_ends_the_open_edit_and_selects_the_copy() {
}
#[test]
fn duplicate_of_an_empty_type_tool_layer_discards_it_as_endedit_does() {
fn duplicate_of_an_empty_type_tool_layer_ends_the_edit_and_copies_it() {
use effectcraft_project::LayerId;
let (mut s, _keep) = session("Hello");
let new = s.execute("layer.newText", json!({"text": "", "position": [80.0, 80.0], "edit": true})).unwrap()["layer"].as_u64().unwrap();
assert!(
s.state.text_edit.as_ref().is_some_and(|e| e.created && e.layer == effectcraft_project::LayerId(new)),
s.state.text_edit.as_ref().is_some_and(|e| e.created && e.layer == LayerId(new)),
"the Type tool opens the edit on its new, empty layer"
);
let before = layer_count(&s);
s.execute("edit.duplicate", json!({})).unwrap();
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
assert!(s.state.text_edit.is_none(), "the edit ends the way text.endEdit ends it");
assert_eq!(layer_count(&s), before - 1, "a Type-tool layer left empty is discarded, not duplicated");
assert!(s.state.selected_layers.is_empty(), "nothing is selected: the layer that would be copied is gone");
// The edit is committed before Duplicate resolves its layers, so naming the very layer
// the empty edit was on addresses nothing: the commit still happened, the duplicate errors.
let new2 = s.execute("layer.newText", json!({"text": "", "position": [90.0, 90.0], "edit": true})).unwrap()["layer"].as_u64().unwrap();
assert!(s.execute("edit.duplicate", json!({"layers": [new2]})).is_err(), "the named layer is gone once the edit commits");
assert!(s.state.text_edit.is_none(), "and the edit ended all the same");
assert_eq!(layer_count(&s), before - 1, "with the empty layer discarded, nothing left to duplicate");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(layer_count(&s), before, "that removal is one undo step of its own, as on text.endEdit");
assert_eq!(layer_count(&s), before + 1, "the empty layer is kept, as text.endEdit keeps it, and copied");
assert_eq!(s.state.selected_layers, vec![LayerId(copy)], "and the copy is selected");
}