Merge main into enhance/avi-import

This commit is contained in:
tylerjenningsw
2026-10-25 14:26:28 -04:00
427 changed files with 24699 additions and 4513 deletions
+104
View File
@@ -0,0 +1,104 @@
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
mod render_queue_snapshot;
mod shared_docking_ui;
mod ui_3d;
mod ui_agent_audit;
mod ui_angles;
mod ui_audio_scrub;
mod ui_azerty_shortcuts;
mod ui_batch_switches;
mod ui_blank_context_menus;
mod ui_camera_tracker;
mod ui_character_eyedropper;
mod ui_color_hex;
mod ui_comp_presets;
mod ui_control_modifiers;
mod ui_dock_resize;
mod ui_drag_drop;
mod ui_effect_controls;
mod ui_effect_drop_highlight;
mod ui_effect_names;
mod ui_font_search;
mod ui_form_fit;
mod ui_frame_step;
mod ui_fx_editors;
mod ui_gpu;
mod ui_gradient_editor;
mod ui_gradient_handles;
mod ui_gradient_value;
mod ui_graph_fit;
mod ui_group_rename;
mod ui_guide_layers;
mod ui_inline_edit;
mod ui_keyframes;
mod ui_large_project;
mod ui_layer_panel_zoom;
mod ui_layer_source_picker;
mod ui_light_settings;
mod ui_live_preview;
mod ui_locked_color;
mod ui_media_browser_cache;
mod ui_media_browser_copy_path;
mod ui_menus;
mod ui_new_primitive;
mod ui_object_matte;
mod ui_panel_focus;
mod ui_panel_windows;
mod ui_panels;
mod ui_parameter_spacing;
mod ui_presets_folder;
mod ui_preview_align;
mod ui_project_delete;
mod ui_project_duplicate;
mod ui_project_keyboard;
mod ui_project_new_folder;
mod ui_project_rows;
mod ui_quick_offset;
mod ui_ram_preview;
mod ui_render_queue;
mod ui_script_console;
mod ui_scriptui;
mod ui_scrolling;
mod ui_settings_info;
mod ui_tabs;
mod ui_tail;
mod ui_text_edit;
mod ui_text_range_handles;
mod ui_theme_favorites;
mod ui_time_zoom_controls;
mod ui_timeline_effect_label;
mod ui_timeline_pan;
mod ui_timeline_shapes;
mod ui_timeline_switches;
mod ui_timeline_trim;
mod ui_timeline_zoom;
mod ui_toolbar_gradient;
mod ui_tracker;
mod ui_unreadable_footage;
mod ui_video_preview_off;
mod ui_viewer;
mod ui_viewers;
mod ui_visual_review;
mod ui_web_gpu_storage;
mod ui_web_job_gpu;
mod ui_web_offload;
mod ui_workflow_selection;
mod ui_workspace_focus;
mod viewer_geometry;
mod viewer_passive_display;
/// Register the managed build's canonical craft-fonts checkout (`CRAFT_FONTS_DIR/fonts`) through
/// the same directory source production scans for `EFFECTCRAFT_FONT_DIRS`. The UI tests then render
/// real Japanese glyphs from the canonical external input instead of relying on host fonts.
pub(crate) fn add_canonical_test_fonts() {
use effectcraft_text::fonts::{self, DirectorySource};
static REGISTER: std::sync::Once = std::sync::Once::new();
REGISTER.call_once(|| {
let Some(root) = std::env::var_os("CRAFT_FONTS_DIR") else { return };
let dir = std::path::PathBuf::from(root).join("fonts");
if dir.is_dir() {
fonts::add_source(&DirectorySource { dirs: vec![dir] });
}
});
}
@@ -1,6 +1,6 @@
//! Headless look at the Render Queue panel (wgpu offscreen). Ignored by default (needs a GPU
//! adapter); run with
//! `RQ_SNAPSHOT=/abs/out.png cargo test -p effectcraft-ui-egui --test render_queue_snapshot -- --ignored`.
//! `RQ_SNAPSHOT=/abs/out.png cargo test -p effectcraft-ui-egui --test it render_queue_snapshot:: -- --ignored`.
use effectcraft_engine::Session;
use effectcraft_engine::project::render_queue::RenderStatus;
@@ -31,7 +31,7 @@ fn render_queue_panel_snapshot() {
let ids: Vec<u64> = s.project.render_queue.iter().take(3).map(|i| i.id).collect();
let mut app = Some(EffectcraftApp::new(s));
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app.take().expect("app"));
h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("rq-ui"), (ids, None::<u64>)));
h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("rq-ui"), (ids, None::<u64>)));
h.state_mut().show_panel(effectcraft_ui_egui::dock::PanelKind::RenderQueue);
h.run_steps(4);
let img = h.render().expect("render");
@@ -154,7 +154,9 @@ fn pointer_drop_merges_floats_and_redocks_without_project_changes() {
let from = rect(&h, "panel.tab.Info").center();
drag(&mut h, from, pos2(700.0, 400.0), Modifiers::COMMAND);
assert_eq!(h.state().ui.floating.len(), 1);
assert_eq!(h.get_by_label("Resize floating panel").accesskit_node().role(), egui::accesskit::Role::Button);
// The grip is a resize handle: the migration's role mapping (`ResizeHandle → Splitter`) applies
// to floating panels too, and the widget reports it (`dock_ui.rs`).
assert_eq!(h.get_by_label("Resize floating panel").accesskit_node().role(), egui::accesskit::Role::Splitter);
action_log.push(action_state(&h, "floated"));
let original = h.state().ui.floating[0].rect;
let grip = rect(&h, "panel.float.0.resize").center();
@@ -162,7 +164,7 @@ fn pointer_drop_merges_floats_and_redocks_without_project_changes() {
assert!(h.state().ui.floating[0].rect[2] > original[2] + 25.0);
assert!(h.state().ui.floating[0].rect[3] > original[3] + 15.0);
action_log.push(action_state(&h, "resized"));
let grip_id = egui::Id::new(("floating-panel", PanelKind::Info.id())).with("resize");
let grip_id = egui::Id::unique(("floating-panel", PanelKind::Info.id())).with("resize");
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(grip_id));
let before_keys = h.state().ui.floating[0].rect;
for key in [egui::Key::ArrowRight, egui::Key::ArrowDown] {
@@ -365,7 +367,7 @@ fn accordion_resize_routes_pointer_and_keyboard_without_changing_closed_entries(
};
h.run_steps(3);
let project = h.state().session.project.clone();
let id = egui::Id::new(("dock-gutter", "s0".to_string()));
let id = egui::Id::unique(("dock-gutter", "s0".to_string()));
h.ctx.memory_mut(|memory| memory.request_focus(id));
h.run_steps(2);
for pressed in [true, false] {
@@ -1,6 +1,6 @@
//! Headless UI checks for the 3D viewer (egui_kittest). The `snapshot` test renders the window
//! with wgpu and writes PNGs when `EC_SNAPSHOT_DIR` is set (`cargo test -p effectcraft-ui-egui
//! --test ui_3d -- --ignored`); the other test only runs the UI logic.
//! --test it ui_3d:: -- --ignored`); the other test only runs the UI logic.
use effectcraft_engine::Session;
use effectcraft_engine::render::three_d::View3D;
@@ -29,7 +29,8 @@ fn harness(s: Session) -> Harness<'static, EffectcraftApp> {
fn send(h: &mut Harness<'_, EffectcraftApp>, method: &str, params: Value) -> Value {
let ctx = h.ctx.clone();
let (req, _rx) = ControlRequest::new(method, params);
match control::handle(h.state_mut(), &ctx, &req) {
let now = h.state().now();
match control::handle(h.state_mut(), &ctx, &req, now) {
Outcome::Done(v) => v,
_ => panic!("{method}: no immediate reply"),
}
@@ -70,7 +71,7 @@ fn interactive(ctx: &egui::Context) -> Vec<(Rect, String)> {
}
let info = w.info(r.id);
// Selectable text (labels sense drags to select their text) is not a control.
if info.is_some_and(|i| i.typ == egui::WidgetType::Label) {
if info.is_some_and(|i| i.role == egui::Role::Label) {
continue;
}
out.push((ir, info.and_then(|i| i.label.clone()).unwrap_or_default()));
@@ -9,6 +9,7 @@ use effectcraft_engine::project::ItemId;
use effectcraft_engine::time::Tick;
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::audio::{AudioDevice, AudioFeed};
use effectcraft_ui_egui::frames::{RemoteDone, RemoteFrame, RemoteFrames, RemoteJob};
use egui::{Event, Modifiers, pos2};
use egui_kittest::Harness;
use serde_json::json;
@@ -30,6 +31,58 @@ impl AudioDevice for Fake {
}
}
/// A frame renderer whose frames the test releases explicitly. Playback then runs against a
/// controlled clock and controlled cache contents instead of racing however many frame workers
/// the host has; a frame that is not released is queued again by the next playback step.
#[derive(Clone, Default)]
struct GatedFrames {
pending: Arc<Mutex<Vec<(i64, u32, u32, RemoteDone)>>>,
}
impl RemoteFrames for GatedFrames {
fn slots(&self) -> usize {
1024
}
fn start(&self, job: RemoteJob, done: RemoteDone) {
let (frame, width, height) = job.project.comp(job.comp).map_or((-1, 1, 1), |c| {
let scale = job.opts.scale.max(0.001);
let width = (c.width as f64 * scale).round().clamp(1.0, 16_384.0) as u32;
let height = (c.height as f64 * scale).round().clamp(1.0, 16_384.0) as u32;
(c.frame_rate.frame_at(job.t), width, height)
});
self.pending.lock().unwrap().push((frame, width, height, done));
}
}
impl GatedFrames {
/// Put `frames` in the cache, dropping every other queued frame (playback asks again).
fn render(&self, h: &mut Harness<'_, EffectcraftApp>, cid: ItemId, frames: &[i64]) {
let scale = h.state().shown_series(cid).scale as f64 / 1000.0;
for &f in frames {
h.state().request_frame(cid, f, scale);
}
while h.state().frames.dispatch_remote() != 0 {}
let pending = std::mem::take(&mut *self.pending.lock().unwrap());
for (frame, width, height, done) in pending {
if frames.contains(&frame) {
let rgba = vec![0; (width as usize).saturating_mul(height as usize).saturating_mul(4)];
done(Ok(RemoteFrame { width, height, rgba, ms: 1.0 }));
} else {
done(Err("held back by the test renderer".into()));
}
}
}
/// Drop everything queued without caching it (the warm-up passes request viewer frames).
fn discard(&self, h: &mut Harness<'_, EffectcraftApp>) {
while h.state().frames.dispatch_remote() != 0 {}
for (_, _, _, done) in std::mem::take(&mut *self.pending.lock().unwrap()) {
done(Err("held back by the test renderer".into()));
}
}
}
/// A 2 s, 30 fps comp whose solid sounds a tone (Audio switch on).
fn setup(audible: bool, fake: &Fake) -> (EffectcraftApp, ItemId) {
setup_long(audible, fake, 2.0)
@@ -120,42 +173,74 @@ fn cmd_dragging_the_current_time_scrubs_and_an_idle_scrub_closes() {
fn preview_with_audio_shows_every_frame_and_sounds_once_cached() {
let fake = Fake::default();
let (mut app, cid) = setup(true, &fake);
// Playback requests at most twice as many frames ahead per step as there are render threads.
// Four times that many frames take at least four steps to cache, so frames are shown silently
// first however fast the renderer is (with a fixed 2 s comp, a machine with many cores cached
// all of it before the first step and the sound started at once).
// Hold rendering in the test: the cache cannot fill ahead of what the test releases, so the
// silently shown frames and the moment the sound starts do not depend on the host's threads.
let gate = GatedFrames::default();
let renderer: Arc<dyn RemoteFrames> = Arc::new(gate.clone());
app.frames.set_remote(Some(renderer));
let ahead = (app.frames.parallelism() * 2).max(4);
app.session.execute("comp.settings", json!({"comp": cid.0, "duration": (4 * ahead) as f64 / 30.0})).unwrap();
// A little under one frame of input time per step.
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 31.0).build_eframe(|_| app);
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app);
// Playback measures time on the app clock, never `ctx.input(|i| i.time)`; drive that clock
// explicitly at one frame per step.
h.state_mut().use_manual_clock();
h.run_steps(2);
gate.discard(&mut h);
let frame = |h: &Harness<'_, EffectcraftApp>| {
let app = h.state();
app.session.project.comp(cid).unwrap().frame_rate.frame_at(app.session.time())
};
let now = h.ctx.input(|i| i.time);
let now = h.state().now();
h.state_mut().play(now);
assert!(h.state().playback.audio_held && h.state().audio.is_none(), "nothing is cached: no sound yet");
let mut last = frame(&h);
let mut shown = 0;
// Wait on the clock, not a step count: under a loaded machine (parallel test binaries) the
// background cache can take far longer than 2000 short steps to get ahead.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
while std::time::Instant::now() < deadline {
// Show frames while only those frames are cached: they play silently first.
for _ in 0..4 {
let target = last + 1;
gate.render(&mut h, cid, &[target]);
h.state_mut().clock_mut().advance(1.0 / 30.0);
h.step();
if h.state().audio.is_some() {
break;
}
let f = frame(&h);
assert!(f == last || f == last + 1 || f < last, "{last} → {f}: a frame was skipped");
// Accumulating 1/30 in f64 can leave the same frame up for one more step; it must never
// jump over a frame.
assert!(f == last || f == last + 1, "{last} → {f}: a frame was skipped");
shown += usize::from(f != last);
last = f;
std::thread::sleep(std::time::Duration::from_millis(1));
}
assert!(h.state().audio.is_some() && fake.0.lock().unwrap().0.is_some(), "the sound started once the frames ahead were cached");
// A frame that has not rendered holds the clock at the frame on screen; once it renders it is
// shown, never jumped over. This exercises the production hold path, not a test-only shortcut.
let waiting = last;
h.state_mut().clock_mut().advance(1.0 / 30.0);
h.step();
assert_eq!(frame(&h), waiting, "an uncached frame holds the preview instead of being skipped");
gate.render(&mut h, cid, &[waiting + 1]);
// f64 clock accumulation need not put the next step exactly on the next frame boundary;
// keep stepping until the held frame appears, and never allow it to jump past that frame.
let mut showed_waiting = false;
for _ in 0..4 {
h.state_mut().clock_mut().advance(1.0 / 30.0);
h.step();
let f = frame(&h);
assert!(f == waiting || f == waiting + 1, "{waiting} → {f}: the held frame was skipped");
if f == waiting + 1 {
showed_waiting = true;
break;
}
}
assert!(showed_waiting, "the waiting frame is shown once it renders");
shown += 1;
last = waiting + 1;
assert!(shown > 0, "frames played silently first");
// A frame that isn't cached stops the sound rather than being skipped. (Frames render fast
// enough here to be cached again before the check: keep them out of the cache.)
assert!(h.state().audio.is_none() && h.state().playback.audio_held, "the rest of the range is not cached: the sound waits");
// Cache everything ahead; only then may the sound start.
let end = h.state().playback.plan.expect("a preview plan").end;
let rest: Vec<i64> = ((last + 1)..=end).collect();
gate.render(&mut h, cid, &rest);
h.step();
assert!(h.state().audio.is_some() && fake.0.lock().unwrap().0.is_some(), "the sound started once the frames ahead were cached");
// A frame that isn't cached stops the sound rather than being skipped.
gate.discard(&mut h);
h.state().frames.set_budget(0);
h.state().frames.clear();
h.step();
@@ -168,27 +253,47 @@ fn preview_with_audio_shows_every_frame_and_sounds_once_cached() {
#[test]
fn preview_with_audio_sounds_once_rendering_keeps_up() {
let fake = Fake::default();
let (app, cid) = setup_long(true, &fake, 30.0);
let (mut app, cid) = setup_long(true, &fake, 30.0);
// Release only the frames this test chooses, and drive the app clock: rendering cannot race
// the clock, so a frame is never skipped by a slow or loaded test host.
let gate = GatedFrames::default();
let renderer: Arc<dyn RemoteFrames> = Arc::new(gate.clone());
app.frames.set_remote(Some(renderer));
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app);
h.state_mut().use_manual_clock();
h.run_steps(2);
gate.discard(&mut h);
let ahead = (h.state().frames.parallelism() * 2).max(4) as i64;
let frame = |h: &Harness<'_, EffectcraftApp>| {
let app = h.state();
app.session.project.comp(cid).unwrap().frame_rate.frame_at(app.session.time())
};
let now = h.ctx.input(|i| i.time);
h.state_mut().play(now);
let start = h.state().now();
h.state_mut().play(start);
assert!(h.state().playback.audio_held && h.state().audio.is_none(), "nothing is cached: no sound yet");
let mut last = frame(&h);
// Two seconds of playback, each step giving the frame workers time to keep up.
for _ in 0..60 {
let mut shown = 0;
// Two seconds of playback at one frame per clock step: keep the prefetch window cached, but
// not the rest of the 30 s comp, so the sound has to start by the real-time rule.
for step in 1..=60 {
let target = last + 1;
let window: Vec<i64> = (target..target + ahead).collect();
gate.render(&mut h, cid, &window);
// Set the clock to this step's exact time rather than adding 1/30 repeatedly: accumulated
// f64 error can put one step just below a frame boundary and the next just above it, so
// `floor(elapsed × fps)` would otherwise jump from frame N to N + 2 for a one-frame step.
let now = h.state().now();
let tick_time = start + step as f64 / 30.0;
h.state_mut().clock_mut().advance(tick_time - now);
h.step();
if h.state().audio.is_some() {
break;
}
let f = frame(&h);
assert!(f == last || f == last + 1, "{last} → {f}: a frame was skipped");
shown += usize::from(f != last);
last = f;
std::thread::sleep(std::time::Duration::from_millis(20));
}
assert!(h.state().audio.is_some() && fake.0.lock().unwrap().0.is_some(), "the sound started within 2 s, at frame {last} of 900");
assert!(shown >= 3, "the sound starts only after the silent preview kept up for a moment");
}
@@ -0,0 +1,244 @@
//! A Timeline row control clicked on one of several selected layers applies to the whole
//! selection (#1849), as in After Effects: the A/V Features and Switches columns, the label
//! swatch and the Modes column. The engine commands already took several layers; the row
//! controls used to pass the clicked layer alone.
use effectcraft_engine::Session;
use effectcraft_engine::color::{BlendMode, Label};
use effectcraft_engine::project::{Layer, LayerId, Switches};
use effectcraft_ui_egui::EffectcraftApp;
use egui::{Event, Modifiers, Pos2, Rect, pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
/// Comp "Main" with solids A (top) to D (bottom); returns their ids top to bottom.
fn harness() -> (Harness<'static, EffectcraftApp>, Vec<u64>) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap();
let mut ids: Vec<u64> = ["D", "C", "B", "A"]
.iter()
.map(|name| s.execute("layer.newSolid", json!({"name": name, "color": "#406080"})).unwrap()["layer"].as_u64().unwrap())
.collect();
ids.reverse();
s.execute("edit.deselectAll", json!({})).unwrap();
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
(h, ids)
}
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone();
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))
}
/// Press on `from`, move to `to` in `steps`, release there.
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, steps: u32) {
h.event(Event::PointerMoved(from));
h.step();
h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
for k in 1..=steps {
h.event(Event::PointerMoved(from + (to - from) * (k as f32 / steps as f32)));
h.step();
}
h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
/// A click on the Timeline control `id`.
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
let p = rect(h, id).center();
drag(h, p, p, 1);
}
/// A click at `p` (a menu entry, found by its label).
fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
drag(h, p, p, 1);
}
fn alt_click(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
let p = rect(h, id).center();
let modifiers = Modifiers::ALT;
h.event(Event::PointerMoved(p));
h.event(Event::ModifiersChanged(modifiers));
h.step();
h.event(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers });
h.step();
h.event(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers });
h.event(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(3);
}
/// Select `ids` in the Timeline, as a Shift range or a Cmd toggle would.
fn select(h: &mut Harness<'_, EffectcraftApp>, ids: &[u64]) {
h.state_mut().session.execute("layer.select", json!({"layers": ids})).unwrap();
h.run_steps(2);
}
/// The state of switch `f` on each layer of `ids`, top to bottom.
fn states(h: &Harness<'_, EffectcraftApp>, ids: &[u64], f: fn(&Layer) -> bool) -> Vec<bool> {
let comp = h.state().session.active_comp().unwrap();
ids.iter().map(|id| f(comp.layer(LayerId(*id)).unwrap())).collect()
}
fn switches(h: &Harness<'_, EffectcraftApp>, id: u64) -> Switches {
h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().switches.clone()
}
fn locked(l: &Layer) -> bool {
l.switches.locked
}
fn video(l: &Layer) -> bool {
l.switches.video
}
fn three_d(l: &Layer) -> bool {
l.switches.three_d
}
fn solo(l: &Layer) -> bool {
l.switches.solo
}
fn shy(l: &Layer) -> bool {
l.switches.shy
}
fn motion_blur(l: &Layer) -> bool {
l.switches.motion_blur
}
fn preserved(l: &Layer) -> bool {
l.preserve_transparency
}
/// #1849: a click on the Lock, 3D, Solo, Shy or Motion Blur switch of a selected layer gives the
/// switch to every selected layer, in one undo step.
#[test]
fn switches_on_a_selected_row_set_the_whole_selection() {
let (mut h, ids) = harness();
select(&mut h, &[ids[0], ids[2]]);
let steps = h.state().session.history.undo.len();
click(&mut h, &format!("timeline.layer.{}.lock", ids[0]));
assert_eq!(states(&h, &ids, locked), [true, false, true, false]);
assert_eq!(h.state().session.history.undo.len(), steps + 1, "one undo step");
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.run_steps(2);
assert_eq!(states(&h, &ids, locked), [false; 4]);
click(&mut h, &format!("timeline.layer.{}.switch.threeD", ids[0]));
assert_eq!(states(&h, &ids, three_d), [true, false, true, false]);
// The switch of the other selected layer acts the same way.
click(&mut h, &format!("timeline.layer.{}.solo", ids[2]));
assert_eq!(states(&h, &ids, solo), [true, false, true, false]);
click(&mut h, &format!("timeline.layer.{}.switch.shy", ids[2]));
assert_eq!(states(&h, &ids, shy), [true, false, true, false]);
click(&mut h, &format!("timeline.layer.{}.switch.motionBlur", ids[0]));
assert_eq!(states(&h, &ids, motion_blur), [true, false, true, false]);
// A second click turns both off again, whatever their own state.
click(&mut h, &format!("timeline.layer.{}.switch.threeD", ids[2]));
assert_eq!(states(&h, &ids, three_d), [false; 4]);
}
/// A switch on a row that isn't selected still changes that layer alone, and leaves the
/// selection.
#[test]
fn a_switch_on_an_unselected_row_sets_that_layer_only() {
let (mut h, ids) = harness();
select(&mut h, &[ids[0], ids[2]]);
click(&mut h, &format!("timeline.layer.{}.lock", ids[1]));
assert_eq!(states(&h, &ids, locked), [false, true, false, false]);
assert_eq!(h.state().session.state.selected_layers, vec![LayerId(ids[0]), LayerId(ids[2])]);
}
/// The targets skip the selected layers a switch doesn't apply to: 3D reaches a null but not a
/// camera (3D by kind), the Effect switch not a layer without effects.
#[test]
fn switch_targets_skip_the_layers_the_switch_does_not_apply_to() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap();
let solid = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
let null = s.execute("layer.newNull", json!({"name": "Ctrl"})).unwrap()["layer"].as_u64().unwrap();
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("effect.apply", json!({"effect": "ec.blur.gaussian", "layers": [solid]})).unwrap();
s.execute("edit.deselectAll", json!({})).unwrap();
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
select(&mut h, &[solid, null, cam]);
click(&mut h, &format!("timeline.layer.{solid}.switch.threeD"));
assert!(switches(&h, solid).three_d && switches(&h, null).three_d, "3D reaches the null");
assert!(!switches(&h, cam).three_d, "a camera is 3D by kind, its own switch stays off");
click(&mut h, &format!("timeline.layer.{solid}.switch.fx"));
assert!(!switches(&h, solid).effects, "the Effect switch reaches the layer that has an effect");
assert!(switches(&h, null).effects && switches(&h, cam).effects, "no effects, no Effect switch");
}
/// Alt-click on an A/V feature sets the selected layers apart (the others off), and the next
/// Alt-click brings the others back — with several layers, the whole selection stands apart.
#[test]
fn alt_click_sets_the_selected_layers_apart() {
let (mut h, ids) = harness();
select(&mut h, &[ids[0], ids[2]]);
alt_click(&mut h, &format!("timeline.layer.{}.video", ids[0]));
assert_eq!(states(&h, &ids, video), [true, false, true, false]);
alt_click(&mut h, &format!("timeline.layer.{}.video", ids[0]));
assert_eq!(states(&h, &ids, video), [true; 4]);
}
/// The label swatch menu of a selected layer sets the label of every selected layer.
#[test]
fn the_label_menu_sets_the_selections_label() {
let (mut h, ids) = harness();
select(&mut h, &[ids[0], ids[2]]);
let green = h.state().session.prefs.label_name(Label::Green);
click(&mut h, &format!("timeline.layer.{}.label", ids[0]));
let entry = h.query_by_label(&green).expect("the label menu is open").rect();
click_at(&mut h, entry.center());
let label = |id: u64| h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().label;
assert_eq!([label(ids[0]), label(ids[1]), label(ids[2]), label(ids[3])], [Label::Green, Label::Red, Label::Green, Label::Red]);
}
/// The Modes column: the Blending Mode menu and the Preserve Transparency checkbox of a selected
/// layer set both selected layers, one undo step each.
#[test]
fn the_modes_column_sets_the_whole_selection() {
let (mut h, ids) = harness();
h.state_mut().ui.timeline.show_modes = true;
h.run_steps(3);
select(&mut h, &[ids[0], ids[2]]);
click(&mut h, &format!("timeline.layer.{}.mode", ids[0]));
let entry = h.query_by_label("Multiply").expect("the blend mode menu is open").rect();
click_at(&mut h, entry.center());
let mode = |id: u64| h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().blend_mode;
assert_eq!([mode(ids[0]), mode(ids[1]), mode(ids[2]), mode(ids[3])], [BlendMode::Multiply, BlendMode::Normal, BlendMode::Multiply, BlendMode::Normal]);
click(&mut h, &format!("timeline.layer.{}.preserveTransparency", ids[0]));
assert_eq!(states(&h, &ids, preserved), [true, false, true, false]);
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.run_steps(2);
assert_eq!(states(&h, &ids, preserved), [false; 4], "one undo step put both back");
}
/// Preserve Transparency toggles from the row it is clicked on, not from the first selected layer:
/// A on and C off, clicking C's box turns both on.
#[test]
fn preserve_transparency_toggles_from_the_clicked_row() {
let (mut h, ids) = harness();
h.state_mut().ui.timeline.show_modes = true;
h.run_steps(3);
h.state_mut().session.execute("layer.setSwitch", json!({"layers": [ids[0]], "switch": "preserveTransparency", "value": true})).unwrap();
h.run_steps(2);
select(&mut h, &[ids[0], ids[2]]);
click(&mut h, &format!("timeline.layer.{}.preserveTransparency", ids[2]));
assert_eq!(states(&h, &ids, preserved), [true, false, true, false]);
// The next click turns both off again.
click(&mut h, &format!("timeline.layer.{}.preserveTransparency", ids[2]));
assert_eq!(states(&h, &ids, preserved), [false; 4]);
}
@@ -1,5 +1,6 @@
//! Right-click actions in the blank Project and Timeline areas, and on Timeline property names.
use effectcraft_engine::Session;
use effectcraft_engine::project::ItemKind;
use effectcraft_ui_egui::{Dialog, EffectcraftApp, dock::PanelKind};
use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2};
use egui_kittest::{Harness, kittest::Queryable as _};
@@ -64,7 +65,7 @@ fn timeline_blank_outline_and_time_graph_create_layers() {
let area = rect(&h, "timeline.layerMarquee");
let p = if graph { pos2(area.max.x + 150.0, area.center().y) } else { area.center() };
right_click(&mut h, p);
h.get_by_label("New ⏵").click();
h.get_by_label("New").click();
h.run_steps(3);
snapshot(&mut h, if graph { "timeline-graph" } else { "timeline-outline" });
h.get_by_label("Null Object").click();
@@ -80,7 +81,7 @@ fn timeline_blank_menu_opens_solid_settings() {
let mut h = harness();
let p = rect(&h, "timeline.layerMarquee").center();
right_click(&mut h, p);
h.get_by_label("New ⏵").click();
h.get_by_label("New").click();
h.run_steps(3);
h.get_by_label("Solid…").click();
h.run_steps(3);
@@ -251,13 +252,13 @@ fn timeline_layer_context_menu_has_the_effects_submenu_and_applies_an_effect() {
let r = rect(&h, &format!("timeline.layer.{layer}.row"));
right_click(&mut h, pos2(r.min.x + 210.0, r.center().y));
// The palette sits between Blending Mode and Arrange, as After Effects' layer menu does.
let blending = h.get_by_label("Blending Mode ⏵").rect();
let effects = h.get_by_label("Effects ⏵").rect();
let arrange = h.get_by_label("Arrange ⏵").rect();
let blending = h.get_by_label("Blending Mode").rect();
let effects = h.get_by_label("Effects").rect();
let arrange = h.get_by_label("Arrange").rect();
assert!(blending.bottom() <= effects.top() && effects.bottom() <= arrange.top(), "Effects between Blending Mode and Arrange");
h.get_by_label("Effects ⏵").click();
h.get_by_label("Effects").click();
h.run_steps(3);
h.get_by_label("Blur & Sharpen ⏵").click();
h.get_by_label("Blur & Sharpen").click();
h.run_steps(3);
h.get_by_label("Gaussian Blur").click();
h.run_steps(3);
@@ -282,11 +283,11 @@ fn timeline_layer_context_menu_has_layer_styles_submenu() {
h.run_steps(3);
let r = rect(&h, &format!("timeline.layer.{solid}.row"));
right_click(&mut h, pos2(r.min.x + 210.0, r.center().y));
let blending = h.get_by_label("Blending Mode ⏵").rect();
let styles = h.get_by_label("Layer Styles ⏵").rect();
let effects = h.get_by_label("Effects ⏵").rect();
let blending = h.get_by_label("Blending Mode").rect();
let styles = h.get_by_label("Layer Styles").rect();
let effects = h.get_by_label("Effects").rect();
assert!(blending.bottom() <= styles.top() && styles.bottom() <= effects.top(), "Layer Styles between Blending Mode and Effects");
h.get_by_label("Layer Styles ⏵").click();
h.get_by_label("Layer Styles").click();
h.run_steps(3);
assert!(h.query_by_label("Layer Style Options...").is_some());
assert!(h.query_by_label("Drop Shadow").is_some());
@@ -299,6 +300,43 @@ fn timeline_layer_context_menu_has_layer_styles_submenu() {
assert!(l.layer_styles().is_some_and(|g| g.sub("dropShadow").is_some()));
}
/// #1809: Reveal Layer Source in Project shows the source in a large project: it clears the
/// Project panel's search, opens the folders holding the item and selects it.
#[test]
fn timeline_layer_context_menu_reveals_the_source_in_project() {
let mut h = harness();
let layer = h.state_mut().session.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
let solid = h.state().session.project.items.values().find(|i| matches!(i.kind, ItemKind::Solid(_))).unwrap().id;
let outer = h.state_mut().session.execute("project.newFolder", json!({"name": "Assets"})).unwrap()["item"].as_u64().unwrap();
let inner = h.state_mut().session.execute("project.newFolder", json!({"name": "Plates"})).unwrap()["item"].as_u64().unwrap();
h.state_mut().session.execute("project.move", json!({"items": [inner], "folder": outer})).unwrap();
h.state_mut().session.execute("project.move", json!({"items": [solid.0], "folder": inner})).unwrap();
h.state_mut().session.execute("project.select", json!({"items": []})).unwrap();
h.state_mut().ui.project_open_folders.clear();
h.state_mut().ui.project_search = "no such item".into();
h.run_steps(3);
assert!(h.state().auto.find(&format!("project.item.{}", solid.0)).is_none(), "the source starts hidden");
let r = rect(&h, &format!("timeline.layer.{layer}.row"));
right_click(&mut h, pos2(r.min.x + 210.0, r.center().y));
h.get_by_label("Reveal Layer Source in Project").click();
h.run_steps(3);
assert_eq!(h.state().session.state.project_selection, vec![solid]);
assert_eq!(h.state().ui.focused, PanelKind::Project);
assert!(h.state().ui.project_search.is_empty(), "the search is cleared");
assert!([outer, inner].iter().all(|folder| h.state().ui.project_open_folders.contains(folder)), "the folders holding the source open");
assert!(h.state().auto.find(&format!("project.item.{}", solid.0)).is_some(), "the source row is visible");
// In a mixed selection the command still appears when the right-clicked layer has no source,
// because the menu acts on the solid layer too.
let shape = h.state_mut().session.execute("layer.newShape", json!({"kind": "none"})).unwrap()["layer"].as_u64().unwrap();
h.state_mut().session.execute("layer.select", json!({"layers": [shape, layer]})).unwrap();
h.run_steps(3);
let r = rect(&h, &format!("timeline.layer.{shape}.row"));
right_click(&mut h, pos2(r.min.x + 210.0, r.center().y));
h.get_by_label("Reveal Layer Source in Project").click();
h.run_steps(3);
assert_eq!(h.state().session.state.project_selection, vec![solid], "the solid's source, not the shape");
}
/// #976: a layer's context menu has After Effects' Markers ▸ Add Marker, which marks the layer that
/// was right-clicked (not the one selected before, and not the composition) at the current time.
#[test]
@@ -313,7 +351,7 @@ fn layer_context_menu_adds_a_layer_marker() {
h.run_steps(3);
let r = rect(&h, &format!("timeline.layer.{top}.row"));
right_click(&mut h, pos2(r.min.x + 210.0, r.center().y));
h.get_by_label("Markers ⏵").click();
h.get_by_label("Markers").click();
h.run_steps(3);
snapshot(&mut h, "layer-markers");
h.get_by_label("Add Marker").click();
@@ -175,6 +175,7 @@ fn viewer_zooms_and_pans_over_the_track_points() {
delta: egui::vec2(0.0, 120.0),
modifiers: Default::default(),
phase: egui::TouchPhase::Move,
source: egui::MouseWheelSource::Wheel,
});
h.run_steps(20);
assert_ne!(h.state().ui.viewer.zoom, zoom, "the wheel zooms over the track points");
@@ -18,6 +18,8 @@ fn frames(app: &mut EffectcraftApp) -> Vec<(bool, egui::Modifiers)> {
if raw.events.is_empty() {
break;
}
// The harness feeds the batch itself; a real UI pass would apply it now.
app.mark_synthetic_applied();
let mut button = false;
for e in &raw.events {
match e {
@@ -34,7 +36,26 @@ fn frames(app: &mut EffectcraftApp) -> Vec<(bool, egui::Modifiers)> {
fn send(app: &mut EffectcraftApp, method: &str, params: Value) {
let ctx = egui::Context::default();
let (req, _rx) = ControlRequest::new(method, params);
let _ = control::handle(app, &ctx, &req);
let now = app.now();
let _ = control::handle(app, &ctx, &req, now);
}
#[test]
fn control_gestures_reject_excessive_counts_without_queue_mutation() {
let mut app = EffectcraftApp::new(Session::default());
send(&mut app, "ui.click", json!({"x": 10, "y": 10, "count": u64::MAX}));
assert!(app.take_synthetic_input().is_empty(), "an oversized click must not partially queue input");
send(&mut app, "ui.drag", json!({"from": {"x": 10, "y": 10}, "to": {"x": 90, "y": 40}, "steps": u64::MAX}));
assert!(app.take_synthetic_input().is_empty(), "an oversized drag must not partially queue input");
send(&mut app, "ui.click", json!({"x": 10, "y": 10, "count": 512}));
send(&mut app, "ui.click", json!({"x": 10, "y": 10, "count": 512}));
assert_eq!(app.take_synthetic_input().len(), 2054, "two maximum accepted clicks fit under the aggregate cap");
for _ in 0..4 {
send(&mut app, "ui.click", json!({"x": 10, "y": 10, "count": 512}));
}
assert_eq!(app.take_synthetic_input().len(), 3081, "the pending event limit is enforced before requests can exceed it");
}
#[test]
@@ -54,7 +54,7 @@ fn focused_dock_resize_owns_arrows_and_keeps_pointer_dragging() {
h.run_steps(3);
let before_project = h.state().session.project.clone();
let before_times = h.state().session.state.times.clone();
let id = Id::new(("dock-gutter", String::new()));
let id = Id::unique(("dock-gutter", String::new()));
h.ctx.memory_mut(|memory| memory.request_focus(id));
h.run_steps(2);
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(id));
@@ -108,6 +108,47 @@ fn project_items_land_where_they_are_dropped_in_the_timeline() {
assert!((in_point(&h, 3) - 1.0).abs() < 1e-9, "Shift starts it at the current time");
}
/// Alt/Option-drag from the Project panel replaces the source of the layer under the pointer
/// instead of adding a layer (#991).
#[test]
fn alt_dragging_a_project_item_replaces_the_layer_source() {
let (mut h, clip) = harness();
let top = layer_id(&h, "Top");
let item = rect(&h, &format!("project.item.{clip}.name")).center();
let row = rect(&h, &format!("timeline.layer.{top}.row"));
let before = stack(&h);
drag(&mut h, item, row.center(), Modifiers::ALT);
assert_eq!(stack(&h), before, "no layer is added");
let layer = h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(top)).unwrap();
assert_eq!(layer.source, effectcraft_engine::project::LayerSource::Comp { item: effectcraft_engine::project::ItemId(clip) });
assert_eq!(layer.name, "Top", "the layer keeps its name");
}
/// Ctrl+Alt+/ (Cmd+Opt+/ on macOS) replaces every selected layer's source with the Project panel
/// selection (#991).
#[test]
fn ctrl_alt_slash_replaces_every_selected_layer_source() {
let (mut h, clip) = harness();
let top = layer_id(&h, "Top");
let middle = layer_id(&h, "Middle");
h.state_mut().session.execute("layer.select", json!({"layers": [top, middle]})).unwrap();
h.state_mut().session.state.project_selection = vec![effectcraft_engine::project::ItemId(clip)];
h.run_steps(3);
let modifiers = Modifiers { command: true, alt: true, ..Default::default() };
for pressed in [true, false] {
h.event(Event::Key { key: egui::Key::Slash, physical_key: Some(egui::Key::Slash), pressed, repeat: false, modifiers });
}
h.run_steps(3);
let comp = h.state().session.active_comp().unwrap();
for lid in [top, middle] {
assert_eq!(
comp.layer(effectcraft_engine::project::LayerId(lid)).unwrap().source,
effectcraft_engine::project::LayerSource::Comp { item: effectcraft_engine::project::ItemId(clip) }
);
}
assert_eq!(stack(&h), ["Top", "Middle", "Bottom"], "no layer is added");
}
/// #85: a Project item dropped on the Composition viewer becomes a layer centred where it was
/// dropped (above the selected layer, like any new layer).
#[test]
@@ -421,7 +421,7 @@ fn right_click_in_effect_controls_shows_the_effect_menu() {
app.show_panel(PanelKind::EffectControls);
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app);
h.run_steps(3);
let category = " Blur & Sharpen ⏵";
let category = " Blur & Sharpen";
// An effect's header: its own menu.
let header = rect_of(&h, &format!("effectControls.effect.{fill}")).center();
right_click(&mut h, header);
@@ -778,6 +778,23 @@ fn effect_control_name_can_be_renamed_from_its_header() {
assert_eq!(current_name(&h), "Point Control");
}
// Regression test for #1497: double-clicking an effect parameter's name in Effect Controls did
// nothing; it should reveal that property in the Timeline, as After Effects does.
#[test]
fn double_clicking_an_effect_property_name_reveals_it_in_the_timeline() {
let (app, x) = app();
let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| app);
settle(&mut h);
let row = format!("timeline.prop.{}.name", x.point);
assert!(!ids(&h).contains(&row), "the effect's property is not in the Timeline before the double-click");
assert!(!h.state().ui.timeline.reveal_props.contains(&x.point));
let point = rect_of(&h, &format!("effectControls.prop.{}.name", x.point)).center();
double_click(&mut h, point);
h.run_steps(4);
assert!(h.state().ui.timeline.reveal_props.contains(&x.point), "the property is marked revealed in the Timeline");
assert!(ids(&h).contains(&row), "the Timeline shows the property's row");
}
#[test]
fn effect_rename_escape_keeps_the_name() {
let (app, x) = app();
@@ -32,7 +32,9 @@ fn harness(language: &str) -> (Harness<'static, EffectcraftApp>, u64) {
app.show_panel(PanelKind::EffectControls);
app.show_panel(PanelKind::EffectsPresets);
app.show_panel(PanelKind::Timeline);
let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(|_| app);
// The Chinese/Japanese names under test have no installed face on every host: draw tofu
// instead of failing the missing-glyph gate (the glyph coverage test covers the bundled set).
let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).allow_missing_glyphs().build_eframe(|_| app);
h.run_steps(4);
(h, fx)
}
@@ -12,7 +12,9 @@ fn harness() -> (Harness<'static, EffectcraftApp>, u64) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4, "fps": 24})).unwrap();
let comp = s.active_comp_id().unwrap().0;
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
// Arabic, Hebrew, Devanagari and Thai names typed below need installed system faces: draw
// tofu on a host without them rather than failing the missing-glyph gate.
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).allow_missing_glyphs().build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(4);
(h, comp)
}
@@ -540,6 +542,98 @@ fn arrow_keys_move_the_caret_in_rename_fields() {
assert_eq!(layer_name, "Pla-te");
}
/// A solid named `name` with its Transform group open in the Timeline; returns the layer, its
/// Opacity uid and its Rotation uid.
fn open_transform(h: &mut Harness<'_, EffectcraftApp>, name: &str) -> (u64, u64, u64) {
let (layer, opacity, rotation, transform) = {
let s = &mut h.state_mut().session;
let l = s.execute("layer.newSolid", json!({"name": name, "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
let tr = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(l)).unwrap().props.sub("transform").unwrap().clone();
(l, tr.get("opacity").unwrap().uid, tr.get("rotation").unwrap().uid, tr.uid)
};
h.state_mut().ui.timeline.open_layers.insert(layer);
h.state_mut().ui.timeline.open_groups.insert(transform);
h.run_steps(3);
(layer, opacity, rotation)
}
fn expression(h: &Harness<'_, EffectcraftApp>, layer: u64, prop: u64) -> Option<String> {
let comp = h.state().session.active_comp().unwrap();
comp.layer(effectcraft_engine::project::LayerId(layer)).unwrap().props.find(prop).unwrap().expr.as_ref().map(|e| e.text.clone())
}
fn commit_expression(h: &mut Harness<'_, EffectcraftApp>) {
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::COMMAND));
h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND });
h.step();
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(3);
}
/// #1072: Alt-clicking a stopwatch adds an expression and its editor takes the keyboard at once,
/// the new expression selected, so typing replaces it (it took another click). That happens
/// once: a field clicked next keeps the keyboard, and removing the expression with another
/// Alt-click leaves nothing to take it.
#[test]
fn alt_clicking_a_stopwatch_focuses_the_new_expression() {
let (mut h, _) = harness();
let (layer, opacity, rotation) = open_transform(&mut h, "Plate");
let alt = Modifiers { alt: true, ..Modifiers::NONE };
let stopwatch = |h: &Harness<'_, EffectcraftApp>, prop: u64| center(h, &format!("timeline.prop.{prop}.stopwatch"));
let at = stopwatch(&h, opacity);
click_with(&mut h, at, alt);
assert!(expression(&h, layer, opacity).is_some(), "an expression was added");
type_text(&mut h, "50");
commit_expression(&mut h);
assert_eq!(expression(&h, layer, opacity).as_deref(), Some("50"), "typing replaced the new expression");
// A field clicked right after an Alt-click keeps the keyboard.
let at = stopwatch(&h, rotation);
click_with(&mut h, at, alt);
let added = expression(&h, layer, rotation).expect("an expression on Rotation");
let p = center(&h, "timeline.timecode");
click(&mut h, p, 2);
type_text(&mut h, "0:00:01:00");
key(&mut h, Key::Enter);
let frame = h.state().session.active_comp().unwrap().frame_rate.frame_at(h.state().session.time());
assert_eq!((frame, expression(&h, layer, rotation)), (24, Some(added)), "the time took the typing, not the expression");
// Alt-click again removes the expression; nothing takes the keyboard after it.
let at = stopwatch(&h, opacity);
click_with(&mut h, at, alt);
h.run_steps(5);
assert_eq!(expression(&h, layer, opacity), None);
assert!(!h.ctx.egui_wants_keyboard_input(), "nothing has the keyboard");
}
/// #1072: an expression added at the bottom of the Timeline brings its editor into view to take
/// the keyboard (its row is added below the last one shown).
#[test]
fn an_expression_added_at_the_bottom_of_the_timeline_comes_into_view() {
let (mut h, _) = harness();
let (layer, opacity, _) = open_transform(&mut h, "Bottom");
for i in 0..40 {
h.state_mut().session.execute("layer.newSolid", json!({"name": format!("Layer {i}"), "color": "#406080"})).unwrap();
}
h.run_steps(3);
// Scroll to the end (the layer is at the bottom of the stack), then until Opacity is the
// last row shown.
h.state_mut().ui.timeline.scroll_y = 1.0e6;
h.run_steps(3);
let rows = h.state().auto.find("timeline.scroll").unwrap().rect;
let bottom = rows[1] + rows[3];
let row = h.state().auto.find(&format!("timeline.prop.{opacity}.row")).unwrap().rect;
h.state_mut().ui.timeline.scroll_y += row[1] + row[3] - bottom;
h.run_steps(3);
let row = h.state().auto.find(&format!("timeline.prop.{opacity}.row")).unwrap().rect;
assert!((row[1] + row[3] - bottom).abs() < 1.0, "Opacity is at the bottom: {row:?}");
let at = center(&h, &format!("timeline.prop.{opacity}.stopwatch"));
click_with(&mut h, at, Modifiers { alt: true, ..Modifiers::NONE });
let editor = h.state().auto.find(&format!("timeline.prop.{opacity}.expression")).expect("the editor is drawn").rect;
assert!(editor[1] >= rows[1] - 0.5 && editor[1] + editor[3] <= bottom + 0.5, "the editor is in view: {editor:?} in {rows:?}");
type_text(&mut h, "25");
commit_expression(&mut h);
assert_eq!(expression(&h, layer, opacity).as_deref(), Some("25"));
}
/// #1434: with several 3D layers selected, typing a Z Position sets Z on each of them; their X
/// and Y stay their own. Every selected layer used to take the typed layer's whole Position, so
/// they stacked on top of it and seemed to disappear.
@@ -65,7 +65,7 @@ fn comp_thumbnail_renders_off_the_ui_thread() {
for _ in 0..400 {
h.step();
if h.state().auto.find("project.thumbnail").is_some()
&& h.ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(egui::Id::new(("proj-thumb", main.0)))).is_some()
&& h.ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(egui::Id::unique(("proj-thumb", main.0)))).is_some()
{
landed = true;
break;
@@ -111,6 +111,7 @@ fn wheel_zooms_around_the_pointer_and_hand_tool_pans() {
delta: vec2(0.0, 120.0),
modifiers: Default::default(),
phase: egui::TouchPhase::Move,
source: egui::MouseWheelSource::Wheel,
});
h.run_steps(20);
let after = rect(&h, "layerPanel.canvas");
@@ -0,0 +1,215 @@
//! Picking a layer-valued property with the shared drop-down: the Timeline draws a layer-valued
//! Light Options property (an Environment light's Source) as a drop-down of the comp's layers,
//! Effect Controls' layer parameters (a Displacement Map's layer) pick through the same widget,
//! and both write an undoable `prop.set`.
use effectcraft_engine::Session;
use effectcraft_engine::keyframe::Value;
use effectcraft_engine::project::LayerId;
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::dock::PanelKind;
use egui::{Pos2, Rect, pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::NodeT;
use egui_kittest::kittest::Queryable;
use serde_json::json;
fn click(h: &mut Harness<'_, EffectcraftApp>, at: Pos2) {
h.event(egui::Event::PointerMoved(at));
h.step();
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(3);
}
fn rect_of(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no automation element {id}")).clone();
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))
}
/// Whether `want` is painted inside `row` (the drop-down's current-value text).
fn painted_in(h: &Harness<'_, EffectcraftApp>, row: Rect, want: &str) -> bool {
h.output().shapes.iter().any(|c| match &c.shape {
egui::Shape::Text(t) => t.galley.text() == want && row.expand(3.0).contains(t.pos),
_ => false,
})
}
/// The current value of a layer-valued property, by uid.
fn layer_value(h: &Harness<'_, EffectcraftApp>, layer: u64, prop: u64) -> Option<u64> {
let comp = h.state().session.active_comp().unwrap();
match &comp.layer(LayerId(layer)).unwrap().props.find(prop).unwrap().value {
Value::Layer(v) => *v,
other => panic!("property {prop} is not layer-valued: {other:?}"),
}
}
/// The "N. name" a drop-down shows for a layer.
fn entry_for(h: &Harness<'_, EffectcraftApp>, layer: u64) -> String {
let comp = h.state().session.active_comp().unwrap();
let i = comp.layers.iter().position(|l| l.id.0 == layer).unwrap();
format!("{}. {}", i + 1, comp.layers[i].name)
}
/// Puts the layer-valued `prop` of `layer` back to `value` with a direct `prop.set` (no merge key,
/// so the pick below records its own step instead of folding into a picker write), picks `none`
/// from the drop-down at `value_id`, and checks the clear takes one undo step that restores `value`.
#[track_caller]
fn none_pick_clears(h: &mut Harness<'_, EffectcraftApp>, value_id: &str, none: &str, layer: u64, prop: u64, value: u64) {
h.state_mut().session.execute("prop.set", json!({"layer": layer, "prop": prop, "value": value})).unwrap();
h.run_steps(2);
assert_eq!(layer_value(h, layer, prop), Some(value));
let steps = h.state().session.history.undo.len();
let at = rect_of(h, value_id).center();
click(h, at);
h.get_by_role_and_label(egui::accesskit::Role::Button, none).click();
h.run_steps(3);
assert_eq!(layer_value(h, layer, prop), None, "selecting None clears the value");
assert_eq!(h.state().session.history.undo.len(), steps + 1, "one clear undo step");
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.run_steps(2);
assert_eq!(layer_value(h, layer, prop), Some(value), "undo restores the selected layer");
}
/// A light's Source (a layer-valued Light Options property) picks a comp layer from the
/// Timeline and undo restores None.
#[test]
fn timeline_source_drop_down_picks_the_layer_and_undo_restores_none() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "frameRate": 30, "duration": 4})).unwrap();
s.execute("comp.settings", json!({"renderer": "advanced3D"})).unwrap();
let sky = s.execute("layer.newSolid", json!({"name": "Sky HDRI", "color": "#66aaff"})).unwrap()["layer"].as_u64().unwrap();
let light = s.execute("layer.newLight", json!({"kind": "Environment", "name": "Env Light"})).unwrap()["layer"].as_u64().unwrap();
let (group, source) = {
let comp = s.active_comp().unwrap();
let opts = comp.layer(LayerId(light)).unwrap().props.sub("lightOptions").unwrap();
(opts.uid, opts.get("source").unwrap().uid)
};
let mut app = EffectcraftApp::new(s);
app.ui.timeline.open_layers.insert(light);
app.ui.timeline.open_groups.insert(group);
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| app);
h.run_steps(4);
let value_id = format!("timeline.prop.{source}.value");
// Unset: the row is a drop-down reading "None" — an element to click, not bare text.
assert_eq!(layer_value(&h, light, source), None);
assert!(painted_in(&h, rect_of(&h, &value_id), "None"), "the Source row shows a None drop-down");
// Opening it lists "None" and every layer of the comp, by index and name.
let at = rect_of(&h, &value_id).center();
click(&mut h, at);
let entry = entry_for(&h, sky);
assert!(h.query_by_role_and_label(egui::accesskit::Role::Button, "None").is_some(), "the list starts with None");
assert!(h.query_by_role_and_label(egui::accesskit::Role::Button, &entry).is_some(), "the comp's layers are listed ({entry})");
let steps = h.state().session.history.undo.len();
h.get_by_role_and_label(egui::accesskit::Role::Button, &entry).click();
h.run_steps(3);
assert_eq!(layer_value(&h, light, source), Some(sky), "the Source holds the layer id");
assert_eq!(h.state().session.history.undo.len(), steps + 1, "one undo step");
// The row names the layer (index and name), not its internal id.
assert!(painted_in(&h, rect_of(&h, &value_id), &entry), "the Source row shows the layer's name");
// Dismissing the list without choosing writes nothing; reopening marks the chosen layer as
// the current entry (and "None" as not current).
click(&mut h, pos2(1500.0, 300.0));
h.run_steps(2);
assert_eq!(layer_value(&h, light, source), Some(sky), "dismissing the list writes nothing");
let reopen = rect_of(&h, &value_id).center();
click(&mut h, reopen);
assert_eq!(
h.get_by_role_and_label(egui::accesskit::Role::Button, &entry).accesskit_node().toggled(),
Some(egui::accesskit::Toggled::True),
"the current entry is highlighted"
);
assert_eq!(
h.get_by_role_and_label(egui::accesskit::Role::Button, "None").accesskit_node().toggled(),
Some(egui::accesskit::Toggled::False),
"None is not the current entry"
);
click(&mut h, pos2(1500.0, 300.0));
h.run_steps(2);
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.run_steps(2);
assert_eq!(layer_value(&h, light, source), None, "undo restores None");
none_pick_clears(&mut h, &value_id, "None", light, source, sky);
}
/// The shared widget is the one Effect Controls already used: a Displacement Map's layer
/// parameter still picks from the drop-down and still undoes.
#[test]
fn effect_controls_layer_param_picks_through_the_shared_widget() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "frameRate": 30, "duration": 4})).unwrap();
let map = s.execute("layer.newSolid", json!({"name": "Disp", "color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
let base = s.execute("layer.newSolid", json!({"name": "Base", "color": "#20c040"})).unwrap()["layer"].as_u64().unwrap();
let fx = s.execute("effect.apply", json!({"layers": [base], "effect": "Displacement Map"})).unwrap()["effects"][0].as_u64().unwrap();
let prop = {
let comp = s.active_comp().unwrap();
comp.layer(LayerId(base)).unwrap().effects().unwrap().find_group(fx).unwrap().get("displacementMapLayer").unwrap().uid
};
s.state.selected_layers = vec![LayerId(base)];
let mut app = EffectcraftApp::new(s);
app.show_panel(PanelKind::EffectControls);
let mut h = Harness::builder().with_size(vec2(1600.0, 1100.0)).build_eframe(|_| app);
h.run_steps(4);
let value_id = format!("effectControls.prop.{prop}.value");
let row = rect_of(&h, &value_id);
click(&mut h, row.center());
let entry = entry_for(&h, map);
assert!(h.query_by_role_and_label(egui::accesskit::Role::Button, &entry).is_some(), "the comp's layers are listed ({entry})");
let steps = h.state().session.history.undo.len();
h.get_by_role_and_label(egui::accesskit::Role::Button, &entry).click();
h.run_steps(3);
assert_eq!(layer_value(&h, base, prop), Some(map), "the parameter holds the layer id");
assert_eq!(h.state().session.history.undo.len(), steps + 1, "one undo step");
// The row names the picked layer, as the Timeline's Source row does.
assert!(painted_in(&h, rect_of(&h, &value_id), &entry), "the parameter row shows the layer's name");
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.run_steps(2);
assert_eq!(layer_value(&h, base, prop), None, "undo restores None");
none_pick_clears(&mut h, &value_id, "None", base, prop, map);
}
/// The visual record (the `ui_3d` ignored-snapshot convention): the Source drop-down open over
/// the layer list, and the row after a layer is chosen. Renders with wgpu when `EC_SNAPSHOT_DIR`
/// is set: `EC_SNAPSHOT_DIR=shots cargo test -p effectcraft-ui-egui --test it ui_layer_source_picker:: -- --ignored`.
#[test]
#[ignore = "renders with wgpu; run with --ignored (set EC_SNAPSHOT_DIR to keep PNGs)"]
fn snapshot_source_dropdown_open_and_chosen() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 1280, "height": 720, "frameRate": 30, "duration": 5})).unwrap();
s.execute("comp.settings", json!({"renderer": "advanced3D"})).unwrap();
let sky = s.execute("layer.newSolid", json!({"name": "Sky HDRI", "color": "#66aaff"})).unwrap()["layer"].as_u64().unwrap();
let light = s.execute("layer.newLight", json!({"kind": "Environment", "name": "Env Light"})).unwrap()["layer"].as_u64().unwrap();
let (group, source) = {
let comp = s.active_comp().unwrap();
let opts = comp.layer(LayerId(light)).unwrap().props.sub("lightOptions").unwrap();
(opts.uid, opts.get("source").unwrap().uid)
};
let mut app = EffectcraftApp::new(s);
app.ui.timeline.open_layers.insert(light);
app.ui.timeline.open_groups.insert(group);
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_pixels_per_point(2.0).wgpu().build_eframe(|_| app);
h.run_steps(4);
let save = |h: &mut Harness<'_, EffectcraftApp>, name: &str| {
if let Ok(dir) = std::env::var("EC_SNAPSHOT_DIR") {
h.render().expect("render").save(format!("{dir}/{name}")).expect("save");
}
};
save(&mut h, "source_unset.png");
let at = rect_of(&h, &format!("timeline.prop.{source}.value")).center();
click(&mut h, at);
save(&mut h, "source_dropdown_open.png");
let entry = entry_for(&h, sky);
h.get_by_role_and_label(egui::accesskit::Role::Button, &entry).click();
h.run_steps(3);
assert_eq!(layer_value(&h, light, source), Some(sky));
save(&mut h, "source_chosen.png");
}
@@ -0,0 +1,201 @@
//! Light Settings dialog (Layer ▸ New ▸ Light, Layer Settings on a light): every light type the
//! engine has, Environment included, and an existing light's type kept when it's reopened (#1588).
use effectcraft_engine::Session;
use effectcraft_engine::project::{Layer, LayerId, LayerSource, LightKind};
use effectcraft_ui_egui::{Dialog, EffectcraftApp};
use egui_kittest::{Harness, kittest::Queryable as _};
use serde_json::{Value, json};
fn harness() -> Harness<'static, EffectcraftApp> {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Lights", "width": 640, "height": 360, "duration": 2})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.step();
h
}
/// An automation element's rect in the last frame drawn (a dialog settles its place in its
/// first frames, so the frame before can be stale).
fn element(h: &Harness<'_, EffectcraftApp>, id: &str) -> Option<effectcraft_ui_egui::automation::Element> {
let auto = &h.state().auto;
auto.elements.iter().chain(auto.previous.iter()).find(|e| e.id == id).cloned()
}
fn click_id(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
let [x, y, w, hh] = element(h, id).unwrap_or_else(|| panic!("{id} is registered")).rect;
let p = egui::pos2(x + w / 2.0, y + hh / 2.0);
h.input_mut().events.push(egui::Event::PointerMoved(p));
h.step();
h.input_mut().events.push(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
h.step();
h.input_mut().events.push(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
h.run_steps(3);
}
fn invoke(h: &mut Harness<'_, EffectcraftApp>, id: &str, params: Value) {
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, id, params).unwrap();
h.run_steps(2);
}
/// Picks the dialog's Light Type `kind` from its menu.
fn choose(h: &mut Harness<'_, EffectcraftApp>, kind: LightKind) {
click_id(h, "dialog.light.type");
let i = LightKind::ALL.iter().position(|k| *k == kind).unwrap();
click_id(h, &format!("dialog.light.typeItem.{i}"));
}
fn ok(h: &mut Harness<'_, EffectcraftApp>) {
click_id(h, "dialog.light.ok");
assert_eq!(h.state().dialog, None);
}
fn layer(h: &Harness<'_, EffectcraftApp>, id: u64) -> Layer {
h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().clone()
}
fn kind(h: &Harness<'_, EffectcraftApp>, id: u64) -> LightKind {
match layer(h, id).source {
LayerSource::Light { kind } => kind,
other => panic!("not a light: {other:?}"),
}
}
fn last_layer(h: &Harness<'_, EffectcraftApp>) -> u64 {
h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id.0).max().unwrap()
}
/// Opens Light Settings for an existing light, as Layer Settings does.
fn reopen(h: &mut Harness<'_, EffectcraftApp>, id: u64) {
invoke(h, "layer.settings", json!({"layer": id}));
assert_eq!(h.state().dialog, Some(Dialog::LightSettings));
}
/// An Environment light lit from a solid, its image turned 30°.
fn environment_light(h: &mut Harness<'_, EffectcraftApp>) -> (u64, u64) {
let s = &mut h.state_mut().session;
let sky = s.execute("layer.newSolid", json!({"width": 256, "height": 128, "name": "Sky"})).unwrap()["layer"].as_u64().unwrap();
let env = s.execute("layer.newLight", json!({"kind": "Environment", "name": "Env", "intensity": 80})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": env, "path": "lightOptions/source", "value": sky})).unwrap();
s.execute("prop.set", json!({"layer": env, "path": "lightOptions/rotation", "value": 30})).unwrap();
h.run_steps(2);
(env, sky)
}
#[test]
fn the_light_type_menu_lists_every_light_type() {
let mut h = harness();
invoke(&mut h, "layer.newLight", json!({}));
click_id(&mut h, "dialog.light.type");
for (i, k) in LightKind::ALL.iter().enumerate() {
let item = element(&h, &format!("dialog.light.typeItem.{i}")).map(|e| e.label);
assert_eq!(item.as_deref(), Some(k.label()));
}
}
#[test]
fn new_light_creates_an_environment_light() {
let mut h = harness();
invoke(&mut h, "layer.newLight", json!({}));
choose(&mut h, LightKind::Environment);
ok(&mut h);
let id = last_layer(&h);
assert_eq!(kind(&h, id), LightKind::Environment);
let l = layer(&h, id);
assert!(l.props.prop("lightOptions/source").is_some() && l.props.prop("lightOptions/falloff").is_none());
// One undoable step, redone as Environment.
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert!(h.state().session.active_comp().unwrap().layer(LayerId(id)).is_none());
h.state_mut().session.execute("edit.redo", json!({})).unwrap();
assert_eq!(kind(&h, id), LightKind::Environment);
}
#[test]
fn reopening_an_environment_light_and_pressing_ok_keeps_it() {
let mut h = harness();
let (env, sky) = environment_light(&mut h);
let before = layer(&h, env);
reopen(&mut h, env);
ok(&mut h);
let after = layer(&h, env);
assert_eq!(kind(&h, env), LightKind::Environment, "not turned into a Spot light");
assert_eq!(after.props.prop("lightOptions/source").unwrap().value.as_layer(), Some(sky));
for path in ["lightOptions/rotation", "lightOptions/intensity"] {
assert_eq!(after.props.prop(path).unwrap().value, before.props.prop(path).unwrap().value, "{path}");
}
}
#[test]
fn cancelling_light_settings_changes_nothing() {
let mut h = harness();
let (env, _) = environment_light(&mut h);
let before = serde_json::to_value(layer(&h, env)).unwrap();
let steps = h.state().session.history.undo.len();
reopen(&mut h, env);
choose(&mut h, LightKind::Spot);
click_id(&mut h, "dialog.light.cancel");
assert_eq!(h.state().dialog, None);
assert_eq!(serde_json::to_value(layer(&h, env)).unwrap(), before);
assert_eq!(h.state().session.history.undo.len(), steps);
}
#[test]
fn changing_a_light_to_environment_undoes_and_redoes() {
let mut h = harness();
let id = h.state_mut().session.execute("layer.newLight", json!({"kind": "Spot", "coneAngle": 45})).unwrap()["layer"].as_u64().unwrap();
h.run_steps(2);
reopen(&mut h, id);
choose(&mut h, LightKind::Environment);
ok(&mut h);
assert_eq!(kind(&h, id), LightKind::Environment);
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert_eq!(kind(&h, id), LightKind::Spot);
assert_eq!(layer(&h, id).props.prop("lightOptions/coneAngle").unwrap().value.as_f64(), 45.0);
h.state_mut().session.execute("edit.redo", json!({})).unwrap();
assert_eq!(kind(&h, id), LightKind::Environment);
}
#[test]
fn the_other_light_types_are_created_and_kept() {
let mut h = harness();
for k in [LightKind::Parallel, LightKind::Spot, LightKind::Point, LightKind::Ambient] {
invoke(&mut h, "layer.newLight", json!({}));
choose(&mut h, k);
ok(&mut h);
let id = last_layer(&h);
assert_eq!(kind(&h, id), k);
reopen(&mut h, id);
ok(&mut h);
assert_eq!(kind(&h, id), k, "{} kept on reopening", k.label());
}
}
#[test]
fn falloff_and_shadow_options_show_only_for_lights_that_have_them() {
let mut h = harness();
invoke(&mut h, "layer.newLight", json!({}));
for k in LightKind::ALL {
choose(&mut h, k);
let shown = !matches!(k, LightKind::Ambient | LightKind::Environment);
for label in ["Falloff", "Casts Shadows", "Shadow Darkness"] {
assert_eq!(h.query_by_label(label).is_some(), shown, "{label} for {}", k.label());
}
}
}
#[test]
fn changing_an_environment_light_to_spot_undoes_to_its_source_and_rotation() {
let mut h = harness();
let (env, sky) = environment_light(&mut h);
reopen(&mut h, env);
choose(&mut h, LightKind::Spot);
ok(&mut h);
assert_eq!(kind(&h, env), LightKind::Spot);
assert!(layer(&h, env).props.prop("lightOptions/source").is_none());
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
let l = layer(&h, env);
assert_eq!(kind(&h, env), LightKind::Environment);
assert_eq!(l.props.prop("lightOptions/source").unwrap().value.as_layer(), Some(sky));
assert_eq!(l.props.prop("lightOptions/rotation").unwrap().value.as_f64(), 30.0);
}
@@ -38,7 +38,7 @@ fn invoke(h: &mut Harness<'_, EffectcraftApp>, command: &str, params: Value) {
}
fn locked_center(h: &Harness<'_, EffectcraftApp>, images: &Images) -> [u8; 4] {
let (_, texture) = h.ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(egui::Id::new("viewer-locked"))).unwrap();
let (_, texture) = h.ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(egui::Id::unique("viewer-locked"))).unwrap();
let images = images.borrow();
let image = images.get(&texture.id()).unwrap();
image.pixels[(image.size[1] / 2) * image.size[0] + image.size[0] / 2].to_array()
@@ -42,7 +42,7 @@ type Listing = (f64, Arc<Result<Vec<Entry>, String>>);
fn listing(h: &Harness<'_, EffectcraftApp>, only: bool) -> Listing {
let dir = h.state().session.state.media_browser.folder.clone().unwrap_or_else(|| "/Shoot".into());
h.ctx.data(|d| d.get_temp(egui::Id::new(("mb-list", dir, only)))).unwrap()
h.ctx.data(|d| d.get_temp(egui::Id::unique(("mb-list", dir, only)))).unwrap()
}
fn names(list: &Arc<Result<Vec<Entry>, String>>) -> Vec<&str> {
@@ -52,7 +52,8 @@ fn file_folder_and_virtual_paths_copy_from_the_context_menu_without_document_cha
let mut session = Session { browser: Some(Arc::new(TestBrowser(tree))), ..Default::default() };
session.prefs.general.language = "en".into();
session.execute("comp.new", json!({"width": 64, "height": 36, "duration": 2})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| {
// The Chinese folder and file names need an installed CJK face.
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).allow_missing_glyphs().build_eframe(|_| {
let mut app = EffectcraftApp::new(session);
app.ui.start_screen = false;
app.show_panel(PanelKind::MediaBrowser);
@@ -96,7 +97,8 @@ fn copy_path_control_command_preserves_the_path_and_rejects_bad_arguments() {
let mut reply = None;
let mut out = ctx.run_ui(Default::default(), |ui| {
let (request, _rx) = ControlRequest::new("ui.menu.invoke", json!({"id": "mediaBrowser.copySourcePath", "params": params}));
reply = match control::handle(&mut app, ui.ctx(), &request) {
let now = app.now();
reply = match control::handle(&mut app, ui.ctx(), &request, now) {
control::Outcome::Done(value) => Some(value),
_ => panic!("expected immediate reply"),
};
@@ -16,12 +16,12 @@ fn harness() -> Harness<'static, EffectcraftApp> {
h
}
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
pub(crate) fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))
}
fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
pub(crate) fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
@@ -29,6 +29,25 @@ fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
h.run_steps(2);
}
/// Press the primary button at `p` and keep it down.
pub(crate) fn press(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.run_steps(2);
}
/// Move the pointer to `p` (press or no press, whichever is held).
pub(crate) fn move_to(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
h.input_mut().events.push(Event::PointerMoved(p));
h.run_steps(2);
}
/// Release the primary button at `p`.
fn release(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
}
/// Press and release `key` (one frame each), then let the UI settle.
fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key) {
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
@@ -248,3 +267,112 @@ fn space_previews_after_opening_the_demo_from_home() {
key(&mut h, Key::Space);
assert!(h.state().playback.playing, "Space previews the demo (#1059)");
}
/// The titles open on the press, not the release, so the menus can be browsed with the button
/// still held down: moving onto an entry and releasing there chooses it (as in After Effects).
#[test]
fn a_menu_opens_on_press_and_browsing_releases_choose_an_entry() {
let mut h = harness();
let edit = rect(&h, "menu.Edit");
press(&mut h, edit.center());
assert!(shown(&h, "Keyboard Shortcuts"), "the Edit menu opens on the press, before any release");
let item = h.query_by_label_contains("Keyboard Shortcuts").expect("the Edit menu is open").rect();
move_to(&mut h, item.center());
release(&mut h, item.center());
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Shortcuts), "the release over the entry chose it");
assert!(!shown(&h, "Quick Apply"), "and closed the menu");
}
/// While a menu is open, hovering another title switches to that menu (native menu bars).
#[test]
fn hovering_another_title_while_a_menu_is_open_switches_to_it() {
let mut h = harness();
let edit = rect(&h, "menu.Edit");
click_at(&mut h, edit.center());
assert!(shown(&h, "Keyboard Shortcuts"), "the Edit menu is open");
let file = rect(&h, "menu.File");
move_to(&mut h, file.center());
assert!(shown(&h, "Open Project"), "hovering File opened the File menu");
assert!(!shown(&h, "Keyboard Shortcuts"), "and closed Edit");
}
/// The press on the title of the open menu closes it again, and the click's release — a plain
/// click on the title — leaves it closed.
#[test]
fn pressing_the_open_title_again_closes_its_menu() {
let mut h = harness();
let file = rect(&h, "menu.File");
click_at(&mut h, file.center());
assert!(shown(&h, "Open Project"), "the File menu is open");
click_at(&mut h, file.center());
assert!(!shown(&h, "Open Project"), "the second press closed it");
}
/// Press-and-hold browse (AE's gesture) reaches into a submenu: while the button is still held,
/// moving onto the submenu row opens it, and the release inside the submenu picks the entry.
#[test]
fn hold_browse_opens_a_submenu_and_the_release_inside_picks_an_entry() {
let mut h = harness();
// Applying an effect needs a selected layer, as in the app after clicking one in the timeline.
let first = {
let cid = h.state().session.active_comp_id().expect("the demo opens a comp");
let comp = h.state().session.project.comp(cid).expect("the demo comp exists");
comp.layers.iter().find(|l| l.effects().is_some()).expect("the demo comp has a layer that takes effects").id
};
h.state_mut().session.state.selected_layers = vec![first];
let effect = rect(&h, "menu.Effect");
press(&mut h, effect.center());
assert!(shown(&h, "Effect Controls"), "the Effect menu opens on the press");
// The categories come from the effect registry; the first alphabetically is "3D Channel".
let cat = h.query_by_label_contains("3D Channel").expect("the category row is shown").rect();
move_to(&mut h, cat.center());
assert!(shown(&h, "Cryptomatte"), "moving onto a category while still holding opened its submenu");
let entry = h.query_by_label_contains("Cryptomatte").expect("the submenu is open").rect();
move_to(&mut h, entry.center());
release(&mut h, entry.center());
assert!(!shown(&h, "Effect Controls"), "the release closed the menus");
assert!(h.state().session.state.last_effect.is_some(), "and the release chose the entry: it applied its effect");
}
/// The open menus close on a press outside them and when the window loses focus. The pointer
/// leaving the window — onto the OS title bar, whose clicks are never forwarded to egui — closes
/// nothing, however long it stays away: there is no click to close on, and a graze or a stay
/// must not drop the menu. The closing press itself is not swallowed: it reaches the widget under
/// it, as a click does in After Effects.
#[test]
fn outside_presses_and_focus_loss_close_the_menus() {
let mut h = harness();
let file = rect(&h, "menu.File");
click_at(&mut h, file.center());
assert!(shown(&h, "Open Project"));
// A press in the empty part of the bar, outside every title and menu.
click_at(&mut h, pos2(1100.0, 12.0));
assert!(!shown(&h, "Open Project"), "a press outside the menus closes them");
// The press reaches the header's workspace tab behind the open menu: the menu closes and the
// tab still acts (the menus don't swallow input).
click_at(&mut h, file.center());
assert!(shown(&h, "Open Project"));
let tab = rect(&h, "header.workspace.Review");
click_at(&mut h, tab.center());
assert_eq!(h.state().ui.workspace, "Review", "the press reached the tab behind the open menu");
assert!(!shown(&h, "Open Project"), "and closed the menu");
// Moving onto the OS title bar fires the cursor-left event — its clicks are never forwarded
// to egui — which egui reports as `PointerGone`. It must not close the menus, however long
// the pointer stays away.
click_at(&mut h, file.center());
assert!(shown(&h, "Open Project"));
h.input_mut().events.push(Event::PointerGone);
h.run_steps(1);
assert!(shown(&h, "Open Project"), "leaving the window alone doesn't drop the menus at once");
let now = h.ctx.input(|i| i.time);
h.input_mut().time = Some(now + 60.0);
h.run_steps(2);
assert!(shown(&h, "Open Project"), "staying away doesn't close them either");
h.input_mut().events.push(Event::PointerMoved(file.center()));
h.run_steps(2);
assert!(shown(&h, "Open Project"), "the pointer coming back keeps them open too");
// Losing focus (alt-tabbing away, or clicking another window).
h.input_mut().events.push(Event::WindowFocused(false));
h.run_steps(2);
assert!(!shown(&h, "Open Project"), "losing focus closes the menus");
}
+227
View File
@@ -0,0 +1,227 @@
//! Layer ▸ New ▸ 3D Primitive… (#1734): the dialog picks a type, name and dimensions and runs
//! `layer.newPrimitive`, which switches a Classic 3D comp to Advanced 3D in the same step.
use effectcraft_engine::Session;
use effectcraft_engine::project::{Layer, LayerSource, PrimitiveKind, Renderer};
use effectcraft_ui_egui::{Dialog, EffectcraftApp};
use egui::{Event, Key, Modifiers, PointerButton};
use egui_kittest::Harness;
use serde_json::json;
fn harness() -> Harness<'static, EffectcraftApp> {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Scene", "width": 640, "height": 360, "duration": 2})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.step();
h
}
/// An automation element's rect in the last frame drawn (a dialog settles its place in its
/// first frames).
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Option<[f32; 4]> {
let auto = &h.state().auto;
auto.elements.iter().chain(auto.previous.iter()).find(|e| e.id == id).map(|e| e.rect)
}
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
let [x, y, w, hh] = rect(h, id).unwrap_or_else(|| panic!("{id} is registered"));
let p = egui::pos2(x + w / 2.0, y + hh / 2.0);
h.input_mut().events.push(Event::PointerMoved(p));
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) {
for pressed in [true, false] {
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers });
}
h.run_steps(2);
}
/// Types `text` over a field's contents and leaves it with Tab.
fn type_into(h: &mut Harness<'_, EffectcraftApp>, id: &str, text: &str) {
click(h, id);
assert!(h.ctx.egui_wants_keyboard_input(), "{id} has focus");
key(h, Key::A, Modifiers::COMMAND);
h.input_mut().events.push(Event::Text(text.into()));
h.step();
key(h, Key::Tab, Modifiers::NONE);
}
/// Layer ▸ New ▸ 3D Primitive…, as the menu runs it.
fn open(h: &mut Harness<'_, EffectcraftApp>) {
let (path, entry) =
effectcraft_engine::menus::entries().into_iter().find(|(_, e)| e.label == "3D Primitive...").expect("Layer ▸ New lists 3D Primitive...");
assert_eq!(path, ["Layer", "New"]);
assert_eq!(entry.command, "layer.newPrimitive");
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, &entry.command, entry.params_or_empty()).unwrap();
h.run_steps(2);
assert_eq!(h.state().dialog, Some(Dialog::NewPrimitive));
}
fn choose(h: &mut Harness<'_, EffectcraftApp>, kind: PrimitiveKind) {
click(h, "dialog.primitive.type");
let i = PrimitiveKind::ALL.iter().position(|k| *k == kind).unwrap();
click(h, &format!("dialog.primitive.typeItem.{i}"));
}
fn layers(h: &Harness<'_, EffectcraftApp>) -> Vec<Layer> {
h.state().session.active_comp().unwrap().layers.clone()
}
fn renderer(h: &Harness<'_, EffectcraftApp>) -> Renderer {
h.state().session.active_comp().unwrap().renderer
}
fn geometry(l: &Layer, id: &str) -> f64 {
l.props.prop(&format!("geometryOptions/{id}")).unwrap_or_else(|| panic!("{id}")).value.as_f64()
}
/// A torus named "Ring" with a 250 px radius, the rest left at their defaults.
fn create_ring(h: &mut Harness<'_, EffectcraftApp>) {
open(h);
choose(h, PrimitiveKind::Torus);
type_into(h, "dialog.primitive.name", "Ring");
type_into(h, "dialog.primitive.radius", "250");
click(h, "dialog.primitive.ok");
assert_eq!(h.state().dialog, None);
}
fn assert_ring(l: &Layer) {
assert_eq!(l.source, LayerSource::Primitive { kind: PrimitiveKind::Torus });
assert_eq!(l.name, "Ring");
assert_eq!(geometry(l, "radius"), 250.0);
assert_eq!(geometry(l, "tubeRadius"), 50.0);
}
#[test]
fn creates_a_primitive_in_a_classic_3d_comp_in_one_undoable_step() {
let mut h = harness();
assert_eq!(renderer(&h), Renderer::Classic3D);
let steps = h.state().session.history.undo.len();
create_ring(&mut h);
let ls = layers(&h);
assert_eq!(ls.len(), 1);
assert_ring(&ls[0]);
assert_eq!(renderer(&h), Renderer::Advanced3D, "the comp switches to Advanced 3D");
assert_eq!(h.state().session.history.undo.len(), steps + 1);
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert!(layers(&h).is_empty());
assert_eq!(renderer(&h), Renderer::Classic3D, "undo restores the renderer too");
h.state_mut().session.execute("edit.redo", json!({})).unwrap();
assert_ring(&layers(&h)[0]);
assert_eq!(renderer(&h), Renderer::Advanced3D);
}
#[test]
fn cancel_creates_nothing() {
let mut h = harness();
let steps = h.state().session.history.undo.len();
open(&mut h);
choose(&mut h, PrimitiveKind::Sphere);
type_into(&mut h, "dialog.primitive.radius", "80");
click(&mut h, "dialog.primitive.cancel");
assert_eq!(h.state().dialog, None);
assert!(layers(&h).is_empty());
assert_eq!(renderer(&h), Renderer::Classic3D);
assert_eq!(h.state().session.history.undo.len(), steps);
}
#[test]
fn each_type_offers_the_dimensions_its_layer_has() {
let mut h = harness();
for kind in PrimitiveKind::ALL {
open(&mut h);
choose(&mut h, kind);
click(&mut h, "dialog.primitive.ok");
let l = layers(&h).into_iter().find(|l| l.source == LayerSource::Primitive { kind }).unwrap();
assert_eq!(l.name, kind.label(), "the name follows the type until typed");
let group = l.props.group("geometryOptions").unwrap();
let ids: Vec<&str> = group.props().map(|p| p.match_id.as_str()).collect();
open(&mut h);
choose(&mut h, kind);
let controls = ["type", "name", "ok", "cancel"];
let shown: Vec<&str> = (h.state().auto.elements.iter())
.filter_map(|e| e.id.strip_prefix("dialog.primitive."))
.filter(|id| !controls.contains(id) && !id.starts_with("typeItem."))
.collect();
assert_eq!(shown, ids, "{} dimensions", kind.label());
click(&mut h, "dialog.primitive.cancel");
}
}
#[test]
fn a_created_primitive_survives_save_and_reopen() {
let mut h = harness();
create_ring(&mut h);
let path = std::env::temp_dir().join(format!("ec-ui-new-primitive-{}.ecproj", std::process::id()));
h.state_mut().session.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap();
let mut o = Session::default();
o.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap();
let _ = std::fs::remove_file(&path);
let (_, comp) = o.project.comps().next().unwrap();
assert_eq!(comp.renderer, Renderer::Advanced3D);
assert_ring(&comp.layers[0]);
}
#[test]
fn untouched_dimensions_take_each_types_defaults_and_typed_ones_carry_over() {
let mut h = harness();
open(&mut h);
choose(&mut h, PrimitiveKind::Plane);
click(&mut h, "dialog.primitive.ok");
let plane = layers(&h).remove(0);
assert_eq!(geometry(&plane, "width"), 400.0, "a plane's default, not the cube's 300");
open(&mut h);
type_into(&mut h, "dialog.primitive.width", "120");
choose(&mut h, PrimitiveKind::Plane);
click(&mut h, "dialog.primitive.ok");
let plane = layers(&h).into_iter().find(|l| l.id != plane.id).unwrap();
assert_eq!(geometry(&plane, "width"), 120.0, "the typed width carries over");
assert_eq!(geometry(&plane, "height"), 400.0);
}
/// A dialog left open while something else changes the editing context closes without creating
/// anything rather than adding the primitive where it was not opened.
fn closes_when(change: impl FnOnce(&mut Session)) {
let mut h = harness();
let a = h.state().session.active_comp_id().unwrap();
open(&mut h);
choose(&mut h, PrimitiveKind::Torus);
change(&mut h.state_mut().session);
let b = h.state().session.active_comp_id().unwrap();
let steps = h.state().session.history.undo.len();
h.run_steps(2);
assert_eq!(h.state().dialog, None, "the dialog closes");
assert!(rect(&h, "dialog.primitive.ok").is_none());
let s = &h.state().session;
for c in [a, b] {
if let Some(c) = s.project.comp(c) {
assert!(c.layers.is_empty(), "nothing is created");
assert_eq!(c.renderer, Renderer::Classic3D);
}
}
assert_eq!(s.history.undo.len(), steps);
assert!(h.state().ui.status.contains("changed"), "{}", h.state().ui.status);
}
#[test]
fn another_comp_made_active_closes_the_dialog() {
closes_when(|s| {
s.execute("comp.new", json!({"name": "Other", "width": 640, "height": 360, "duration": 2})).unwrap();
});
}
#[test]
fn a_replaced_project_closes_the_dialog_even_when_the_comp_id_is_reused() {
closes_when(|s| {
let before = s.active_comp_id().unwrap();
s.execute("file.newProject", json!({})).unwrap();
s.execute("comp.new", json!({"name": "Scene", "width": 640, "height": 360, "duration": 2})).unwrap();
assert_eq!(s.active_comp_id(), Some(before), "the new project reuses the comp's id");
});
}
@@ -187,3 +187,47 @@ fn double_clicking_an_effect_in_the_timeline_opens_effect_controls() {
assert_eq!(st.selected_props.iter().map(|(l, u)| (l.0, *u)).collect::<Vec<_>>(), vec![(red, uid)], "with the effect selected");
assert!(!h.state().ui.timeline.open_groups.contains(&uid), "the effect did not twirl open");
}
// Regression test for #354: the focused panel's blue outline was painted before the panel body,
// so the body's background and widgets painted over it. It must be drawn after every panel body.
#[test]
fn focused_panel_outline_is_painted_over_the_panel_bodies() {
fn flatten<'a>(shape: &'a egui::Shape, clip: egui::Rect, out: &mut Vec<(egui::Rect, &'a egui::epaint::RectShape)>) {
match shape {
egui::Shape::Rect(r) => out.push((clip, r)),
egui::Shape::Vec(v) => v.iter().for_each(|s| flatten(s, clip, out)),
_ => {}
}
}
let (mut h, _, _) = harness();
for panel in [PanelKind::Timeline, PanelKind::Project] {
h.state_mut().ui.focused = panel;
h.run_steps(3);
let focus = h.state().tokens.focus;
let mut rects = vec![];
h.output().shapes.iter().for_each(|c| flatten(&c.shape, c.clip_rect, &mut rects));
// The one-pixel, unfilled outline round the focused panel's frame.
let body = rect(&h, &format!("panel.{}", panel.id()));
let outlines: Vec<usize> = rects
.iter()
.enumerate()
.filter(|(_, (_, r))| r.fill.a() == 0 && r.stroke.color == focus && r.stroke.width == 1.0 && r.rect.contains_rect(body))
.map(|(i, _)| i)
.collect();
let Some(&at) = outlines.last() else { panic!("{panel:?}: no focus outline painted") };
let outline = rects[at].1.rect;
// Nothing painted after it reaches the one-pixel ring it draws, where a panel's
// background or rows would hide it.
let ring = [
egui::Rect::from_min_max(outline.min, egui::pos2(outline.right(), outline.top() + 1.0)),
egui::Rect::from_min_max(egui::pos2(outline.left(), outline.bottom() - 1.0), outline.max),
egui::Rect::from_min_max(outline.min, egui::pos2(outline.left() + 1.0, outline.bottom())),
egui::Rect::from_min_max(egui::pos2(outline.right() - 1.0, outline.top()), outline.max),
];
for (clip, r) in &rects[at + 1..] {
let painted = r.rect.intersect(*clip);
let over = ring.iter().any(|edge| edge.intersect(painted).area() > 0.0);
assert!(r.fill.a() == 0 || !over, "{panel:?}: a filled rect {:?} (fill {:?}) is painted over the focus outline {outline:?}", r.rect, r.fill);
}
}
}
@@ -52,7 +52,7 @@ fn a_panel_in_its_own_window_leaves_the_main_window() {
h.run_steps(3);
// No in-window floating group is drawn for it (no move strip, no floating area)...
assert!(!h.state().auto.elements.iter().any(|e| e.id.starts_with("panel.float.")));
let area = egui::Id::new(("floating-panel", PanelKind::Project.id()));
let area = egui::Id::unique(("floating-panel", PanelKind::Project.id()));
assert!(!h.ctx.memory(|m| m.layer_ids().any(|layer| layer.id == area && m.areas().is_visible(&layer))));
// ...the window draws it (here embedded: the test harness has no native windows).
assert!(h.state().auto.elements.iter().any(|e| e.id == "panel.Project"));
@@ -218,7 +218,7 @@ fn saved_workspace_is_listed_in_the_workspace_menu() {
h.run_steps(3);
assert_ne!(h.state().ui.dock, saved);
click(&mut h, "menu.Window");
let ws = h.query_by_label(" Workspace ⏵").expect("Window ▸ Workspace").rect();
let ws = h.query_by_label(" Workspace").expect("Window ▸ Workspace").rect();
hover(&mut h, ws.center());
let entry = h.state().auto.find("menu.savedWorkspaces.0").expect("the saved workspace is listed").clone();
assert_eq!(entry.label, "My Layout");
@@ -239,7 +239,7 @@ fn saved_workspace_is_listed_in_the_workspace_menu() {
fn edit_label_menu_shows_the_label_colours() {
let mut h = harness();
click(&mut h, "menu.Edit");
let label = h.query_by_label(" Label ⏵").expect("Edit ▸ Label").rect();
let label = h.query_by_label(" Label").expect("Edit ▸ Label").rect();
hover(&mut h, label.center());
// (Not Dark Green; the entry's label starts with its check-mark column.)
let green = h.query_by_label(" Green").expect("the Label submenu is open").rect();
@@ -388,6 +388,7 @@ fn settings_buttons_stay_in_view_at_200_percent() {
delta: egui::vec2(0.0, -1000.0),
modifiers: Default::default(),
phase: egui::TouchPhase::Move,
source: egui::MouseWheelSource::Wheel,
});
h.run_steps(5);
};
@@ -414,7 +415,7 @@ fn settings_buttons_stay_in_view_at_200_percent() {
}
/// Headless look at the panels (wgpu offscreen; needs a GPU adapter). Run with
/// `PANELS_SNAPSHOT=/abs/dir cargo test -p effectcraft-ui-egui --test ui_panels -- --ignored`.
/// `PANELS_SNAPSHOT=/abs/dir cargo test -p effectcraft-ui-egui --test it ui_panels:: -- --ignored`.
#[test]
#[ignore]
fn panels_snapshot() {
@@ -37,7 +37,8 @@ fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) -> Vec<String> {
fn presets_menu_copies_the_configured_folder_without_editing_the_project() {
let expected = expected_folder();
for language in ["en", "ja"] {
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| {
// The `ja` run draws Japanese menu text, which needs an installed CJK face.
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).allow_missing_glyphs().build_eframe(|_| {
let mut app = EffectcraftApp::new(Session::default());
app.session.prefs.general.language = language.into();
app.ui.start_screen = false;
@@ -69,7 +70,8 @@ fn presets_folder_control_command_returns_the_path_or_a_configuration_error() {
let mut reply = None;
let mut output = ctx.run_ui(Default::default(), |ui| {
let (request, _rx) = ControlRequest::new("ui.menu.invoke", json!({"id": "effects.copyPresetsFolderPath"}));
reply = match control::handle(&mut app, ui.ctx(), &request) {
let now = app.now();
reply = match control::handle(&mut app, ui.ctx(), &request, now) {
control::Outcome::Done(value) => Some(value),
_ => panic!("expected an immediate reply"),
};
@@ -128,7 +130,7 @@ fn shared_preset_rows_search_apply_and_undo_the_selected_definition() {
h.run_steps(3);
let shared = std::sync::Arc::new(entries);
let now = h.ctx.input(|i| i.time);
h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("fx-user-presets"), (now, shared.clone())));
h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("fx-user-presets"), (now, shared.clone())));
let search = |h: &mut Harness<'_, EffectcraftApp>, text: &str| {
click(h, "effects.search");
h.input_mut().events.push(Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND });
@@ -160,7 +162,7 @@ fn shared_preset_rows_search_apply_and_undo_the_selected_definition() {
h.run_steps(2);
assert_eq!(h.state().session.active_comp().unwrap().layers[0].props.prop("transform/opacity").unwrap().value.as_f64(), 100.0);
}
let (_, cached) = h.ctx.data(|d| d.get_temp::<(f64, std::sync::Arc<Vec<PresetFile>>)>(egui::Id::new("fx-user-presets"))).unwrap();
let (_, cached) = h.ctx.data(|d| d.get_temp::<(f64, std::sync::Arc<Vec<PresetFile>>)>(egui::Id::unique("fx-user-presets"))).unwrap();
assert!(std::sync::Arc::ptr_eq(&shared, &cached));
assert_eq!(shared.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["Custom Cache A", "Custom Cache B"]);
}
@@ -259,6 +259,45 @@ fn cache_frames_when_idle_fills_the_work_area() {
assert_eq!(n, 240, "the work area is cached while idle");
}
/// A preview that plays into frames it already cached (after a loop) keeps rendering the next
/// uncached ones: the prefetch window used to end within the cached run, and the workers stood
/// idle until the playhead came near its end.
#[test]
fn playback_prefetches_past_the_cached_frames_ahead() {
let mut h = harness();
let cid = h.state().session.active_comp_id().unwrap();
let series = h.state().shown_series(cid);
let scale = series.scale as f64 / 1000.0;
// The first 120 frames (5 s) are cached: more than the widest old window (16 workers × 6).
for f in 0..120 {
h.state().request_frame(cid, f, scale);
}
for _ in 0..4000 {
if h.state().frames.cached_frames(&series).len() >= 120 && h.state().frames.inflight() == 0 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(2));
}
assert_eq!(h.state().frames.cached_frames(&series), (0..120).collect::<Vec<_>>());
h.state_mut().session.set_time(Tick::ZERO);
invoke(&mut h, "playback.toggle", json!({}));
h.run_steps(2);
assert!(h.state().playback.playing);
let cur = h.state().session.time();
assert!(cur < Tick::from_seconds_f64(1.0), "{cur:?}");
for _ in 0..4000 {
if h.state().frames.cached_frames(&series).iter().any(|&f| f >= 120) {
break;
}
h.step();
std::thread::sleep(std::time::Duration::from_millis(1));
if h.state().session.time() >= Tick::from_seconds_f64(1.0) {
break;
}
}
assert!(h.state().frames.cached_frames(&series).iter().any(|&f| f >= 120), "frames past the cached run render while it plays the first second");
}
#[test]
fn cache_frames_when_idle_survives_an_inverted_work_area() {
let mut h = harness();
@@ -0,0 +1,172 @@
//! #2207: Duplicate is reachable from a Project row's context menu, with the same targets,
//! naming, undo and shortcut preset as Edit > Duplicate.
use effectcraft_engine::Session;
use effectcraft_engine::project::ItemId;
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::dock::PanelKind;
use effectcraft_ui_egui::menus::{parse_shortcut, shortcut_text};
use egui::{Event, Key, Modifiers, PointerButton, Rect, Shape, pos2};
use egui_kittest::Harness;
use serde_json::json;
const DUPLICATE: &str = "project.context.duplicate";
fn harness() -> (Harness<'static, EffectcraftApp>, ItemId, ItemId, ItemId) {
let mut s = Session::default();
let mut comp =
|name: &str| ItemId(s.execute("comp.new", json!({"name": name, "width": 64, "height": 64, "duration": 1})).unwrap()["comp"].as_u64().unwrap());
let main = comp("Main");
let other = comp("Other");
let folder = ItemId(s.execute("project.newFolder", json!({"name": "Bin"})).unwrap()["item"].as_u64().unwrap());
s.execute("project.select", json!({"items": []})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
(h, main, other, folder)
}
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))
}
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str, button: PointerButton, modifiers: Modifiers) {
let p = rect(h, id).center();
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
h.input_mut().events.push(Event::PointerMoved(p));
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button, pressed: true, modifiers });
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button, pressed: false, modifiers });
h.run_steps(3);
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
h.step();
}
fn row(h: &mut Harness<'_, EffectcraftApp>, item: ItemId, button: PointerButton, modifiers: Modifiers) {
click(h, &format!("project.item.{}.name", item.0), button, modifiers);
}
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.step();
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers });
h.run_steps(2);
}
fn copy_name(h: &Harness<'_, EffectcraftApp>, id: ItemId) -> String {
h.state().session.project.item(id).unwrap().name.clone()
}
fn menu_text(h: &Harness<'_, EffectcraftApp>, text: &str) -> bool {
fn contains(shape: &Shape, bounds: Rect, text: &str) -> bool {
match shape {
Shape::Text(t) => bounds.contains(t.pos) && t.galley.text() == text,
Shape::Vec(shapes) => shapes.iter().any(|s| contains(s, bounds, text)),
_ => false,
}
}
let bounds = rect(h, DUPLICATE);
h.output().shapes.iter().any(|s| contains(&s.shape, bounds, text))
}
#[test]
fn unselected_row_duplicates_only_itself_and_undo_restores_the_project() {
let (mut h, main, other, _) = harness();
row(&mut h, other, PointerButton::Primary, Modifiers::NONE);
let before = h.state().session.project.clone();
let undo = h.state().session.history.undo.len();
row(&mut h, main, PointerButton::Secondary, Modifiers::NONE);
assert_eq!(h.state().session.state.project_selection, [main]);
assert!(menu_text(&h, &shortcut_text("Cmd+D")), "the default platform shortcut is shown");
click(&mut h, DUPLICATE, PointerButton::Primary, Modifiers::NONE);
let selected = h.state().session.state.project_selection.clone();
let [copy] = selected.as_slice() else { panic!("one selected copy") };
assert!(!before.items.contains_key(copy), "the copy has a fresh ID");
assert_eq!(copy_name(&h, *copy), "Main 2");
assert_eq!(copy_name(&h, other), "Other");
assert_eq!(h.state().session.project.items.len(), before.items.len() + 1);
assert_eq!(h.state().session.history.undo.len(), undo + 1);
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert_eq!(h.state().session.project, before);
}
#[test]
fn selected_rows_duplicate_together_in_selection_order_with_numbered_names() {
let (mut h, main, other, _) = harness();
// Deliberately reverse alphabetical row order: command targets retain selection order.
row(&mut h, other, PointerButton::Primary, Modifiers::NONE);
row(&mut h, main, PointerButton::Primary, Modifiers::COMMAND);
assert_eq!(h.state().session.state.project_selection, [other, main]);
let before = h.state().session.project.clone();
let undo = h.state().session.history.undo.len();
row(&mut h, main, PointerButton::Secondary, Modifiers::NONE);
click(&mut h, DUPLICATE, PointerButton::Primary, Modifiers::NONE);
let selected = h.state().session.state.project_selection.clone();
let [a, b] = selected.as_slice() else { panic!("two selected copies") };
assert_ne!(a, b);
assert!(selected.iter().all(|id| !before.items.contains_key(id)));
assert_eq!((copy_name(&h, *a), copy_name(&h, *b)), ("Other 2".to_string(), "Main 2".to_string()));
assert_eq!(h.state().session.project.items.len(), before.items.len() + 2);
assert_eq!(h.state().session.history.undo.len(), undo + 1);
// Duplicate the selected copies again: the existing engine numbering continues.
row(&mut h, *b, PointerButton::Secondary, Modifiers::NONE);
click(&mut h, DUPLICATE, PointerButton::Primary, Modifiers::NONE);
let names: Vec<_> = h.state().session.state.project_selection.iter().map(|id| copy_name(&h, *id)).collect();
assert_eq!(names, ["Other 3", "Main 3"]);
}
#[test]
fn folders_and_mixed_selections_disable_duplicate_without_partial_changes() {
for mixed in [false, true] {
let (mut h, main, _, folder) = harness();
row(&mut h, main, PointerButton::Primary, Modifiers::NONE);
if mixed {
row(&mut h, folder, PointerButton::Primary, Modifiers::COMMAND);
}
// An unselected folder replaces the selection; a selected non-folder keeps the mixed set.
row(&mut h, if mixed { main } else { folder }, PointerButton::Secondary, Modifiers::NONE);
let before = h.state().session.project.clone();
let selected = h.state().session.state.project_selection.clone();
assert!(selected.contains(&folder));
let undo = h.state().session.history.undo.len();
let status = h.state().ui.status.clone();
click(&mut h, DUPLICATE, PointerButton::Primary, Modifiers::NONE);
assert_eq!(h.state().session.project, before, "mixed={mixed}: no partial copy");
assert_eq!(h.state().session.state.project_selection, selected);
assert_eq!(h.state().session.history.undo.len(), undo);
assert_eq!(h.state().ui.status, status, "no failing engine command was dispatched");
}
}
#[test]
fn duplicate_shortcut_still_dispatches_and_the_menu_tracks_rebinding_and_unbinding() {
let (mut h, main, _, _) = harness();
row(&mut h, main, PointerButton::Primary, Modifiers::NONE);
assert_eq!(h.state().ui.focused, PanelKind::Project);
let (d, command) = parse_shortcut("Cmd+D").unwrap();
key(&mut h, command, d);
let selected = h.state().session.state.project_selection.clone();
let [copy] = selected.as_slice() else { panic!("one shortcut copy") };
assert_ne!(*copy, main);
assert_eq!(copy_name(&h, *copy), "Main 2");
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.run_steps(2);
h.state_mut().session.execute("shortcuts.set", json!({"command": "edit.duplicate", "keys": "F6"})).unwrap();
row(&mut h, main, PointerButton::Secondary, Modifiers::NONE);
assert!(menu_text(&h, "F6"));
assert!(!menu_text(&h, &shortcut_text("Cmd+D")));
key(&mut h, Key::Escape, Modifiers::NONE);
row(&mut h, main, PointerButton::Primary, Modifiers::NONE);
let before = h.state().session.project.clone();
key(&mut h, command, d);
assert_eq!(h.state().session.project, before, "the former shortcut stays unbound");
key(&mut h, Key::F6, Modifiers::NONE);
assert_eq!(h.state().session.project.items.len(), before.items.len() + 1);
h.state_mut().session.execute("shortcuts.set", json!({"command": "edit.duplicate", "keys": null})).unwrap();
row(&mut h, main, PointerButton::Secondary, Modifiers::NONE);
assert!(!menu_text(&h, "F6"));
assert!(!menu_text(&h, &shortcut_text("Cmd+D")));
click(&mut h, DUPLICATE, PointerButton::Primary, Modifiers::NONE);
assert_eq!(h.state().session.project.items.len(), before.items.len() + 2, "unbinding keeps the menu action usable");
}
@@ -150,7 +150,8 @@ fn engine_execute_only_creates_the_folder() {
let before = folders(&h);
let ctx = h.ctx.clone();
let (req, _rx) = ControlRequest::new("engine.execute", json!({"command": "project.newFolder"}));
let _ = control::handle(h.state_mut(), &ctx, &req);
let now = h.state().now();
let _ = control::handle(h.state_mut(), &ctx, &req, now);
h.run_steps(3);
let [folder] = new_folders(&h, &before)[..] else { panic!("one new folder") };
assert!(!renaming(&h, folder), "no name field for an agent's folder");
@@ -90,8 +90,11 @@ fn prefetch_waits_while_edits_keep_coming() {
let mut s = Session::default();
let c = s.execute("comp.new", json!({"name": "Drag", "width": 64, "height": 36, "duration": 2})).unwrap()["comp"].as_u64().unwrap();
let solid = s.execute("layer.newSolid", json!({"color": "#3080ff", "width": 16, "height": 16})).unwrap()["layer"].as_u64().unwrap();
// Short steps: input time advances 50 ms per frame.
// Short steps between edits; the app clock below is advanced explicitly.
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(0.05).build_eframe(|_| EffectcraftApp::new(s));
// Prefetch waits for EDIT_QUIET on the app clock; a manual clock makes the pause
// deterministic instead of depending on how long the test host took to run the steps.
h.state_mut().use_manual_clock();
settle(&mut h);
let frame = |h: &Harness<'_, EffectcraftApp>| {
let app = h.state();
@@ -116,6 +119,8 @@ fn prefetch_waits_while_edits_keep_coming() {
assert_eq!(h.state().frames.inflight(), 0, "edit {i}");
}
// The edits pause: the frames around the current one fill in.
h.state_mut().clock_mut().advance(1.0);
h.run_steps(1);
settle(&mut h);
for _ in 0..20 {
h.step();
@@ -31,7 +31,7 @@ fn render_settings_template_applies_from_a_menu_moved_to_fit_the_window() {
let mut app = Some(EffectcraftApp::new(s));
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app.take().expect("app"));
// Twirl the item open, as clicking its twirl does.
h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("rq-ui"), (vec![id], None::<u64>)));
h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("rq-ui"), (vec![id], None::<u64>)));
h.state_mut().show_panel(PanelKind::RenderQueue);
h.run_steps(4);
let dd = h.state().auto.find(&format!("renderQueue.item.{id}.renderSettings")).expect("Render Settings menu").clone();
@@ -80,7 +80,7 @@ fn clicking_the_output_file_name_opens_the_save_dialog() {
}));
let mut app = Some(app);
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app.take().expect("app"));
h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("rq-ui"), (vec![id], None::<u64>)));
h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("rq-ui"), (vec![id], None::<u64>)));
h.state_mut().show_panel(PanelKind::RenderQueue);
h.run_steps(4);
let path = h.state().auto.find(&format!("renderQueue.item.{id}.outputPath")).expect("the output file name").clone();
@@ -88,7 +88,7 @@ fn clicking_the_output_file_name_opens_the_save_dialog() {
click_with(&mut h, at, Modifiers::NONE);
assert_eq!(*asked.borrow(), [(expected_default, ext.to_string())], "the dialog opened at the current output");
assert_eq!(h.state().session.project.render_queue[0].output.output, chosen);
let editing = |h: &Harness<'_, EffectcraftApp>| h.ctx.data(|d| d.get_temp::<(u64, String)>(egui::Id::new("rq-edit-output")));
let editing = |h: &Harness<'_, EffectcraftApp>| h.ctx.data(|d| d.get_temp::<(u64, String)>(egui::Id::unique("rq-edit-output")));
assert_eq!(editing(&h), None, "no inline editor");
// Alt-click edits the template text instead.
click_with(&mut h, at, Modifiers::ALT);
@@ -44,7 +44,7 @@ fn script_console_runs_scripts() {
}
/// Headless look at the panel (wgpu offscreen); run with
/// `SC_SNAPSHOT=/abs/out.png cargo test -p effectcraft-ui-egui --test ui_script_console -- --ignored`.
/// `SC_SNAPSHOT=/abs/out.png cargo test -p effectcraft-ui-egui --test it ui_script_console:: -- --ignored`.
#[test]
#[ignore]
fn script_console_snapshot() {
@@ -271,3 +271,18 @@ fn click_at(h: &mut Harness<'_, EffectcraftApp>, c: egui::Pos2) {
}
h.run_steps(3);
}
#[test]
fn scheduled_import_reveals_its_composition_without_manual_clicks() {
let mut s = effectcraft_host::session();
let result = s.execute("script.run", json!({"code": "var w=new Window('palette','Automatic import');var b=w.add('button',undefined,'Open');b.onClick=function(){app.scheduleTask(\"var c=app.project.items.addComp('Imported',64,64,1,1,25);c.openInViewer();\",0,false);};w.show();"})).unwrap();
assert_eq!(result["ok"], true, "{result}");
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s));
h.state_mut().ui.start_screen = true;
h.run_steps(2);
h.state_mut().session.execute("scriptui.click", json!({"window":"Automatic import","widget":"Open"})).unwrap();
h.run_steps(4);
let active = h.state().session.active_comp_id().unwrap();
assert_eq!(h.state().session.project.items.get(&active).unwrap().name, "Imported");
assert!(!h.state().ui.start_screen, "Home must not hide a composition opened by the scheduled import");
h.state_mut().session.execute("scriptui.close", json!({"window":"Automatic import"})).unwrap();
}
+443
View File
@@ -0,0 +1,443 @@
//! Long menus and lists scroll instead of running off the window, with a scroll bar to drag
//! (egui_kittest, UI logic only).
use effectcraft_engine::Session;
use effectcraft_ui_egui::EffectcraftApp;
use egui::{Event, Pos2, Rect, pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
// The pointer/geometry helpers are shared with the sibling menu and long-tail test modules:
// `rect`, `click_at`, `move_to` and press-and-hold (`hold`) live in ui_menus, `drag` in ui_tail.
use super::ui_menus::{click_at, move_to, press as hold, rect};
use super::ui_tail::drag;
/// Open the demo project in a UI test harness with the requested window size.
fn harness(w: f32, h: f32) -> Harness<'static, EffectcraftApp> {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
let mut h = Harness::builder().with_size(vec2(w, h)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
h
}
fn wheel(h: &mut Harness<'_, EffectcraftApp>, at: Pos2, dy: f32) {
h.input_mut().events.push(Event::PointerMoved(at));
h.input_mut().events.push(Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, dy),
modifiers: Default::default(),
phase: egui::TouchPhase::Move,
source: egui::MouseWheelSource::Wheel,
});
h.run_steps(20);
}
/// The open menu popup's rect (frame included), from egui's area memory: the only tall area up.
fn open_popup(h: &Harness<'_, EffectcraftApp>) -> Rect {
h.ctx
.memory(|m| {
m.areas()
.visible_layer_ids()
.into_iter()
.filter(|l| l.order != egui::Order::Background)
.filter_map(|l| m.area_rect(l.id))
.find(|r| r.height() > 100.0)
})
.unwrap_or_else(|| panic!("no menu popup is open"))
}
/// Edit is taller than a short window: it scrolls (with the wheel, and a scroll bar that always
/// shows) so its last entries can be reached and clicked, instead of running off the bottom of
/// the window (#269).
#[test]
fn a_menu_taller_than_the_window_scrolls() {
let mut h = harness(1200.0, 520.0);
assert!(!h.ctx.global_style().spacing.scroll.floating, "scroll bars show without hovering the list");
let edit = rect(&h, "menu.Edit");
click_at(&mut h, edit.center());
let last = h.query_by_label_contains("Keyboard Shortcuts").expect("the Edit menu is open").rect();
assert!(last.max.y > 520.0, "Edit is taller than the window ({last:?})");
let first = h.query_by_label_contains("Quick Apply").expect("Quick Apply").rect();
wheel(&mut h, first.center(), -2000.0);
let last = h.query_by_label_contains("Keyboard Shortcuts").expect("still open").rect();
assert!(last.min.y >= 0.0 && last.max.y <= 520.0, "scrolled into the window ({last:?})");
click_at(&mut h, last.center());
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Shortcuts), "the entry takes the click");
}
/// Open Edit in a window shorter than the menu (holding or clicking its title) and return the
/// last entry's rect before any scroll, plus the open popup's rect. `opened` is the failure
/// message when the menu did not open.
fn open_tall_edit(h: &mut Harness<'_, EffectcraftApp>, held: bool, opened: &str) -> (Rect, Rect) {
let edit = rect(h, "menu.Edit");
if held {
hold(h, edit.center());
} else {
click_at(h, edit.center());
}
let before = h.query_by_label_contains("Keyboard Shortcuts").expect(opened).rect();
assert!(before.max.y > 520.0, "Edit is taller than the window ({before:?})");
let menu = open_popup(h);
(before, menu)
}
/// With the title still held down (press-and-browse), resting the pointer against the bottom
/// edge of a menu too tall for the window scrolls it from there — a crawl, then a run, as native
/// menus do — so the long list can be walked with no wheel.
#[test]
fn holding_the_title_scrolls_the_menu_from_its_bottom_edge() {
let mut h = harness(1200.0, 520.0);
let (before, menu) = open_tall_edit(&mut h, true, "the Edit menu opens on the press");
// Creeping down the edge: the 18 pt band above the menu's inner bottom lies at one of these
// positions, whatever the frame's margin (they are 6 pt apart).
let mut walked = false;
for step in 0..5 {
move_to(&mut h, pos2(menu.center().x, menu.max.y - 6.0 - 6.0 * step as f32));
h.run_steps(8);
let now = h.query_by_label_contains("Keyboard Shortcuts").expect("still open").rect();
if now.min.y < before.min.y {
walked = true;
break;
}
}
assert!(walked, "resting on the bottom edge of the held-down menu scrolled its entries up");
}
/// Without the title held down — the menu opened by a plain click — the same edge rest scrolls
/// nothing: the wheel (above) is how a click-opened menu scrolls, the pointer alone must not
/// move it (a hover may align the row under it by a few points; a crawl there would be a bug).
#[test]
fn without_the_hold_the_edge_does_not_scroll_the_menu() {
let mut h = harness(1200.0, 520.0);
let (before, menu) = open_tall_edit(&mut h, false, "the Edit menu is open");
for step in 0..5 {
move_to(&mut h, pos2(menu.center().x, menu.max.y - 6.0 - 6.0 * step as f32));
h.run_steps(8);
let now = h.query_by_label_contains("Keyboard Shortcuts").expect("still open").rect();
let crawled = before.min.y - now.min.y;
assert!(crawled < 60.0, "step {step}: the edge scrolled the menu with no title held down ({before:?} -> {now:?})");
}
}
/// Find the actual Edit popup's bottom crawl band while keeping the title press held.
fn held_edit_crawl(h: &mut Harness<'_, EffectcraftApp>) -> Pos2 {
let edit = rect(h, "menu.Edit");
hold(h, edit.center());
let before = h.query_by_label_contains("Keyboard Shortcuts").expect("Edit opens on press").rect();
let menu = open_popup(h);
for step in 0..5 {
let p = pos2(menu.center().x, menu.max.y - 6.0 - 6.0 * step as f32);
move_to(h, p);
h.run_steps(8);
let after = h.query_by_label_contains("Keyboard Shortcuts").expect("Edit stays open").rect();
if after.min.y < before.min.y - 0.1 {
return p;
}
}
panic!("the held Edit menu never crawled from its bottom edge");
}
fn menu_at(h: &mut Harness<'_, EffectcraftApp>, time: f64) {
h.input_mut().time = Some(time);
h.step();
}
/// A discarded layout pass may consume the queued offset once, but must not crawl again for
/// the same RawInput time. A subsequent real iteration must still queue and consume its step.
#[test]
fn menu_edge_crawl_advances_once_on_replay_then_continues_at_a_later_time() {
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
let mut h = harness(1200.0, 520.0);
held_edit_crawl(&mut h);
let discard = Arc::new(AtomicBool::new(true));
h.ctx.on_end_pass(
"menu crawl replay regression",
Arc::new(move |ui| {
if discard.swap(false, Ordering::SeqCst) {
ui.ctx().request_discard("replay an actual held menu");
}
}),
);
let time = h.ctx.input(|i| i.time) + 1.0 / 60.0;
menu_at(&mut h, time);
assert_eq!(h.output().platform_output.num_completed_passes, 2, "the actual menu was replayed");
let once = h.query_by_label_contains("Keyboard Shortcuts").unwrap().rect();
for _ in 0..3 {
menu_at(&mut h, time);
assert_eq!(h.query_by_label_contains("Keyboard Shortcuts").unwrap().rect(), once, "same-time replay cannot queue another crawl");
}
let later = time + 1.0 / 60.0;
menu_at(&mut h, later);
menu_at(&mut h, later);
let advanced = h.query_by_label_contains("Keyboard Shortcuts").unwrap().rect();
assert!(advanced.min.y < once.min.y - 0.1, "a later input time advances the held menu ({once:?} -> {advanced:?})");
menu_at(&mut h, later);
assert_eq!(h.query_by_label_contains("Keyboard Shortcuts").unwrap().rect(), advanced, "the later time also takes only one step");
}
/// The actual menu viewport's vertical range, rather than its outer Area/frame rectangle.
/// Popup's accessibility parent is its title, so background panels and other menus cannot match.
fn menu_scrollbar(h: &Harness<'_, EffectcraftApp>, title: &str) -> Rect {
let menu = h.get_by_label(title);
let bars: Vec<_> = menu.query_all_by_role(egui::accesskit::Role::ScrollBar).map(|node| node.rect()).filter(|r| r.height() > r.width()).collect();
assert_eq!(bars.len(), 1, "{title}: exactly one actual vertical menu scrollbar ({bars:?})");
let bar = bars[0];
assert!(bar.height() > 36.0, "{title}: both crawl bands fit inside the viewport ({bar:?})");
bar
}
/// Exercise the real popup, its bounds, band exit and subsequent wheel input. Switching to a
/// different real menu must not consume Edit's pending target or reuse its crawl clock.
#[test]
fn menu_edge_crawl_is_bounded_stops_and_keeps_wheel_and_menu_state_independent() {
let mut h = harness(1200.0, 520.0);
held_edit_crawl(&mut h);
h.run_steps(180);
let bottom = h.query_by_label_contains("Keyboard Shortcuts").unwrap().rect();
assert!(bottom.max.y <= 520.0, "the last item is inside the viewport at the bottom ({bottom:?})");
h.run_steps(20);
assert_eq!(h.query_by_label_contains("Keyboard Shortcuts").unwrap().rect(), bottom, "the bottom bound stops the crawl");
let bar = menu_scrollbar(&h, "Edit");
let x = bar.min.x - 12.0; // Inside the menu content, not on the scroll-bar track.
let top_band = pos2(x, bar.min.y + 6.0);
eprintln!("Edit actual viewport scrollbar {bar:?}; top-band input {top_band:?}");
move_to(&mut h, top_band);
h.run_steps(180);
let top = h.query_by_label_contains("Quick Apply").unwrap().rect();
assert!(top.min.y >= 0.0 && top.max.y <= 520.0, "the first entries return into view ({top:?}); actual bar {bar:?}, pointer {top_band:?}");
h.run_steps(20);
assert_eq!(h.query_by_label_contains("Quick Apply").unwrap().rect(), top, "the top bound stops the crawl");
let last_at_top = h.query_by_label_contains("Keyboard Shortcuts").unwrap().rect();
move_to(&mut h, pos2(x, bar.max.y - 6.0));
h.run_steps(15);
let scrolled = h.query_by_label_contains("Keyboard Shortcuts").unwrap().rect();
assert!(scrolled.min.y < last_at_top.min.y - 0.1, "the bottom band starts scrolling again ({last_at_top:?} -> {scrolled:?})");
move_to(&mut h, pos2(x, bar.center().y));
h.run_steps(2); // Consume the last one-shot target before checking the stopped menu.
let stopped = h.query_by_label_contains("Keyboard Shortcuts").unwrap().rect();
h.run_steps(20);
assert_eq!(h.query_by_label_contains("Keyboard Shortcuts").unwrap().rect(), stopped, "leaving the band stops the crawl");
assert!(
last_at_top.min.y > stopped.min.y + 1.0,
"there is upward wheel room: bottom {bottom:?}, top {last_at_top:?}, resumed {scrolled:?}, stopped {stopped:?}"
);
wheel(&mut h, pos2(x, bar.center().y), 120.0);
let wheeled_up = h.query_by_label_contains("Keyboard Shortcuts").unwrap().rect();
assert!(
wheeled_up.min.y > stopped.min.y + 1.0,
"upward wheel input moves the menu after the one-shot target: bottom {bottom:?}, top {last_at_top:?}, resumed {scrolled:?}, stopped {stopped:?}, wheeled {wheeled_up:?}"
);
assert!(wheeled_up.min.y > bottom.min.y + 1.0, "there is downward wheel room ({bottom:?} -> {wheeled_up:?})");
wheel(&mut h, pos2(x, bar.center().y), -120.0);
let wheeled = h.query_by_label_contains("Keyboard Shortcuts").unwrap().rect();
assert!(
wheeled.min.y < wheeled_up.min.y - 1.0,
"wheel input still moves the menu after the one-shot target ({wheeled_up:?} -> {wheeled:?}); bottom {bottom:?}, top {last_at_top:?}, resumed {scrolled:?}, stopped {stopped:?}"
);
// Queue another Edit crawl, then switch titles before its next draw can consume the target.
move_to(&mut h, pos2(x, bar.max.y - 6.0));
h.run_steps(15);
let file = rect(&h, "menu.File");
move_to(&mut h, file.center());
let fresh = h.query_by_label_contains("Open Project").expect("File opens while browsing").rect();
assert!(fresh.min.y >= file.max.y && fresh.max.y <= 520.0, "File starts at its own top, not Edit's offset ({fresh:?})");
h.run_steps(12);
assert_eq!(h.query_by_label_contains("Open Project").unwrap().rect(), fresh, "Edit's crawl does not continue in File");
// Release on its title rather than an entry, retaining a click-opened popup when possible.
h.input_mut().events.push(Event::PointerButton { pos: file.center(), button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
if h.query_by_label_contains("Open Project").is_none() {
click_at(&mut h, file.center());
}
let bar = menu_scrollbar(&h, "File");
let x = bar.min.x - 12.0;
let released = h.query_by_label_contains("Open Project").unwrap().rect();
move_to(&mut h, pos2(x, bar.max.y - 6.0));
h.run_steps(20);
assert_eq!(h.query_by_label_contains("Open Project").unwrap().rect(), released, "without the hold, the edge does not restart a crawl");
}
/// A scroll bar's "scroll/max" automation label.
fn scrolled(h: &Harness<'_, EffectcraftApp>, id: &str) -> (f32, f32) {
let label = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).label.clone();
let (s, max) = label.split_once('/').unwrap_or_else(|| panic!("{id}: {label}"));
(s.parse().unwrap(), max.parse().unwrap())
}
/// Every panel that scrolls its own rows shows a scroll bar while they overflow, and dragging it
/// scrolls them: without a mouse wheel or touchpad there was no way down most panels (#271).
#[test]
fn panels_taller_than_their_space_have_a_scroll_bar_to_drag() {
use effectcraft_ui_egui::dock::PanelKind;
// (The Project panel's list starts below its item preview and search: a taller window.)
for (panel, id, height) in [
(PanelKind::EffectControls, "effectControls.scroll", 240.0),
(PanelKind::Properties, "properties.scroll", 240.0),
(PanelKind::EffectsPresets, "effects.scroll", 240.0),
(PanelKind::Project, "project.vscroll", 330.0),
(PanelKind::Timeline, "timeline.scroll", 240.0),
(PanelKind::RenderQueue, "renderQueue.scroll", 240.0),
(PanelKind::History, "history.scroll", 240.0),
(PanelKind::Preview, "preview.scroll", 240.0),
(PanelKind::Character, "character.scroll", 240.0),
(PanelKind::Paragraph, "paragraph.scroll", 240.0),
(PanelKind::Info, "info.scroll", 240.0),
] {
let mut h = harness(1200.0, height);
let app = h.state_mut();
let lid = app.session.active_comp().unwrap().layers[0].id.0;
app.session.execute("layer.select", json!({"layers": [lid]})).unwrap();
for e in ["slider", "angle", "checkbox", "color", "point"] {
app.session.execute("effect.apply", json!({"effect": format!("ec.control.{e}"), "layers": [lid]})).unwrap();
}
for _ in 0..6 {
app.session.execute("renderQueue.add", json!({})).unwrap();
}
app.show_panel(panel);
app.ui.maximized = Some(panel);
h.run_steps(3);
let (_, max) = scrolled(&h, id);
assert!(max > 0.0, "{id}: the rows overflow");
let bar = rect(&h, id);
// A press on the track moves the thumb there (History starts at its current state, the
// last); then the thumb drags to the end.
click_at(&mut h, pos2(bar.center().x, bar.min.y + 2.0));
assert_eq!(scrolled(&h, id).0, 0.0, "{id}: at the top");
drag(&mut h, pos2(bar.center().x, bar.min.y + 6.0), pos2(bar.center().x, bar.max.y + 40.0));
assert_eq!(scrolled(&h, id).0, max, "{id}: dragged to the end");
}
}
/// In a narrow Effect Controls panel an animated point's values stay clear of its keyframe
/// navigator (â—€ â—† â–¶), which was drawn over them (#271).
#[test]
fn effect_values_stay_clear_of_the_keyframe_navigator() {
use effectcraft_ui_egui::dock::PanelKind;
let mut h = harness(1600.0, 1000.0);
let app = h.state_mut();
let lid = app.session.active_comp().unwrap().layers[0].id.0;
app.session.execute("layer.select", json!({"layers": [lid]})).unwrap();
app.session.execute("effect.apply", json!({"effect": "ec.control.point", "layers": [lid]})).unwrap();
let path = "effects/#1/point";
app.session.execute("prop.toggleAnimation", json!({"layer": lid, "path": path})).unwrap();
app.session.execute("prop.set", json!({"layer": lid, "path": path, "value": [123456.7, 123456.7]})).unwrap();
let uid = app.session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(lid)).unwrap().props.prop(path).unwrap().uid;
assert!(app.edit_layout(|l| l.float(PanelKind::EffectControls, [300.0, 150.0, 330.0, 500.0])));
h.run_steps(3);
let value = rect(&h, &format!("effectControls.prop.{uid}.value.1"));
let nav = rect(&h, &format!("effectControls.prop.{uid}.prevKey"));
assert!(value.max.x <= nav.min.x, "the value ({value:?}) ends before the navigator ({nav:?})");
assert!(value.min.x > rect(&h, &format!("effectControls.prop.{uid}.stopwatch")).max.x, "and after the stopwatch");
}
/// The Settings dialog fits its width: its page column was wider than the dialog, so the whole
/// dialog scrolled sideways a little (#284).
#[test]
fn settings_dialog_does_not_scroll_sideways() {
let mut h = harness(1400.0, 900.0);
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "app.settings", json!({"page": "general"})).unwrap();
h.run_steps(3);
let (ok, page) = (rect(&h, "settings.ok"), rect(&h, "settings.page.general"));
h.input_mut().events.push(Event::PointerMoved(page.center()));
h.input_mut().events.push(Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(-400.0, 0.0),
modifiers: Default::default(),
phase: egui::TouchPhase::Move,
source: egui::MouseWheelSource::Wheel,
});
h.run_steps(20);
assert_eq!((rect(&h, "settings.ok"), rect(&h, "settings.page.general")), (ok, page), "nothing moved sideways");
}
/// #2034: the Settings sidebar uses the translated page names when deciding its width.
#[test]
fn settings_page_column_fits_japanese_names() {
use effectcraft_text::fonts;
use effectcraft_ui_egui::theme::Tokens;
let mut session = Session::default();
session.prefs.general.language = "ja".into();
let app = EffectcraftApp::new(session);
// The panel titles a page with its Settings menu row, minus the ellipsis.
let name = effectcraft_ui_egui::menus::menu_items(&app)
.into_iter()
.find(|m| m.id == "app.settings" && m.params["page"] == "scripting")
.and_then(|m| m.label.strip_suffix("...").map(str::to_owned))
.expect("the Settings menu lists the Scripting & Expressions page");
assert!(name.chars().any(|c| c >= '\u{3000}'), "the actual sidebar label is Japanese: {name}");
// Use the same external-directory scan and face coverage as production, before rendering.
// The managed job supplies CRAFT_FONTS_DIR; register its canonical fonts folder through the
// same directory source production scans for EFFECTCRAFT_FONT_DIRS, without changing the env.
crate::add_canonical_test_fonts();
fonts::scan_system();
let base = fonts::resolve("Inter", "Regular").face;
let japanese = fonts::face(fonts::fallback_for_locale('あ', base, "ja"));
for ch in ['あ', 'フ'].into_iter().chain(name.chars()) {
assert!(japanese.has_char(ch), "Japanese fixture lacks {ch:?} for {name:?}: {:?}", japanese.info);
}
let required_canonical = std::env::var("CRAFT_FONTS_REQUIRED").as_deref() == Ok("1");
if required_canonical {
let root = std::env::var_os("CRAFT_FONTS_DIR").expect("required canonical craft-fonts input");
let expected =
std::path::PathBuf::from(root).join("fonts/biz-ud-pgothic/BIZUDPGothic-Regular.ttf").canonicalize().expect("canonical BIZ UDPGothic Regular input");
let resolved = fonts::resolve("BIZ UDPGothic", "Regular");
assert!(!resolved.missing, "required BIZ UDPGothic Regular must resolve without substitution");
let face = fonts::face(resolved.face);
assert_eq!(face.info.family, "BIZ UDPGothic", "{:?}", face.info);
assert_eq!(face.info.style, "Regular", "{:?}", face.info);
assert_eq!(japanese.id, face.id, "required production fallback is the canonical BIZ face: selected {:?}, canonical {:?}", japanese.info, face.info);
let fonts::FaceData::File(path) = &face.info.data else { panic!("required BIZ face must come from canonical external input: {:?}", face.info) };
assert_eq!(path.canonicalize().unwrap(), expected, "required face source: {:?}", face.info);
let expected_bytes = std::fs::read(&expected).unwrap();
let font = face.font().expect("required canonical BIZ face parses");
assert!(font.data().as_bytes() == expected_bytes.as_slice(), "resolved BIZ bytes match canonical input {expected:?}");
for ch in ['あ', 'フ'].into_iter().chain(name.chars()) {
assert!(face.has_char(ch), "canonical BIZ input lacks actual label glyph {ch:?}: {:?}", face.info);
}
eprintln!("required Japanese input {expected:?}: {:?}", face.info);
}
let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(|_| app);
h.run_steps(3);
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "app.settings", json!({"page": "scripting"})).unwrap();
h.run_steps(3);
h.ctx.fonts_mut(|fonts| {
let definitions = fonts.definitions();
assert!(
definitions.font_data.contains_key("japanese-system"),
"production installs the Japanese fallback; installed {:?}",
definitions.font_data.keys().collect::<Vec<_>>()
);
assert!(
definitions.families.get(&egui::FontFamily::Proportional).unwrap().iter().any(|name| name == "japanese-system"),
"production proportional stack reaches its Japanese fallback"
);
if required_canonical {
let registered = definitions.font_data.get("japanese-system").unwrap();
let selected = japanese.font().expect("selected canonical Japanese face parses");
assert_eq!(registered.index, japanese.info.index, "production registration retains selected face index");
assert!(
registered.font.as_ref().as_ref() == selected.data().as_bytes(),
"production japanese-system bytes equal the selected canonical fallback: {:?}",
japanese.info
);
}
assert!(fonts.has_glyphs(&Tokens::ui(12.5), &name), "production glyph lookup covers actual Japanese sidebar label {name:?}");
});
let text_width = h.ctx.fonts_mut(|fonts| fonts.layout_no_wrap(name.clone(), Tokens::ui(12.5), egui::Color32::WHITE).size().x);
let row = rect(&h, "settings.page.scripting");
assert!(row.width() >= text_width + 20.0, "{name} needs {text_width} points; row is {row:?}");
}
@@ -123,7 +123,7 @@ fn dragged_tabs_land_where_the_insertion_mark_is() {
assert_eq!(group_of(&dock(&h), PanelKind::Timeline), Some(vec![PanelKind::RenderQueue, PanelKind::Timeline, PanelKind::EffectControls]));
}
/// `cargo test -p effectcraft-ui-egui --test ui_tabs -- --ignored`: the insertion mark mid-drag
/// `cargo test -p effectcraft-ui-egui --test it ui_tabs:: -- --ignored`: the insertion mark mid-drag
/// (`target/test-out/tab-drag.png`).
#[test]
#[ignore]
@@ -25,7 +25,7 @@ fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))
}
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) {
pub(crate) 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: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
@@ -197,6 +197,7 @@ fn layer_style_controls_stay_reachable_in_a_small_window() {
delta: vec2(-2000.0, -2000.0),
modifiers: Default::default(),
phase: egui::TouchPhase::Move,
source: egui::MouseWheelSource::Wheel,
});
h.run_steps(5);
let at = in_view(&h, "dialog.layerStyle.value.dropShadow/noise");
File diff suppressed because it is too large Load Diff
@@ -194,3 +194,76 @@ fn a_bar_on_a_box_handle_leaves_it_the_press() {
assert_eq!(layer.props.find(start).unwrap().value.as_f64(), 0.0, "Start stays");
assert_ne!(scale(&h), before, "the handle scaled the layer");
}
/// The text the Timeline paints in property `uid`'s value field.
fn timeline_value(h: &Harness<'_, EffectcraftApp>, uid: u64) -> String {
let e = h.state().auto.find(&format!("timeline.prop.{uid}.value")).unwrap_or_else(|| panic!("timeline.prop.{uid}.value isn't shown"));
let field = egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]));
h.output()
.shapes
.iter()
.filter_map(|c| match &c.shape {
egui::Shape::Text(t) if field.contains(t.visual_bounding_rect().center()) => Some(t.galley.text().to_string()),
_ => None,
})
.collect()
}
/// The centre of the first painted text that reads `label` exactly (a popup's option).
fn text_at(h: &Harness<'_, EffectcraftApp>, label: &str) -> Option<Pos2> {
h.output().shapes.iter().find_map(|c| match &c.shape {
egui::Shape::Text(t) if t.galley.text() == label => Some(t.visual_bounding_rect().center()),
_ => None,
})
}
fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
h.event(Event::PointerMoved(p));
h.step();
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
/// #1814: with Units = Index the Timeline shows the Range Selector's Start, End and Offset as
/// counts, without the % sign they carry with Units = Percentage. Units is changed the way a
/// user changes it: its Timeline popup, then the Index option.
#[test]
fn index_units_show_start_end_and_offset_without_a_percent_sign() {
let (s, ids) = setup();
let l = LayerId(ids.layer);
let layer = s.active_comp().unwrap().layer(l).unwrap().clone();
let text = layer.props.sub("text").unwrap();
let anim = animator(&s, ids.layer);
let selectors = anim.sub("selectors").unwrap();
let sel = selectors.groups().next().unwrap();
let (offset, units) = (sel.get("offset").unwrap().uid, sel.prop("advanced/units").unwrap().uid);
let advanced = sel.sub("advanced").unwrap().uid;
let mut h = harness(s);
let tl = &mut h.state_mut().ui.timeline;
tl.open_layers.insert(ids.layer);
let animators = text.sub("animators").unwrap().uid;
tl.open_groups.extend([text.uid, animators, anim.uid, selectors.uid, sel.uid, advanced]);
h.run_steps(3);
for uid in [ids.start, ids.end, offset] {
assert!(timeline_value(&h, uid).ends_with('%'), "Percentage units: {}", timeline_value(&h, uid));
}
// Units ▸ Index through the Timeline: open its popup, pick the option.
let units_value = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(l).unwrap().props.find(units).unwrap().value.clone();
assert_eq!(units_value(&h), effectcraft_engine::keyframe::Value::Enum(0), "Percentage to start with");
let field = h
.state()
.auto
.find(&format!("timeline.prop.{units}.value"))
.map(|e| pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0))
.expect("Units is shown");
click_at(&mut h, field);
let index = text_at(&h, "Index").expect("the Units popup lists Index");
click_at(&mut h, index);
assert_eq!(units_value(&h), effectcraft_engine::keyframe::Value::Enum(1), "the popup set Units to Index");
for uid in [ids.start, ids.end, offset] {
let v = timeline_value(&h, uid);
assert!(!v.is_empty() && !v.contains('%'), "Index units: {v:?}");
}
}
@@ -42,7 +42,8 @@ fn harness(graph: bool) -> Harness<'static, EffectcraftApp> {
fn send(h: &mut Harness<'_, EffectcraftApp>, method: &str, params: Value) {
let ctx = h.ctx.clone();
let (req, _rx) = ControlRequest::new(method, params);
if let Outcome::Done(v) = control::handle(h.state_mut(), &ctx, &req) {
let now = h.state().now();
if let Outcome::Done(v) = control::handle(h.state_mut(), &ctx, &req, now) {
assert!(v["ok"] != false, "{method}: {v}");
}
for e in h.state_mut().take_synthetic_input() {
@@ -0,0 +1,61 @@
//! Spacebar-drag panning in the Timeline: the Hand tool scrolls the layer rows vertically as well
//! as the time graph horizontally (#2083).
use effectcraft_engine::Session;
use effectcraft_ui_egui::EffectcraftApp;
use egui::{Event, Modifiers, Pos2, pos2, vec2};
use egui_kittest::Harness;
use serde_json::json;
/// A 10 s comp with 40 solids, enough rows to scroll.
fn harness() -> (Harness<'static, EffectcraftApp>, u64) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Many", "width": 320, "height": 180, "frameRate": 30, "duration": 10})).unwrap();
let mut top = 0;
for i in 0..40 {
top = s.execute("layer.newSolid", json!({"name": format!("Layer {i}"), "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
}
s.execute("layer.select", json!({"layers": []})).unwrap();
let app = EffectcraftApp::new(s);
let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).build_eframe(|_| app);
h.run_steps(4);
(h, top)
}
fn space(h: &mut Harness<'_, EffectcraftApp>, pressed: bool) {
h.event(Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE });
h.step();
}
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) {
h.event(Event::PointerMoved(from));
h.step();
h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
for k in 1..=10 {
h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 10.0)));
h.step();
}
h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
#[test]
fn spacebar_drag_pans_the_rows_vertically() {
let (mut h, layer) = harness();
assert_eq!(h.state().ui.timeline.scroll_y, 0.0);
let bar = h.state().auto.find(&format!("timeline.layer.{layer}.bar")).expect("layer bar").rect;
let from = pos2(bar[0] + 40.0, bar[1] + bar[3] / 2.0);
let timing = |h: &Harness<'_, EffectcraftApp>| {
let l = &h.state().session.active_comp().unwrap().layers[0];
(l.in_point.seconds(), l.out_point.seconds(), l.start_time.seconds())
};
let before = timing(&h);
space(&mut h, true);
drag(&mut h, from, from - vec2(0.0, 180.0));
space(&mut h, false);
let y = h.state().ui.timeline.scroll_y;
assert!(y > 100.0, "the rows scrolled vertically: {y}");
assert_eq!(h.state().ui.timeline.start, 0.0, "the time graph did not pan sideways");
assert_eq!(timing(&h), before, "the layer under the hand did not move");
}
@@ -75,6 +75,29 @@ fn shape_contents_add_menu_adds_path_operations() {
assert_eq!(items, ["group", "trim"]);
}
/// A shape group's inner Contents is elided in After Effects: opening the group lists its items
/// (here the path) directly, with no "Contents" row between them.
#[test]
fn shape_group_contents_has_no_inner_contents_row() {
let mut s = session();
let l = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap();
let layer = s.active_comp().unwrap().layer(LayerId(l)).unwrap().clone();
let contents = layer.props.sub("contents").unwrap().clone();
let group = contents.groups().next().unwrap().clone();
let inner = group.sub("contents").unwrap().clone();
let item = inner.groups().next().unwrap().clone();
let mut h = harness(s);
h.state_mut().ui.timeline.open_layers.insert(l);
h.state_mut().ui.timeline.open_groups.insert(contents.uid);
h.state_mut().ui.timeline.open_groups.insert(group.uid);
h.run_steps(2);
let name = |uid: u64| format!("timeline.group.{uid}.name");
assert!(h.state().auto.find(&name(contents.uid)).is_some(), "the layer's Contents row");
assert!(h.state().auto.find(&name(group.uid)).is_some(), "the shape group row");
assert!(h.state().auto.find(&name(inner.uid)).is_none(), "no inner Contents row");
assert!(h.state().auto.find(&name(item.uid)).is_some(), "the path lists directly under the group");
}
#[test]
fn classic_3d_shape_layers_offer_change_renderer() {
let mut s = session();
@@ -119,3 +119,17 @@ fn dragging_bar_ends_trims_in_and_out() {
let (i, o, s) = timing(&h, pre);
assert!((i - 3.0).abs() < 1e-6 && (o - 8.0).abs() < 1.5 / 30.0 && s.abs() < 1e-6, "out extended to ~8 s, not slipped: in {i} out {o} start {s}");
}
/// With Frames and Frame Count Start at 1, In moves to 00061 but the Duration column still counts
/// the layer's 120 frames (#1807).
#[test]
fn duration_column_counts_frames_from_any_frame_count() {
let (mut h, solid, _) = harness();
effectcraft_ui_egui::panels::timeline::set_column(&mut h.state_mut().ui.timeline, "in", true).unwrap();
effectcraft_ui_egui::panels::timeline::set_column(&mut h.state_mut().ui.timeline, "duration", true).unwrap();
h.state_mut().session.execute("file.projectSettings", json!({"timeDisplay": "frames", "frameStart": 1})).unwrap();
h.run_steps(3);
let cell = |h: &Harness<'_, EffectcraftApp>, kind: &str| h.state().auto.find(&format!("timeline.layer.{solid}.{kind}")).expect("cell").label.clone();
assert_eq!(cell(&h, "in"), "00061");
assert_eq!(cell(&h, "duration"), "00120");
}
@@ -30,7 +30,13 @@ fn over_bar(h: &Harness<'_, EffectcraftApp>, layer: u64) -> Pos2 {
fn alt_wheel(h: &mut Harness<'_, EffectcraftApp>, at: Pos2, dy: f32) {
h.event(Event::PointerMoved(at));
h.event(Event::ModifiersChanged(Modifiers::ALT));
h.event(Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, dy), modifiers: Modifiers::ALT, phase: egui::TouchPhase::Move });
h.event(Event::MouseWheel {
unit: MouseWheelUnit::Point,
delta: vec2(0.0, dy),
modifiers: Modifiers::ALT,
phase: egui::TouchPhase::Move,
source: egui::MouseWheelSource::Wheel,
});
h.step();
h.event(Event::ModifiersChanged(Modifiers::NONE));
h.step();
@@ -78,7 +84,13 @@ fn ctrl_wheel_and_pinch_zoom_time() {
let at = over_bar(&h, layer);
h.event(Event::PointerMoved(at));
h.event(Event::ModifiersChanged(Modifiers::COMMAND));
h.event(Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, 240.0), modifiers: Modifiers::COMMAND, phase: egui::TouchPhase::Move });
h.event(Event::MouseWheel {
unit: MouseWheelUnit::Point,
delta: vec2(0.0, 240.0),
modifiers: Modifiers::COMMAND,
phase: egui::TouchPhase::Move,
source: egui::MouseWheelSource::Wheel,
});
h.step();
h.event(Event::ModifiersChanged(Modifiers::NONE));
h.step();
@@ -21,7 +21,7 @@ fn an_audio_only_preview_says_video_preview_is_off() {
s.prefs.preview.get_mut(sc).include_video = false;
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(4);
let chip = |h: &Harness<'_, EffectcraftApp>| h.ctx.data(|d| d.get_temp::<egui::Rect>(egui::Id::new("viewer-video-off-chip")));
let chip = |h: &Harness<'_, EffectcraftApp>| h.ctx.data(|d| d.get_temp::<egui::Rect>(egui::Id::unique("viewer-video-off-chip")));
assert!(chip(&h).is_none());
let now = h.ctx.input(|i| i.time);
h.state_mut().play(now);
@@ -30,7 +30,7 @@ fn an_audio_only_preview_says_video_preview_is_off() {
assert!(chip(&h).is_some(), "the viewer shows the Video-off notice");
assert_eq!(frame(&h), 0, "the current time stays put with Video off");
h.state_mut().stop();
h.ctx.data_mut(|d| d.remove::<egui::Rect>(egui::Id::new("viewer-video-off-chip")));
h.ctx.data_mut(|d| d.remove::<egui::Rect>(egui::Id::unique("viewer-video-off-chip")));
h.run_steps(3);
assert!(h.state().video_preview_off().is_none());
assert!(chip(&h).is_none(), "no notice once stopped");
@@ -193,7 +193,8 @@ fn each_comp_keeps_its_viewer_resolution() {
assert_eq!(h.state().viewer_res(), Resolution::Auto);
let (req, _reply) = effectcraft_ui_egui::control::ControlRequest::new("ui.set", json!({"viewer": {"res": "quarter"}}));
let ctx = h.ctx.clone();
let _ = effectcraft_ui_egui::control::handle(h.state_mut(), &ctx, &req);
let now = h.state().now();
let _ = effectcraft_ui_egui::control::handle(h.state_mut(), &ctx, &req, now);
assert_eq!(h.state().viewer_res(), Resolution::Quarter);
let ctx = h.ctx.clone();
assert_eq!(effectcraft_ui_egui::control::inspect(h.state(), &ctx)["ui"]["viewer"]["res"], json!("Quarter"));
@@ -759,6 +760,74 @@ fn path_editing_hides_layer_bounds_and_keeps_corner_vertices_editable() {
}
}
/// #1769: the eye hides a layer's pixels, not the controls of a selected AV layer: a hidden shape
/// layer keeps its transform box, anchor and shape path, and its handles still scale it.
#[test]
fn hidden_shape_layer_keeps_its_controls_and_can_still_scale() {
let mut h = harness();
let (layer, path) = {
let r = h
.state_mut()
.session
.execute(
"shape.newPath",
json!({
"vertices": [[200.0, 100.0], [400.0, 100.0], [400.0, 260.0], [200.0, 260.0]],
"closed": true,
"fill": false,
"stroke": "#ffffff",
"strokeWidth": 4
}),
)
.unwrap();
(r["layer"].as_u64().unwrap(), r["path"].as_u64().unwrap())
};
h.state_mut().session.execute("layer.select", json!({"layers": [layer]})).unwrap();
h.state_mut().session.state.selected_vertices.clear();
h.state_mut().session.execute("layer.setSwitch", json!({"layers": [layer], "switch": "video", "value": false})).unwrap();
h.run_steps(2);
let shown = h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap().clone();
assert!(!shown.switches.video);
assert!(shown.source.is_av());
assert_eq!(h.state().session.state.selected_layers, vec![LayerId(layer)]);
assert!(h.state().auto.find(&format!("viewer.handle.{layer}.0")).is_some(), "a hidden shape keeps its transform box");
assert!(h.state().auto.find(&format!("viewer.anchor.{layer}")).is_some(), "and its anchor");
assert!(h.state().auto.find(&format!("viewer.shapePath.{path}.vertex.0")).is_some(), "and its shape path");
let scale = |h: &Harness<'_, EffectcraftApp>| {
h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("transform/scale").unwrap().value.as_vec3()
};
let before = scale(&h);
let from = rect(&h, &format!("viewer.handle.{layer}.5")).center();
drag(&mut h, from, from + vec2(24.0, 0.0));
assert_ne!(scale(&h), before, "a hidden layer's handle still scales it");
}
/// #1769: a hidden masked layer keeps its mask path and vertices, and they remain editable.
#[test]
fn hidden_masked_layer_keeps_its_mask_path_editable() {
let mut h = harness();
let plate = layer_id(&h, "Plate");
let corners = [[100.0, 60.0], [500.0, 60.0], [500.0, 300.0], [100.0, 300.0]];
let mask = {
let s = &mut h.state_mut().session;
s.execute("layer.select", json!({"layers": [plate]})).unwrap();
let m = s.execute("mask.new", json!({"layer": plate, "vertices": corners, "closed": true})).unwrap()["mask"].as_u64().unwrap();
s.state.selected_vertices.clear();
s.execute("layer.setSwitch", json!({"layers": [plate], "switch": "video", "value": false})).unwrap();
m
};
h.run_steps(2);
assert!(h.state().auto.find(&format!("viewer.handle.{plate}.0")).is_some(), "a hidden masked layer keeps its transform box");
let vertex = format!("viewer.mask.{mask}.vertex.0");
assert!(h.state().auto.find(&vertex).is_some(), "and its mask path");
let before = mask_path(&h, "Plate");
let from = rect(&h, &vertex).center();
drag(&mut h, from, from + vec2(15.0, 12.0));
let after = mask_path(&h, "Plate");
assert_ne!(after.vertices[0], before.vertices[0], "the hidden layer's mask vertex can still be moved");
assert_eq!(after.vertices[1..], before.vertices[1..]);
}
#[test]
fn path_tools_keep_bounds_on_layers_without_visible_paths() {
let mut h = harness();
@@ -1802,6 +1871,14 @@ fn key_twice(h: &mut Harness<'_, EffectcraftApp>, k: egui::Key) {
#[test]
fn property_shortcuts_reveal_and_key_transform_properties() {
let mut h = harness();
// Reveal shortcuts compare wall-clock instants; drive the app clock so a step count
// cannot decide whether a second press is "quick" or a later one.
h.state_mut().use_manual_clock();
h.run_steps(1);
let wait = |h: &mut Harness<'_, EffectcraftApp>| {
h.state_mut().clock_mut().advance(1.0);
h.run_steps(2);
};
h.state_mut().session.execute("layer.select", json!({"layers": ["Box"]})).unwrap();
h.run_steps(2);
let t = |h: &Harness<'_, EffectcraftApp>, p: &str| shown(h, prop_uid(h, "Box", &format!("transform/{p}")));
@@ -1838,16 +1915,16 @@ fn property_shortcuts_reveal_and_key_transform_properties() {
h.run_steps(2);
key(&mut h, egui::Key::U, none);
only(&h, &["opacity"]);
h.run_steps(40);
wait(&mut h);
key(&mut h, egui::Key::U, none);
only(&h, &[]);
h.state_mut().session.execute("prop.set", json!({"layer": "Box", "path": "transform/rotation", "value": 30})).unwrap();
h.run_steps(40);
wait(&mut h);
key_twice(&mut h, egui::Key::U);
assert!(t(&h, "opacity") && t(&h, "rotation"), "UU: animated and changed properties");
assert!(!t(&h, "scale"), "an untouched property stays hidden");
// Ctrl+`: twirl the selected layer open, then closed.
h.run_steps(40);
wait(&mut h);
key(&mut h, egui::Key::Backtick, egui::Modifiers::COMMAND);
let id = layer_id(&h, "Box");
assert!(h.state().ui.timeline.open_layers.contains(&id) && h.state().ui.timeline.reveal.is_empty());
@@ -1858,6 +1935,14 @@ fn property_shortcuts_reveal_and_key_transform_properties() {
#[test]
fn double_press_shortcuts_reveal_their_second_set() {
let mut h = harness();
// Reveal shortcuts compare wall-clock instants; drive the app clock so a step count
// cannot decide whether a second press is "quick" or a later one.
h.state_mut().use_manual_clock();
h.run_steps(1);
let wait = |h: &mut Harness<'_, EffectcraftApp>| {
h.state_mut().clock_mut().advance(1.0);
h.run_steps(2);
};
h.state_mut().session.execute("layer.select", json!({"layers": ["Box"]})).unwrap();
h.state_mut().session.execute("prop.setExpression", json!({"layer": "Box", "path": "transform/scale", "expression": "value"})).unwrap();
h.run_steps(2);
@@ -1866,24 +1951,24 @@ fn double_press_shortcuts_reveal_their_second_set() {
key_twice(&mut h, egui::Key::E);
assert_eq!(reveal(&h), vec!["expressions"]);
assert!(shown(&h, prop_uid(&h, "Box", "transform/scale")));
h.run_steps(40);
wait(&mut h);
// TT: Mask Opacity; MM: every mask property; a single M: Mask Path.
key_twice(&mut h, egui::Key::T);
assert_eq!(reveal(&h), vec!["maskOpacity"]);
h.run_steps(40);
wait(&mut h);
key(&mut h, egui::Key::M, Default::default());
assert_eq!(reveal(&h), vec!["maskPath"]);
h.run_steps(40);
wait(&mut h);
key_twice(&mut h, egui::Key::M);
assert_eq!(reveal(&h), vec!["masks"]);
h.run_steps(40);
wait(&mut h);
// RR Time Remap, AA Material Options, PP paint/puppet, SS selected properties, FF missing effects.
for (k, want) in
[(egui::Key::R, "timeRemap"), (egui::Key::A, "material"), (egui::Key::P, "paint"), (egui::Key::S, "selected"), (egui::Key::F, "missingEffects")]
{
key_twice(&mut h, k);
assert_eq!(reveal(&h), vec![want], "{k:?}{k:?}");
h.run_steps(40);
wait(&mut h);
}
}
@@ -134,7 +134,7 @@ fn a_locked_viewer_keeps_its_comp_when_the_project_panel_opens_another() {
assert_eq!((h.state().ui.active_viewer, shows(&h, 0), shows(&h, 1)), (1, Some(a), Some(b)));
}
/// `cargo test -p effectcraft-ui-egui --test ui_viewers -- --ignored`: two viewers side by side
/// `cargo test -p effectcraft-ui-egui --test it ui_viewers:: -- --ignored`: two viewers side by side
/// (`target/test-out/viewers.png`).
#[test]
#[ignore]
@@ -172,7 +172,7 @@ fn gpu_worker_renders_effect_frames_and_the_disk_cache_survives_a_reload() {
assert!(frames2.remote_disk());
let until = std::time::Instant::now() + std::time::Duration::from_secs(5);
let bar = loop {
let bar = frames2.disk_frames(&source(&s, None), &key(fx), 30, &opts);
let bar = frames2.disk_frames(&source(&s, None), &key(fx), 0..30, &opts);
if !bar.is_empty() {
break bar;
}
@@ -211,7 +211,8 @@ impl StorageHost for Storage {
fn send(h: &mut Harness<'_, EffectcraftApp>, method: &str, params: Value) {
let ctx = h.ctx.clone();
let (req, _rx) = ControlRequest::new(method, params);
if let Outcome::Done(v) = control::handle(h.state_mut(), &ctx, &req) {
let now = h.state().now();
if let Outcome::Done(v) = control::handle(h.state_mut(), &ctx, &req, now) {
assert!(v["ok"] != false, "{method}: {v}");
}
// kittest doesn't call `raw_input_hook`: feed the synthetic click in.
@@ -57,7 +57,7 @@ fn page() -> (Session, Arc<Manual>) {
s.execute("prop.addKey", json!({"layer": l, "path": "effects/#1/transform/offset", "time": 0.0, "value": [0.5, 0.5]})).unwrap();
s.execute("prop.addKey", json!({"layer": l, "path": "effects/#1/transform/offset", "time": 0.5, "value": [0.6, 0.56]})).unwrap();
s.execute("effect.apply", json!({"layers": [l], "effect": "ec.blur.gaussian"})).unwrap();
s.execute("prop.set", json!({"layer": l, "path": "effects/#2/blurriness", "value": 3.0})).unwrap();
s.execute("prop.set", json!({"layer": l, "path": "effects/#2/blurriness", "value": 12.0})).unwrap();
(s, off)
}
@@ -299,7 +299,7 @@ fn viewer_context_select_reaches_an_obscured_layer() {
h.step();
}
h.run_steps(3);
h.get_by_label("Select ⏵").click();
h.get_by_label("Select").click();
h.run_steps(3);
h.get_by_label("Tile 1").click();
h.run_steps(3);
@@ -637,3 +637,80 @@ fn timeline_context_menu_creates_shapes_and_masks_from_text() {
assert!(h.query_by_label("Create Shapes from Text").is_none(), "no text conversion for a solid");
assert!(h.query_by_label("Create Masks from Text").is_none(), "no text conversion for a solid");
}
// Regression test for #979 (#958): right-clicking a vector footage layer (SVG) in the Timeline
// or the Composition viewer offered no Create Shapes from Vector Layer; raster layers must not.
#[test]
fn layer_context_menus_offer_create_shapes_from_vector_layer_for_vector_footage() {
use effectcraft_engine::project::LayerSource;
let dir = std::env::temp_dir().join(format!("ec-ui-vector-menu-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let svg = dir.join("logo.svg");
std::fs::write(
&svg,
r##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20" viewBox="0 0 40 20"><rect width="40" height="20" fill="#ff0000"/></svg>"##,
)
.unwrap();
let png = dir.join("photo.png");
image::RgbaImage::from_pixel(16, 16, image::Rgba([0, 128, 255, 255])).save(&png).unwrap();
let mut s = effectcraft_host::session();
let item = s.execute("file.import", json!({"paths": [svg.to_string_lossy()]})).unwrap()["items"][0].as_u64().unwrap();
let raster = s.execute("file.import", json!({"paths": [png.to_string_lossy()]})).unwrap()["items"][0].as_u64().unwrap();
s.execute("comp.new", json!({"name":"Vec","width":320,"height":180,"duration":4})).unwrap();
// The raster layer sits below the SVG layer, so the viewer click below lands on the SVG.
let photo = s.execute("layer.addItem", json!({"item": raster})).unwrap()["layer"].as_u64().unwrap();
let logo = s.execute("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap();
let plate = s.execute("layer.newSolid", json!({"name":"Plate","width":40,"height":40,"color":"#406080"})).unwrap()["layer"].as_u64().unwrap();
s.execute("edit.deselectAll", json!({})).unwrap();
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
let right_click = |h: &mut Harness<'_, EffectcraftApp>, p: Pos2| {
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(3);
};
let row = |h: &Harness<'_, EffectcraftApp>, id: u64| {
let r = rect(h, &format!("timeline.layer.{id}.row"));
pos2(r.min.x + 210.0, r.center().y)
};
let label = "Create Shapes from Vector Layer";
let shapes =
|h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layers.iter().filter(|l| matches!(l.source, LayerSource::Shape)).count();
// A raster-less solid does not offer the conversion.
h.state_mut().session.execute("layer.select", json!({"layers": [plate]})).unwrap();
h.run_steps(3);
let at = row(&h, plate);
right_click(&mut h, at);
assert!(h.query_by_label(label).is_none(), "no vector conversion for a solid");
h.key_press(egui::Key::Escape);
h.run_steps(3);
// Nor does raster footage.
h.state_mut().session.execute("layer.select", json!({"layers": [photo]})).unwrap();
h.run_steps(3);
let at = row(&h, photo);
right_click(&mut h, at);
assert!(h.query_by_label(label).is_none(), "no vector conversion for raster footage");
h.key_press(egui::Key::Escape);
h.run_steps(3);
// The Timeline row of the SVG layer offers it, and it converts the layer.
h.state_mut().session.execute("layer.select", json!({"layers": [logo]})).unwrap();
h.run_steps(3);
let at = row(&h, logo);
right_click(&mut h, at);
h.get_by_label(label).click();
h.run_steps(3);
assert_eq!(shapes(&h), 1, "a shape layer was created from the SVG layer");
// The Composition viewer's menu offers it for the SVG layer under the pointer too.
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.state_mut().session.execute("prop.set", json!({"layer": plate, "path": "transform/position", "value": [40.0, 40.0]})).unwrap();
// The solid stays selected: the menu must follow the layer under the pointer, not the selection.
h.state_mut().session.execute("layer.select", json!({"layers": [plate]})).unwrap();
h.run_steps(3);
let comp = rect(&h, "viewer.comp");
right_click(&mut h, comp.center());
assert!(h.query_by_label(label).is_some(), "the viewer's context menu offers the vector conversion");
let _ = std::fs::remove_dir_all(&dir);
}
@@ -128,23 +128,23 @@ fn tab_and_shift_tab_traverse_toolbar_and_space_activates_only_focused_tool() {
h.run_steps(3);
for _ in 0..8 {
key(&mut h, Key::Tab, Modifiers::NONE);
if h.ctx.memory(|m| m.focused()) == Some(Id::new(("tool-slot", 0usize))) {
if h.ctx.memory(|m| m.focused()) == Some(Id::unique(("tool-slot", 0usize))) {
break;
}
}
assert_eq!(h.ctx.memory(|m| m.focused()), Some(Id::new(("tool-slot", 0usize))));
assert_eq!(h.ctx.memory(|m| m.focused()), Some(Id::unique(("tool-slot", 0usize))));
key(&mut h, Key::Tab, Modifiers::NONE);
assert_eq!(h.ctx.memory(|m| m.focused()), Some(Id::new(("tool-slot", 1usize))));
assert_eq!(h.ctx.memory(|m| m.focused()), Some(Id::unique(("tool-slot", 1usize))));
assert_eq!(h.state().ui.tool, Tool::Selection, "focus movement alone must not select a tool");
key(&mut h, Key::Tab, Modifiers::NONE);
assert_eq!(h.ctx.memory(|m| m.focused()), Some(Id::new(("tool-slot", 2usize))));
assert_eq!(h.ctx.memory(|m| m.focused()), Some(Id::unique(("tool-slot", 2usize))));
key(&mut h, Key::Tab, Modifiers::SHIFT);
assert_eq!(h.ctx.memory(|m| m.focused()), Some(Id::new(("tool-slot", 1usize))));
assert_eq!(h.ctx.memory(|m| m.focused()), Some(Id::unique(("tool-slot", 1usize))));
key(&mut h, Key::Space, Modifiers::NONE);
assert_eq!(h.state().ui.tool, Tool::Hand);
assert!(!h.state().playback.playing, "Space activates the focused widget, not preview playback");
assert!(Arc::ptr_eq(&h.state().session.project, &project), "keyboard focus and tool selection do not modify the project");
let response = h.ctx.read_response(Id::new(("tool-slot", 1usize))).unwrap();
let response = h.ctx.read_response(Id::unique(("tool-slot", 1usize))).unwrap();
assert!(
h.output().shapes.iter().any(|shape| {
matches!(&shape.shape, egui::Shape::Rect(r) if r.rect == response.rect.shrink(1.0) && r.stroke.color == h.state().tokens.text && r.stroke.width > 0.0)
@@ -176,7 +176,7 @@ fn tab_traversal_activates_an_inactive_dock_tab_without_editing_the_project() {
h.run_steps(3);
assert!(h.state().auto.find("panel.tab.EffectControls").is_some(), "the inactive tab must be visible in this fixture");
let before = h.state().ui.dock.clone();
let target = Id::new(("tab", "aaa".to_string(), 1usize));
let target = Id::unique(("tab", "aaa".to_string(), 1usize));
let mut tabs = 0;
for _ in 0..80 {
key(&mut h, Key::Tab, Modifiers::NONE);
@@ -205,7 +205,7 @@ fn tab_traversal_activates_an_inactive_dock_tab_without_editing_the_project() {
let inspected = effectcraft_ui_egui::control::inspect(h.state(), &h.ctx);
assert_eq!(inspected["keyboardFocus"]["automationId"], "panel.tab.EffectControls");
key(&mut h, Key::Tab, Modifiers::SHIFT);
assert_eq!(h.ctx.memory(|m| m.focused()), Some(Id::new(("tab", "aaa".to_string(), 0usize))));
assert_eq!(h.ctx.memory(|m| m.focused()), Some(Id::unique(("tab", "aaa".to_string(), 0usize))));
key(&mut h, Key::Enter, Modifiers::NONE);
assert_eq!(h.state().ui.focused, PanelKind::Project);
assert!(!h.state().playback.playing);
@@ -284,7 +284,7 @@ fn control_root_divider_drag_after_toolbar_and_dock_tab_keyboard_activation() {
d.ok("ui.click", json!({"id":"tools.Selection"}), true);
// The toolbar can grow as upstream adds controls. Traverse to the actual response
// identity rather than assuming a fixed number of focus stops.
let target = Id::new(("tab", "aaa".to_string(), 1usize));
let target = Id::unique(("tab", "aaa".to_string(), 1usize));
for tabs in 1..=100 {
d.ok("ui.key", json!({"key":"Tab"}), true);
assert!(d.h.state().ui.mini_flowchart.is_none(), "dock-tab traversal must not open a foreground popup");
@@ -95,7 +95,13 @@ fn compact_control_row_scrolls_and_preserves_interactions() {
let mut h = harness(300.0, ThemeKind::Light);
assert!(rect(&h, "viewer.timecode").right() > 300.0);
h.event(Event::PointerMoved(pos2(150.0, 345.0)));
h.event(Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(-1500.0, 0.0), modifiers: Default::default(), phase: egui::TouchPhase::Move });
h.event(Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(-1500.0, 0.0),
modifiers: Default::default(),
phase: egui::TouchPhase::Move,
source: egui::MouseWheelSource::Wheel,
});
h.run_steps(30);
let timecode = rect(&h, "viewer.timecode");
let row = rect(&h, "viewer.controls");
@@ -160,7 +166,7 @@ fn click_control(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
#[test]
fn tab_traversal_reveals_the_last_control() {
let mut h = harness(300.0, ThemeKind::Dark);
let last = egui::Id::new("vw-timecode");
let last = egui::Id::unique("vw-timecode");
for _ in 0..80 {
h.key_press(egui::Key::Tab);
h.run_steps(3);
@@ -232,7 +238,13 @@ fn painted_scrollbar(h: &Harness<'_, EffectcraftApp>, kind: ThemeKind, scale: f3
fn scroll_controls_to_end(h: &mut Harness<'_, EffectcraftApp>) {
h.event(Event::PointerMoved(pos2(150.0, 345.0)));
h.event(Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(-1500.0, 0.0), modifiers: Default::default(), phase: egui::TouchPhase::Move });
h.event(Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(-1500.0, 0.0),
modifiers: Default::default(),
phase: egui::TouchPhase::Move,
source: egui::MouseWheelSource::Wheel,
});
h.run_steps(30);
h.event(Event::PointerMoved(pos2(150.0, 180.0)));
h.run_steps(3);
+52
View File
@@ -0,0 +1,52 @@
//! Accessibility of the app's own controls.
//!
//! The shared widgets in `widgets.rs` are raw `ui.interact` rectangles. Without a `WidgetInfo`
//! they are not in the accessibility tree at all, so neither a screen reader nor kittest can
//! reach them; and an input control without a name is unreachable even when it is in the tree.
//! `Harness::run` checks the tree it has, but only for the screen it settled on: this opens every
//! panel and checks again.
//!
//! Limit: a control that reports no `WidgetInfo` at all has no node, so it is not "unnamed" here.
//! `widgets::checkbox` and the raw-painted row controls (`icon_toggle`, `scroll_bar`, hot-number
//! values) are still in that state; naming every call site is separate work.
use effectcraft_engine::Session;
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::dock::PanelKind;
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use serde_json::json;
fn harness() -> Harness<'static, EffectcraftApp> {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
// A text layer under edit gives the Character / Paragraph panels their controls.
let _ = s.execute("layer.select", json!({"layers": ["EFFECTCRAFT"]}));
Harness::builder().with_size(egui::vec2(1680.0, 1020.0)).build_eframe(|_| EffectcraftApp::new(s))
}
#[test]
fn custom_toolbar_buttons_are_named() {
let mut h = harness();
h.run();
// An icon button carries no text of its own: its tooltip is its accessible name.
let home = h.query_by_label("Home").expect("the toolbar's Home icon button is named by its tooltip");
assert_eq!(home.accesskit_node().role(), egui::accesskit::Role::Button);
assert!(h.unnamed_widgets().is_empty(), "{:?}", h.unnamed_widgets());
}
#[test]
fn every_panel_names_its_input_controls() {
let mut h = harness();
h.run();
let mut unnamed = vec![];
for p in PanelKind::ALL {
h.state_mut().show_panel(p);
h.state_mut().ui.maximized = Some(p);
h.run_steps(4);
for w in h.unnamed_widgets() {
unnamed.push(format!("{}: {w}", p.title()));
}
}
assert!(unnamed.is_empty(), "{} control(s) have no accessible name:\n{}", unnamed.len(), unnamed.join("\n"));
}
-165
View File
@@ -1,165 +0,0 @@
//! Long menus and lists scroll instead of running off the window, with a scroll bar to drag
//! (egui_kittest, UI logic only).
use effectcraft_engine::Session;
use effectcraft_ui_egui::EffectcraftApp;
use egui::{Event, Pos2, Rect, pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
fn harness(w: f32, h: f32) -> Harness<'static, EffectcraftApp> {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
let mut h = Harness::builder().with_size(vec2(w, h)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
h
}
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))
}
fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
}
fn wheel(h: &mut Harness<'_, EffectcraftApp>, at: Pos2, dy: f32) {
h.input_mut().events.push(Event::PointerMoved(at));
h.input_mut().events.push(Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, dy),
modifiers: Default::default(),
phase: egui::TouchPhase::Move,
});
h.run_steps(20);
}
/// Edit is taller than a short window: it scrolls (with the wheel, and a scroll bar that always
/// shows) so its last entries can be reached and clicked, instead of running off the bottom of
/// the window (#269).
#[test]
fn a_menu_taller_than_the_window_scrolls() {
let mut h = harness(1200.0, 520.0);
assert!(!h.ctx.global_style().spacing.scroll.floating, "scroll bars show without hovering the list");
let edit = rect(&h, "menu.Edit");
click_at(&mut h, edit.center());
let last = h.query_by_label_contains("Keyboard Shortcuts").expect("the Edit menu is open").rect();
assert!(last.max.y > 520.0, "Edit is taller than the window ({last:?})");
let first = h.query_by_label_contains("Quick Apply").expect("Quick Apply").rect();
wheel(&mut h, first.center(), -2000.0);
let last = h.query_by_label_contains("Keyboard Shortcuts").expect("still open").rect();
assert!(last.min.y >= 0.0 && last.max.y <= 520.0, "scrolled into the window ({last:?})");
click_at(&mut h, last.center());
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Shortcuts), "the entry takes the click");
}
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: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
for i in 1..=8 {
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 8.0)));
h.step();
}
h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
}
/// A scroll bar's "scroll/max" automation label.
fn scrolled(h: &Harness<'_, EffectcraftApp>, id: &str) -> (f32, f32) {
let label = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).label.clone();
let (s, max) = label.split_once('/').unwrap_or_else(|| panic!("{id}: {label}"));
(s.parse().unwrap(), max.parse().unwrap())
}
/// Every panel that scrolls its own rows shows a scroll bar while they overflow, and dragging it
/// scrolls them: without a mouse wheel or touchpad there was no way down most panels (#271).
#[test]
fn panels_taller_than_their_space_have_a_scroll_bar_to_drag() {
use effectcraft_ui_egui::dock::PanelKind;
// (The Project panel's list starts below its item preview and search: a taller window.)
for (panel, id, height) in [
(PanelKind::EffectControls, "effectControls.scroll", 240.0),
(PanelKind::Properties, "properties.scroll", 240.0),
(PanelKind::EffectsPresets, "effects.scroll", 240.0),
(PanelKind::Project, "project.vscroll", 330.0),
(PanelKind::Timeline, "timeline.scroll", 240.0),
(PanelKind::RenderQueue, "renderQueue.scroll", 240.0),
(PanelKind::History, "history.scroll", 240.0),
(PanelKind::Preview, "preview.scroll", 240.0),
(PanelKind::Character, "character.scroll", 240.0),
(PanelKind::Paragraph, "paragraph.scroll", 240.0),
(PanelKind::Info, "info.scroll", 240.0),
] {
let mut h = harness(1200.0, height);
let app = h.state_mut();
let lid = app.session.active_comp().unwrap().layers[0].id.0;
app.session.execute("layer.select", json!({"layers": [lid]})).unwrap();
for e in ["slider", "angle", "checkbox", "color", "point"] {
app.session.execute("effect.apply", json!({"effect": format!("ec.control.{e}"), "layers": [lid]})).unwrap();
}
for _ in 0..6 {
app.session.execute("renderQueue.add", json!({})).unwrap();
}
app.show_panel(panel);
app.ui.maximized = Some(panel);
h.run_steps(3);
let (_, max) = scrolled(&h, id);
assert!(max > 0.0, "{id}: the rows overflow");
let bar = rect(&h, id);
// A press on the track moves the thumb there (History starts at its current state, the
// last); then the thumb drags to the end.
click_at(&mut h, pos2(bar.center().x, bar.min.y + 2.0));
assert_eq!(scrolled(&h, id).0, 0.0, "{id}: at the top");
drag(&mut h, pos2(bar.center().x, bar.min.y + 6.0), pos2(bar.center().x, bar.max.y + 40.0));
assert_eq!(scrolled(&h, id).0, max, "{id}: dragged to the end");
}
}
/// In a narrow Effect Controls panel an animated point's values stay clear of its keyframe
/// navigator (â—€ â—† â–¶), which was drawn over them (#271).
#[test]
fn effect_values_stay_clear_of_the_keyframe_navigator() {
use effectcraft_ui_egui::dock::PanelKind;
let mut h = harness(1600.0, 1000.0);
let app = h.state_mut();
let lid = app.session.active_comp().unwrap().layers[0].id.0;
app.session.execute("layer.select", json!({"layers": [lid]})).unwrap();
app.session.execute("effect.apply", json!({"effect": "ec.control.point", "layers": [lid]})).unwrap();
let path = "effects/#1/point";
app.session.execute("prop.toggleAnimation", json!({"layer": lid, "path": path})).unwrap();
app.session.execute("prop.set", json!({"layer": lid, "path": path, "value": [123456.7, 123456.7]})).unwrap();
let uid = app.session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(lid)).unwrap().props.prop(path).unwrap().uid;
assert!(app.edit_layout(|l| l.float(PanelKind::EffectControls, [300.0, 150.0, 330.0, 500.0])));
h.run_steps(3);
let value = rect(&h, &format!("effectControls.prop.{uid}.value.1"));
let nav = rect(&h, &format!("effectControls.prop.{uid}.prevKey"));
assert!(value.max.x <= nav.min.x, "the value ({value:?}) ends before the navigator ({nav:?})");
assert!(value.min.x > rect(&h, &format!("effectControls.prop.{uid}.stopwatch")).max.x, "and after the stopwatch");
}
/// The Settings dialog fits its width: its page column was wider than the dialog, so the whole
/// dialog scrolled sideways a little (#284).
#[test]
fn settings_dialog_does_not_scroll_sideways() {
let mut h = harness(1400.0, 900.0);
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "app.settings", json!({"page": "general"})).unwrap();
h.run_steps(3);
let (ok, page) = (rect(&h, "settings.ok"), rect(&h, "settings.page.general"));
h.input_mut().events.push(Event::PointerMoved(page.center()));
h.input_mut().events.push(Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(-400.0, 0.0),
modifiers: Default::default(),
phase: egui::TouchPhase::Move,
});
h.run_steps(20);
assert_eq!((rect(&h, "settings.ok"), rect(&h, "settings.page.general")), (ok, page), "nothing moved sideways");
}
-572
View File
@@ -1,572 +0,0 @@
//! 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");
}
}