Files
effectcraft/crates/ui-egui/tests/ui_text_edit.rs
Elias 9af5560c1f UI: let Type tool edit selected overlapping text while respecting visibility
(cherry picked from commit 9fd5b1e5618a6ad19638494b480460e9527debde)

(cherry picked from commit 812e34fd7834e828bebd3f79440f831d188a1a99)
(cherry picked from commit 283adb1a30e6b51ab6603488e57b9997a0ca83f0)

(cherry picked from commit cb2e659baad5e4fe853c124dafe32491335b8232)
2026-10-12 19:22:17 +01:00

573 lines
30 KiB
Rust

//! Headless checks for on-canvas text editing in the viewer and the Character / Paragraph panels
//! acting on the selection (egui_kittest, UI logic only: no GPU needed).
use effectcraft_engine::Session;
use effectcraft_engine::commands::text_edit::layer_doc;
use effectcraft_engine::project::LayerId;
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::dock::PanelKind;
use egui::{Event, Key, Modifiers, PointerButton, Pos2};
use egui_kittest::Harness;
use serde_json::json;
fn app() -> EffectcraftApp {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap();
EffectcraftApp::new(s)
}
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> egui::Rect {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
egui::Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))
}
fn click(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, modifiers: Modifiers) {
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers });
h.run_steps(1);
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers });
h.run_steps(2);
}
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) {
h.input_mut().events.push(Event::PointerMoved(from));
h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
for i in 1..=8 {
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 8.0)));
h.run_steps(1);
}
h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
}
fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) {
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers });
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers });
h.run_steps(2);
}
fn type_text(h: &mut Harness<'_, EffectcraftApp>, t: &str) {
h.input_mut().events.push(Event::Text(t.into()));
h.run_steps(2);
}
fn edited(h: &Harness<'_, EffectcraftApp>) -> (u64, String, usize, usize) {
let s = &h.state().session;
let e = s.state.text_edit.clone().expect("editing");
(e.layer.0, layer_doc(s, e.layer).unwrap().text, e.anchor, e.caret)
}
#[test]
fn type_tool_skips_hidden_and_unsoloed_text_layers() {
for switch in ["video", "solo"] {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap();
let mut ids = vec![];
for _ in 0..2 {
let lid = s.execute("layer.newText", json!({"text": "alpha beta", "size": 80, "position": [100, 200], "justify": "left"})).unwrap()["layer"]
.as_u64()
.unwrap();
ids.push(lid);
}
let (target, value) = if switch == "video" { (ids[1], false) } else { (ids[0], true) };
s.execute("layer.setSwitch", json!({"layers": [target], "switch": switch, "value": value})).unwrap();
s.execute("edit.deselectAll", json!({})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s));
h.state_mut().ui.tool = effectcraft_ui_egui::state::Tool::Type;
h.run_steps(3);
let comp = rect(&h, "viewer.comp");
let k = comp.width() / 640.0;
click(&mut h, comp.min + egui::vec2(330.0 * k, 180.0 * k), Modifiers::NONE);
assert_eq!(edited(&h).0, ids[0], "{switch}");
}
}
#[test]
fn modal_blocks_background_text_events() {
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app());
h.run_steps(3);
h.state_mut().ui.tool = effectcraft_ui_egui::state::Tool::Type;
h.run_steps(1);
let p = rect(&h, "viewer.comp").center();
click(&mut h, p, Modifiers::NONE);
type_text(&mut h, "Preserve");
let before = edited(&h).1;
h.state_mut().dialog = Some(effectcraft_ui_egui::Dialog::About);
h.run_steps(3);
h.input_mut().events.push(Event::Text("changed".into()));
h.input_mut().events.push(Event::Paste("pasted".into()));
key(&mut h, Key::Backspace, Modifiers::NONE);
assert_eq!(edited(&h).1, before);
}
#[test]
fn type_tool_click_type_edit_and_commit() {
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| app());
h.state_mut().show_panel(PanelKind::Composition);
h.run_steps(3);
h.state_mut().ui.tool = effectcraft_ui_egui::state::Tool::Type;
h.run_steps(1);
let comp = rect(&h, "viewer.comp");
// Click: a point text layer in edit mode, caret ready.
click(&mut h, comp.center(), Modifiers::NONE);
assert_eq!(h.state().session.active_comp().unwrap().layers.len(), 1);
type_text(&mut h, "Hello");
type_text(&mut h, " world");
let (lid, text, a, c) = edited(&h);
assert_eq!((text.as_str(), a, c), ("Hello world", 11, 11));
assert!(h.state().auto.find("viewer.textEdit").is_some(), "edit region registered");
// Arrow keys, Shift-extend, Backspace.
key(&mut h, Key::ArrowLeft, Modifiers::NONE);
key(&mut h, Key::ArrowLeft, Modifiers::SHIFT);
assert_eq!((edited(&h).2, edited(&h).3), (10, 9));
key(&mut h, Key::Backspace, Modifiers::NONE);
assert_eq!(edited(&h).1, "Hello word");
// Word jump and Home / End.
key(&mut h, Key::Home, Modifiers::NONE);
assert_eq!(edited(&h).3, 0);
key(&mut h, Key::ArrowRight, Modifiers::COMMAND);
assert_eq!(edited(&h).3, 5);
key(&mut h, Key::End, Modifiers::NONE);
assert_eq!(edited(&h).3, 10);
// Enter starts a new paragraph.
key(&mut h, Key::Enter, Modifiers::NONE);
type_text(&mut h, "two");
assert_eq!(edited(&h).1, "Hello word\ntwo");
// Select all, then the Character panel styles the selection only.
key(&mut h, Key::ArrowUp, Modifiers::NONE);
assert!(edited(&h).3 <= 10, "up to the first line");
key(&mut h, Key::Home, Modifiers::NONE);
key(&mut h, Key::End, Modifiers::SHIFT);
let (_, _, a, c) = edited(&h);
assert_eq!((a, c), (0, 10));
h.state_mut().show_panel(PanelKind::Character);
h.run_steps(3);
let bold = rect(&h, "character.fauxBold");
click(&mut h, bold.center(), Modifiers::NONE);
let doc = layer_doc(&h.state().session, LayerId(lid)).unwrap();
assert!(doc.style_at(2).faux_bold, "selection is bold");
assert!(!doc.style_at(12).faux_bold, "second paragraph untouched");
assert_eq!(doc.runs().len(), 2);
// Undo the style; Escape commits.
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert!(layer_doc(&h.state().session, LayerId(lid)).unwrap().is_uniform());
h.state_mut().show_panel(PanelKind::Composition);
h.run_steps(3);
h.state_mut().session.execute("text.setSelection", json!({"start": 0})).unwrap();
h.run_steps(2);
key(&mut h, Key::Escape, Modifiers::NONE);
assert!(h.state().session.state.text_edit.is_none());
}
/// #831: clicking another panel (a solid's row in the Timeline) ends text editing and gives the
/// keyboard back, whether the edit began with the Type tool (which stays selected) or by command:
/// typed characters change no text and Space plays. The Character panel keeps the edit, and a
/// text layer clicked into being and left empty goes away.
#[test]
fn clicking_another_panel_stops_typing_into_text() {
for type_tool in [false, true] {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap();
let solid = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
let title =
s.execute("layer.newText", json!({"text": "Title", "size": 80, "position": [100, 120], "justify": "left"})).unwrap()["layer"].as_u64().unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| EffectcraftApp::new(s));
h.state_mut().show_panel(PanelKind::Composition);
h.run_steps(3);
let layers = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layers.len();
// (Its row moves down as text layers are added.)
let row = |h: &Harness<'_, EffectcraftApp>| rect(h, &format!("timeline.layer.{solid}.row")).center();
if type_tool {
h.state_mut().ui.tool = effectcraft_ui_egui::state::Tool::Type;
h.run_steps(1);
let at = rect(&h, "viewer.comp").center();
click(&mut h, at, Modifiers::NONE);
assert_eq!(layers(&h), 3, "the Type tool started a text layer");
let at_row = row(&h);
click(&mut h, at_row, Modifiers::NONE);
assert_eq!(layers(&h), 2, "left empty, it is removed");
click(&mut h, at, Modifiers::NONE);
} else {
h.state_mut().session.execute("text.edit", json!({"layer": title})).unwrap();
h.run_steps(2);
}
type_text(&mut h, "Hello");
// The Character panel formats the selection and keeps the edit: typing carries on.
key(&mut h, Key::ArrowLeft, Modifiers::SHIFT);
let range = edited(&h);
h.state_mut().show_panel(PanelKind::Character);
h.run_steps(3);
let bold = rect(&h, "character.fauxBold").center();
click(&mut h, bold, Modifiers::NONE);
assert_eq!(edited(&h), range, "type_tool {type_tool}: Character keeps the edit and its selection");
key(&mut h, Key::End, Modifiers::NONE);
type_text(&mut h, "!");
let (lid, text, ..) = edited(&h);
assert!(text.ends_with("Hello!"), "typing goes into the text: {text:?}");
let at_row = row(&h);
click(&mut h, at_row, Modifiers::NONE);
assert!(h.state().session.state.text_edit.is_none(), "type_tool {type_tool}: clicking the Timeline ends the edit");
assert_eq!(h.state().session.state.selected_layers, vec![LayerId(solid)]);
assert!(!h.ctx.memory(|m| m.focused().is_some()), "the keyboard is the app's again");
type_text(&mut h, "abc");
assert!(!h.state().playback.playing);
h.input_mut().events.push(Event::Key { key: Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.input_mut().events.push(Event::Text(" ".into()));
h.input_mut().events.push(Event::Key { key: Key::Space, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, text, "type_tool {type_tool}: the text is unchanged");
assert!(h.state().playback.playing, "type_tool {type_tool}: Space plays");
assert_eq!(type_tool, matches!(h.state().ui.tool, effectcraft_ui_egui::state::Tool::Type), "the tool is kept");
}
}
/// #831: opening a formatting panel's tab behind Effects & Presets in its group (Properties, in
/// Undocked Panels) keeps the edit and its selected characters: the press on the tab used to
/// focus the shown panel and end the edit, after which Faux Bold styled the whole layer.
#[test]
fn opening_a_formatting_tab_keeps_the_text_selection() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap();
let lid =
s.execute("layer.newText", json!({"text": "Hello world", "size": 80, "position": [60, 200], "justify": "left"})).unwrap()["layer"].as_u64().unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| EffectcraftApp::new(s));
h.run_steps(3);
let ctx = h.ctx.clone();
// Effects & Presets shown, Properties, Character and Paragraph behind it.
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "window.workspace", json!({"name": "Undocked Panels"})).unwrap();
h.state_mut().session.execute("text.edit", json!({"layer": lid})).unwrap();
h.state_mut().session.execute("text.setSelection", json!({"anchor": 0, "caret": 5})).unwrap();
h.run_steps(3);
assert!(h.state().auto.find("properties.text.style").is_none(), "Properties is behind Effects & Presets");
let tab = rect(&h, "panel.tab.Properties").center();
click(&mut h, tab, Modifiers::NONE);
assert!(h.state().auto.find("properties.text.style").is_some(), "the tab opened Properties");
assert_eq!(edited(&h), (lid, "Hello world".into(), 0, 5), "the edit and its selection are kept");
h.state_mut().show_panel(PanelKind::Character);
h.run_steps(3);
let bold = rect(&h, "character.fauxBold").center();
click(&mut h, bold, Modifiers::NONE);
let doc = layer_doc(&h.state().session, LayerId(lid)).unwrap();
assert!(doc.style_at(1).faux_bold && !doc.style_at(7).faux_bold, "only \"Hello\" is bold");
}
/// #831: a click away and keys in one frame's input. The text gets the keys typed before the
/// click and none after it; those later keys don't run as shortcuts either (Delete would remove a
/// layer). From the next frame the keys are the app's: Space plays.
#[test]
fn keys_after_a_click_away_in_the_same_frame_skip_the_text() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap();
let solid = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
let lid = s.execute("layer.newText", json!({"text": "Hello", "size": 80, "position": [60, 200], "justify": "left"})).unwrap()["layer"].as_u64().unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| EffectcraftApp::new(s));
h.state_mut().show_panel(PanelKind::Composition);
h.run_steps(3);
h.state_mut().session.execute("text.edit", json!({"layer": lid, "caret": 5})).unwrap();
h.run_steps(2);
let row = rect(&h, &format!("timeline.layer.{solid}.row")).center();
let key = |key: Key, pressed: bool| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE };
h.input_mut().events.extend([
Event::Text("A".into()),
Event::PointerMoved(row),
Event::PointerButton { pos: row, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE },
Event::PointerButton { pos: row, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE },
Event::Text("x".into()),
key(Key::Space, true),
Event::Text(" ".into()),
key(Key::Space, false),
key(Key::Delete, true),
key(Key::Delete, false),
]);
h.run_steps(3);
let comp = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layers.len();
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, "HelloA", "only the key before the click");
assert!(h.state().session.state.text_edit.is_none(), "the click ended the edit");
assert_eq!((comp(&h), h.state().playback.playing), (2, false), "the keys after it ran no shortcut");
h.input_mut().events.extend([key(Key::Space, true), Event::Text(" ".into()), key(Key::Space, false)]);
h.run_steps(2);
assert!(h.state().playback.playing, "Space plays");
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, "HelloA");
}
#[test]
fn switching_from_type_to_selection_commits_and_moves_text_layer() {
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| app());
h.state_mut().show_panel(PanelKind::Composition);
h.run_steps(3);
h.state_mut().ui.tool = effectcraft_ui_egui::state::Tool::Type;
h.run_steps(1);
let comp = rect(&h, "viewer.comp");
let k = comp.width() / 640.0;
click(&mut h, comp.min + egui::vec2(320.0 * k, 180.0 * k), Modifiers::NONE);
type_text(&mut h, "Move me");
let layer_id = h.state().session.state.text_edit.as_ref().unwrap().layer;
let layer_position = |h: &Harness<'_, EffectcraftApp>| {
h.state().session.active_comp().unwrap().layer(layer_id).unwrap().props.prop("transform/position").unwrap().value.as_vec3()
};
let original_text = layer_doc(&h.state().session, layer_id).unwrap().text;
let before = layer_position(&h);
h.state_mut().ui.tool = effectcraft_ui_egui::state::Tool::Selection;
h.run_steps(2);
assert!(h.state().session.state.text_edit.is_none(), "leaving the Type tool commits text editing");
type_text(&mut h, " ignored");
assert_eq!(layer_doc(&h.state().session, layer_id).unwrap().text, original_text, "Selection does not type into the layer");
let from = comp.min + egui::vec2(340.0 * k, 150.0 * k);
let to = from + egui::vec2(60.0 * k, 50.0 * k);
drag(&mut h, from, to);
let after = layer_position(&h);
assert!(after[0] > before[0] && after[1] > before[1], "Selection dragging moves the text layer: {before:?} -> {after:?}");
}
#[test]
fn double_click_enters_editing_and_selects_words() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap();
let lid =
s.execute("layer.newText", json!({"text": "alpha beta", "size": 80, "position": [100, 200], "justify": "left"})).unwrap()["layer"].as_u64().unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| EffectcraftApp::new(s));
h.state_mut().show_panel(PanelKind::Composition);
h.run_steps(3);
// A point inside "beta" (comp space → window).
let comp = rect(&h, "viewer.comp");
let k = comp.width() / 640.0;
let p = comp.min + egui::vec2(330.0 * k, 180.0 * k);
click(&mut h, p, Modifiers::NONE);
click(&mut h, p, Modifiers::NONE);
let e = h.state().session.state.text_edit.clone().expect("double-click edits text");
assert_eq!(e.layer.0, lid);
assert_eq!((e.anchor.min(e.caret), e.anchor.max(e.caret)), (0, 10), "everything selected");
// Double-clicking inside the edited text selects a word.
h.run_steps(40);
click(&mut h, p, Modifiers::NONE);
click(&mut h, p, Modifiers::NONE);
let e = h.state().session.state.text_edit.clone().unwrap();
assert_eq!((e.anchor, e.caret), (6, 10));
// Typing replaces it; Cmd+Z undoes while editing.
type_text(&mut h, "gamma");
assert_eq!(edited(&h).1, "alpha gamma");
key(&mut h, Key::Z, Modifiers::COMMAND);
assert_eq!(edited(&h).1, "alpha beta");
// Paragraph panel: alignment applies to the edited paragraph (the South Asian and Middle
// Eastern text engine shows the direction popup too).
h.state_mut().session.prefs.type_.text_engine = "southAsian".into();
h.state_mut().show_panel(PanelKind::Paragraph);
h.run_steps(3);
let r = rect(&h, "paragraph.right");
click(&mut h, r.center(), Modifiers::NONE);
let doc = layer_doc(&h.state().session, LayerId(lid)).unwrap();
assert_eq!(doc.justify, effectcraft_engine::keyframe::Justify::Right);
for id in
["paragraph.indentLeft", "paragraph.spaceBefore", "paragraph.direction", "paragraph.composer", "paragraph.hangingPunctuation", "paragraph.justifyAll"]
{
assert!(h.state().auto.find(id).is_some(), "missing {id}");
}
}
#[test]
fn type_tool_drag_makes_paragraph_text_with_box_handles() {
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| app());
h.state_mut().show_panel(PanelKind::Composition);
h.run_steps(3);
h.state_mut().ui.tool = effectcraft_ui_egui::state::Tool::Type;
h.run_steps(1);
let comp = rect(&h, "viewer.comp");
let a = comp.min + egui::vec2(comp.width() * 0.2, comp.height() * 0.2);
let b = comp.min + egui::vec2(comp.width() * 0.7, comp.height() * 0.6);
h.input_mut().events.push(Event::PointerMoved(a));
h.input_mut().events.push(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
for i in 1..=8 {
h.input_mut().events.push(Event::PointerMoved(a + (b - a) * (i as f32 / 8.0)));
h.run_steps(1);
}
h.input_mut().events.push(Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
let (lid, _, _, _) = edited(&h);
let doc = layer_doc(&h.state().session, LayerId(lid)).unwrap();
let bs = doc.box_size.expect("paragraph text");
assert!((bs[0] - 320.0).abs() < 4.0 && (bs[1] - 144.0).abs() < 4.0, "{bs:?}");
type_text(&mut h, "Some words that wrap inside the box when there are enough of them");
h.run_steps(2);
assert!(h.state().auto.find("viewer.textBox.handle.4").is_some());
// Dragging the bottom-right handle widens the box.
let hr = rect(&h, "viewer.textBox.handle.4").center();
let to = hr + egui::vec2(60.0, 0.0);
h.input_mut().events.push(Event::PointerMoved(hr));
h.input_mut().events.push(Event::PointerButton { pos: hr, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
for i in 1..=6 {
h.input_mut().events.push(Event::PointerMoved(hr + (to - hr) * (i as f32 / 6.0)));
h.run_steps(1);
}
h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
let doc2 = layer_doc(&h.state().session, LayerId(lid)).unwrap();
assert!(doc2.box_size.unwrap()[0] > bs[0] + 20.0, "{:?}", doc2.box_size);
assert!(h.state().session.state.text_edit.is_some(), "still editing after resizing");
}
/// #866: Layer ▸ New ▸ Text (menu or its shortcut) puts an insertion point in the comp, as After
/// Effects does, so typed keys reach Source Text (and the layer name follows) instead of running
/// shortcuts while the layer keeps the placeholder "Text".
#[test]
fn layer_new_text_menu_and_shortcut_start_typing_into_source_text() {
for via_shortcut in [false, true] {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap();
s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| EffectcraftApp::new(s));
h.state_mut().show_panel(PanelKind::Composition);
h.run_steps(3);
if via_shortcut {
key(&mut h, Key::T, Modifiers::COMMAND | Modifiers::ALT | Modifiers::SHIFT);
} else {
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.newText", json!({})).unwrap();
h.run_steps(2);
}
let (lid, text, _, _) = edited(&h);
assert_eq!(text, "", "no placeholder to type over (shortcut: {via_shortcut})");
type_text(&mut h, "My First Project");
key(&mut h, Key::Escape, Modifiers::NONE);
assert!(h.state().session.state.text_edit.is_none(), "Escape commits");
let s = &h.state().session;
assert_eq!(layer_doc(s, LayerId(lid)).unwrap().text, "My First Project", "typed text is the Source Text");
assert_eq!(s.active_comp().unwrap().layer(LayerId(lid)).unwrap().name, "My First Project", "the name follows the text");
}
}
/// Double-clicking a text layer in the Timeline, on its name or on its bar, edits its text in the
/// Composition viewer with the whole text selected, as in After Effects (Enter renames).
#[test]
fn timeline_double_click_on_a_text_layer_edits_its_text() {
for target in ["row", "bar"] {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap();
let lid = s.execute("layer.newText", json!({"text": "alpha beta"})).unwrap()["layer"].as_u64().unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| EffectcraftApp::new(s));
h.state_mut().show_panel(PanelKind::Composition);
h.run_steps(3);
let p = rect(&h, &format!("timeline.layer.{lid}.{target}")).center();
click(&mut h, p, Modifiers::NONE);
click(&mut h, p, Modifiers::NONE);
let (layer, text, anchor, caret) = edited(&h);
assert_eq!(layer, lid, "{target}");
assert_eq!((anchor.min(caret), anchor.max(caret)), (0, 10), "everything selected ({target})");
assert!(h.state().auto.find(&format!("timeline.layer.{lid}.nameEdit")).is_none(), "no rename field ({target})");
assert_eq!(text, "alpha beta");
type_text(&mut h, "gamma");
key(&mut h, Key::Escape, Modifiers::NONE);
assert!(h.state().session.state.text_edit.is_none(), "Escape commits ({target})");
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, "gamma", "typing replaced the text ({target})");
}
}
#[test]
fn duplicated_text_boxes_both_remain_editable() {
let mut a = app();
let original = a
.session
.execute("layer.newText", json!({"text": "alpha beta", "size": 40, "position": [40, 100], "justify": "left", "box": [40, 60, 250, 100]}))
.unwrap()["layer"]
.as_u64()
.unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| a);
h.run_steps(3);
h.state_mut().ui.focused = PanelKind::Composition;
key(&mut h, Key::D, Modifiers::COMMAND);
let duplicate = h.state().session.state.selected_layers[0].0;
assert_ne!(original, duplicate);
h.state_mut().session.execute("prop.set", json!({"layer": duplicate, "path": "transform/position", "value": [465, 110]})).unwrap();
h.run_steps(3);
for (layer, x, text) in [(original, 80.0, "original edited"), (duplicate, 380.0, "duplicate edited")] {
h.run_steps(40);
let comp = rect(&h, "viewer.comp");
let k = comp.width() / 640.0;
let p = comp.min + egui::vec2(x * k, 85.0 * k);
click(&mut h, p, Modifiers::NONE);
click(&mut h, p, Modifiers::NONE);
assert_eq!(edited(&h).0, layer);
type_text(&mut h, text);
key(&mut h, Key::Escape, Modifiers::NONE);
assert_eq!(layer_doc(&h.state().session, LayerId(layer)).unwrap().text, text);
}
}
#[test]
fn overlapping_duplicate_text_remains_editable_by_double_click_and_selected_type_tool() {
let mut a = app();
let original = a.session.execute("layer.newText", json!({"text": "alpha beta", "size": 40, "box": [40, 60, 250, 100]})).unwrap()["layer"].as_u64().unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| a);
h.run_steps(3);
h.state_mut().ui.focused = PanelKind::Composition;
key(&mut h, Key::D, Modifiers::COMMAND);
let duplicate = h.state().session.state.selected_layers[0].0;
assert_ne!(original, duplicate);
let comp = rect(&h, "viewer.comp");
let p = comp.min + egui::vec2(80.0, 85.0) * (comp.width() / 640.0);
click(&mut h, p, Modifiers::NONE);
click(&mut h, p, Modifiers::NONE);
assert_eq!(edited(&h).0, duplicate, "double-click edits the topmost overlapping copy");
type_text(&mut h, "copy edited");
assert_eq!(edited(&h).1, "copy edited");
key(&mut h, Key::Z, Modifiers::COMMAND);
assert_eq!(edited(&h).1, "alpha beta", "undo restores the duplicate's text");
key(&mut h, Key::Escape, Modifiers::NONE);
h.state_mut().session.execute("layer.select", json!({"layers": [original]})).unwrap();
h.state_mut().ui.focused = PanelKind::Composition;
key(&mut h, Key::T, Modifiers::COMMAND);
h.run_steps(40);
click(&mut h, p, Modifiers::NONE);
assert_eq!(edited(&h).0, original, "the Type tool must honor the explicitly selected overlapping text layer");
key(&mut h, Key::A, Modifiers::COMMAND);
type_text(&mut h, "original edited");
assert_eq!(edited(&h).1, "original edited");
assert_eq!(layer_doc(&h.state().session, LayerId(duplicate)).unwrap().text, "alpha beta", "editing the original leaves the duplicate unchanged");
key(&mut h, Key::Z, Modifiers::COMMAND);
assert_eq!(edited(&h).1, "alpha beta");
}
#[test]
fn type_tool_selected_text_preference_skips_ineligible_layers_and_falls_back_to_topmost() {
for exclusion in ["hidden", "locked", "inactive", "unsoloed", "outside", "unselected"] {
let mut a = app();
let mut ids = vec![];
for _ in 0..2 {
ids.push(
a.session.execute("layer.newText", json!({"text": "alpha beta", "size": 40, "box": [40, 60, 250, 100]})).unwrap()["layer"].as_u64().unwrap(),
);
}
let original = ids[0];
let topmost = ids[1];
match exclusion {
"hidden" | "locked" => {
let switch = if exclusion == "hidden" { "video" } else { "locked" };
a.session.execute("layer.setSwitch", json!({"layers": [original], "switch": switch, "value": exclusion == "locked"})).unwrap();
}
"inactive" => {
let cid = a.session.active_comp_id().unwrap();
std::sync::Arc::make_mut(&mut a.session.project).comp_mut(cid).unwrap().layer_mut(LayerId(original)).unwrap().out_point =
effectcraft_engine::time::Tick::ZERO;
}
"unsoloed" => {
a.session.execute("layer.setSwitch", json!({"layers": [topmost], "switch": "solo", "value": true})).unwrap();
}
"outside" => {
a.session.execute("prop.set", json!({"layer": original, "path": "transform/position", "value": [500, 300]})).unwrap();
}
_ => {}
}
a.session.state.selected_layers = if exclusion == "unselected" { vec![] } else { vec![LayerId(original)] };
a.ui.tool = effectcraft_ui_egui::state::Tool::Type;
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| a);
h.run_steps(3);
let comp = rect(&h, "viewer.comp");
let p = comp.min + egui::vec2(80.0, 85.0) * (comp.width() / 640.0);
click(&mut h, p, Modifiers::NONE);
assert_eq!(edited(&h).0, topmost, "{exclusion} selected text must not override the eligible hit");
}
}