//! #1011: a text layer's Range Selector shows its Start and End as bars on the text in the //! Composition viewer, as in After Effects, while the selector or its animator is selected. A //! drag sets Start / End in one undo step (a keyframe at the current time when animated) and //! doesn't move the layer. Uses only APIs that existed before the bars, so it runs (and fails) //! on the code without them. use effectcraft_engine::Session; use effectcraft_engine::commands::text_edit::layer_doc; use effectcraft_engine::project::{LayerId, PropGroup}; use effectcraft_engine::text::layout_doc; use effectcraft_ui_egui::EffectcraftApp; use effectcraft_ui_egui::panels::viewer::comp_to_screen; use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; use egui_kittest::Harness; use serde_json::json; struct Ids { layer: u64, animator: u64, color: u64, start: u64, end: u64, } fn animator(s: &Session, l: u64) -> PropGroup { let layer = s.active_comp().unwrap().layer(LayerId(l)).unwrap(); layer.props.sub("text").unwrap().sub("animators").unwrap().groups().next().unwrap().clone() } /// A 640×360 comp with the point text "abcdefgh" and a Fill Color animator (one Range Selector). fn setup() -> (Session, Ids) { let mut s = Session::default(); s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "duration": 4})).unwrap(); let l = s.execute("layer.newText", json!({"text": "abcdefgh", "size": 60})).unwrap()["layer"].as_u64().unwrap(); s.execute("layer.addTextAnimator", json!({"layer": l, "property": "fillColor"})).unwrap(); let a = animator(&s, l); let sel = a.sub("selectors").unwrap().groups().next().unwrap(); let color = a.sub("properties").unwrap().children.first().unwrap().uid(); let ids = Ids { layer: l, animator: a.uid, color, start: sel.get("start").unwrap().uid, end: sel.get("end").unwrap().uid }; s.state.selected_layers = vec![LayerId(l)]; (s, ids) } fn harness(s: Session) -> Harness<'static, EffectcraftApp> { let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); h.run_steps(4); h } fn handle(h: &Harness<'_, EffectcraftApp>, uid: u64) -> Option { h.state().auto.find(&format!("viewer.textRange.{uid}")).map(|e| pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)) } fn value(h: &Harness<'_, EffectcraftApp>, ids: &Ids, uid: u64) -> f64 { h.state().session.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().props.find(uid).unwrap().value.as_f64() } /// Screen x of the caret before character `ci` of the text. fn caret_x(h: &Harness<'_, EffectcraftApp>, ids: &Ids, ci: usize) -> f32 { let s = &h.state().session; let lay = layout_doc(&layer_doc(s, LayerId(ids.layer)).unwrap()); let pos = s.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().props.prop("transform/position").unwrap().value.as_vec2(); let (_, bottom) = lay.caret(ci); comp_to_screen(&h.ctx, [(pos[0] + bottom.x) as f32, (pos[1] + bottom.y) as f32]).unwrap().x } fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { h.event(Event::PointerMoved(from)); h.step(); h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); h.step(); for k in 1..=8 { h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 8.0))); h.step(); } h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); h.run_steps(3); } /// With the Range Selector's animator selected, End is a bar at the end of the text; dragging it /// back to the boundary after the fourth character sets End to 50%, in one undo step, without /// moving Start or the layer. #[test] fn dragging_end_sets_it_in_one_undo_step() { let (mut s, ids) = setup(); s.state.selected_props = vec![(LayerId(ids.layer), ids.animator)]; let mut h = harness(s); let at = handle(&h, ids.end).unwrap_or_else(|| panic!("no viewer.textRange.{}: the Range Selector's End isn't in the viewer", ids.end)); assert!(handle(&h, ids.start).is_some(), "and its Start"); let position = h.state().session.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().props.prop("transform/position").unwrap().value.clone(); let undo = h.state().session.history.undo.len(); let dx = caret_x(&h, &ids, 4) - caret_x(&h, &ids, 8); drag(&mut h, at, at + vec2(dx, 0.0)); let end = value(&h, &ids, ids.end); assert!((end - 50.0).abs() < 1.0, "End at the middle of 8 characters: {end}"); assert_eq!(value(&h, &ids, ids.start), 0.0, "Start stays"); let layer = h.state().session.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().clone(); assert_eq!(layer.props.prop("transform/position").unwrap().value, position, "the layer didn't move"); assert_eq!(h.state().session.history.undo.len(), undo + 1, "the drag is one undo step"); h.state_mut().session.execute("edit.undo", json!({})).unwrap(); assert_eq!(value(&h, &ids, ids.end), 100.0, "one undo puts it back"); } /// End animated from 0% (0 s) to 100% (2 s): at 1 s the bar sits at 50%, and dragging it adds a /// keyframe at 1 s with the new value; the other keys stay. #[test] fn dragging_an_animated_end_keys_the_current_time() { let (mut s, ids) = setup(); s.execute("prop.addKey", json!({"layer": ids.layer, "prop": ids.end, "time": 0.0, "value": 0.0})).unwrap(); s.execute("prop.addKey", json!({"layer": ids.layer, "prop": ids.end, "time": 2.0, "value": 100.0})).unwrap(); s.execute("time.set", json!({"time": 1.0})).unwrap(); s.state.selected_props = vec![(LayerId(ids.layer), ids.start)]; let mut h = harness(s); let at = handle(&h, ids.end).expect("an End bar (selecting Start shows the selector)"); assert!((at.x - caret_x(&h, &ids, 4)).abs() < 12.0, "at 1 s End is 50%: the bar is after the fourth character"); let dx = caret_x(&h, &ids, 6) - caret_x(&h, &ids, 4); drag(&mut h, at, at + vec2(dx, 0.0)); let layer = h.state().session.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().clone(); let keys: Vec<(f64, f64)> = layer.props.find(ids.end).unwrap().keys.iter().map(|k| (k.time.seconds(), k.value.as_f64())).collect(); assert_eq!(keys.len(), 3, "{keys:?}"); // (Times on the comp's frames: 29.97 fps puts "1 s" at 1.001 s.) let at = |k: (f64, f64), t: f64, v: f64| (k.0 - t).abs() < 0.01 && (k.1 - v).abs() < 1.0; assert!(at(keys[0], 0.0, 0.0) && at(keys[2], 2.0, 100.0), "the other keys stay: {keys:?}"); assert!(at(keys[1], 1.0, 75.0), "a key at 1 s at 75%: {keys:?}"); } /// The bars show while the selector, its animator or a property in the animator is selected, /// and only with the Selection tool and layer controls on. #[test] fn bars_show_only_for_a_selected_selector_or_animator() { let (s, ids) = setup(); let h = harness(s); assert!(handle(&h, ids.end).is_none(), "only the layer is selected"); let (mut s, ids) = setup(); let position = s.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().props.prop("transform/position").unwrap().uid; s.state.selected_props = vec![(LayerId(ids.layer), position)]; let h = harness(s); assert!(handle(&h, ids.end).is_none(), "a property outside the animator is selected"); let (mut s, ids) = setup(); s.state.selected_props = vec![(LayerId(ids.layer), ids.color)]; let mut h = harness(s); assert!(handle(&h, ids.end).is_some(), "Fill Color, in the animator, is selected"); h.state_mut().ui.viewer.show_layer_controls = false; h.run_steps(3); assert!(handle(&h, ids.end).is_none(), "View ▸ Show Layer Controls off hides them"); h.state_mut().ui.viewer.show_layer_controls = true; h.state_mut().ui.tool = effectcraft_ui_egui::state::Tool::Pen; h.run_steps(3); assert!(handle(&h, ids.end).is_none(), "only the Selection tool drags them"); } /// Offset 25%: the Start bar sits two characters in, where the range begins, and dragging it to /// the boundary after the fourth character writes Start = 50% − Offset, leaving Offset alone. #[test] fn bars_include_the_offset_and_a_drag_leaves_it() { let (mut s, ids) = setup(); let offset = animator(&s, ids.layer).sub("selectors").unwrap().groups().next().unwrap().get("offset").unwrap().uid; s.execute("prop.set", json!({"layer": ids.layer, "prop": offset, "value": 25.0})).unwrap(); s.state.selected_props = vec![(LayerId(ids.layer), ids.animator)]; let mut h = harness(s); let at = handle(&h, ids.start).expect("a Start bar"); assert!((at.x - caret_x(&h, &ids, 2)).abs() < 12.0, "Start 0% + Offset 25%: after the second character"); let dx = caret_x(&h, &ids, 4) - caret_x(&h, &ids, 2); drag(&mut h, at, at + vec2(dx, 0.0)); assert!((value(&h, &ids, ids.start) - 25.0).abs() < 1.0, "Start {}", value(&h, &ids, ids.start)); assert_eq!(value(&h, &ids, offset), 25.0, "Offset stays"); } /// Small text: Start at 0% sits within the reach of the bounding box's left-side scale handle. A /// press on the handle still scales the layer and leaves Start alone. #[test] fn a_bar_on_a_box_handle_leaves_it_the_press() { let mut s = Session::default(); s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "duration": 4})).unwrap(); let l = s.execute("layer.newText", json!({"text": "abcdefgh", "size": 12})).unwrap()["layer"].as_u64().unwrap(); s.execute("layer.addTextAnimator", json!({"layer": l, "property": "fillColor"})).unwrap(); let a = animator(&s, l); let start = a.sub("selectors").unwrap().groups().next().unwrap().get("start").unwrap().uid; s.state.selected_layers = vec![LayerId(l)]; s.state.selected_props = vec![(LayerId(l), a.uid)]; let mut h = harness(s); let bar = handle(&h, start).expect("a Start bar"); let at = (0..8) .filter_map(|i| h.state().auto.find(&format!("viewer.handle.{l}.{i}")).map(|e| pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0))) .min_by(|p, q| (*p - bar).length().total_cmp(&(*q - bar).length())) .expect("the box's handles"); assert!((bar - at).length() < 9.0, "the fixture puts Start within the handle's reach: {bar:?} vs {at:?}"); let scale = |h: &Harness<'_, EffectcraftApp>| { h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("transform/scale").unwrap().value.clone() }; let before = scale(&h); drag(&mut h, at, at - vec2(40.0, 0.0)); let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone(); assert_eq!(layer.props.find(start).unwrap().value.as_f64(), 0.0, "Start stays"); assert_ne!(scale(&h), before, "the handle scaled the layer"); }