From e043fc0bb02b18086d92a4d521d17d3a3cff61b3 Mon Sep 17 00:00:00 2001 From: nanox333 Date: Tue, 13 Oct 2026 15:47:05 +0200 Subject: [PATCH 1/4] #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. --- crates/ui-egui/src/color_picker.rs | 233 ++++++++++++++++++- crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/panels/dialogs.rs | 2 + crates/ui-egui/src/panels/effect_controls.rs | 26 +++ crates/ui-egui/src/panels/fx_widgets.rs | 7 +- crates/ui-egui/src/panels/text_panels.rs | 3 +- 6 files changed, 261 insertions(+), 13 deletions(-) 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 { From e50971022737863fe0f73f096298f393f3aa8a81 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:29:50 +0000 Subject: [PATCH 2/4] #712: register an automation id for the popup eyedropper's viewer surface Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01RDvxCS8xcR28VArjzn1x7U --- crates/ui-egui/src/panels/effect_controls/viewer.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/ui-egui/src/panels/effect_controls/viewer.rs b/crates/ui-egui/src/panels/effect_controls/viewer.rs index 5f471953c..c7f8981d1 100644 --- a/crates/ui-egui/src/panels/effect_controls/viewer.rs +++ b/crates/ui-egui/src/panels/effect_controls/viewer.rs @@ -186,6 +186,8 @@ fn popup_eyedropper(app: &mut EffectcraftApp, ui: &mut egui::Ui, painter: &egui: return; } let _ = ui.interact(map.area, egui::Id::unique("viewer-popup-eyedropper"), Sense::click()); + // The sampling surface, apart from the picker's button that arms it (`colorPicker.eyedropper`). + app.auto.add("viewer.colorPickerEyedropper", map.area, "Sample color"); 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)); From 6da89b6ea8e58be2da7f84d97b39f36f91c2a045 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Fri, 30 Oct 2026 02:17:34 +0100 Subject: [PATCH 3/4] #712: share eyedropper previews and translate the sampling hint --- crates/ui-egui/src/i18n/ui.rs | 8 ++++ .../src/panels/effect_controls/viewer.rs | 40 ++++++++----------- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 46493296b..a7a9be62b 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -163,6 +163,7 @@ pub(crate) fn tr_args(text: &str, args: &[&dyn Display]) -> String { /// Japanese: the same rows, in the same order, as the Chinese catalogs below. const JAPANESE: &[(&str, &str)] = &[ + ("Click to sample color", "クリックして色をサンプル"), ("Appearance Settings…", "外観設定…"), ("Add to favorites", "お気に入りに追加"), ("Remove from favorites", "お気に入りから削除"), @@ -904,6 +905,7 @@ const JAPANESE: &[(&str, &str)] = &[ ]; const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ + ("Click to sample color", "单击以取样颜色"), ("Appearance Settings…", "外观设置…"), ("Add to favorites", "添加到收藏"), ("Remove from favorites", "从收藏中移除"), @@ -1644,6 +1646,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ]; const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ + ("Click to sample color", "按一下以取樣顏色"), ("Appearance Settings…", "外觀設定…"), ("Add to favorites", "加入最愛"), ("Remove from favorites", "從最愛移除"), @@ -2332,6 +2335,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ /// Ukrainian (uk): original translations of the same interface rows as the Chinese catalogs. /// Technical names, key names and substituted project content keep their original spelling. const UKRAINIAN: &[(&str, &str)] = &[ + ("Click to sample color", "Клацніть, щоб узяти зразок кольору"), ("Appearance Settings…", "Налаштування вигляду…"), ("Add to favorites", "Додати до вибраного"), ("Remove from favorites", "Видалити з вибраного"), @@ -2906,6 +2910,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ // Brazilian Portuguese. const PORTUGUESE: &[(&str, &str)] = &[ + ("Click to sample color", "Clique para obter uma amostra de cor"), (" OK ", " OK "), (" Delete ", " Excluir "), (" Open Auto-Save ", " Abrir salvamento automático "), @@ -3357,6 +3362,7 @@ const PORTUGUESE: &[(&str, &str)] = &[ /// Czech (cs): original translations covering every row in the Chinese panel catalogs. const CZECH: &[(&str, &str)] = &[ + ("Click to sample color", "Kliknutím naberete barvu"), (" OK ", " OK "), (" Delete ", " Odstranit "), (" Open Auto-Save ", " Otevřít automatickou zálohu "), @@ -3911,6 +3917,7 @@ const CZECH: &[(&str, &str)] = &[ /// order. Terms follow the Spanish menu catalog (composición, material de archivo, cola de procesamiento, /// ajuste preestablecido, método abreviado). Written from the English source. const SPANISH: &[(&str, &str)] = &[ + ("Click to sample color", "Haz clic para tomar una muestra de color"), ("Copy Source Path", "Copiar ruta de origen"), ("Appearance Settings…", "Ajustes de apariencia…"), ("Add to favorites", "Añadir a favoritos"), @@ -4467,6 +4474,7 @@ const SPANISH: &[(&str, &str)] = &[ /// Georgian (ka): every row of the Chinese catalogs, in the same order (the tests keep them in step). const GEORGIAN: &[(&str, &str)] = &[ + ("Click to sample color", "დააწკაპუნეთ ფერის ნიმუშის ასაღებად"), ("Appearance Settings…", "იერსახის პარამეტრები…"), ("Add to favorites", "რჩეულებში დამატება"), ("Remove from favorites", "რჩეულებიდან ამოშლა"), diff --git a/crates/ui-egui/src/panels/effect_controls/viewer.rs b/crates/ui-egui/src/panels/effect_controls/viewer.rs index c7f8981d1..7bf65d40c 100644 --- a/crates/ui-egui/src/panels/effect_controls/viewer.rs +++ b/crates/ui-egui/src/panels/effect_controls/viewer.rs @@ -119,6 +119,21 @@ fn fx_pick_session( ControlFlow::Continue(point_preview) } +/// Shared hint chip and sampled-color swatch for text and popup eyedroppers. +fn color_pick_preview(app: &EffectcraftApp, painter: &egui::Painter, map: &crate::panels::viewer::ViewerMap, hint: &str, pointer: Option) { + 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, hint, Tokens::ui(11.5), app.tokens.text); + if let Some(pos) = pointer + && let Some(img) = &app.viewer_image + && let Some(sample) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(painter.ctx(), map.comp), map.to_comp(pos)) + { + let swatch = Rect::from_min_size(pos + vec2(14.0, 14.0), vec2(26.0, 18.0)); + painter.rect_filled(swatch, 2.0, Color32::from_rgb((sample[0] * 255.0) as u8, (sample[1] * 255.0) as u8, (sample[2] * 255.0) as u8)); + painter.rect_stroke(swatch, 2.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Outside); + } +} + /// An armed text-colour pick: sample the shown frame on click, Esc cancels. fn text_color_pick( app: &mut EffectcraftApp, @@ -128,7 +143,6 @@ fn text_color_pick( ectx: &EvalCtx, actions: &mut Actions, ) { - let t = app.tokens; let ctx = ui.ctx().clone(); let Some(pick) = app.ui.text_color_pick.clone() else { return; @@ -140,20 +154,11 @@ fn text_color_pick( app.viewer_pixels(); let resp = ui.interact(map.area, egui::Id::unique("viewer-text-color-pick"), Sense::click()); app.auto.add("viewer.textColorPick", map.area, &pick.field); - 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, crate::i18n::tr_args("Click to sample text {}", &[&pick.field]), Tokens::ui(11.5), t.text); + color_pick_preview(app, painter, map, &crate::i18n::tr_args("Click to sample text {}", &[&pick.field]), resp.hover_pos()); if let Some(pos) = resp.hover_pos() { ctx.set_cursor_icon(egui::CursorIcon::Crosshair); let c = map.to_comp(pos); app.pointer_comp = Some([c[0] as f32, c[1] as f32]); - if let Some(img) = &app.viewer_image - && let Some(s) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(&ctx, map.comp), c) - { - let sw = Rect::from_min_size(pos + 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 resp.clicked() && let Some(pos) = resp.interact_pointer_pos() @@ -180,7 +185,6 @@ fn text_color_pick( /// 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. fn popup_eyedropper(app: &mut EffectcraftApp, ui: &mut egui::Ui, painter: &egui::Painter, map: &crate::panels::viewer::ViewerMap) { - let t = app.tokens; let ctx = ui.ctx().clone(); if !crate::color_picker::eyedropper_armed(&ctx) { return; @@ -190,17 +194,7 @@ fn popup_eyedropper(app: &mut EffectcraftApp, ui: &mut egui::Ui, painter: &egui: app.auto.add("viewer.colorPickerEyedropper", map.area, "Sample color"); 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, crate::panels::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); - } + color_pick_preview(app, painter, map, crate::i18n::tr("Click to sample color"), crate::color_picker::eyedropper_point(&ctx)); if let Some(pos) = crate::color_picker::sample_press(&ctx) && let Some(img) = &app.viewer_image && let Some(s) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(&ctx, map.comp), map.to_comp(pos)) From 725750eabb1e3d2a19162fa2de61465ad08af9b8 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Fri, 30 Oct 2026 03:23:57 +0100 Subject: [PATCH 4/4] #1328: sample the displayed frame and guard modal ownership --- crates/ui-egui/src/app/frame.rs | 2 + crates/ui-egui/src/color_picker.rs | 116 +++++++++++++++++- crates/ui-egui/src/frames.rs | 16 ++- crates/ui-egui/src/frames/tests.rs | 6 + .../src/panels/effect_controls/viewer.rs | 9 +- crates/ui-egui/src/panels/fx_widgets.rs | 13 +- crates/ui-egui/src/panels/viewer_tools.rs | 80 ++++++++++++ 7 files changed, 223 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index f519bcdde..01245fde3 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -372,6 +372,7 @@ impl EffectcraftApp { // Video Preview is a second viewport: only a real pass can update it. prefs_live::frame(self, &ctx); self.auto.begin_frame(); + panels::viewer_tools::clear_sample_source(&ctx); if let Some(rect) = crate::color_picker::take_automation(&ctx) { self.auto.add("colorPicker.eyedropper", rect, "Eyedropper"); } @@ -398,6 +399,7 @@ impl EffectcraftApp { let header = egui::Rect::from_min_size(egui::pos2(full.min.x, top), egui::vec2(full.width(), header_h)); header::show(self, ui, header); let body = egui::Rect::from_min_max(egui::pos2(full.min.x + 4.0, header.max.y + 2.0), egui::pos2(full.max.x - 4.0, full.max.y - 4.0)); + crate::color_picker::note_dialog(&ctx, self.dialog); self.dock_area(ui, body); #[cfg(not(target_arch = "wasm32"))] prefs_live::dropped_files(self, ui, full); diff --git a/crates/ui-egui/src/color_picker.rs b/crates/ui-egui/src/color_picker.rs index b4fd70544..c75cc0242 100644 --- a/crates/ui-egui/src/color_picker.rs +++ b/crates/ui-egui/src/color_picker.rs @@ -122,12 +122,27 @@ struct Armed { popup: egui::Id, pass: u64, sample: Option<[f32; 3]>, + dialog: Option, } fn armed_key() -> egui::Id { egui::Id::unique("color-picker-eyedropper") } +fn dialog_key() -> egui::Id { + egui::Id::unique("color-picker-dialog") +} + +/// Record the modal before drawing the viewer. A picker may sample behind its own dialog, but +/// a new, unrelated modal must block it from its very first pass, before its rect is known. +pub(crate) fn note_dialog(ctx: &egui::Context, dialog: Option) { + ctx.data_mut(|data| data.insert_temp(dialog_key(), dialog)); +} + +fn current_dialog(ctx: &egui::Context) -> Option { + ctx.data(|data| data.get_temp::>(dialog_key())).flatten() +} + /// 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 { @@ -147,7 +162,8 @@ pub fn eyedropper_armed(ctx: &egui::Context) -> bool { 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 })); + let dialog = current_dialog(ctx); + ctx.data_mut(|d| d.insert_temp(armed_key(), Armed { popup, pass, sample: None, dialog })); } fn disarm(ctx: &egui::Context, popup: egui::Id) { @@ -218,6 +234,9 @@ fn overlaid(ctx: &egui::Context, pos: egui::Pos2) -> bool { /// 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 { + if armed_record(ctx).is_some_and(|armed| armed.dialog != current_dialog(ctx)) { + return None; + } 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())?; @@ -646,6 +665,101 @@ pub fn hsv_to_rgb([h, s, v]: [f32; 3]) -> [f32; 3] { mod tests { use super::*; + fn sampling_viewer() -> egui_kittest::Harness<'static, crate::EffectcraftApp> { + let mut session = effectcraft_engine::Session::default(); + session.execute("comp.new", serde_json::json!({"width": 320, "height": 180, "duration": 2})).unwrap(); + session.execute("layer.newSolid", serde_json::json!({"color": "#204060"})).unwrap(); + let mut harness = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_eframe(|_| crate::EffectcraftApp::new(session)); + settle_sampling_viewer(&mut harness); + harness + } + + fn settle_sampling_viewer(harness: &mut egui_kittest::Harness<'_, crate::EffectcraftApp>) { + for _ in 0..200 { + harness.step(); + if harness.state().frames.inflight() == 0 && harness.state().viewer_image.is_some() { + harness.run_steps(3); + return; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + panic!("the synthetic viewer frame did not arrive"); + } + + fn press_sampling_viewer(harness: &mut egui_kittest::Harness<'_, crate::EffectcraftApp>) -> Option<[f32; 3]> { + let ctx = harness.ctx.clone(); + arm(&ctx, egui::Id::unique("test-popup")); + let point = crate::panels::viewer::comp_to_screen(&ctx, [20.0, 20.0]).unwrap(); + harness.event(egui::Event::PointerMoved(point)); + harness.event(egui::Event::PointerButton { pos: point, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + harness.step(); + let sample = ctx.data(|data| data.get_temp::(armed_key())).and_then(|armed| armed.sample); + harness.event(egui::Event::PointerButton { pos: point, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + harness.step(); + sample + } + + #[test] + fn popup_eyedropper_samples_the_displayed_channel_and_exposure() { + let mut harness = sampling_viewer(); + harness.state_mut().session.state.viewer.channel = effectcraft_engine::viewer::Channel::Green; + let green = press_sampling_viewer(&mut harness).unwrap(); + assert!(green.iter().all(|value| (*value - 64.0 / 255.0).abs() < 0.01), "{green:?}"); + let options = &mut harness.state_mut().session.state.viewer; + options.channel = effectcraft_engine::viewer::Channel::Rgb; + options.exposure = 1.0; + let exposed = press_sampling_viewer(&mut harness).unwrap(); + let mut expected = [[32, 64, 96, 255]]; + effectcraft_engine::viewer::display_transform(&mut expected, effectcraft_engine::viewer::Channel::Rgb, false, 1.0); + for (actual, expected) in exposed.iter().zip(expected[0]) { + assert!((*actual - expected as f32 / 255.0).abs() < 0.01, "{exposed:?}"); + } + } + + #[test] + fn popup_eyedropper_samples_snapshot_and_refuses_wireframe() { + let mut harness = sampling_viewer(); + harness.state_mut().session.execute("view.takeSnapshot", serde_json::json!({})).unwrap(); + harness.state_mut().session.execute("layer.newSolid", serde_json::json!({"color": "#a0b0c0"})).unwrap(); + settle_sampling_viewer(&mut harness); + harness.event(egui::Event::Key { key: egui::Key::F5, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }); + // Let the snapshot texture's discard passes finish before arming this synthetic picker. + harness.step(); + let snapshot = press_sampling_viewer(&mut harness).unwrap(); + for (actual, expected) in snapshot.iter().zip([32, 64, 96]) { + assert!((*actual - expected as f32 / 255.0).abs() < 0.01, "{snapshot:?}"); + } + let options = &mut harness.state_mut().session.state.viewer; + options.show_snapshot = false; + options.fast_previews = effectcraft_engine::commands::viewer_cmds::FastPreviews::Wireframe; + harness.event(egui::Event::Key { key: egui::Key::F5, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }); + assert_eq!(press_sampling_viewer(&mut harness), None, "wireframe must not sample an old pixel frame"); + } + + #[test] + fn a_new_modal_backdrop_cannot_deliver_a_pending_sample() { + let mut harness = sampling_viewer(); + harness.state_mut().dialog = Some(crate::Dialog::About); + assert_eq!(press_sampling_viewer(&mut harness), None, "the first modal pass must already exclude its backdrop"); + } + + #[test] + fn a_picker_can_sample_behind_its_own_dialog_but_not_a_new_modal() { + let ctx = egui::Context::default(); + let point = pos2(20.0, 20.0); + let mut output = ctx.run_ui(egui::RawInput { events: vec![egui::Event::PointerMoved(point)], ..Default::default() }, |ui| { + let ctx = ui.ctx(); + note_dialog(ctx, Some(crate::Dialog::SolidSettings)); + note_viewer(ctx, Rect::from_min_size(pos2(0.0, 0.0), vec2(320.0, 180.0))); + note_overlay(ctx, Rect::from_min_size(pos2(100.0, 100.0), vec2(120.0, 80.0))); + arm(ctx, egui::Id::unique("test-picker")); + assert_eq!(eyedropper_point(ctx), Some(point)); + note_dialog(ctx, Some(crate::Dialog::About)); + assert_eq!(eyedropper_point(ctx), None); + }); + output.textures_delta.clear(); + } + #[test] fn armed_eyedropper_takes_one_sample_then_disarms() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index a7023a494..53e2248c5 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -534,18 +534,16 @@ impl Default for Frames { /// Premultiplied f32 → egui premultiplied Color32 (alpha kept, so the viewer can show the /// transparency grid or the comp background colour underneath). pub fn to_color_image(img: &effectcraft_engine::render::Image) -> egui::ColorImage { - let px: Vec = img - .data - .par_iter() - .map(|p| { - let a = p[3].clamp(0.0, 1.0); - let c = |v: f32| (v.clamp(0.0, a) * 255.0 + 0.5) as u8; - egui::Color32::from_rgba_premultiplied(c(p[0]), c(p[1]), c(p[2]), (a * 255.0 + 0.5) as u8) - }) - .collect(); + let px: Vec = img.data.par_iter().map(|p| display_pixel(*p)).collect(); egui::ColorImage::new([img.width as usize, img.height as usize], px) } +pub(crate) fn display_pixel(pixel: [f32; 4]) -> egui::Color32 { + let alpha = if pixel[3].is_nan() { 0.0 } else { pixel[3].clamp(0.0, 1.0) }; + let component = |value: f32| (value.clamp(0.0, alpha) * 255.0 + 0.5) as u8; + egui::Color32::from_rgba_premultiplied(component(pixel[0]), component(pixel[1]), component(pixel[2]), (alpha * 255.0 + 0.5) as u8) +} + /// The whole factor an image of `size` is averaged down by for a texture: at least `min_k`, and /// the smallest that fits its sides in `max_side` (frames of comps wider than the GPU's texture /// limit, #201). diff --git a/crates/ui-egui/src/frames/tests.rs b/crates/ui-egui/src/frames/tests.rs index 6da1dc269..9a522dc72 100644 --- a/crates/ui-egui/src/frames/tests.rs +++ b/crates/ui-egui/src/frames/tests.rs @@ -1,5 +1,11 @@ use super::*; +#[test] +fn converting_a_frame_with_nan_alpha_keeps_the_viewer_alive() { + let image = effectcraft_engine::render::Image::filled(1, 1, [0.5, 0.25, 0.1, f32::NAN]); + assert_eq!(to_color_image(&image).pixels, [egui::Color32::TRANSPARENT]); +} + struct ProbeDisk { keys: Mutex>, probes: std::sync::atomic::AtomicUsize, diff --git a/crates/ui-egui/src/panels/effect_controls/viewer.rs b/crates/ui-egui/src/panels/effect_controls/viewer.rs index 7bf65d40c..6f59d8c96 100644 --- a/crates/ui-egui/src/panels/effect_controls/viewer.rs +++ b/crates/ui-egui/src/panels/effect_controls/viewer.rs @@ -125,8 +125,7 @@ fn color_pick_preview(app: &EffectcraftApp, painter: &egui::Painter, map: &crate painter.rect_filled(chip, 11.0, Color32::from_black_alpha(180)); painter.text(chip.center(), Align2::CENTER_CENTER, hint, Tokens::ui(11.5), app.tokens.text); if let Some(pos) = pointer - && let Some(img) = &app.viewer_image - && let Some(sample) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(painter.ctx(), map.comp), map.to_comp(pos)) + && let Some(sample) = crate::panels::viewer_tools::sample_displayed(app, painter.ctx(), map.to_comp(pos)) { let swatch = Rect::from_min_size(pos + vec2(14.0, 14.0), vec2(26.0, 18.0)); painter.rect_filled(swatch, 2.0, Color32::from_rgb((sample[0] * 255.0) as u8, (sample[1] * 255.0) as u8, (sample[2] * 255.0) as u8)); @@ -162,8 +161,7 @@ fn text_color_pick( } if resp.clicked() && let Some(pos) = resp.interact_pointer_pos() - && let Some(img) = &app.viewer_image - && let Some(s) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(&ctx, map.comp), map.to_comp(pos)) + && let Some(s) = crate::panels::viewer_tools::sample_displayed(app, &ctx, map.to_comp(pos)) { let mut params = json!({"layer": pick.layer}); params[&pick.field] = json!([s[0], s[1], s[2]]); @@ -196,8 +194,7 @@ fn popup_eyedropper(app: &mut EffectcraftApp, ui: &mut egui::Ui, painter: &egui: app.viewer_pixels(); color_pick_preview(app, painter, map, crate::i18n::tr("Click to sample color"), crate::color_picker::eyedropper_point(&ctx)); if let Some(pos) = crate::color_picker::sample_press(&ctx) - && let Some(img) = &app.viewer_image - && let Some(s) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(&ctx, map.comp), map.to_comp(pos)) + && let Some(s) = crate::panels::viewer_tools::sample_displayed(app, &ctx, map.to_comp(pos)) { crate::color_picker::deliver_sample(&ctx, [s[0], s[1], s[2]]); } diff --git a/crates/ui-egui/src/panels/fx_widgets.rs b/crates/ui-egui/src/panels/fx_widgets.rs index fd71461e6..b3140d394 100644 --- a/crates/ui-egui/src/panels/fx_widgets.rs +++ b/crates/ui-egui/src/panels/fx_widgets.rs @@ -196,14 +196,21 @@ pub fn layer_to_comp(l2c: &Mat3, p: [f64; 2]) -> [f64; 2] { /// `region` ([x, y, w, h] in comp pixels: the whole comp, or the region of interest or Extended /// Viewer area it was rendered for), maybe at a reduced resolution. `None` outside it. pub fn frame_pixel(img: &egui::ColorImage, region: [f64; 4], pt: [f64; 2]) -> Option { + img.pixels.get(frame_pixel_index(img.size, region, pt)?).copied() +} + +pub(crate) fn frame_pixel_index(size: [usize; 2], region: [f64; 4], pt: [f64; 2]) -> Option { + if size.contains(&0) { + return None; + } let [x, y, w, h] = region; let (fx, fy) = ((pt[0] - x) / w, (pt[1] - y) / h); if !(0.0..1.0).contains(&fx) || !(0.0..1.0).contains(&fy) { return None; } - let sx = ((fx * img.size[0] as f64).floor() as usize).min(img.size[0].saturating_sub(1)); - let sy = ((fy * img.size[1] as f64).floor() as usize).min(img.size[1].saturating_sub(1)); - img.pixels.get(sy.checked_mul(img.size[0])?.checked_add(sx)?).copied() + let sx = ((fx * size[0] as f64).floor() as usize).min(size[0].saturating_sub(1)); + let sy = ((fy * size[1] as f64).floor() as usize).min(size[1].saturating_sub(1)); + sy.checked_mul(size[0])?.checked_add(sx) } /// Straight RGBA (0..1) of the displayed frame at comp point `pt` ([`frame_pixel`]). diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index f86a7bfee..3429f5404 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -366,6 +366,7 @@ pub(crate) fn display_key(app: &EffectcraftApp, has_conversion: bool) -> String /// is set), or the snapshot while Show Snapshot is on / F5 is held, through Show Channel and the /// exposure. Fast Previews ▸ Wireframe draws layer outlines instead. pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: &egui::Painter, comp_rect: Rect, cid: ItemId, ectx: &EvalCtx) { + clear_sample_source(ctx); let opts = app.session.state.viewer.clone(); let snap = showing_snapshot(app, ctx); // Settings ▸ Video ▸ Mirror on Computer Monitor off: playback goes to Video Preview only. @@ -393,6 +394,8 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: let dc = vw::DisplayColor::of(&app.session); let dc_key = display_key(app, dc.is_some()); let plain = opts.channel == Channel::Rgb && opts.exposure == 0.0 && dc.is_none(); + let display = SampleDisplay { channel: opts.channel, colorized: opts.colorized, exposure: opts.exposure, conversion: dc.clone() }; + let region = shown_region(ctx, [ectx.comp.width as f32, ectx.comp.height as f32]); let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); if snap { let Some(s) = app.session.snapshot.clone() else { return }; @@ -415,6 +418,12 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: } }; painter.image(tex.id(), comp_rect, uv, Color32::WHITE); + note_sample_source( + ctx, + SampleSource::Snapshot(s.image.clone()), + [0.0, 0.0, ectx.comp.width as f64, ectx.comp.height as f64], + (!plain).then_some(display), + ); painter.text(comp_rect.left_top() + vec2(6.0, 6.0), Align2::LEFT_TOP, crate::i18n::tr("Snapshot"), Tokens::ui(11.0), SNAP_COLOR); return; } @@ -428,6 +437,9 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: ctx.request_repaint(); } painter.image(id, comp_rect, uv, Color32::WHITE); + if let Some((_, key)) = app.viewer_shown { + note_sample_source(ctx, SampleSource::Gpu(key), region, None); + } return; } comp_rect @@ -444,6 +456,9 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: if plain { let uv = app.viewer_image.as_ref().map_or(uv, |src| fitted_uv(ctx, src.size, minify)); painter.image(tex.id(), rect, uv, Color32::WHITE); + if let Some(image) = app.viewer_image.clone() { + note_sample_source(ctx, SampleSource::Cpu(image), region, None); + } return; } rect @@ -470,6 +485,71 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: } }; painter.image(tex.id(), rect, fitted_uv(ctx, src.size, minify), Color32::WHITE); + note_sample_source(ctx, SampleSource::Cpu(src), region, Some(display)); +} + +#[derive(Clone)] +enum SampleSource { + Cpu(std::sync::Arc), + Gpu(crate::frames::FrameKey), + Snapshot(std::sync::Arc), +} + +#[derive(Clone)] +struct SampleDisplay { + channel: Channel, + colorized: bool, + exposure: f32, + conversion: Option, +} + +#[derive(Clone)] +struct DisplayedSample { + pass: u64, + source: SampleSource, + region: [f64; 4], + display: Option, +} + +fn sample_source_id() -> egui::Id { + egui::Id::unique("viewer-displayed-sample-source") +} + +pub(crate) fn clear_sample_source(ctx: &egui::Context) { + ctx.data_mut(|data| data.remove::(sample_source_id())); +} + +fn note_sample_source(ctx: &egui::Context, source: SampleSource, region: [f64; 4], display: Option) { + let sample = DisplayedSample { pass: ctx.cumulative_pass_nr(), source, region, display }; + ctx.data_mut(|data| data.insert_temp(sample_source_id(), sample)); +} + +/// Sample only a frame actually drawn this pass, with that draw's channel, exposure and +/// display conversion. Hidden and wireframe views deliberately have no sample source. +pub(crate) fn sample_displayed(app: &EffectcraftApp, ctx: &egui::Context, point: [f64; 2]) -> Option<[f32; 4]> { + let sample = ctx.data(|data| data.get_temp::(sample_source_id()))?; + if sample.pass != ctx.cumulative_pass_nr() { + return None; + } + let pixel = match sample.source { + SampleSource::Cpu(image) => super::fx_widgets::frame_pixel(&image, sample.region, point)?, + SampleSource::Gpu(key) => { + if app.viewer_shown.map(|(_, shown)| shown) != Some(key) { + return None; + } + super::fx_widgets::frame_pixel(app.viewer_image.as_ref()?, sample.region, point)? + } + SampleSource::Snapshot(image) => { + let index = super::fx_widgets::frame_pixel_index([image.width as usize, image.height as usize], sample.region, point)?; + crate::frames::display_pixel(*image.data.get(index)?) + } + }; + let image = egui::ColorImage::new([1, 1], vec![pixel]); + let image = match sample.display { + Some(display) => transformed(&image, display.channel, display.colorized, display.exposure, display.conversion), + None => image, + }; + super::fx_widgets::sample_frame(&image, [0.0, 0.0, 1.0, 1.0], [0.5, 0.5]) } /// The texture coordinates of a frame of `size` in its texture, fitted with at least `minify`