#864: Track Matte menu: a mode without a matte layer takes the layer above

Picking Alpha / Alpha Inverted / Luma / Luma Inverted in a layer's Timeline
Track Matte menu only dispatched a command when the layer already had a
matte layer, so with "No Track Matte" the click closed the menu and did
nothing. The mode items now run layer.trackMatte (Layer > Track Matte),
which keeps an existing matte and otherwise takes the layer above, or
reports that the top layer has none. The menu ticks the current mode and
the cell's tooltip names the matte layer and the mode (the 104 px cell
has no room for both).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
vangel76andClaude Opus 5.5 committed 2026-10-11 17:46:05 +02:00
1 parent 9a7d3e7699
commit 4a3889270b
3 files changed
+91 -12

No files matched your search

+26
View File
@@ -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();
+57 -12
View File
@@ -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<LayerId> = 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();
+8
View File
@@ -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());
}