mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-01 05:58:19 -06:00
Merge main into enhance/avi-import
This commit is contained in:
@@ -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] });
|
||||
}
|
||||
});
|
||||
}
|
||||
+2
-2
@@ -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");
|
||||
+5
-3
@@ -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()));
|
||||
+130
-25
@@ -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]);
|
||||
}
|
||||
+50
-12
@@ -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();
|
||||
+1
@@ -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");
|
||||
+22
-1
@@ -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]
|
||||
+18
-1
@@ -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();
|
||||
+3
-1
@@ -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.
|
||||
+1
-1
@@ -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;
|
||||
+1
@@ -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);
|
||||
}
|
||||
+1
-1
@@ -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()
|
||||
+1
-1
@@ -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> {
|
||||
+4
-2
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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() {
|
||||
+6
-4
@@ -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"]);
|
||||
}
|
||||
+39
@@ -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");
|
||||
}
|
||||
+2
-1
@@ -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();
|
||||
+3
-3
@@ -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);
|
||||
+1
-1
@@ -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();
|
||||
}
|
||||
@@ -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
+73
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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");
|
||||
}
|
||||
+23
@@ -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();
|
||||
+14
@@ -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");
|
||||
}
|
||||
+14
-2
@@ -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();
|
||||
+2
-2
@@ -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]
|
||||
+3
-2
@@ -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)
|
||||
}
|
||||
|
||||
+78
-1
@@ -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);
|
||||
}
|
||||
+9
-9
@@ -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");
|
||||
+15
-3
@@ -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);
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user