Merge pull request #2639 from storytold/land/1328

#712: color pickers get an eyedropper everywhere (#1328)
This commit is contained in:
BFlat
2026-10-30 09:52:54 +07:00
committed by GitHub
10 changed files with 502 additions and 38 deletions
+5
View File
@@ -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);
+341 -6
View File
@@ -1,6 +1,7 @@
//! The colour picker every colour input opens, laid out like After Effects' Color Picker: a
//! square and a slider for the channel chosen with the H / S / B / R / G / B buttons, the new
//! colour over the original, the HSB and RGB values, the hex code, and OK / Cancel.
//! colour over the original, the HSB and RGB values, the hex code with an eyedropper that samples
//! the Composition viewer, and OK / Cancel.
//!
//! Colours are sRGB components (0–1), as the project stores them. Changes apply as they are made
//! (After Effects' Preview); Cancel or Escape goes back to the colour the picker opened with.
@@ -30,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::<u64>(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::<u64>(open_pass_id());
});
} else {
refresh_armed(ui.ctx(), id);
// Still open after this pass: dialogs leave Escape to the picker (see `open_recently`).
let pass = ui.ctx().cumulative_pass_nr();
ui.data_mut(|d| {
@@ -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<crate::Dialog>,
}
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<crate::Dialog>) {
ctx.data_mut(|data| data.insert_temp(dialog_key(), dialog));
}
fn current_dialog(ctx: &egui::Context) -> Option<crate::Dialog> {
ctx.data(|data| data.get_temp::<Option<crate::Dialog>>(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<Armed> {
let armed: Option<Armed> = ctx.data(|d| d.get_temp(armed_key()));
let pass = ctx.cumulative_pass_nr();
armed.filter(|a| a.pass.saturating_add(1) >= pass)
}
fn armed_for(ctx: &egui::Context, popup: egui::Id) -> bool {
armed_record(ctx).is_some_and(|a| a.popup == popup)
}
/// Some picker's eyedropper is armed: a press on the Composition viewer samples the frame for it.
pub fn eyedropper_armed(ctx: &egui::Context) -> bool {
armed_record(ctx).is_some()
}
fn arm(ctx: &egui::Context, popup: egui::Id) {
let pass = ctx.cumulative_pass_nr();
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<Armed> = ctx.data(|d| d.get_temp(armed_key()));
if armed.is_some_and(|a| a.popup == popup) {
ctx.data_mut(|d| d.remove::<Armed>(armed_key()));
}
}
/// Keeps `popup`'s eyedropper armed while its picker is drawn.
fn refresh_armed(ctx: &egui::Context, popup: egui::Id) {
if let Some(armed) = armed_record(ctx).filter(|a| a.popup == popup) {
let pass = ctx.cumulative_pass_nr();
ctx.data_mut(|d| d.insert_temp(armed_key(), Armed { pass, ..armed }));
}
}
/// The colour the viewer sampled for `popup`, once: taking it disarms the eyedropper.
fn take_sample(ctx: &egui::Context, popup: egui::Id) -> Option<[f32; 3]> {
let armed = armed_record(ctx).filter(|a| a.popup == popup)?;
let sample = armed.sample?;
disarm(ctx, popup);
Some(sample)
}
/// The viewer sampled `rgb` for the armed picker, which takes it on its next draw.
pub fn deliver_sample(ctx: &egui::Context, rgb: [f32; 3]) {
if let Some(armed) = armed_record(ctx) {
ctx.data_mut(|d| d.insert_temp(armed_key(), Armed { sample: Some(rgb), ..armed }));
ctx.request_repaint();
}
}
fn viewer_key() -> egui::Id {
egui::Id::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::<Vec<(u64, Rect)>>(overlay_key()).unwrap_or_default();
rects.retain(|(p, _)| p.saturating_add(1) >= pass);
rects.push((pass, rect));
d.insert_temp(overlay_key(), rects);
});
}
/// Whether `pos` is over a popup or dialog drawn this pass or the last.
fn overlaid(ctx: &egui::Context, pos: egui::Pos2) -> bool {
let pass = ctx.cumulative_pass_nr();
let rects: Option<Vec<(u64, Rect)>> = ctx.data(|d| d.get_temp(overlay_key()));
rects.unwrap_or_default().iter().any(|(p, r)| p.saturating_add(1) >= pass && r.contains(pos))
}
/// Where an armed eyedropper samples: the pointer, if it is over the viewer and not over a popup or
/// dialog. The pointer is read rather than the viewer's responses, since a modal dialog over the
/// viewer takes its clicks.
pub fn eyedropper_point(ctx: &egui::Context) -> Option<egui::Pos2> {
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<egui::Pos2> {
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<Rect> {
ctx.data_mut(|d| {
let rect = d.get_temp::<Rect>(automation_key());
d.remove::<Rect>(automation_key());
rect
})
}
/// The eyedropper of the hex row. A press arms it, or disarms it while armed; the next press on the
/// Composition viewer then samples the colour under the pointer into this picker.
fn eyedropper(ui: &mut Ui, popup: egui::Id) {
let armed = armed_for(ui.ctx(), popup);
let (rect, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click());
let resp = resp.on_hover_text(tr("Eyedropper: click, then click in the Composition panel"));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::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>(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)));
}
}
+7 -9
View File
@@ -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<egui::Color32> = 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<egui::Color32> = 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).
+6
View File
@@ -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<HashSet<u128>>,
probes: std::sync::atomic::AtomicUsize,
+9
View File
@@ -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", "რჩეულებიდან ამოშლა"),
+2
View File
@@ -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));
});
}
@@ -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<egui::Pos2>) {
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() {
+11 -9
View File
@@ -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<Color32> {
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<usize> {
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`]).
+2 -1
View File
@@ -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 {
+80
View File
@@ -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<egui::ColorImage>),
Gpu(crate::frames::FrameKey),
Snapshot(std::sync::Arc<effectcraft_engine::render::Image>),
}
#[derive(Clone)]
struct SampleDisplay {
channel: Channel,
colorized: bool,
exposure: f32,
conversion: Option<vw::DisplayColor>,
}
#[derive(Clone)]
struct DisplayedSample {
pass: u64,
source: SampleSource,
region: [f64; 4],
display: Option<SampleDisplay>,
}
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::<DisplayedSample>(sample_source_id()));
}
fn note_sample_source(ctx: &egui::Context, source: SampleSource, region: [f64; 4], display: Option<SampleDisplay>) {
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::<DisplayedSample>(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`