mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-08 03:22:22 +00:00
Merge main into enhance/avi-import
This commit is contained in:
commit
a2f3ac41b1
427 files changed
+24699
-4513
No files matched your search
@@ -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()));
|
||||
File renamed without changes.
+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");
|
||||
}
|
||||
File renamed without changes.
@@ -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");
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+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();
|
||||
File renamed without changes.
+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)
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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.
|
||||
File renamed without changes.
+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);
|
||||
}
|
||||
File renamed without changes.
+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");
|
||||
});
|
||||
}
|
||||
File renamed without changes.
@@ -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() {
|
||||
File renamed without changes.
+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();
|
||||
File renamed without changes.
@@ -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");
|
||||
}
|
||||
File renamed without changes.
+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");
|
||||
File renamed without changes.
File renamed without changes.
@@ -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:?}");
|
||||
}
|
||||
File renamed without changes.
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
File renamed without changes.
+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() {
|
||||
File renamed without changes.
@@ -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();
|
||||
File renamed without changes.
+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();
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+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]
|
||||
File renamed without changes.
+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)
|
||||
}
|
||||
|
||||
File renamed without changes.
+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);
|
||||
File renamed without changes.
@@ -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