viewer: eyedropper mode -- crosshair cursor, click to sample the frame
This commit is contained in:
@@ -16,7 +16,7 @@ use gpui::timeline::{FrameRate, TimeDisplay, format_timecode};
|
||||
use gpui::{
|
||||
AnyElement, App, AsyncWindowContext, Bounds, ClickEvent, Context, Entity, EventEmitter,
|
||||
FocusHandle, Focusable, Keystroke, KeyDownEvent, KeyUpEvent, MouseButton, MouseMoveEvent,
|
||||
ObjectFit, Point, Pixels, Render, RenderImage, SharedString, SurfaceSource, Window,
|
||||
ObjectFit, Point, Pixels, Render, RenderImage, Rgba, SharedString, SurfaceSource, Window,
|
||||
canvas, colors::DefaultColors, div, img, prelude::*, px, surface,
|
||||
};
|
||||
use std::cell::Cell;
|
||||
@@ -94,6 +94,12 @@ pub enum ViewerEvent {
|
||||
/// The keystroke (modifiers + key name).
|
||||
keystroke: Keystroke,
|
||||
},
|
||||
/// A colour sampled from the picture by the eyedropper (only emitted
|
||||
/// while armed; the host routes it back to the picker that armed it).
|
||||
EyedropperPick {
|
||||
/// The sampled colour (0..1 components).
|
||||
color: Rgba,
|
||||
},
|
||||
}
|
||||
|
||||
/// The kind of a pointer event inside the picture area.
|
||||
@@ -279,6 +285,10 @@ pub struct ViewerWidget<C: PlaybackClock> {
|
||||
/// (the interact pointer forwarding maps window events to the picture's
|
||||
/// local pixels from these).
|
||||
picture_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
|
||||
/// Whether the eyedropper is armed: the cursor becomes a crosshair and a
|
||||
/// click samples the pixel under it instead of forwarding an interact
|
||||
/// pointer event.
|
||||
eyedropper_armed: bool,
|
||||
}
|
||||
|
||||
impl<C: PlaybackClock> ViewerWidget<C> {
|
||||
@@ -320,6 +330,7 @@ impl<C: PlaybackClock> ViewerWidget<C> {
|
||||
show_fps: false,
|
||||
fps: FpsCounter::new(),
|
||||
picture_bounds: Rc::new(Cell::new(None)),
|
||||
eyedropper_armed: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,6 +365,23 @@ impl<C: PlaybackClock> ViewerWidget<C> {
|
||||
self.picture_bounds.get()
|
||||
}
|
||||
|
||||
/// Whether the eyedropper is armed: the cursor becomes a crosshair and a
|
||||
/// click samples the pixel under it into a [`ViewerEvent::EyedropperPick`]
|
||||
/// instead of forwarding an interact pointer event.
|
||||
pub fn eyedropper_armed(&self) -> bool {
|
||||
self.eyedropper_armed
|
||||
}
|
||||
|
||||
/// Arm / disarm the eyedropper and repaint. No-op when unchanged, so
|
||||
/// calling this every frame from a host poll is cheap.
|
||||
pub fn set_eyedropper_armed(&mut self, armed: bool, cx: &mut Context<Self>) {
|
||||
if self.eyedropper_armed == armed {
|
||||
return;
|
||||
}
|
||||
self.eyedropper_armed = armed;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The current safe-margin overlay state.
|
||||
pub fn safe_margins(&self) -> SafeMargins {
|
||||
self.safe_margins
|
||||
@@ -416,6 +444,35 @@ impl<C: PlaybackClock> ViewerWidget<C> {
|
||||
});
|
||||
}
|
||||
|
||||
/// Sample the pixel under `position` (window pixels) from the current CPU
|
||||
/// frame and emit [`ViewerEvent::EyedropperPick`]. No-op without a CPU
|
||||
/// frame, when the frame carries no bytes, or when the point lands in the
|
||||
/// letterbox. Only called while armed, by the picture's mouse-down handler.
|
||||
fn sample_eyedropper(&self, position: Point<Pixels>, cx: &mut Context<Self>) {
|
||||
let Some(bounds) = self.picture_bounds.get() else {
|
||||
return;
|
||||
};
|
||||
let Some(ViewerFrameSource::CpuFrame(image)) = &self.frame_source else {
|
||||
return;
|
||||
};
|
||||
let size = image.size(0);
|
||||
let (w, h) = (size.width.0 as u32, size.height.0 as u32);
|
||||
let Some(bytes) = image.as_bytes(0) else {
|
||||
return;
|
||||
};
|
||||
let local = position - bounds.origin;
|
||||
let (box_w, box_h) = (f32::from(bounds.size.width), f32::from(bounds.size.height));
|
||||
let u = f32::from(local.x) / box_w;
|
||||
let v = f32::from(local.y) / box_h;
|
||||
let Some((uu, vv)) = contain_uv(w, h, box_w, box_h, u, v) else {
|
||||
return;
|
||||
};
|
||||
let Some(color) = sample_bgra8(bytes, w, h, uu, vv) else {
|
||||
return;
|
||||
};
|
||||
cx.emit(ViewerEvent::EyedropperPick { color });
|
||||
}
|
||||
|
||||
fn poll_clock(&mut self, cx: &mut Context<Self>) {
|
||||
let clock = self.clock.read(cx);
|
||||
let frame = clock.current_frame();
|
||||
@@ -434,6 +491,53 @@ impl<C: PlaybackClock> ViewerWidget<C> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a point inside a `box_w` x `box_h` widget to unit coordinates in an
|
||||
/// `img_w` x `img_h` image laid out with `ObjectFit::Contain` (the `img`
|
||||
/// default). Returns `None` when the point falls in the letterbox.
|
||||
fn contain_uv(
|
||||
img_w: u32,
|
||||
img_h: u32,
|
||||
box_w: f32,
|
||||
box_h: f32,
|
||||
u: f32,
|
||||
v: f32,
|
||||
) -> Option<(f32, f32)> {
|
||||
let (w, h) = (img_w as f32, img_h as f32);
|
||||
if w <= 0.0 || h <= 0.0 || box_w <= 0.0 || box_h <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let scale = (box_w / w).min(box_h / h);
|
||||
let cw = w * scale;
|
||||
let ch = h * scale;
|
||||
let ox = (box_w - cw) / 2.0;
|
||||
let oy = (box_h - ch) / 2.0;
|
||||
let px = u * box_w - ox;
|
||||
let py = v * box_h - oy;
|
||||
if px < 0.0 || py < 0.0 || px > cw || py > ch {
|
||||
return None;
|
||||
}
|
||||
Some((px / cw, py / ch))
|
||||
}
|
||||
|
||||
/// Sample a BGRA8 frame (row-major, top-to-bottom — gpui's `img` byte order)
|
||||
/// at unit coordinates (0..1 each); out-of-range coordinates clamp to the
|
||||
/// nearest edge pixel. `None` for an empty frame or truncated bytes.
|
||||
fn sample_bgra8(bytes: &[u8], w: u32, h: u32, u: f32, v: f32) -> Option<Rgba> {
|
||||
if w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
let x = (u.clamp(0.0, 1.0) * (w - 1) as f32).round() as u32;
|
||||
let y = (v.clamp(0.0, 1.0) * (h - 1) as f32).round() as u32;
|
||||
let i = ((y * w + x) * 4) as usize;
|
||||
let px = bytes.get(i..i + 4)?;
|
||||
Some(Rgba {
|
||||
r: px[2] as f32 / 255.0,
|
||||
g: px[1] as f32 / 255.0,
|
||||
b: px[0] as f32 / 255.0,
|
||||
a: px[3] as f32 / 255.0,
|
||||
})
|
||||
}
|
||||
|
||||
impl<C: PlaybackClock> EventEmitter<ViewerEvent> for ViewerWidget<C> {}
|
||||
|
||||
impl<C: PlaybackClock> Focusable for ViewerWidget<C> {
|
||||
@@ -451,6 +555,7 @@ impl<C: PlaybackClock> Render for ViewerWidget<C> {
|
||||
// The picture area: surface (or placeholder), safe frames and zoom.
|
||||
let mut picture = div()
|
||||
.id("gpui-widgets-viewer-picture")
|
||||
.debug_selector(|| "gpui-widgets-viewer-picture".into())
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.min_h_0()
|
||||
@@ -467,6 +572,11 @@ impl<C: PlaybackClock> Render for ViewerWidget<C> {
|
||||
// picture (the panel decides what an active interact consumes).
|
||||
.focusable()
|
||||
.on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, _window, cx| {
|
||||
// While armed the pointer is a crosshair; the eyedropper
|
||||
// only reacts to a click, so moves are swallowed.
|
||||
if this.eyedropper_armed {
|
||||
return;
|
||||
}
|
||||
if let Some(bounds) = this.picture_bounds.get() {
|
||||
this.emit_interact_pointer(
|
||||
InteractPointerKind::Move,
|
||||
@@ -480,6 +590,10 @@ impl<C: PlaybackClock> Render for ViewerWidget<C> {
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(|this, event: &gpui::MouseDownEvent, _window, cx| {
|
||||
if this.eyedropper_armed {
|
||||
this.sample_eyedropper(event.position, cx);
|
||||
return;
|
||||
}
|
||||
if let Some(bounds) = this.picture_bounds.get() {
|
||||
this.emit_interact_pointer(
|
||||
InteractPointerKind::Down,
|
||||
@@ -494,6 +608,9 @@ impl<C: PlaybackClock> Render for ViewerWidget<C> {
|
||||
.on_mouse_up(
|
||||
MouseButton::Left,
|
||||
cx.listener(|this, event: &gpui::MouseUpEvent, _window, cx| {
|
||||
if this.eyedropper_armed {
|
||||
return;
|
||||
}
|
||||
if let Some(bounds) = this.picture_bounds.get() {
|
||||
this.emit_interact_pointer(
|
||||
InteractPointerKind::Up,
|
||||
@@ -518,6 +635,10 @@ impl<C: PlaybackClock> Render for ViewerWidget<C> {
|
||||
});
|
||||
}));
|
||||
|
||||
if self.eyedropper_armed {
|
||||
picture = picture.cursor_crosshair();
|
||||
}
|
||||
|
||||
if let Some(source) = &self.frame_source {
|
||||
let picture_element: AnyElement = match self.zoom {
|
||||
// Fit renders the whole frame, contained inside the area.
|
||||
@@ -994,7 +1115,7 @@ mod tests {
|
||||
.viewer
|
||||
.clone()
|
||||
.update(app, |viewer, cx| viewer.set_cpu_frame(Some(frame), cx));
|
||||
window.draw(app);
|
||||
let _ = window.draw(app);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
@@ -1012,13 +1133,137 @@ mod tests {
|
||||
.viewer
|
||||
.clone()
|
||||
.update(app, |viewer, cx| viewer.set_cpu_frame(None, cx));
|
||||
window.draw(app);
|
||||
let _ = window.draw(app);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let is_none = cx.read(|app| host.read(app).viewer.read(app).frame_source.is_none());
|
||||
assert!(is_none);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contain_uv_maps_letterbox() {
|
||||
// A 4x3 image inside a 72x48 box: scale = min(18, 16) = 16, so the
|
||||
// content is 64x48, letterboxed by 4px on each side.
|
||||
assert_eq!(contain_uv(4, 3, 72.0, 48.0, 0.5, 0.5), Some((0.5, 0.5)));
|
||||
assert_eq!(contain_uv(4, 3, 72.0, 48.0, 0.0, 0.5), None, "left letterbox");
|
||||
assert_eq!(contain_uv(4, 3, 72.0, 48.0, 1.0, 0.5), None, "right letterbox");
|
||||
// The content's right edge sits at u = 4/72 + 64/72.
|
||||
let right = contain_uv(4, 3, 72.0, 48.0, 68.0 / 72.0, 0.5).unwrap();
|
||||
assert!((right.0 - 1.0).abs() < 1e-6 && (right.1 - 0.5).abs() < 1e-6);
|
||||
// Degenerate boxes / images sample nothing.
|
||||
assert_eq!(contain_uv(0, 3, 72.0, 48.0, 0.5, 0.5), None);
|
||||
assert_eq!(contain_uv(4, 3, 0.0, 48.0, 0.5, 0.5), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_bgra8_reads_corners() {
|
||||
// A 4x2 BGRA frame: red top-left, green top-right, blue bottom-left,
|
||||
// white/128 bottom-right.
|
||||
let mut bytes = vec![0u8; 4 * 2 * 4];
|
||||
let put = |bytes: &mut [u8], x: u32, y: u32, b: u8, g: u8, r: u8, a: u8| {
|
||||
let i = ((y * 4 + x) * 4) as usize;
|
||||
bytes[i..i + 4].copy_from_slice(&[b, g, r, a]);
|
||||
};
|
||||
put(&mut bytes, 0, 0, 0, 0, 255, 255); // red
|
||||
put(&mut bytes, 3, 0, 0, 255, 0, 255); // green
|
||||
put(&mut bytes, 0, 1, 255, 0, 0, 255); // blue
|
||||
put(&mut bytes, 3, 1, 255, 255, 255, 128); // white half-alpha
|
||||
|
||||
let close = |a: Rgba, b: Rgba| {
|
||||
(a.r - b.r).abs() < 1e-6
|
||||
&& (a.g - b.g).abs() < 1e-6
|
||||
&& (a.b - b.b).abs() < 1e-6
|
||||
&& (a.a - b.a).abs() < 1e-6
|
||||
};
|
||||
|
||||
let red = Rgba { r: 1.0, g: 0.0, b: 0.0, a: 1.0 };
|
||||
let green = Rgba { r: 0.0, g: 1.0, b: 0.0, a: 1.0 };
|
||||
let blue = Rgba { r: 0.0, g: 0.0, b: 1.0, a: 1.0 };
|
||||
let white_half = Rgba { r: 1.0, g: 1.0, b: 1.0, a: 128.0 / 255.0 };
|
||||
assert!(close(sample_bgra8(&bytes, 4, 2, 0.0, 0.0).unwrap(), red), "top-left");
|
||||
assert!(close(sample_bgra8(&bytes, 4, 2, 1.0, 0.0).unwrap(), green), "top-right");
|
||||
assert!(close(sample_bgra8(&bytes, 4, 2, 0.0, 1.0).unwrap(), blue), "bottom-left");
|
||||
assert!(
|
||||
close(sample_bgra8(&bytes, 4, 2, 1.0, 1.0).unwrap(), white_half),
|
||||
"bottom-right translucent"
|
||||
);
|
||||
|
||||
// Out-of-range coordinates clamp to the nearest edge pixel.
|
||||
assert!(close(sample_bgra8(&bytes, 4, 2, 2.0, -0.5).unwrap(), green), "clamps u/v");
|
||||
assert!(close(sample_bgra8(&bytes, 4, 2, -1.0, 2.0).unwrap(), blue), "clamps negative");
|
||||
// (0.5, 0.5) rounds to pixel (2, 1), which was never set (transparent
|
||||
// black) — exercises the rounding path.
|
||||
let mid = sample_bgra8(&bytes, 4, 2, 0.5, 0.5).unwrap();
|
||||
assert!(mid.r.abs() < 1e-6 && mid.a.abs() < 1e-6, "middle pixel unset");
|
||||
// Truncated bytes sample nothing: pixel (2, 1) needs bytes 24..28, so
|
||||
// a 27-byte buffer leaves the range short.
|
||||
assert!(sample_bgra8(&bytes[..27], 4, 2, 0.5, 0.5).is_none());
|
||||
}
|
||||
|
||||
/// While armed, clicking the picture samples the pixel under the cursor
|
||||
/// into an [`ViewerEvent::EyedropperPick`] and swallows the interact
|
||||
/// pointer events.
|
||||
#[gpui::test]
|
||||
async fn armed_eyedropper_click_samples_the_picture(cx: &mut TestAppContext) {
|
||||
use gpui::RenderImage;
|
||||
use image::{Frame, RgbaImage};
|
||||
|
||||
// A 2x2 frame: red / green / blue / white corners, RGBA -> BGRA as
|
||||
// gpui expects.
|
||||
let mut rgba = RgbaImage::new(2, 2);
|
||||
rgba.put_pixel(0, 0, image::Rgba([255, 0, 0, 255]));
|
||||
rgba.put_pixel(1, 0, image::Rgba([0, 255, 0, 255]));
|
||||
rgba.put_pixel(0, 1, image::Rgba([0, 0, 255, 255]));
|
||||
rgba.put_pixel(1, 1, image::Rgba([255, 255, 255, 255]));
|
||||
for pixel in rgba.chunks_exact_mut(4) {
|
||||
pixel.swap(0, 2);
|
||||
}
|
||||
let frame = Arc::new(RenderImage::new(smallvec::SmallVec::from_elem(
|
||||
Frame::new(rgba),
|
||||
1,
|
||||
)));
|
||||
|
||||
let (cx, host) = make_host(cx);
|
||||
cx.update(|window, app| {
|
||||
host.read(app)
|
||||
.viewer
|
||||
.clone()
|
||||
.update(app, |viewer, cx| {
|
||||
viewer.set_cpu_frame(Some(frame), cx);
|
||||
viewer.set_eyedropper_armed(true, cx);
|
||||
});
|
||||
let _ = window.draw(app);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
// The 2x2 square is contained in the (wide) picture area, so the
|
||||
// picture centre maps to image pixel (1, 1) = white.
|
||||
let picture = cx
|
||||
.debug_bounds("gpui-widgets-viewer-picture")
|
||||
.expect("picture painted");
|
||||
cx.simulate_click(picture.center(), Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
|
||||
let (pick, no_interact) = cx.read(|app| {
|
||||
let events = &host.read(app).events;
|
||||
(
|
||||
events.iter().find_map(|e| match e {
|
||||
ViewerEvent::EyedropperPick { color } => Some(*color),
|
||||
_ => None,
|
||||
}),
|
||||
!events
|
||||
.iter()
|
||||
.any(|e| matches!(e, ViewerEvent::InteractPointer { .. })),
|
||||
)
|
||||
});
|
||||
let pick = pick.expect("an EyedropperPick should be emitted");
|
||||
assert!(
|
||||
(pick.r - 1.0).abs() < 1e-6 && (pick.g - 1.0).abs() < 1e-6 && (pick.b - 1.0).abs() < 1e-6,
|
||||
"the picture centre should sample white, got {pick:?}"
|
||||
);
|
||||
assert!(no_interact, "an armed click must not forward interact pointers");
|
||||
}
|
||||
|
||||
fn make_host(cx: &mut TestAppContext) -> (&'static mut VisualTestContext, Entity<Host>) {
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(640.0), px(420.0)), |window, cx| {
|
||||
|
||||
Reference in New Issue
Block a user