diff --git a/crates/ui-egui/src/color_picker.rs b/crates/ui-egui/src/color_picker.rs index 35d8e4eb4..a692d78db 100644 --- a/crates/ui-egui/src/color_picker.rs +++ b/crates/ui-egui/src/color_picker.rs @@ -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::(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 { + let armed: Option = 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 = ctx.data(|d| d.get_temp(armed_key())); + if armed.is_some_and(|a| a.popup == popup) { + ctx.data_mut(|d| d.remove::(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::>(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> = 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 { + 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 { + 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 { + ctx.data_mut(|d| { + let rect = d.get_temp::(automation_key()); + d.remove::(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))); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 105a075e7..67fa405f7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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(); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index d773fc433..2ed25070e 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -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)); }); } diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index bad1db39f..b42047c75 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -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() { diff --git a/crates/ui-egui/src/panels/fx_widgets.rs b/crates/ui-egui/src/panels/fx_widgets.rs index ca49c3fd0..bce037a10 100644 --- a/crates/ui-egui/src/panels/fx_widgets.rs +++ b/crates/ui-egui/src/panels/fx_widgets.rs @@ -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 } diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index ae2294f1f..ed5c531cb 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -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 {