diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 7796e02ed..32d9100c6 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -378,6 +378,10 @@ 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"); + } let tab_had_widget_focus = ctx.egui_wants_keyboard_input(); menus::handle_shortcuts(self, &ctx); if !ctx.input(|i| i.pointer.primary_down()) { @@ -406,6 +410,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 acc01cc65..c75cc0242 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,35 +31,55 @@ 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); + } } + // 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(); // A press in the pass that first draws the picker must not close it: an Area's rect is only // at its requested position from its second pass on (the first pass reports it at the // origin), so the click that just opened the picker would be read as a press outside it and // the picker would flash and close in that one frame (#1476). let pass_id = id.with("open-pass"); let drawn_before = ui.data(|d| d.get_temp::(pass_id)).is_some_and(|p| p < ui.ctx().cumulative_pass_nr()); - if drawn_before && pressed_outside(ui.ctx(), &area.response) { + if drawn_before && 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); @@ -67,6 +88,7 @@ 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| { @@ -74,7 +96,7 @@ pub fn color_popup(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3]) d.insert_temp(open_pass_id(), pass); }); } - out.changed + out.changed || sampled } /// The `egui::Id` of the pass number in which a colour picker last stayed open, which @@ -92,6 +114,196 @@ 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]>, + 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 { + 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(); + 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) { + 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::unique("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::unique("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 { + 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())?; + (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::unique("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::Role::Button, resp.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 { @@ -255,6 +467,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); }); }); }); @@ -447,3 +660,125 @@ 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::*; + + 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(); + let popup = egui::Id::unique("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/frames.rs b/crates/ui-egui/src/frames.rs index 883cbf172..c79b023b2 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -539,18 +539,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 49f77a6cf..d04793f50 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/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index fde48fbe6..ec70e3197 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -173,6 +173,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", "お気に入りから削除"), @@ -1222,6 +1223,7 @@ const JAPANESE: &[(&str, &str)] = &[ ]; const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ + ("Click to sample color", "单击以取样颜色"), ("Appearance Settings…", "外观设置…"), ("Add to favorites", "添加到收藏"), ("Remove from favorites", "从收藏中移除"), @@ -2330,6 +2332,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ]; const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ + ("Click to sample color", "按一下以取樣顏色"), ("Appearance Settings…", "外觀設定…"), ("Add to favorites", "加入最愛"), ("Remove from favorites", "從最愛移除"), @@ -3386,6 +3389,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", "Видалити з вибраного"), @@ -3962,6 +3966,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ // Brazilian Portuguese. const PORTUGUESE: &[(&str, &str)] = &[ + ("Click to sample color", "Clique para obter uma amostra de cor"), ("Export Scene to Blender...", "Exportar cena para o Blender..."), (" OK ", " OK "), (" Delete ", " Excluir "), @@ -4715,6 +4720,7 @@ const PORTUGUESE: &[(&str, &str)] = &[ /// Polish (pl): original translations covering every row in the Chinese panel catalogs. const POLISH: &[(&str, &str)] = &[ + ("Click to sample color", "Kliknij, aby pobrać próbkę koloru"), ("Appearance Settings…", "Ustawienia wyglądu…"), ("Add to favorites", "Dodaj do ulubionych"), ("Remove from favorites", "Usuń z ulubionych"), @@ -5462,6 +5468,7 @@ const POLISH: &[(&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 "), @@ -6018,6 +6025,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"), @@ -6576,6 +6584,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/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 2a70d057d..a0c4b7651 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -265,6 +265,8 @@ pub(crate) fn modal_fit_placed(ctx: &egui::Context, title: &str, size: egui::Vec 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/viewer.rs b/crates/ui-egui/src/panels/effect_controls/viewer.rs index b11b63a35..6f59d8c96 100644 --- a/crates/ui-egui/src/panels/effect_controls/viewer.rs +++ b/crates/ui-egui/src/panels/effect_controls/viewer.rs @@ -119,6 +119,20 @@ 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(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)); + 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 +142,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,25 +153,15 @@ 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() - && 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]]); @@ -176,6 +179,27 @@ fn text_color_pick( } } +/// 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. +fn popup_eyedropper(app: &mut EffectcraftApp, ui: &mut egui::Ui, painter: &egui::Painter, map: &crate::panels::viewer::ViewerMap) { + let ctx = ui.ctx().clone(); + if !crate::color_picker::eyedropper_armed(&ctx) { + 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(); + 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(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]]); + } +} + /// Effect Controls' part of the Composition viewer, called by the viewer after its own /// interaction (so these controls sit on top): /// @@ -195,12 +219,14 @@ 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 point_preview = match fx_pick_session(app, ui, painter, map, ectx, l2c, &mut actions) { ControlFlow::Break(()) => return, ControlFlow::Continue(p) => p, }; text_color_pick(app, ui, painter, map, ectx, &mut actions); + popup_eyedropper(app, ui, painter, map); 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 b23000d27..b3140d394 100644 --- a/crates/ui-egui/src/panels/fx_widgets.rs +++ b/crates/ui-egui/src/panels/fx_widgets.rs @@ -172,12 +172,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 } @@ -201,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/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 2e7e62a08..7c3222c48 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -269,7 +269,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::unique(("char-eye", key)), &t); + let eye = super::fx_widgets::eyedropper_button(ui, er, active, egui::Id::unique(("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 { 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`