diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 7e0653914..957aa8984 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -135,6 +135,32 @@ fn track_matte_alpha() { assert!(img.get(150, 50)[3] < 0.01); } +/// Every matte kind with an opaque white 50×50 matte at the left of a 200×100 comp: the plain +/// kinds keep the fill only inside the matte, the inverted kinds only outside it (#864). +#[test] +fn track_matte_kinds_keep_and_invert() { + for (kind, inside) in [(MatteKind::Alpha, true), (MatteKind::AlphaInverted, false), (MatteKind::Luma, true), (MatteKind::LumaInverted, false)] { + let (mut p, cid, comp) = setup(); + let mut matte = solid(&mut p, &comp, [1.0, 1.0, 1.0], 50, 50); + matte.props.prop_mut("transform/position").unwrap().value = Value::Vec3([50.0, 50.0, 0.0]); + matte.switches.video = false; + let mut fill = solid(&mut p, &comp, [0.0, 0.0, 1.0], 200, 100); + fill.track_matte = Some(TrackMatte { layer: matte.id, kind }); + let c = p.comp_mut(cid).unwrap(); + c.layers.push(matte); + c.layers.push(fill); + let img = render_frame(&p, cid, Tick::ZERO, 1.0); + let (a_in, a_out) = (img.get(50, 50)[3], img.get(150, 50)[3]); + if inside { + assert!(a_in > 0.99 && a_out < 0.01, "{kind:?}: {a_in} inside, {a_out} outside"); + assert!(img.get(50, 50)[2] > 0.99, "{kind:?} keeps the fill's colour"); + } else { + assert!(a_in < 0.01 && a_out > 0.99, "{kind:?}: {a_in} inside, {a_out} outside"); + assert!(img.get(150, 50)[2] > 0.99, "{kind:?} keeps the fill's colour"); + } + } +} + #[test] fn shape_layer_draws_fill_and_stroke() { let (mut p, cid, comp) = setup(); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 61b2d22d4..86d3a1c69 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1931,7 +1931,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { Some(m) => comp.layer(m.layer).map(|l| format!("{} {}", idx_of(l.id), l.name)).unwrap_or("None".into()), None => "No Track Matte".into(), }; - let tresp = widgets::dropdown(ui, mr, &label, &t, egui::Id::new(("trkmat", layer.id.0))); + // The cell is too narrow for the mode as well: it is in the tooltip and ticked in the menu. + let tip = layer.track_matte.map(|m| format!("{label} · {}", crate::i18n::tr(m.kind.label()))); + let tresp = widgets::dropdown_tip(ui, mr, &label, tip.as_deref(), &t, egui::Id::new(("trkmat", layer.id.0))); app.auto.add(&format!("timeline.layer.{}.trackMatte", layer.id.0), mr, "Track Matte"); let pop = egui::Id::new(("trk-pop", layer.id.0)); if tresp.clicked() { @@ -1951,17 +1953,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { opts.push(k.label().into()); } } - if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, None) { - if i == 0 { - actions.push(("layer.setTrackMatte".into(), json!({"layer": layer.id.0, "matte": null}))); - } else if i >= 2 && i < 2 + candidates.len() { - let kind = layer.track_matte.map(|m| m.kind).unwrap_or_default(); - actions - .push(("layer.setTrackMatte".into(), json!({"layer": layer.id.0, "matte": candidates[i - 2].id.0, "kind": kind_name(kind)}))); - } else if let Some(k) = MatteKind::ALL.into_iter().find(|k| k.label() == opts[i]) - && let Some(m) = layer.track_matte - { - actions.push(("layer.setTrackMatte".into(), json!({"layer": layer.id.0, "matte": m.layer.0, "kind": kind_name(k)}))); + let current = layer.track_matte.and_then(|m| opts.iter().position(|o| o == m.kind.label())); + if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, current) { + let ids: Vec = candidates.iter().map(|l| l.id).collect(); + if let Some(a) = opts.get(i).and_then(|o| track_matte_pick(layer, &ids, i, o)) { + actions.push(a); } } if layer.track_matte.is_some() { @@ -3396,6 +3392,21 @@ fn text_anim_popups( } } +/// The command for the Track Matte menu item `i` (`choice`) of `layer`, whose menu lists "No Track +/// Matte", a separator, the matte `candidates`, a separator and the modes. A mode without a matte +/// layer takes the layer above, like Layer ▸ Track Matte (#864: it used to do nothing). +fn track_matte_pick(layer: &Layer, candidates: &[LayerId], i: usize, choice: &str) -> Option<(String, serde_json::Value)> { + if i == 0 { + return Some(("layer.setTrackMatte".into(), json!({"layer": layer.id.0, "matte": null}))); + } + if let Some(m) = i.checked_sub(2).and_then(|j| candidates.get(j)) { + let kind = layer.track_matte.map(|m| m.kind).unwrap_or_default(); + return Some(("layer.setTrackMatte".into(), json!({"layer": layer.id.0, "matte": m.0, "kind": kind_name(kind)}))); + } + let k = MatteKind::ALL.into_iter().find(|k| k.label() == choice)?; + Some(("layer.trackMatte".into(), json!({"layers": [layer.id.0], "op": kind_name(k)}))) +} + fn kind_name(k: MatteKind) -> &'static str { match k { MatteKind::Alpha => "alpha", @@ -3698,6 +3709,40 @@ mod tests { assert!(edge_scroll_speed_x(100.0, 500.0, 600.0) > edge_scroll_speed_x(100.0, 500.0, 520.0)); } + /// #864: picking only a mode in a layer's Track Matte menu sets a matte (the layer above); + /// it used to close the menu and change nothing. + #[test] + fn a_track_matte_mode_without_a_matte_takes_the_layer_above() { + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"name": "T", "width": 64, "height": 64, "duration": 1})).unwrap(); + for name in ["Chain Bottom", "Matte", "Chain Top"] { + s.execute("layer.newSolid", json!({"name": name, "color": "#ffffff", "width": 64, "height": 64})).unwrap(); + } + let find = |s: &effectcraft_engine::Session, n: &str| s.active_comp().unwrap().layers.iter().find(|l| l.name == n).unwrap().clone(); + let (bottom, matte, top) = (find(&s, "Chain Bottom"), find(&s, "Matte"), find(&s, "Chain Top")); + let candidates = [top.id, matte.id]; + let pick = |s: &mut effectcraft_engine::Session, name: &str, i: usize, choice: &str| { + let (id, params) = track_matte_pick(&find(s, name), &candidates, i, choice).expect("a menu item is an action"); + s.execute(&id, params) + }; + pick(&mut s, "Chain Bottom", 5, "Alpha Inverted Matte").unwrap(); + let m = find(&s, "Chain Bottom").track_matte.expect("a matte"); + assert_eq!((m.layer, m.kind), (matte.id, MatteKind::AlphaInverted)); + assert!(!find(&s, "Matte").switches.video, "the matte layer is hidden"); + // A mode change keeps the matte layer; a matte change keeps the mode. + pick(&mut s, "Chain Bottom", 6, "Luma Matte").unwrap(); + assert_eq!(find(&s, "Chain Bottom").track_matte.map(|m| (m.layer, m.kind)), Some((matte.id, MatteKind::Luma))); + pick(&mut s, "Chain Bottom", 2, "1. Chain Top").unwrap(); + assert_eq!(find(&s, "Chain Bottom").track_matte.map(|m| (m.layer, m.kind)), Some((top.id, MatteKind::Luma))); + assert!(s.undo()); + assert_eq!(find(&s, "Chain Bottom").track_matte.map(|m| m.layer), Some(matte.id), "one undo step per pick"); + // The top layer has nothing above it: an error for the status bar, not a silent no-op. + assert!(pick(&mut s, "Chain Top", 5, "Alpha Matte").is_err()); + pick(&mut s, "Chain Bottom", 0, "No Track Matte").unwrap(); + assert!(find(&s, "Chain Bottom").track_matte.is_none()); + assert!(track_matte_pick(&bottom, &candidates, 1, "-").is_none(), "a separator does nothing"); + } + /// A comp with two solids; "Box" has an animated Position and a Gaussian Blur. fn app() -> EffectcraftApp { let mut s = effectcraft_engine::Session::default(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 18c6aedb2..c47a6b509 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -307,6 +307,11 @@ fn field(ui: &mut Ui, rect: Rect, text: &mut String, hint: &str, t: &Tokens, sea /// Dropdown-looking control ("Full ▾"). pub fn dropdown(ui: &mut Ui, rect: Rect, text: &str, t: &Tokens, id: egui::Id) -> Response { + dropdown_tip(ui, rect, text, None, t, id) +} + +/// [`dropdown`] with a tooltip `tip` (shown always; it replaces the elided text's tooltip). +pub fn dropdown_tip(ui: &mut Ui, rect: Rect, text: &str, tip: Option<&str>, t: &Tokens, id: egui::Id) -> Response { let resp = ui.interact(rect, id, Sense::click()); ui.painter().rect_filled(rect, 3.0, if resp.hovered() { t.hover } else { t.field_bg }); ui.painter().rect_stroke(rect, 3.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); @@ -328,6 +333,9 @@ pub fn dropdown(ui: &mut Ui, rect: Rect, text: &str, t: &Tokens, id: egui::Id) - }; ui.painter().text(pos2(rect.min.x + 7.0, rect.center().y), Align2::LEFT_CENTER, shown, Tokens::ui(11.5), t.text); icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.max.x - 9.0, rect.center().y), vec2(8.0, 8.0)), Icon::ChevronDown, t.text_dim); + if let Some(tip) = tip { + return resp.on_hover_text(tip.to_string()); + } if text.chars().count() > 0 && !fits(text) { return resp.on_hover_text(text.to_string()); }