diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index b54938412..192c547cc 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -316,7 +316,6 @@ const JAPANESE: &[(&str, &str)] = &[ ("Duration:", "デュレーション:"), ("Edit Gradient…", "グラデーションを編集…"), ("Edit Target: {}", "編集対象:{}"), - ("Edit in the Timeline", "タイムラインで編集"), ("EffectCraft didn't quit normally last time.", "EffectCraftは前回正常に終了しませんでした。"), ("Effects", "エフェクト"), ("Effects & Presets options", "エフェクト&プリセットのオプション"), @@ -1291,6 +1290,19 @@ const JAPANESE: &[(&str, &str)] = &[ ("Blend Mode", "描画モード"), ("Show mesh", "メッシュを表示"), ("Record Options...", "記録オプション..."), + ("Apply", "適用"), + ("Analyze", "分析"), + ("Add Point", "ポイントを追加"), + ("Remove Point", "ポイントを削除"), + ("Render", "レンダリング"), + ("Save Current as Template…", "テンプレートとして保存…"), + ("New Composition From Footage", "フッテージから新規コンポジション"), + ("Search project", "プロジェクトを検索"), + ("Search", "検索"), + ("(no composition)", "(コンポジションなし)"), + ("per second", "毎秒"), + ("Waveform", "波形"), + ("steps", "ステップ"), ]; const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ @@ -1431,7 +1443,6 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Duration:", "持续时间:"), ("Edit Gradient…", "编辑渐变…"), ("Edit Target: {}", "编辑目标:{}"), - ("Edit in the Timeline", "在时间轴中编辑"), ("EffectCraft didn't quit normally last time.", "EffectCraft 上次未正常退出。"), ("Effects", "效果"), ("Effects & Presets options", "效果和预设选项"), @@ -2540,7 +2551,6 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Duration:", "持續時間:"), ("Edit Gradient…", "編輯漸變…"), ("Edit Target: {}", "編輯目標:{}"), - ("Edit in the Timeline", "在時間軸中編輯"), ("EffectCraft didn't quit normally last time.", "EffectCraft 上次未正常結束。"), ("Effects", "效果"), ("Effects & Presets options", "效果和預設集選項"), @@ -3602,7 +3612,6 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Duration:", "Тривалість:"), ("Edit Gradient…", "Редагувати градієнт…"), ("Edit Target: {}", "Ціль редагування: {}"), - ("Edit in the Timeline", "Редагувати на шкалі часу"), ("EffectCraft didn't quit normally last time.", "Минулого разу EffectCraft завершив роботу некоректно."), ("Effects", "Ефекти"), ("Effects & Presets options", "Параметри ефектів і наборів"), @@ -4166,7 +4175,6 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("Duration:", "Duração:"), ("Edit Gradient…", "Editar gradiente…"), ("Edit Target: {}", "Editar destino: {}"), - ("Edit in the Timeline", "Editar na linha do tempo"), ("EffectCraft didn't quit normally last time.", "O EffectCraft não foi encerrado normalmente da última vez."), ("Effects", "Efeitos"), ("Effects & Presets options", "Opções de Efeitos e predefinições"), @@ -4929,7 +4937,6 @@ const POLISH: &[(&str, &str)] = &[ ("Duration:", "Czas trwania:"), ("Edit Gradient…", "Edytuj gradient…"), ("Edit Target: {}", "Cel edycji: {}"), - ("Edit in the Timeline", "Edytuj na osi czasu"), ("EffectCraft didn't quit normally last time.", "EffectCraft nie zakończył się poprawnie ostatnim razem."), ("Effects & Presets options", "Opcje efektów i ustawień"), ("Copy Presets Folder Path", "Kopiuj ścieżkę folderu ustawień"), @@ -5674,7 +5681,6 @@ const CZECH: &[(&str, &str)] = &[ ("Duration:", "Délka trvání:"), ("Edit Gradient…", "Upravit přechod…"), ("Edit Target: {}", "Upravit cíl: {}"), - ("Edit in the Timeline", "Upravit na časové ose"), ("EffectCraft didn't quit normally last time.", "EffectCraft nebyl při posledním použití ukončen běžným způsobem."), ("Effects", "Efekty"), ("Effects & Presets options", "Možnosti efektů a přednastavení"), @@ -6242,7 +6248,6 @@ const SPANISH: &[(&str, &str)] = &[ ("Duration:", "Duración:"), ("Edit Gradient…", "Editar degradado…"), ("Edit Target: {}", "Destino de la edición: {}"), - ("Edit in the Timeline", "Editar en la cronología"), ("EffectCraft didn't quit normally last time.", "EffectCraft no se cerró normalmente la última vez."), ("Effects", "Efectos"), ("Effects & Presets options", "Opciones de Efectos y ajustes preestablecidos"), @@ -6794,7 +6799,6 @@ const GEORGIAN: &[(&str, &str)] = &[ ("Duration:", "ხანგრძლივობა:"), ("Edit Gradient…", "გრადიენტის რედაქტირება…"), ("Edit Target: {}", "რედაქტირების სამიზნე: {}"), - ("Edit in the Timeline", "რედაქტირება დროის ხაზზე"), ("EffectCraft didn't quit normally last time.", "EffectCraft ბოლოს ნორმალურად არ დახურულა."), ("Effects", "ეფექტები"), ("Effects & Presets options", "ეფექტებისა და პრესეტების პარამეტრები"), diff --git a/crates/ui-egui/src/panels/gradient_editor.rs b/crates/ui-egui/src/panels/gradient_editor.rs index 25e3d1d44..0f0bbe412 100644 --- a/crates/ui-egui/src/panels/gradient_editor.rs +++ b/crates/ui-egui/src/panels/gradient_editor.rs @@ -245,7 +245,7 @@ pub fn open(app: &mut EffectcraftApp, p: &Json) -> Result { /// Set the property to the draft: one `prop.set` at the time the editor opened, in composition /// time, or nothing when the draft is unchanged (so OK adds no keyframe). fn commit(app: &mut EffectcraftApp, ctx: &egui::Context) { - app.dialog = None; + close(app); let d = std::mem::take(&mut app.dialog_state.gradient); let g = d.gradient(); // The preview already set the property to this draft, in one merged undo step: OK keeps it. @@ -279,7 +279,12 @@ pub fn finish(app: &mut EffectcraftApp, ok: bool, ctx: &egui::Context) { preview_merge::rollback(app, cp, d.applied_at.as_ref()); } app.session.history.merge_key = None; - app.dialog = None; + close(app); +} + +/// Close the editor: back to the Layer Style dialog when it opened from there (#2057). +fn close(app: &mut EffectcraftApp) { + app.dialog = std::mem::take(&mut app.dialog_state.layer_style.gradient_open).then_some(Dialog::LayerStyles); } /// Keep the property in step with the draft while Preview is on: apply a changed draft with one diff --git a/crates/ui-egui/src/panels/layer_styles_dialog.rs b/crates/ui-egui/src/panels/layer_styles_dialog.rs index 5b415187d..09b775e30 100644 --- a/crates/ui-egui/src/panels/layer_styles_dialog.rs +++ b/crates/ui-egui/src/panels/layer_styles_dialog.rs @@ -23,6 +23,8 @@ pub struct LayerStyleState { pub selected: String, pub undo_mark: usize, checkpoint: Option, + /// The Gradient Editor opened from this dialog: closing it comes back here (#2057). + pub gradient_open: bool, } /// The project and undo history as a dialog found them, for a Cancel that rolls its live edits back. @@ -58,7 +60,8 @@ pub fn open(app: &mut EffectcraftApp, params: &Value) -> Result<(), String> { return Err(format!("unknown layer style `{style}`")); } let checkpoint = checkpoint(app); - app.dialog_state.layer_style = LayerStyleState { layer, selected: style, undo_mark: app.session.history.undo.len(), checkpoint: Some(checkpoint) }; + app.dialog_state.layer_style = + LayerStyleState { layer, selected: style, undo_mark: app.session.history.undo.len(), checkpoint: Some(checkpoint), gradient_open: false }; app.dialog = Some(Dialog::LayerStyles); Ok(()) } @@ -118,10 +121,6 @@ fn control(ui: &mut egui::Ui, p: &Property, v: &KV) -> Option { let changed = crate::widgets::srgb_color_button(ui, &mut rgb).changed(); changed.then(|| json!(rgb)) } - (KV::Gradient(_), _) => { - ui.label(egui::RichText::new(tr("Edit in the Timeline")).weak()); - None - } _ => { ui.label(egui::RichText::new(format!("{v:?}").chars().take(24).collect::()).weak()); None @@ -228,6 +227,19 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { } let lbl = ui.label(&p.name); let v = p.value_at(lt); + if matches!(v, KV::Gradient(_)) { + // The Gradient Editor, over this dialog (a style the dialog + // only shows has no property to edit yet). + let path = g.match_path_of(p.uid).unwrap_or_default(); + let r = ui.add_enabled(missing.is_none(), egui::Button::new(tr("Edit Gradient…"))); + if r.clicked() { + actions.push(("prop.editGradient".into(), json!({"layer": lid, "prop": p.uid}))); + app.dialog_state.layer_style.gradient_open = true; + } + app.auto.add(&format!("dialog.layerStyle.value.{sel}/{path}"), r.rect, &p.name); + ui.end_row(); + return; + } let edit = ui.scope(|ui| control(ui, p, &v)); let path = g.match_path_of(p.uid).unwrap_or_default(); if let Some(nv) = edit.inner { diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index d084dd006..5c4272066 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1674,8 +1674,9 @@ fn phase_clicked(app: &mut EffectcraftApp, ectx: &EvalCtx, resp: &egui::Response let _ = app.session.execute("mask.convertVertex", json!({"layer": h.layer.0, "mask": h.mask, "index": h.index})); } else if let Some(k) = ov::motion_key_at(&key_hits, pos) { // A key showing direction handles becomes Linear; one without, Auto Bezier. + // Separated X / Y Position have no spatial interpolation to convert. let curved = key_hits.iter().any(|h| h.tangent.is_some() && h.layer == k.layer && h.prop == k.prop && h.time == k.time); - if select_motion_key(app, &k) { + if k.axes.is_none() && select_motion_key(app, &k) { let spatial = if curved { "linear" } else { "autoBezier" }; let _ = app.session.execute("keys.interpolation", json!({"spatial": spatial})); } diff --git a/crates/ui-egui/src/panels/viewer/tools.rs b/crates/ui-egui/src/panels/viewer/tools.rs index 8a3fbf0f3..da4245e85 100644 --- a/crates/ui-egui/src/panels/viewer/tools.rs +++ b/crates/ui-egui/src/panels/viewer/tools.rs @@ -137,14 +137,18 @@ pub(super) fn draw_rigs( /// layer, or a new shape layer when nothing is selected. The placed point snaps. /// Add a Position keyframe at a motion path's frame, keeping the path where it is. pub(super) fn add_motion_key(app: &mut EffectcraftApp, f: ov::PathFrame) { - if let Err(e) = app.session.execute("prop.addKey", json!({"layer": f.layer.0, "prop": f.prop, "time": f.time.seconds()})) { - app.ui.status = e.to_string(); + // Separated dimensions: the key goes on X and Y Position. + for prop in f.axes.map_or_else(|| vec![f.prop], Vec::from) { + if let Err(e) = app.session.execute("prop.addKey", json!({"layer": f.layer.0, "prop": prop, "time": f.time.seconds()})) { + app.ui.status = e.to_string(); + } } } -/// Select only the motion path's keyframe `k` (what the vertex tools act on). +/// Select only the motion path's keyframe `k` (what the vertex tools act on; with separated +/// dimensions, the X and Y Position keys at its time). pub(super) fn select_motion_key(app: &mut EffectcraftApp, k: &ov::KeyHit) -> bool { - app.session.execute("keys.select", json!({"keys": [{"layer": k.layer.0, "prop": k.prop, "time": k.time.seconds()}]})).is_ok() + app.session.execute("keys.select", json!({"keys": k.select_entries()})).is_ok() } /// The pointer for a Pen slot tool: the tool's own pen, the Pen showing Add Vertex's over a path diff --git a/crates/ui-egui/src/panels/viewer_overlays.rs b/crates/ui-egui/src/panels/viewer_overlays.rs index ab6d3d561..00c07425d 100644 --- a/crates/ui-egui/src/panels/viewer_overlays.rs +++ b/crates/ui-egui/src/panels/viewer_overlays.rs @@ -7,7 +7,7 @@ use effectcraft_engine::geom::{Mat3, vec2 as gv2}; use effectcraft_engine::keyframe::ShapePath; -use effectcraft_engine::project::{GroupKind, Layer, LayerId}; +use effectcraft_engine::project::{GroupKind, Layer, LayerId, Property}; use effectcraft_engine::render::EvalCtx; use effectcraft_engine::time::Tick; use effectcraft_engine::{KeyRef, VertexRef}; @@ -309,6 +309,23 @@ pub(crate) struct KeyHit { pub value: [f64; 3], /// Parent space → comp. pub p2c: Mat3, + /// Separated dimensions: X and Y Position's uids, and whether each has a key at `time`. + pub axes: Option<[(u64, bool); 2]>, +} + +impl KeyHit { + /// The keys this dot stands for: Position's, or the X and Y Position keys at its time. + pub(crate) fn key_refs(&self) -> Vec { + match self.axes { + Some(axes) => axes.iter().filter(|(_, has)| *has).map(|&(prop, _)| KeyRef { layer: self.layer, prop, time: self.time }).collect(), + None => vec![KeyRef { layer: self.layer, prop: self.prop, time: self.time }], + } + } + + /// [`Self::key_refs`] as `keys.select` entries. + pub(crate) fn select_entries(&self) -> Vec { + self.key_refs().iter().map(|k| json!({"layer": k.layer.0, "prop": k.prop, "time": k.time.seconds()})).collect() + } } /// A frame on a motion path (one of its dots): where the Pen and Add Vertex tools add a @@ -320,6 +337,21 @@ pub(crate) struct PathFrame { /// Layer time of the frame. pub time: Tick, pub pos: Pos2, + /// Separated dimensions: X and Y Position's uids, which get the key instead of `prop`. + pub axes: Option<[u64; 2]>, +} + +/// Layer times of the keys of separated X and Y Position together, in order. +fn separated_key_times(x: &Property, y: &Property) -> Vec { + let mut times: Vec = x.keys.iter().chain(y.keys.iter()).map(|k| k.time).collect(); + times.sort(); + times.dedup(); + times +} + +/// Separated X and Y Position at layer time `t` (z 0: motion paths are for 2D layers). +fn separated_value(x: &Property, y: &Property, t: Tick) -> [f64; 3] { + [x.value_at(t).as_f64(), y.value_at(t).as_f64(), 0.0] } /// The keyframe dot on a motion path under `pos`. @@ -361,44 +393,61 @@ pub(crate) fn motion_path( hits: &mut Vec, frames: &mut Vec, ) { - let Some(pos) = l.transform().and_then(|tr| tr.get("position")) else { return }; - if pos.keys.len() < 2 || l.is_3d() { + let Some(tr) = l.transform() else { return }; + let Some(pos) = tr.get("position") else { return }; + // Separate Dimensions: the layer follows X / Y Position, so the path is drawn from their keys. + let sep = tr.get("positionX").zip(tr.get("positionY")); + let key_times: Vec = match sep { + Some((x, y)) => separated_key_times(x, y), + None => pos.keys.iter().map(|k| k.time).collect(), + }; + if key_times.len() < 2 || l.is_3d() { return; } + let prop = sep.map_or(pos.uid, |(x, _)| x.uid); + let frame_axes = sep.map(|(x, y)| [x.uid, y.uid]); + let value_at = |t: Tick| match sep { + Some((x, y)) => separated_value(x, y, t), + None => pos.value_at(t).as_vec3(), + }; let p2c = parent_to_comp(ectx, l); let to_scr = |v: [f64; 3]| { let c = p2c.apply(gv2(v[0], v[1])); map.to_screen([c.x, c.y]) }; // Settings ▸ Composition ▸ Motion Path: all keyframes, none, or a window around the CTI. - let times: Vec = pos.keys.iter().map(|k| k.time).collect(); - let Some((lt0, lt1)) = app.session.prefs.motion_path_span(×, l.layer_time(ectx.time)) else { return }; + let Some((lt0, lt1)) = app.session.prefs.motion_path_span(&key_times, l.layer_time(ectx.time)) else { return }; let fd = ectx.comp.frame_duration(); let mut pts = vec![]; let mut lt = lt0; while lt <= lt1 && pts.len() < 20_000 { - let q = to_scr(pos.value_at(lt).as_vec3()); - frames.push(PathFrame { layer: l.id, prop: pos.uid, time: lt, pos: q }); + let q = to_scr(value_at(lt)); + frames.push(PathFrame { layer: l.id, prop, time: lt, pos: q, axes: frame_axes }); pts.push(q); lt += fd; } - pts.push(to_scr(pos.value_at(lt1).as_vec3())); + pts.push(to_scr(value_at(lt1))); let faint = col.gamma_multiply(0.8); painter.add(egui::Shape::line(pts.clone(), Stroke::new(1.0, faint))); for q in &pts { painter.circle_filled(*q, 1.2, faint); } let sel = &app.session.state.selected_keys; - for (i, k) in pos.keys.iter().enumerate() { - if k.time < lt0 || k.time > lt1 { + for (i, kt) in key_times.iter().enumerate() { + if *kt < lt0 || *kt > lt1 { continue; } - let v = k.value.as_vec3(); + // The combined Position key at this time; none with separated dimensions. + let key = pos.keys.get(i).filter(|_| sep.is_none()); + let v = key.map_or_else(|| value_at(*kt), |k| k.value.as_vec3()); let s = to_scr(v); - let selected = sel.contains(&KeyRef { layer: l.id, prop: pos.uid, time: k.time }); + let axes = sep.map(|(x, y)| [x, y].map(|a| (a.uid, a.keys.iter().any(|k| k.time == *kt)))); + let hit = KeyHit { layer: l.id, prop, time: *kt, pos: s, tangent: None, value: v, p2c, axes }; + let selected = hit.key_refs().iter().any(|k| sel.contains(k)); // A muted position key is off the motion path: no tangents, and a dimmed point. - let kc = if k.muted { col.gamma_multiply(if selected { 0.55 } else { 0.35 }) } else { col }; - if !k.muted { + let kc = if key.is_some_and(|k| k.muted) { col.gamma_multiply(if selected { 0.55 } else { 0.35 }) } else { col }; + // Separated X / Y have no spatial tangents. + if let Some(k) = key.filter(|k| !k.muted) { // Auto-Bezier keys show handles on a side the path leaves straight too (a two-key path's // ends), to drag it into a curve; Linear keys (no tangents) show none. let (tin, tout) = if k.spatial_auto { @@ -416,7 +465,7 @@ pub(crate) fn motion_path( painter.circle_filled(h, 3.0, kc); let side = if out { "out" } else { "in" }; app.auto.add(&format!("viewer.motionPath.{}.{i}.{side}", l.id.0), Rect::from_center_size(h, vec2(8.0, 8.0)), "Spatial tangent"); - hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: h, tangent: Some(out), value: v, p2c }); + hits.push(KeyHit { layer: l.id, prop, time: *kt, pos: h, tangent: Some(out), value: v, p2c, axes: None }); } } let r = Rect::from_center_size(s, vec2(7.0, 7.0)); @@ -427,7 +476,7 @@ pub(crate) fn motion_path( painter.rect_stroke(r, 0.0, Stroke::new(1.0, kc), StrokeKind::Inside); } app.auto.add(&format!("viewer.motionPath.{}.{i}", l.id.0), r.expand(2.0), "Position keyframe"); - hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: s, tangent: None, value: v, p2c }); + hits.push(hit); } } @@ -544,7 +593,7 @@ fn point_in_quad(p: Pos2, q: &[Pos2; 4]) -> bool { #[derive(Clone, Debug)] pub(crate) enum Drag { /// A motion-path keyframe: its value moves with the pointer (parent space). - Key { layer: LayerId, prop: u64, time: Tick, start: [f64; 3], press: [f64; 2], inv: Mat3 }, + Key { layer: LayerId, prop: u64, time: Tick, start: [f64; 3], press: [f64; 2], inv: Mat3, axes: Option<[u64; 2]> }, /// A spatial tangent handle of a motion-path key. KeyTangent { layer: LayerId, prop: u64, time: Tick, out: bool, key: [f64; 3], inv: Mat3 }, /// Free transform: 0 move, 1 scale (anchor, axes), 2 rotate about `anchor`. @@ -569,11 +618,17 @@ pub(crate) fn begin_key_drag(app: &mut EffectcraftApp, hits: &[KeyHit], press: P match h.tangent { Some(out) => Some(Drag::KeyTangent { layer: h.layer, prop: h.prop, time: h.time, out, key: h.value, inv }), None => { - let k = KeyRef { layer: h.layer, prop: h.prop, time: h.time }; - if !app.session.state.selected_keys.contains(&k) || shift { - let _ = app.session.execute("keys.select", json!({"keys": [{"layer": h.layer.0, "prop": h.prop, "time": h.time.seconds()}], "add": shift})); + // Separated dimensions: both X and Y need a key here before the drag moves them. + let mut h = *h; + for (uid, has) in h.axes.iter_mut().flatten().filter(|(_, has)| !*has) { + let _ = app.session.execute("prop.addKey", json!({"layer": h.layer.0, "prop": *uid, "time": h.time.seconds()})); + *has = true; } - Some(Drag::Key { layer: h.layer, prop: h.prop, time: h.time, start: h.value, press: cpt, inv }) + if !h.key_refs().iter().all(|k| app.session.state.selected_keys.contains(k)) || shift { + let _ = app.session.execute("keys.select", json!({"keys": h.select_entries(), "add": shift})); + } + let axes = h.axes.map(|a| a.map(|(uid, _)| uid)); + Some(Drag::Key { layer: h.layer, prop: h.prop, time: h.time, start: h.value, press: cpt, inv, axes }) } } } @@ -639,17 +694,22 @@ pub(crate) fn update( snap: &mut dyn FnMut(&EffectcraftApp, [f64; 2], LayerId) -> [f64; 2], ) -> Option { match d.clone() { - Drag::Key { layer, prop, time, start, press, inv } => { + Drag::Key { layer, prop, time, start, press, inv, axes } => { // Snap the key's on-screen position like a layer drag. let start_c = inv.inverse().map(|m| m.apply(gv2(start[0], start[1]))).map(|v| [v.x, v.y]).unwrap_or([start[0], start[1]]); let mut target = [start_c[0] + cpt[0] - press[0], start_c[1] + cpt[1] - press[1]]; let s = snap(app, target, layer); target = [target[0] + s[0], target[1] + s[1]]; let p = inv.apply(gv2(target[0], target[1])); - let v = json!([p.x, p.y, start[2]]); - let r = app.session.execute("keys.set", json!({"layer": layer.0, "prop": prop, "time": time.seconds(), "value": v, "merge": merge})); - if let Err(e) = r { - app.ui.status = e.to_string(); + let edits = match axes { + Some([ux, uy]) => vec![(ux, json!(p.x)), (uy, json!(p.y))], + None => vec![(prop, json!([p.x, p.y, start[2]]))], + }; + for (uid, v) in edits { + let r = app.session.execute("keys.set", json!({"layer": layer.0, "prop": uid, "time": time.seconds(), "value": v, "merge": merge})); + if let Err(e) = r { + app.ui.status = e.to_string(); + } } None } @@ -714,6 +774,42 @@ pub(crate) fn update( mod tests { use super::*; + fn scalar_prop(uid: u64, keys: &[(i64, f64)]) -> Property { + use effectcraft_engine::keyframe::{Keyframe, Value}; + let mut p = Property::new(uid, "positionX", "X Position", Value::Scalar(0.0)); + p.keys = keys.iter().map(|&(t, v)| Keyframe::new(Tick(t), Value::Scalar(v))).collect(); + p + } + + /// #2242: with separated dimensions the motion path follows the keys of X and Y Position. + #[test] + fn separated_motion_path_uses_x_and_y_keys() { + let x = scalar_prop(1, &[(0, 10.0), (100, 30.0)]); + let y = scalar_prop(2, &[(0, 0.0), (50, 20.0), (100, 0.0)]); + assert_eq!(separated_key_times(&x, &y), vec![Tick(0), Tick(50), Tick(100)]); + let mid = separated_value(&x, &y, Tick(50)); + assert!((mid[0] - 20.0).abs() < 1e-9 && (mid[1] - 20.0).abs() < 1e-9 && mid[2] == 0.0, "{mid:?}"); + assert_eq!(separated_value(&x, &y, Tick(100)), [30.0, 0.0, 0.0]); + } + + /// #2242: a separated dot selects the X and Y keys at its time, not the nearest X key. + #[test] + fn separated_key_dot_refers_to_its_axis_keys() { + let mut h = KeyHit { + layer: LayerId(7), + prop: 1, + time: Tick(50), + pos: Pos2::ZERO, + tangent: None, + value: [0.0; 3], + p2c: Mat3::IDENTITY, + axes: Some([(1, false), (2, true)]), + }; + assert_eq!(h.key_refs(), vec![KeyRef { layer: LayerId(7), prop: 2, time: Tick(50) }]); + h.axes = None; + assert_eq!(h.key_refs(), vec![KeyRef { layer: LayerId(7), prop: 1, time: Tick(50) }]); + } + fn info(vertices: Vec<[f64; 2]>, closed: bool) -> PathInfo { let n = vertices.len(); let sp = ShapePath { vertices, in_tangents: vec![[0.0; 2]; n], out_tangents: vec![[0.0; 2]; n], closed, feather: Vec::new() }; diff --git a/crates/ui-egui/tests/it/ui_tail.rs b/crates/ui-egui/tests/it/ui_tail.rs index bd87a01a7..a824e065c 100644 --- a/crates/ui-egui/tests/it/ui_tail.rs +++ b/crates/ui-egui/tests/it/ui_tail.rs @@ -175,6 +175,30 @@ fn layer_style_buttons_stay_in_view() { } } +/// #2057: Gradient Overlay's gradient has an Edit Gradient… button in the Layer Style dialog. It +/// opens the Gradient Editor on that property, and closing the editor goes back to the dialog. +#[test] +fn layer_style_gradient_opens_the_gradient_editor() { + for ok in [true, false] { + let mut h = harness(1400.0); + let lid = h.state().session.active_comp().unwrap().layers[0].id.0; + h.state_mut().session.execute("layer.select", json!({"layers": [lid]})).unwrap(); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.style.gradientOverlay", json!({})).unwrap(); + h.run_steps(5); + let edit = in_view(&h, "dialog.layerStyle.value.gradientOverlay/colors"); + click(&mut h, edit); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::GradientEditor), "Edit Gradient… opened the editor"); + effectcraft_ui_egui::panels::gradient_editor::finish(h.state_mut(), ok, &ctx); + h.run_steps(2); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::LayerStyles), "closing the editor (OK {ok}) went back to the dialog"); + // The dialog closes as before once the editor is gone. + effectcraft_ui_egui::panels::layer_styles_dialog::finish(h.state_mut(), true); + h.run_steps(2); + assert_eq!(h.state().dialog, None); + } +} + /// #1062: with the buttons kept at the bottom, the style's controls still scroll both ways, so /// every one of them can be reached in the smallest window (960×600) at 200 % UI Scale, and in a /// dialog opened in a large window that then narrows: here Noise, near the end of Drop Shadow,