#712: color pickers get an eyedropper everywhere

The Character panel's pipette (#938) only sampled text. Every color picker now has an eyedropper in its hex row: click it, then click in the Composition panel to take the color under the pointer. The picker is one shared popup, so Solid Settings, Fill and Stroke, attached images, effects and the rest get it without caller changes.

- color_picker: the eyedropper arms the picker. Escape or a second click disarms it and the picker stays open. While armed, the viewer shows a crosshair, a "Click to sample color" chip and a swatch under the pointer, and the next press on the viewer samples into the picker. A press over the picker or a dialog is not a sample, and a press elsewhere closes the picker as before.
- The Character panel's pipette glyph moves to color_picker::draw_pipette, so both show the same icon.
- Automation id colorPicker.eyedropper.
- Modal dialogs (modal_fit) and the picker's popup register their rects as overlays, so a press on them never samples the viewer beneath.

Not compiled, tested or rustfmt-checked: the machine it was written on has no Rust toolchain. Run cargo xtask ci before relying on it. Pushed to the fork for testing only.
This commit is contained in:
nanox333
2026-10-13 15:47:05 +02:00
parent 549e9d8251
commit e043fc0bb0
6 changed files with 261 additions and 13 deletions
+227 -6
View File
@@ -1,6 +1,7 @@
//! The colour picker every colour input opens, laid out like After Effects' Color Picker: a
//! square and a slider for the channel chosen with the H / S / B / R / G / B buttons, the new
//! colour over the original, the HSB and RGB values, the hex code, and OK / Cancel.
//! colour over the original, the HSB and RGB values, the hex code with an eyedropper that samples
//! the Composition viewer, and OK / Cancel.
//!
//! Colours are sRGB components (0–1), as the project stores them. Changes apply as they are made
//! (After Effects' Preview); Cancel or Escape goes back to the colour the picker opened with.
@@ -30,29 +31,49 @@ pub fn srgb_color_button(ui: &mut Ui, c: &mut [f32; 3]) -> Response {
}
/// The colour picker as a popup at `pos`, bound to `c`, opened with [`open_popup`]. Returns true
/// when the colour changed (Cancel, Escape and the original swatch change it back).
/// when the colour changed (Cancel, Escape and the original swatch change it back; the eyedropper
/// changes it to the colour it samples).
pub fn color_popup(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3]) -> bool {
if !popup_is_open(ui, id) {
return false;
}
let orig_id = id.with("original");
let original = ui.data_mut(|d| *d.get_temp_mut_or_insert_with(orig_id, || *c));
// Read before a sample is taken: the press that samples the viewer lands outside the picker, and
// must not close it.
let armed = armed_for(ui.ctx(), id);
let mut sampled = false;
if let Some(rgb) = take_sample(ui.ctx(), id)
&& rgb != *c
{
*c = rgb;
sampled = true;
}
let mut out = Outcome::default();
let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos + vec2(0.0, 4.0)).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| out = color_picker(ui, id, c, original));
});
// Escape is the picker's: consume it, so nothing later this pass takes it too.
// Escape is the picker's: consume it, so nothing later this pass takes it too. While the
// eyedropper is armed, Escape cancels it and the picker stays open.
if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
out.close = Some(false);
if armed {
disarm(ui.ctx(), id);
} else {
out.close = Some(false);
}
}
if pressed_outside(ui.ctx(), &area.response) {
// A press outside closes the picker, unless it is the armed eyedropper's press on the viewer.
let on_viewer = armed && eyedropper_point(ui.ctx()).is_some();
if pressed_outside(ui.ctx(), &area.response) && !on_viewer {
out.close.get_or_insert(true);
}
note_overlay(ui.ctx(), area.response.rect);
if let Some(keep) = out.close {
if !keep && *c != original {
*c = original;
out.changed = true;
}
disarm(ui.ctx(), id);
ui.data_mut(|d| {
d.insert_temp(id.with("open"), false);
d.remove::<[f32; 3]>(orig_id);
@@ -60,11 +81,12 @@ pub fn color_popup(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3])
d.remove::<u64>(open_pass_id());
});
} else {
refresh_armed(ui.ctx(), id);
// Still open after this pass: dialogs leave Escape to the picker (see `open_recently`).
let pass = ui.ctx().cumulative_pass_nr();
ui.data_mut(|d| d.insert_temp(open_pass_id(), pass));
}
out.changed
out.changed || sampled
}
fn open_pass_id() -> egui::Id {
@@ -80,6 +102,177 @@ pub fn open_recently(ctx: &egui::Context) -> bool {
pass.is_some_and(|p| p.saturating_add(1) >= ctx.cumulative_pass_nr())
}
/// The armed eyedropper of a picker: the picker's popup id, the pass it was last drawn in, and the
/// colour the viewer sampled for it, if one is waiting for the picker's next draw. It lives in the
/// egui context, since the viewer that samples has no picker in hand.
#[derive(Clone, Copy)]
struct Armed {
popup: egui::Id,
pass: u64,
sample: Option<[f32; 3]>,
}
fn armed_key() -> egui::Id {
egui::Id::new("color-picker-eyedropper")
}
/// The armed eyedropper, if its picker was drawn in this pass or the last: one whose picker stops
/// being drawn (its dialog gone) lapses.
fn armed_record(ctx: &egui::Context) -> Option<Armed> {
let armed: Option<Armed> = ctx.data(|d| d.get_temp(armed_key()));
let pass = ctx.cumulative_pass_nr();
armed.filter(|a| a.pass.saturating_add(1) >= pass)
}
fn armed_for(ctx: &egui::Context, popup: egui::Id) -> bool {
armed_record(ctx).is_some_and(|a| a.popup == popup)
}
/// Some picker's eyedropper is armed: a press on the Composition viewer samples the frame for it.
pub fn eyedropper_armed(ctx: &egui::Context) -> bool {
armed_record(ctx).is_some()
}
fn arm(ctx: &egui::Context, popup: egui::Id) {
let pass = ctx.cumulative_pass_nr();
ctx.data_mut(|d| d.insert_temp(armed_key(), Armed { popup, pass, sample: None }));
}
fn disarm(ctx: &egui::Context, popup: egui::Id) {
let armed: Option<Armed> = ctx.data(|d| d.get_temp(armed_key()));
if armed.is_some_and(|a| a.popup == popup) {
ctx.data_mut(|d| d.remove::<Armed>(armed_key()));
}
}
/// Keeps `popup`'s eyedropper armed while its picker is drawn.
fn refresh_armed(ctx: &egui::Context, popup: egui::Id) {
if let Some(armed) = armed_record(ctx).filter(|a| a.popup == popup) {
let pass = ctx.cumulative_pass_nr();
ctx.data_mut(|d| d.insert_temp(armed_key(), Armed { pass, ..armed }));
}
}
/// The colour the viewer sampled for `popup`, once: taking it disarms the eyedropper.
fn take_sample(ctx: &egui::Context, popup: egui::Id) -> Option<[f32; 3]> {
let armed = armed_record(ctx).filter(|a| a.popup == popup)?;
let sample = armed.sample?;
disarm(ctx, popup);
Some(sample)
}
/// The viewer sampled `rgb` for the armed picker, which takes it on its next draw.
pub fn deliver_sample(ctx: &egui::Context, rgb: [f32; 3]) {
if let Some(armed) = armed_record(ctx) {
ctx.data_mut(|d| d.insert_temp(armed_key(), Armed { sample: Some(rgb), ..armed }));
ctx.request_repaint();
}
}
fn viewer_key() -> egui::Id {
egui::Id::new("color-picker-viewer")
}
/// The Composition viewer's rect as it was drawn this pass: where an armed eyedropper samples.
pub fn note_viewer(ctx: &egui::Context, rect: Rect) {
let pass = ctx.cumulative_pass_nr();
ctx.data_mut(|d| d.insert_temp(viewer_key(), (pass, rect)));
}
fn overlay_key() -> egui::Id {
egui::Id::new("color-picker-overlays")
}
/// A popup or dialog drawn over the viewer: a press inside `rect` is not a sample.
pub fn note_overlay(ctx: &egui::Context, rect: Rect) {
let pass = ctx.cumulative_pass_nr();
ctx.data_mut(|d| {
// This pass's and the last's: the viewer may be drawn before an overlay in a pass.
let mut rects = d.get_temp::<Vec<(u64, Rect)>>(overlay_key()).unwrap_or_default();
rects.retain(|(p, _)| p.saturating_add(1) >= pass);
rects.push((pass, rect));
d.insert_temp(overlay_key(), rects);
});
}
/// Whether `pos` is over a popup or dialog drawn this pass or the last.
fn overlaid(ctx: &egui::Context, pos: egui::Pos2) -> bool {
let pass = ctx.cumulative_pass_nr();
let rects: Option<Vec<(u64, Rect)>> = ctx.data(|d| d.get_temp(overlay_key()));
rects.unwrap_or_default().iter().any(|(p, r)| p.saturating_add(1) >= pass && r.contains(pos))
}
/// Where an armed eyedropper samples: the pointer, if it is over the viewer and not over a popup or
/// dialog. The pointer is read rather than the viewer's responses, since a modal dialog over the
/// viewer takes its clicks.
pub fn eyedropper_point(ctx: &egui::Context) -> Option<egui::Pos2> {
let viewer: Option<(u64, Rect)> = ctx.data(|d| d.get_temp(viewer_key()));
let viewer = viewer.filter(|(p, _)| p.saturating_add(1) >= ctx.cumulative_pass_nr())?.1;
let pos = ctx.input(|i| i.pointer.interact_pos())?;
(viewer.contains(pos) && !overlaid(ctx, pos)).then_some(pos)
}
/// The press that samples for an armed eyedropper this frame, if there is one.
pub fn sample_press(ctx: &egui::Context) -> Option<egui::Pos2> {
if ctx.input(|i| i.pointer.primary_pressed()) { eyedropper_point(ctx) } else { None }
}
fn automation_key() -> egui::Id {
egui::Id::new("color-picker-eyedropper-rect")
}
/// The eyedropper's rect, for the automation registry; [`take_automation`] hands it over.
fn note_automation(ctx: &egui::Context, rect: Rect) {
ctx.data_mut(|d| d.insert_temp(automation_key(), rect));
}
/// The eyedropper rect the last picker drawing noted, once. Pickers have no registry in hand, so the
/// app registers it each frame.
pub fn take_automation(ctx: &egui::Context) -> Option<Rect> {
ctx.data_mut(|d| {
let rect = d.get_temp::<Rect>(automation_key());
d.remove::<Rect>(automation_key());
rect
})
}
/// The eyedropper of the hex row. A press arms it, or disarms it while armed; the next press on the
/// Composition viewer then samples the colour under the pointer into this picker.
fn eyedropper(ui: &mut Ui, popup: egui::Id) {
let armed = armed_for(ui.ctx(), popup);
let (rect, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click());
let resp = resp.on_hover_text(tr("Eyedropper: click, then click in the Composition panel"));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), "Eyedropper"));
let fill = if armed {
ui.visuals().selection.bg_fill
} else if resp.hovered() {
ui.visuals().widgets.hovered.bg_fill
} else {
Color32::TRANSPARENT
};
ui.painter().rect_filled(rect, 2.0, fill);
let col = if armed { ui.visuals().selection.stroke.color } else { ui.visuals().strong_text_color() };
draw_pipette(ui.painter(), rect, col);
if resp.clicked() {
if armed {
disarm(ui.ctx(), popup);
} else {
arm(ui.ctx(), popup);
}
}
note_automation(ui.ctx(), rect);
}
/// A pipette: a diagonal glass tube with a bulb at the top right and a tip at the bottom left, drawn
/// within `rect`.
pub fn draw_pipette(p: &egui::Painter, rect: Rect, col: Color32) {
let r = rect.shrink(rect.width() * 0.2);
let at = |x: f32, y: f32| pos2(r.min.x + x * r.width(), r.min.y + y * r.height());
p.line_segment([at(0.08, 0.92), at(0.62, 0.38)], Stroke::new(2.2, col));
p.circle_filled(at(0.76, 0.24), r.width() * 0.2, col);
p.line_segment([at(0.5, 0.3), at(0.7, 0.5)], Stroke::new(1.4, col));
}
/// What the picker did this frame.
#[derive(Default)]
struct Outcome {
@@ -237,6 +430,7 @@ fn color_picker(ui: &mut Ui, id: egui::Id, c: &mut [f32; 3], original: [f32; 3])
if hex_field(ui, id.with("hex"), &mut rgb) {
pick = Pick { hsv: keep_hue(rgb_to_hsv(rgb), pick.hsv), rgb };
}
eyedropper(ui, id);
});
});
});
@@ -429,3 +623,30 @@ pub fn hsv_to_rgb([h, s, v]: [f32; 3]) -> [f32; 3] {
};
[f(5.0), f(3.0), f(1.0)]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn armed_eyedropper_takes_one_sample_then_disarms() {
let ctx = egui::Context::default();
let popup = egui::Id::new("test-picker");
arm(&ctx, popup);
assert!(eyedropper_armed(&ctx));
assert_eq!(take_sample(&ctx, popup), None);
deliver_sample(&ctx, [0.1, 0.2, 0.3]);
assert_eq!(take_sample(&ctx, popup), Some([0.1, 0.2, 0.3]));
assert!(!eyedropper_armed(&ctx));
assert_eq!(take_sample(&ctx, popup), None);
}
#[test]
fn points_over_a_dialog_are_not_samples() {
let ctx = egui::Context::default();
let dialog = Rect::from_min_size(pos2(100.0, 100.0), vec2(120.0, 80.0));
note_overlay(&ctx, dialog);
assert!(overlaid(&ctx, pos2(150.0, 150.0)));
assert!(!overlaid(&ctx, pos2(20.0, 20.0)));
}
}
+3
View File
@@ -1614,6 +1614,9 @@ impl EffectcraftApp {
self.apply_gpu_failure(&ctx);
self.run_dialog_answers();
self.auto.begin_frame();
if let Some(rect) = crate::color_picker::take_automation(&ctx) {
self.auto.add("colorPicker.eyedropper", rect, "Eyedropper");
}
self.frames.set_context(&ctx);
if self.session.render_job.is_some() {
self.session.poll_render();
+2
View File
@@ -213,6 +213,8 @@ pub(crate) fn modal_fit(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &
ui.add_space(10.0);
let title_h = ui.cursor().top() - ui.min_rect().top();
body(ui, (size.y - 36.0 - title_h).max(40.0));
// A colour picker's eyedropper samples the viewer under the dialog, but not the dialog itself.
crate::color_picker::note_overlay(ui.ctx(), ui.min_rect().expand(18.0));
});
}
@@ -1455,6 +1455,7 @@ pub fn viewer_hook(
) {
let t = app.tokens;
let ctx = ui.ctx().clone();
crate::color_picker::note_viewer(&ctx, map.area);
let mut actions: Actions = vec![];
let mut point_preview = None;
if let Some(pick) = app.ui.fx_pick.clone() {
@@ -1574,6 +1575,31 @@ pub fn viewer_hook(
}
}
}
// A colour picker's eyedropper is armed (its popup stays open): the viewer shows the colour under
// the pointer, and the next press on it samples the frame into the picker. The press is read from
// the pointer, since a modal dialog over the viewer takes its clicks.
if crate::color_picker::eyedropper_armed(&ctx) {
let _ = ui.interact(map.area, egui::Id::new("viewer-popup-eyedropper"), Sense::click());
ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
app.viewer_pixels();
let chip = Rect::from_min_size(map.area.min + vec2(10.0, 10.0), vec2(220.0, 22.0));
painter.rect_filled(chip, 11.0, Color32::from_black_alpha(180));
painter.text(chip.center(), Align2::CENTER_CENTER, "Click to sample color", Tokens::ui(11.5), t.text);
if let Some(hp) = crate::color_picker::eyedropper_point(&ctx)
&& let Some(img) = &app.viewer_image
&& let Some(s) = fw::sample_frame(img, super::viewer_tools::shown_region(&ctx, map.comp), map.to_comp(hp))
{
let sw = Rect::from_min_size(hp + vec2(14.0, 14.0), vec2(26.0, 18.0));
painter.rect_filled(sw, 2.0, Color32::from_rgb((s[0] * 255.0) as u8, (s[1] * 255.0) as u8, (s[2] * 255.0) as u8));
painter.rect_stroke(sw, 2.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Outside);
}
if let Some(pos) = crate::color_picker::sample_press(&ctx)
&& let Some(img) = &app.viewer_image
&& let Some(s) = fw::sample_frame(img, super::viewer_tools::shown_region(&ctx, map.comp), map.to_comp(pos))
{
crate::color_picker::deliver_sample(&ctx, [s[0], s[1], s[2]]);
}
}
let picking = app.ui.fx_pick.is_some() || app.ui.text_color_pick.is_some();
let point_pick = app.ui.fx_pick.clone().filter(|p| p.kind == "point");
if app.ui.viewer.show_layer_controls || point_pick.is_some() {
+1 -6
View File
@@ -171,12 +171,7 @@ pub fn eyedropper_button(ui: &mut egui::Ui, rect: Rect, active: bool, id: egui::
} else {
t.text_dim
};
// A pipette: a diagonal glass tube with a bulb at the top right and a tip at the bottom left.
let r = rect.shrink(rect.width() * 0.2);
let at = |x: f32, y: f32| pos2(r.min.x + x * r.width(), r.min.y + y * r.height());
p.line_segment([at(0.08, 0.92), at(0.62, 0.38)], Stroke::new(2.2, col));
p.circle_filled(at(0.76, 0.24), r.width() * 0.2, col);
p.line_segment([at(0.5, 0.3), at(0.7, 0.5)], Stroke::new(1.4, col));
crate::color_picker::draw_pipette(p, rect, col);
resp
}
+2 -1
View File
@@ -204,7 +204,8 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
let er = Rect::from_min_size(pos2(fs.max.x + 4.0, y + 1.0), vec2(18.0, 18.0));
let active = app.ui.text_color_pick.as_ref().is_some_and(|pick| pick.layer == target.as_ref().map_or(0, |tt| tt.layer) && pick.field == key);
let eye = super::fx_widgets::eyedropper_button(ui, er, active, egui::Id::new(("char-eye", key)), &t);
let eye = super::fx_widgets::eyedropper_button(ui, er, active, egui::Id::new(("char-eye", key)), &t)
.on_hover_text(tr("Eyedropper: click, then click in the Composition panel"));
app.auto.add(&format!("character.{key}.eyedropper"), er, &format!("{label} eyedropper"));
if eye.clicked() {
if active {