Timeline: colour properties get an eyedropper after their swatch, as in After Effects (#633)

A shape's Fill and Stroke Color (and every other colour property) in the
Timeline only had a swatch that opens the colour picker; After Effects puts
an eyedropper next to it to sample a colour from the Composition panel.
The Timeline now shows that eyedropper (`timeline.prop.<uid>.eyedropper`),
reusing Effect Controls' viewer pick: click it, then click in the
Composition panel, and the property is set to the sampled colour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
This commit is contained in:
Claude 2026-10-10 21:11:53 +00:00
parent d3b26f2a65
commit 1bcfe6912d
No known key found for this signature in database
3 changed files with 60 additions and 2 deletions

View File

@ -186,8 +186,9 @@ fn key_navigator(
}
}
/// Toggle a viewer pick (crosshair / eyedropper) for a parameter.
fn toggle_pick(app: &mut EffectcraftApp, kind: &str, layer: &Layer, prop: &Property) {
/// Toggle a viewer pick (crosshair / eyedropper) for a parameter (also the Timeline's colour
/// eyedroppers).
pub(crate) fn toggle_pick(app: &mut EffectcraftApp, kind: &str, layer: &Layer, prop: &Property) {
let pick = FxPick { kind: kind.into(), layer: layer.id.0, prop: prop.uid, name: prop.name.clone() };
if app.ui.fx_pick.as_ref() == Some(&pick) {
app.ui.fx_pick = None;

View File

@ -3591,6 +3591,17 @@ fn value_editor(
if resp.clicked() {
widgets::open_popup(ui, pop);
}
// The eyedropper after the swatch samples the Composition panel (shape Fill and
// Stroke colours, text and effect colours), as in After Effects' Timeline (#633).
let er = Rect::from_center_size(pos2(r.max.x + 12.0, at.y), vec2(16.0, 16.0));
let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == "color");
if super::fx_widgets::eyedropper_button(ui, er, active, egui::Id::new(("tl-eye", uid)), &t)
.on_hover_text(crate::i18n::tr("Eyedropper: click, then click in the Composition panel"))
.clicked()
{
super::effect_controls::toggle_pick(app, "color", layer, prop);
}
app.auto.add(&format!("timeline.prop.{uid}.eyedropper"), er, &prop.name);
let mut rgb = [c[0] as f32, c[1] as f32, c[2] as f32];
if crate::widgets::color_popup(ui, pop, r.left_bottom(), &mut rgb) {
set(actions, json!([rgb[0], rgb[1], rgb[2], 1.0]));

View File

@ -197,3 +197,49 @@ fn toolbar_stroke_edits_the_selected_shape_layer() {
assert_eq!(width, Some(4.0), "a stroke was added with the toolbar's width");
assert_eq!(h.state().session.state.shape_tool.stroke_width, 4.0);
}
/// Issue #633: a colour property in the Timeline (a shape's Fill Color) has an eyedropper after
/// its swatch, as in After Effects: click it, then click in the Composition panel to sample.
#[test]
fn timeline_colour_eyedropper_samples_the_comp() {
let mut s = session();
s.execute("layer.newSolid", json!({"name": "Back", "color": "#20c040"})).unwrap();
let l = s.execute("layer.newShape", json!({"kind": "rect", "fill": [1.0, 0.0, 0.0], "size": [100.0, 60.0]})).unwrap()["layer"].as_u64().unwrap();
let layer = s.active_comp().unwrap().layer(LayerId(l)).unwrap().clone();
let mut fill = None;
layer.props.walk("", &mut |_, p| {
if fill.is_none() && matches!(p.value, effectcraft_engine::keyframe::Value::Color(_)) {
fill = Some(p.uid);
}
});
let fill = fill.expect("a fill colour");
fn groups(g: &effectcraft_engine::project::PropGroup, out: &mut Vec<u64>) {
for c in g.groups() {
out.push(c.uid);
groups(c, out);
}
}
let mut open = vec![];
groups(&layer.props, &mut open);
let mut h = harness(s);
h.state_mut().ui.timeline.open_groups.extend(open);
open_layer(&mut h, l);
h.run_steps(2);
click(&mut h, &format!("timeline.prop.{fill}.eyedropper"));
assert_eq!(h.state().ui.fx_pick.as_ref().map(|p| (p.prop, p.kind.as_str())), Some((fill, "color")), "the eyedropper waits for a viewer click");
let pos = effectcraft_ui_egui::panels::viewer::comp_to_screen(&h.ctx, [6.0, 6.0]).unwrap();
click_at(&mut h, pos);
assert!(h.state().ui.fx_pick.is_none(), "the pick is used up");
let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone();
let mut got = None;
layer.props.walk("", &mut |_, p| {
if p.uid == fill {
got = Some(p.value.clone());
}
});
let Some(effectcraft_engine::keyframe::Value::Color(c)) = got else { panic!("{got:?}") };
let want = [0x20 as f64 / 255.0, 0xc0 as f64 / 255.0, 0x40 as f64 / 255.0];
for k in 0..3 {
assert!((c[k] - want[k]).abs() < 0.02, "{c:?} vs {want:?}");
}
}