T26b Media: crop and annotate

ImageCropper crops a picture by a box in shares of it: a press holds a
corner, a side or the body (crop::grip_at), a drag through gpui's drag
moves them (crop::dragged), kept inside the picture with a least side;
with an aspect only the corners size it and the box keeps its shape; the
arrows move it. The picture sits inset by half a handle, the focus border
outside it, so handles on its edge stay whole and it keeps its shape.
ImageUpload (T06e) takes pictures only, from a drop or the dialog
(forms::taken, DropZone::kinds), decodes the file for its shape, and with
no crop yet reports the largest centered box of its aspect once. The
annotator draws boxes, arrows, lines and numbered pins in chart colors
through forms::pen; Select chooses a mark, Delete removes it, and Cmd-Z
and Shift-Cmd-Z undo and redo through forms::History. theme.media() holds
their sizes.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-26 16:16:29 -04:00
1 parent 4acedc60c0
commit 5cd7ee0ea8
21 files changed
+2305 -65

No files matched your search

+433
View File
@@ -0,0 +1,433 @@
use std::rc::Rc;
use gpui::{
App, Bounds, ElementId, Entity, ImageSource, InteractiveElement, IntoElement, MouseButton,
ParentElement, Pixels, RenderOnce, Styled, Window, canvas, div, prelude::*, relative,
transparent_black,
};
use super::marks::{Look, Mark, Tool, mark_at, paint, pin_numbers};
use crate::{
buttons::{IconButton, ToggleGroup, ToggleItem},
forms::{ColorSwatch, Drawing, History, pen},
layout::seeded::{Seeded, use_seeded},
primitives::{FocusRing, IconName, Image, checked_ratio, framed, tab_stop},
theme::{ActiveTheme, ControlSize, TextSize},
};
type OnMarks = Rc<dyn Fn(&[Mark], &mut Window, &mut App)>;
/// How many chart colors a mark may take.
const COLORS: usize = 4;
/// Each tool, its key, its icon, and its name.
const TOOLS: [(Tool, &str, IconName, &str); 5] = [
(Tool::Select, "v", IconName::MousePointer2, "Select"),
(Tool::Box, "b", IconName::Square, "Box"),
(Tool::Arrow, "a", IconName::ArrowUpRight, "Arrow"),
(Tool::Pen, "p", IconName::PenLine, "Pen"),
(Tool::Pin, "n", IconName::MapPin, "Pin"),
];
/// The tool and color in hand, the mark chosen, and the steps to undo.
struct Hand {
tool: Tool,
color: usize,
chosen: Option<usize>,
history: History<Vec<Mark>>,
}
/// The marks, the hand, and the host to tell.
#[derive(Clone)]
struct Board {
marks: Entity<Seeded<Vec<Mark>>>,
hand: Entity<Hand>,
on_change: Option<OnMarks>,
}
impl Board {
/// Sets the marks, keeping the ones before for Undo, and tells the host.
fn commit(&self, next: Vec<Mark>, what: &str, window: &mut Window, cx: &mut App) {
let before = self.marks.read(cx).value.clone();
self.hand
.update(cx, |hand, _| hand.history.record(before, false));
self.set(next, what, window, cx);
}
fn set(&self, next: Vec<Mark>, what: &str, window: &mut Window, cx: &mut App) {
self.marks.update(cx, |marks, cx| {
marks.value = next.clone();
cx.notify();
});
log::info!("image annotator: {what}, {} marks", next.len());
if let Some(on_change) = &self.on_change {
on_change(&next, window, cx);
}
}
/// Undo, or Redo when not `back`.
fn step(&self, back: bool, window: &mut Window, cx: &mut App) {
let current = self.marks.read(cx).value.clone();
let stepped = self.hand.update(cx, |hand, _| {
hand.chosen = None;
if back {
hand.history.undo(current)
} else {
hand.history.redo(current)
}
});
if let Some(stepped) = stepped {
self.set(stepped, if back { "undo" } else { "redo" }, window, cx);
}
}
fn remove(&self, window: &mut Window, cx: &mut App) {
let Some(ix) = self.hand.update(cx, |hand, _| hand.chosen.take()) else {
return;
};
let mut next = self.marks.read(cx).value.clone();
next.remove(ix);
self.commit(next, "removed a mark", window, cx);
}
fn pick(&self, tool: Tool, cx: &mut App) {
self.hand.update(cx, |hand, cx| {
(hand.tool, hand.chosen) = (tool, None);
cx.notify();
});
}
}
/// Where `at`, in window pixels, falls on `bounds`, in shares.
fn share(bounds: Bounds<Pixels>, at: gpui::Point<Pixels>) -> (f32, f32) {
let at = at - bounds.origin;
(
f32::from(at.x) / f32::from(bounds.size.width),
f32::from(at.y) / f32::from(bounds.size.height),
)
}
/// A picture to mark up, its shape from the host: boxes, arrows, freehand lines and numbered pins, each in one of four chart colors. Tools and colors sit above; V, B, A, P and N pick a tool; with Select a press chooses a mark, Delete removes it and Escape lets it go; Cmd-Z and Shift-Cmd-Z undo and redo. The host keeps the marks.
#[derive(IntoElement)]
pub struct ImageAnnotator {
id: ElementId,
source: ImageSource,
ratio: f32,
marks: Vec<Mark>,
on_change: Option<OnMarks>,
}
impl ImageAnnotator {
/// `ratio` is the picture's width over its height.
pub fn new(
id: impl Into<ElementId>,
source: impl Into<ImageSource>,
ratio: f32,
marks: impl IntoIterator<Item = Mark>,
) -> Self {
let marks: Vec<Mark> = marks.into_iter().collect();
for mark in &marks {
assert!(
mark.color() < COLORS,
"a mark in color {} of {COLORS}",
mark.color()
);
}
Self {
id: id.into(),
source: source.into(),
ratio: checked_ratio(ratio),
marks,
on_change: None,
}
}
pub fn on_change(mut self, handler: impl Fn(&[Mark], &mut Window, &mut App) + 'static) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ImageAnnotator {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let board = Board {
marks: use_seeded((self.id.clone(), "marks"), self.marks, window, cx),
hand: window.use_keyed_state((self.id.clone(), "hand"), cx, |_, _| Hand {
tool: Tool::Box,
color: 0,
chosen: None,
history: History::default(),
}),
on_change: self.on_change.clone(),
};
let drawing =
window.use_keyed_state((self.id.clone(), "drawing"), cx, |_, _| Drawing::default());
let editable = self.on_change.is_some();
let focus = tab_stop((self.id.clone(), "focus").into(), editable, window, cx);
let now = board.marks.read(cx).value.clone();
let (tool, color, chosen, can_undo, can_redo) = {
let hand = board.hand.read(cx);
let chosen = hand.chosen.filter(|ix| *ix < now.len());
(
hand.tool,
hand.color,
chosen,
hand.history.can_undo(),
hand.history.can_redo(),
)
};
let theme = cx.theme();
let colors = theme.colors.clone();
let sizes = theme.media();
let rem = window.rem_size();
let (stroke, head, reach) = (
sizes.mark_stroke.to_pixels(rem),
sizes.arrow_head.to_pixels(rem),
f32::from(sizes.pin.to_pixels(rem)) / 2.0,
);
let chart = colors.chart;
let tint = move |ix: usize| chart[ix % chart.len()];
let picked = board.clone();
let tools = TOOLS
.iter()
.fold(
ToggleGroup::new((self.id.clone(), "tools")).size(ControlSize::Sm),
|group, (_, key, icon, name)| {
group.item(
ToggleItem::new(*key)
.icon(*icon)
.tooltip(format!("{name}, {}", key.to_uppercase())),
)
},
)
.selected([TOOLS
.iter()
.find(|(each, ..)| *each == tool)
.expect("a listed tool")
.1])
.on_change(move |values, _, cx| {
if let Some(value) = values.first() {
let next = TOOLS
.iter()
.find(|(_, key, ..)| *key == value.as_ref())
.expect("a listed tool")
.0;
picked.pick(next, cx);
}
});
let swatches = (0..COLORS).map(|ix| {
let painted = board.hand.clone();
ColorSwatch::new((self.id.clone(), format!("color-{ix}")), tint(ix))
.size(ControlSize::Sm)
.selected(ix == color)
.on_click(move |_, cx| {
painted.update(cx, |hand, cx| {
hand.color = ix;
cx.notify();
})
})
});
let action = |key: &'static str,
icon: IconName,
tip: &'static str,
enabled: bool,
run: fn(&Board, &mut Window, &mut App)| {
let board = board.clone();
IconButton::new((self.id.clone(), key), icon)
.size(ControlSize::Sm)
.tooltip(tip)
.disabled(!enabled)
.on_click(move |_, window, cx| run(&board, window, cx))
};
let strip = div()
.flex()
.flex_wrap()
.items_center()
.justify_between()
.gap_2()
.child(
div()
.flex()
.flex_wrap()
.items_center()
.gap_3()
.child(tools)
.child(div().flex().gap_1().children(swatches)),
)
.child(
div()
.flex()
.gap_1()
.child(action(
"undo",
IconName::Undo2,
"Undo",
can_undo,
|board, window, cx| board.step(true, window, cx),
))
.child(action(
"redo",
IconName::Redo2,
"Redo",
can_redo,
|board, window, cx| board.step(false, window, cx),
))
.child(action(
"remove",
IconName::Trash2,
"Remove",
chosen.is_some(),
Board::remove,
)),
);
let pins =
now.iter()
.zip(pin_numbers(&now))
.enumerate()
.filter_map(|(ix, (mark, number))| {
let (Mark::Pin { at, color }, Some(number)) = (mark, number) else {
return None;
};
let pin = div()
.flex_none()
.size(sizes.pin)
.rounded_full()
.flex()
.items_center()
.justify_center()
.bg(tint(*color))
.border_2()
.border_color(if chosen == Some(ix) {
colors.accent
} else {
transparent_black()
})
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.on_media)
.child(number.to_string());
Some(
div()
.absolute()
.left(relative(at.0))
.top(relative(at.1))
.size_0()
.flex()
.items_center()
.justify_center()
.child(pin),
)
});
let look = Look {
stroke,
head,
palette: colors.chart,
halo: colors.accent,
};
let (painted, sketch) = (now.clone(), drawing.clone());
let measured = drawing.clone();
let plot = canvas(
move |bounds, window, cx| {
if measured.read(cx).bounds != bounds {
measured.update(cx, |drawing, cx| {
drawing.bounds = bounds;
cx.notify();
});
window.request_animation_frame();
}
},
move |bounds, _, window, cx| {
let points: Vec<(f32, f32)> = sketch
.read(cx)
.stroke
.iter()
.map(|p| share(bounds, bounds.origin + *p))
.collect();
let drawn = Mark::made(tool, &points, color)
.filter(|mark| !matches!(mark, Mark::Pin { .. }));
paint(&painted, drawn, chosen, look, bounds, window);
},
)
.absolute()
.top_0()
.left_0()
.size_full();
let picture = framed(self.ratio, cx)
.debug_selector(|| "image-annotator".into())
.child(Image::new((self.id.clone(), "picture"), self.source).size_full())
.child(plot)
.children(pins);
let area = div()
.id(self.id.clone())
.w_full()
.border_1()
.border_color(transparent_black())
.child(picture);
let area = match (editable, tool) {
(false, _) => area,
(true, Tool::Select) => {
let (chooser, measured) = (board.clone(), drawing.clone());
area.on_mouse_down(MouseButton::Left, move |event, _, cx| {
let bounds = measured.read(cx).bounds;
let size = (f32::from(bounds.size.width), f32::from(bounds.size.height));
let found = mark_at(
&chooser.marks.read(cx).value,
share(bounds, event.position),
size,
reach,
);
chooser.hand.update(cx, |hand, cx| {
hand.chosen = found;
cx.notify();
});
})
}
(true, _) => {
let (maker, measured) = (board.clone(), drawing.clone());
pen(area, &drawing, move |stroke, window, cx| {
let bounds = measured.read(cx).bounds;
let points: Vec<(f32, f32)> = stroke
.iter()
.map(|p| share(bounds, bounds.origin + *p))
.collect();
if let Some(mark) = Mark::made(tool, &points, color) {
let mut next = maker.marks.read(cx).value.clone();
next.push(mark);
maker.commit(next, "added a mark", window, cx);
}
})
}
};
let keyed = board.clone();
let area = area.when(editable, |area| {
area.track_focus(&focus)
.focus_ring(cx)
.on_key_down(move |event, window, cx| {
let stroke = &event.keystroke;
let command = stroke.modifiers.platform || stroke.modifiers.control;
match (stroke.key.as_str(), command, stroke.modifiers.shift) {
("z", true, back) => keyed.step(!back, window, cx),
("delete" | "backspace", false, _) => keyed.remove(window, cx),
("escape", false, _) => keyed.hand.update(cx, |hand, cx| {
hand.chosen = None;
cx.notify();
}),
(key, false, false) => {
match TOOLS.iter().find(|(_, each, ..)| *each == key) {
Some((tool, ..)) => keyed.pick(*tool, cx),
None => return,
}
}
_ => return,
}
cx.stop_propagation();
})
});
div()
.w_full()
.flex()
.flex_col()
.gap_3()
.when(editable, |column| column.child(strip))
.child(area)
}
}
+333
View File
@@ -0,0 +1,333 @@
/// A crop of a picture: its box in shares of the picture's width and height, each from 0 to 1.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Crop {
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
}
impl Crop {
/// The whole picture.
pub const WHOLE: Self = Self {
x: 0.0,
y: 0.0,
w: 1.0,
h: 1.0,
};
/// The largest box of `aspect`, its width over height in pixels, inside this one and centered on it, on a picture of `ratio`.
pub fn fitted(self, aspect: f32, ratio: f32) -> Self {
fitted(self, aspect / ratio)
}
/// Fails loud on a box with no size or one that leaves its picture.
pub(crate) fn checked(self) -> Self {
let inside =
|start: f32, side: f32| start >= 0.0 && side > 0.0 && start + side <= 1.0 + 1e-4;
assert!(
inside(self.x, self.w) && inside(self.y, self.h),
"a crop past its picture: {self:?}"
);
self
}
}
/// The sides a drag moves: none moves the whole box, one an edge, two a corner.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub(crate) struct Grip {
pub left: bool,
pub right: bool,
pub top: bool,
pub bottom: bool,
}
impl Grip {
pub(crate) fn corner(self) -> bool {
(self.left || self.right) && (self.top || self.bottom)
}
}
/// What a press at `at`, in shares of the picture, holds: a side or corner within `reach` shares across and down, else the body when inside, else nothing. A shaped crop sizes by corners only, so its edges hold the body.
pub(crate) fn grip_at(crop: Crop, at: (f32, f32), reach: (f32, f32), shaped: bool) -> Option<Grip> {
let (x2, y2) = (crop.x + crop.w, crop.y + crop.h);
let near = |a: f32, b: f32, r: f32| (a - b).abs() <= r;
let around = at.0 >= crop.x - reach.0
&& at.0 <= x2 + reach.0
&& at.1 >= crop.y - reach.1
&& at.1 <= y2 + reach.1;
let inside = at.0 >= crop.x && at.0 <= x2 && at.1 >= crop.y && at.1 <= y2;
let (left, top) = (near(at.0, crop.x, reach.0), near(at.1, crop.y, reach.1));
let grip = Grip {
left,
right: !left && near(at.0, x2, reach.0),
top,
bottom: !top && near(at.1, y2, reach.1),
};
let sizes = grip != Grip::default() && (grip.corner() || !shaped);
match (around, sizes, inside) {
(true, true, _) => Some(grip),
(_, _, true) => Some(Grip::default()),
_ => None,
}
}
/// The crop after `grip` moves by `by`, in shares, kept inside the picture with sides of at least `least`. With `shape`, a width over height in shares, only corners resize and the box keeps that shape, anchored at the opposite corner.
pub(crate) fn dragged(
crop: Crop,
grip: Grip,
by: (f32, f32),
least: f32,
shape: Option<f32>,
) -> Crop {
let (mut x1, mut y1, mut x2, mut y2) = (crop.x, crop.y, crop.x + crop.w, crop.y + crop.h);
if grip == Grip::default() {
let dx = by.0.clamp(-x1, 1.0 - x2);
let dy = by.1.clamp(-y1, 1.0 - y2);
return Crop {
x: x1 + dx,
y: y1 + dy,
..crop
};
}
if shape.is_some() && !grip.corner() {
return crop;
}
if grip.left {
x1 = (x1 + by.0).clamp(0.0, x2 - least);
}
if grip.right {
x2 = (x2 + by.0).clamp(x1 + least, 1.0);
}
if grip.top {
y1 = (y1 + by.1).clamp(0.0, y2 - least);
}
if grip.bottom {
y2 = (y2 + by.1).clamp(y1 + least, 1.0);
}
if let Some(shape) = shape {
let tall = if grip.top { y2 } else { 1.0 - y1 };
let wide = if grip.left { x2 } else { 1.0 - x1 };
let w = (x2 - x1)
.max(least * shape.max(1.0))
.min(tall * shape)
.min(wide);
let h = w / shape;
if grip.left {
x1 = x2 - w;
} else {
x2 = x1 + w;
}
if grip.top {
y1 = y2 - h;
} else {
y2 = y1 + h;
}
}
Crop {
x: x1,
y: y1,
w: x2 - x1,
h: y2 - y1,
}
}
/// The largest box of `shape`, a width over height in shares, inside `crop` and centered on it.
pub(crate) fn fitted(crop: Crop, shape: f32) -> Crop {
let (w, h) = if crop.w / crop.h > shape {
(crop.h * shape, crop.h)
} else {
(crop.w, crop.w / shape)
};
Crop {
x: crop.x + (crop.w - w) / 2.0,
y: crop.y + (crop.h - h) / 2.0,
w,
h,
}
}
#[cfg(test)]
mod tests {
use super::{Crop, Grip, dragged, fitted, grip_at};
const HALF: Crop = Crop {
x: 0.25,
y: 0.25,
w: 0.5,
h: 0.5,
};
fn near(a: Crop, b: Crop) -> bool {
[(a.x, b.x), (a.y, b.y), (a.w, b.w), (a.h, b.h)]
.iter()
.all(|(a, b)| (a - b).abs() < 1e-5)
}
#[test]
fn the_body_moves_whole_and_stops_at_the_picture_edge() {
let moved = dragged(HALF, Grip::default(), (0.1, -0.4), 0.05, None);
assert!(
near(
moved,
Crop {
x: 0.35,
y: 0.0,
..HALF
}
),
"{moved:?}"
);
}
#[test]
fn an_edge_or_corner_moves_its_sides_and_keeps_the_least_size() {
let right = Grip {
right: true,
..Grip::default()
};
assert!(near(
dragged(HALF, right, (0.1, 0.3), 0.05, None),
Crop { w: 0.6, ..HALF }
));
assert!(near(
dragged(HALF, right, (-0.9, 0.0), 0.05, None),
Crop { w: 0.05, ..HALF }
));
let top_left = Grip {
left: true,
top: true,
..Grip::default()
};
let grown = dragged(HALF, top_left, (-0.5, -0.1), 0.05, None);
assert!(
near(
grown,
Crop {
x: 0.0,
y: 0.15,
w: 0.75,
h: 0.6
}
),
"{grown:?}"
);
}
#[test]
fn a_shaped_crop_resizes_by_corners_only_and_keeps_its_shape_inside_the_picture() {
let bottom_right = Grip {
right: true,
bottom: true,
..Grip::default()
};
let square = dragged(HALF, bottom_right, (0.1, 0.0), 0.05, Some(1.0));
assert!(
near(
square,
Crop {
w: 0.6,
h: 0.6,
..HALF
}
),
"{square:?}"
);
let stopped = dragged(HALF, bottom_right, (0.25, 0.0), 0.05, Some(2.0));
assert!(
near(
stopped,
Crop {
w: 0.75,
h: 0.375,
..HALF
}
),
"{stopped:?}"
);
let edge = Grip {
top: true,
..Grip::default()
};
assert_eq!(dragged(HALF, edge, (0.0, -0.1), 0.05, Some(1.0)), HALF);
}
#[test]
fn a_new_shape_takes_the_largest_centered_box_inside() {
let wide = fitted(HALF, 2.0);
assert!(
near(
wide,
Crop {
x: 0.25,
y: 0.375,
w: 0.5,
h: 0.25
}
),
"{wide:?}"
);
let tall = fitted(Crop::WHOLE, 0.5);
assert!(
near(
tall,
Crop {
x: 0.25,
y: 0.0,
w: 0.5,
h: 1.0
}
),
"{tall:?}"
);
}
#[test]
fn a_press_holds_the_nearest_corner_or_side_else_the_body_else_nothing() {
let reach = (0.02, 0.02);
let corner = grip_at(HALF, (0.26, 0.74), reach, false);
assert_eq!(
corner,
Some(Grip {
left: true,
bottom: true,
..Grip::default()
})
);
let side = grip_at(HALF, (0.5, 0.24), reach, false);
assert_eq!(
side,
Some(Grip {
top: true,
..Grip::default()
})
);
assert_eq!(
grip_at(HALF, (0.5, 0.5), reach, false),
Some(Grip::default())
);
assert_eq!(grip_at(HALF, (0.1, 0.5), reach, false), None);
assert_eq!(
grip_at(HALF, (0.5, 0.26), reach, true),
Some(Grip::default()),
"a shaped crop's edge holds its body"
);
assert_eq!(
grip_at(HALF, (0.5, 0.24), reach, true),
None,
"and just outside it holds nothing"
);
}
#[test]
#[should_panic(expected = "a crop past its picture")]
fn a_crop_past_its_picture_fails_loud() {
Crop {
x: 0.8,
y: 0.0,
w: 0.5,
h: 0.5,
}
.checked();
}
}
+267
View File
@@ -0,0 +1,267 @@
use std::rc::Rc;
use gpui::{
App, Bounds, CursorStyle, DragMoveEvent, ElementId, EmptyView, EntityId, ImageSource,
InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, Point, RenderOnce,
StatefulInteractiveElement, Styled, Window, canvas, div, prelude::*, relative,
transparent_black,
};
use super::crop::{Crop, Grip, dragged, grip_at};
use crate::{
layout::seeded::use_seeded,
primitives::{FocusRing, Image, checked_ratio, framed, tab_stop},
theme::ActiveTheme,
};
pub(super) type OnCrop = Rc<dyn Fn(Crop, &mut Window, &mut App)>;
/// Least side of a crop, as a share of its picture.
const LEAST: f32 = 0.05;
/// What an arrow moves the crop, and Shift with it.
const NUDGE: (f32, f32) = (0.01, 0.1);
/// A crop's drag, marked with its cropper.
struct Hold(EntityId);
/// The stage's bounds, what the press holds, and where the pointer was last.
#[derive(Default)]
struct Stage {
bounds: Bounds<Pixels>,
held: Option<Grip>,
from: Point<Pixels>,
}
/// A picture and a box to crop it by, the picture's shape from the host, inset by half a handle so handles on its edge stay whole. Drag the box to move it and its handles to size it; with an aspect, only its corners size it and it keeps that shape. The arrows move it a hundredth of the picture, a tenth with Shift. The host keeps the crop; `Crop::fitted` gives a box of a new aspect.
#[derive(IntoElement)]
pub struct ImageCropper {
id: ElementId,
source: ImageSource,
ratio: f32,
crop: Crop,
aspect: Option<f32>,
on_change: Option<OnCrop>,
}
impl ImageCropper {
/// `ratio` is the picture's width over its height.
pub fn new(
id: impl Into<ElementId>,
source: impl Into<ImageSource>,
ratio: f32,
crop: Crop,
) -> Self {
Self {
id: id.into(),
source: source.into(),
ratio: checked_ratio(ratio),
crop: crop.checked(),
aspect: None,
on_change: None,
}
}
/// The crop's width over its height, in pixels; none leaves it free.
pub fn aspect(mut self, aspect: Option<f32>) -> Self {
self.aspect = aspect.map(checked_ratio);
self
}
pub fn on_change(mut self, handler: impl Fn(Crop, &mut Window, &mut App) + 'static) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ImageCropper {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let crop = use_seeded((self.id.clone(), "crop"), self.crop, window, cx);
let stage = window.use_keyed_state((self.id.clone(), "stage"), cx, |_, _| Stage::default());
let editable = self.on_change.is_some();
let focus = tab_stop((self.id.clone(), "focus").into(), editable, window, cx);
let now = crop.read(cx).value;
let shape = self.aspect.map(|aspect| aspect / self.ratio);
let owner = crop.entity_id();
let theme = cx.theme();
let (veil, line) = (
theme.colors.media_backdrop.alpha(0.55),
theme.colors.on_media,
);
let side = theme.media().crop_handle;
let hit = side.to_pixels(window.rem_size());
let change = {
let (crop, on_change) = (crop.clone(), self.on_change.clone());
move |next: Crop, window: &mut Window, cx: &mut App| {
crop.update(cx, |crop, cx| {
crop.value = next;
cx.notify();
});
if let Some(on_change) = &on_change {
on_change(next, window, cx);
}
}
};
let pane = |left: f32, top: f32, w: f32, h: f32| {
div()
.absolute()
.left(relative(left))
.top(relative(top))
.w(relative(w))
.h(relative(h))
.bg(veil)
};
let (x2, y2) = (now.x + now.w, now.y + now.h);
let rule = |across: bool, at: f32| {
let rule = div().absolute().border_color(line.alpha(0.35));
if across {
rule.left_0().right_0().top(relative(at)).border_t_1()
} else {
rule.top_0().bottom_0().left(relative(at)).border_l_1()
}
};
let (fall, rise) = (
CursorStyle::ResizeUpLeftDownRight,
CursorStyle::ResizeUpRightDownLeft,
);
let corners = [
(0.0, 0.0, fall),
(1.0, 0.0, rise),
(0.0, 1.0, rise),
(1.0, 1.0, fall),
];
let (tall, wide) = (CursorStyle::ResizeUpDown, CursorStyle::ResizeLeftRight);
let edges = [
(0.5, 0.0, tall),
(0.5, 1.0, tall),
(0.0, 0.5, wide),
(1.0, 0.5, wide),
];
let marks = corners
.into_iter()
.chain(edges.into_iter().filter(|_| shape.is_none()))
.map(|(x, y, cursor)| {
div()
.absolute()
.left(relative(x))
.top(relative(y))
.size_0()
.flex()
.items_center()
.justify_center()
.child(div().flex_none().size(side).bg(line).cursor(cursor))
});
let body = div()
.absolute()
.left(relative(now.x))
.top(relative(now.y))
.w(relative(now.w))
.h(relative(now.h))
.border_1()
.border_color(line)
.children([1.0 / 3.0, 2.0 / 3.0].map(|at| rule(true, at)))
.children([1.0 / 3.0, 2.0 / 3.0].map(|at| rule(false, at)))
.when(editable, |body| body.children(marks));
let (measure, pressed, moving, keyed) =
(stage.clone(), stage.clone(), stage.clone(), crop.clone());
let (drag_change, key_change) = (change.clone(), change);
let pressed_crop = crop.clone();
let picture = framed(self.ratio, cx)
.debug_selector(|| "image-cropper".into())
.child(Image::new((self.id.clone(), "picture"), self.source).size_full())
.child(
canvas(
move |bounds, window, cx| {
if measure.read(cx).bounds != bounds {
measure.update(cx, |stage, cx| {
stage.bounds = bounds;
cx.notify();
});
window.request_animation_frame();
}
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full(),
)
.child(pane(0.0, 0.0, 1.0, now.y))
.child(pane(0.0, y2, 1.0, 1.0 - y2))
.child(pane(0.0, now.y, now.x, now.h))
.child(pane(x2, now.y, 1.0 - x2, now.h));
let lid = div()
.absolute()
.top(side / 2.0)
.left(side / 2.0)
.right(side / 2.0)
.bottom(side / 2.0)
.child(body);
div()
.id(self.id.clone())
.relative()
.w_full()
.p(side / 2.0)
.border_1()
.border_color(transparent_black())
.child(picture)
.child(lid)
.when(editable, |area| {
area.track_focus(&focus)
.focus_ring(cx)
.on_mouse_down(MouseButton::Left, move |event, _, cx| {
let now = pressed_crop.read(cx).value;
pressed.update(cx, |stage, _| {
let (w, h) = (
f32::from(stage.bounds.size.width),
f32::from(stage.bounds.size.height),
);
let at = event.position - stage.bounds.origin;
let spot = (f32::from(at.x) / w, f32::from(at.y) / h);
let reach = (f32::from(hit) / w, f32::from(hit) / h);
stage.held = grip_at(now, spot, reach, shape.is_some());
stage.from = event.position;
})
})
.on_drag(Hold(owner), |_, _, _, cx| cx.new(|_| EmptyView))
.on_drag_move(move |event: &DragMoveEvent<Hold>, window, cx| {
if event.drag(cx).0 != owner {
return;
}
let at = event.event.position;
let (bounds, held, from) = moving.update(cx, |stage, _| {
let from = std::mem::replace(&mut stage.from, at);
(stage.bounds, stage.held, from)
});
let Some(grip) = held else {
return;
};
let (w, h) = (f32::from(bounds.size.width), f32::from(bounds.size.height));
let by = (f32::from(at.x - from.x) / w, f32::from(at.y - from.y) / h);
let before = keyed.read(cx).value;
let next = dragged(before, grip, by, LEAST, shape);
if next != before {
drag_change(next, window, cx);
}
})
.on_key_down(move |event, window, cx| {
let step = if event.keystroke.modifiers.shift {
NUDGE.1
} else {
NUDGE.0
};
let by = match event.keystroke.key.as_str() {
"left" => (-step, 0.0),
"right" => (step, 0.0),
"up" => (0.0, -step),
"down" => (0.0, step),
_ => return,
};
cx.stop_propagation();
let next = dragged(now, Grip::default(), by, LEAST, shape);
log::info!("image cropper: moved to {:.2}, {:.2}", next.x, next.y);
key_change(next, window, cx);
})
})
}
}
+331
View File
@@ -0,0 +1,331 @@
use gpui::{Bounds, Hsla, PathBuilder, Pixels, Window, point};
use crate::forms::ink;
/// A mark on a picture, its places in shares of the picture's width and height, its color an index into the chart palette.
#[derive(Clone, Debug, PartialEq)]
pub enum Mark {
/// A box from one corner to the other.
Box {
from: (f32, f32),
to: (f32, f32),
color: usize,
},
/// An arrow from its tail to its head.
Arrow {
from: (f32, f32),
to: (f32, f32),
color: usize,
},
/// A line drawn freehand.
Pen {
points: Vec<(f32, f32)>,
color: usize,
},
/// A numbered pin, counted in the order pins were placed.
Pin { at: (f32, f32), color: usize },
}
/// How marks look: the line's width, an arrow head's length, the chart colors, and the chosen mark's halo.
#[derive(Clone, Copy)]
pub(crate) struct Look {
pub stroke: Pixels,
pub head: Pixels,
pub palette: [Hsla; 8],
pub halo: Hsla,
}
/// Paints `marks` and the one being drawn on `bounds`; the chosen one sits on the halo.
pub(crate) fn paint(
marks: &[Mark],
sketch: Option<Mark>,
chosen: Option<usize>,
look: Look,
bounds: Bounds<Pixels>,
window: &mut Window,
) {
let Look {
stroke,
head,
palette,
halo,
} = look;
let place = |p: (f32, f32)| {
point(
bounds.left() + bounds.size.width * p.0,
bounds.top() + bounds.size.height * p.1,
)
};
let mut draw = |mark: &Mark, width: Pixels, color: Hsla| {
let path = match mark {
Mark::Box { from, to, .. } => {
let mut path = PathBuilder::stroke(width);
path.move_to(place(*from));
for corner in [(to.0, from.1), *to, (from.0, to.1), *from] {
path.line_to(place(corner));
}
path.build().ok()
}
Mark::Arrow { from, to, .. } => {
let (tail, tip) = (place(*from), place(*to));
let angle = f32::from(tip.y - tail.y).atan2(f32::from(tip.x - tail.x));
let barb = |turn: f32| {
point(
tip.x - head * (angle + turn).cos(),
tip.y - head * (angle + turn).sin(),
)
};
let mut path = PathBuilder::stroke(width);
path.move_to(tail);
path.line_to(tip);
path.move_to(barb(0.45));
path.line_to(tip);
path.line_to(barb(-0.45));
path.build().ok()
}
Mark::Pen { points, .. } => {
let points: Vec<_> = points.iter().map(|p| place(*p) - bounds.origin).collect();
ink(&points, bounds.origin, width)
}
Mark::Pin { .. } => None,
};
if let Some(path) = path {
window.paint_path(path, color);
}
};
if let Some(ix) = chosen {
draw(&marks[ix], stroke * 3.0, halo);
}
for mark in marks.iter().chain(&sketch) {
draw(mark, stroke, palette[mark.color() % palette.len()]);
}
}
/// What a drag on the picture makes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Tool {
/// Chooses a mark instead of making one.
Select,
Box,
Arrow,
Pen,
Pin,
}
impl Mark {
/// The mark a tool makes from a stroke of points: none from Select, and none but a pin from a tap in place.
pub(crate) fn made(tool: Tool, stroke: &[(f32, f32)], color: usize) -> Option<Self> {
let (&first, &last) = (stroke.first()?, stroke.last()?);
let moved = stroke.iter().any(|point| *point != first);
match tool {
Tool::Select => None,
Tool::Box | Tool::Arrow if last == first => None,
Tool::Pen if !moved => None,
Tool::Box => Some(Self::Box {
from: first,
to: last,
color,
}),
Tool::Arrow => Some(Self::Arrow {
from: first,
to: last,
color,
}),
Tool::Pen => Some(Self::Pen {
points: stroke.to_vec(),
color,
}),
Tool::Pin => Some(Self::Pin { at: last, color }),
}
}
pub(crate) fn color(&self) -> usize {
match self {
Self::Box { color, .. }
| Self::Arrow { color, .. }
| Self::Pen { color, .. }
| Self::Pin { color, .. } => *color,
}
}
/// How far `at` sits from the mark, measured in pixels of a picture `size` across and down: a box by its edges, a line by its nearest stretch, a pin by its middle.
pub(crate) fn distance(&self, at: (f32, f32), size: (f32, f32)) -> f32 {
let scale = |p: (f32, f32)| (p.0 * size.0, p.1 * size.1);
let at = scale(at);
let along = |points: &[(f32, f32)]| {
let points: Vec<_> = points.iter().map(|p| scale(*p)).collect();
match points.as_slice() {
[only] => (only.0 - at.0).hypot(only.1 - at.1),
_ => points
.windows(2)
.map(|pair| segment(at, pair[0], pair[1]))
.fold(f32::INFINITY, f32::min),
}
};
match self {
Self::Box { from, to, .. } => {
let (a, b) = (*from, *to);
along(&[a, (b.0, a.1), b, (a.0, b.1), a])
}
Self::Arrow { from, to, .. } => along(&[*from, *to]),
Self::Pen { points, .. } => along(points),
Self::Pin { at: pin, .. } => along(&[*pin]),
}
}
}
/// How far `p` sits from the stretch between `a` and `b`.
fn segment(p: (f32, f32), a: (f32, f32), b: (f32, f32)) -> f32 {
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let length = dx * dx + dy * dy;
let t = if length == 0.0 {
0.0
} else {
(((p.0 - a.0) * dx + (p.1 - a.1) * dy) / length).clamp(0.0, 1.0)
};
(p.0 - (a.0 + t * dx)).hypot(p.1 - (a.1 + t * dy))
}
/// The topmost mark within `reach` pixels of `at`, a place in shares of a picture `size` across and down.
pub(crate) fn mark_at(
marks: &[Mark],
at: (f32, f32),
size: (f32, f32),
reach: f32,
) -> Option<usize> {
marks
.iter()
.enumerate()
.rev()
.map(|(ix, mark)| (ix, mark.distance(at, size)))
.filter(|(_, far)| *far <= reach)
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(ix, _)| ix)
}
/// Each pin's number, counting from one in the order the pins were placed.
pub(crate) fn pin_numbers(marks: &[Mark]) -> Vec<Option<usize>> {
let mut count = 0;
marks
.iter()
.map(|mark| {
matches!(mark, Mark::Pin { .. }).then(|| {
count += 1;
count
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::{Mark, Tool, mark_at, pin_numbers};
const SIZE: (f32, f32) = (400.0, 200.0);
#[test]
fn a_stroke_becomes_the_tools_mark() {
let stroke = [(0.1, 0.1), (0.2, 0.3), (0.5, 0.4)];
assert_eq!(
Mark::made(Tool::Box, &stroke, 2),
Some(Mark::Box {
from: (0.1, 0.1),
to: (0.5, 0.4),
color: 2
})
);
assert_eq!(
Mark::made(Tool::Pin, &stroke, 0),
Some(Mark::Pin {
at: (0.5, 0.4),
color: 0
})
);
assert_eq!(Mark::made(Tool::Select, &stroke, 0), None);
assert_eq!(Mark::made(Tool::Pen, &[], 0), None);
let tap = [(0.4, 0.4)];
assert_eq!(Mark::made(Tool::Box, &tap, 0), None, "a tap makes no box");
assert_eq!(Mark::made(Tool::Arrow, &tap, 0), None);
assert_eq!(Mark::made(Tool::Pen, &tap, 0), None, "nor a line");
let back = [(0.4, 0.4), (0.6, 0.5), (0.4, 0.4)];
assert_eq!(
Mark::made(Tool::Box, &back, 0),
None,
"a drag back to its start makes no box"
);
assert!(
Mark::made(Tool::Pen, &back, 0).is_some(),
"but draws a line"
);
assert_eq!(
Mark::made(Tool::Pin, &tap, 0),
Some(Mark::Pin {
at: (0.4, 0.4),
color: 0
}),
"but a pin"
);
}
#[test]
fn a_press_finds_the_nearest_mark_by_its_edge_or_line_and_prefers_the_top() {
let boxed = Mark::Box {
from: (0.25, 0.25),
to: (0.75, 0.75),
color: 0,
};
let arrow = Mark::Arrow {
from: (0.0, 0.5),
to: (1.0, 0.5),
color: 1,
};
let marks = [boxed.clone(), arrow];
assert_eq!(
boxed.distance((0.5, 0.5), SIZE),
50.0,
"inside a box is its edge's distance away"
);
assert_eq!(
mark_at(&marks, (0.25, 0.4), SIZE, 6.0),
Some(0),
"on the box's left edge"
);
assert_eq!(
mark_at(&marks, (0.1, 0.51), SIZE, 6.0),
Some(1),
"beside the arrow's line"
);
assert_eq!(mark_at(&marks, (0.1, 0.1), SIZE, 6.0), None);
let twins = [
Mark::Pin {
at: (0.5, 0.5),
color: 0,
},
Mark::Pin {
at: (0.5, 0.5),
color: 1,
},
];
assert_eq!(
mark_at(&twins, (0.5, 0.5), SIZE, 6.0),
Some(1),
"the later one lies on top"
);
}
#[test]
fn pins_count_in_order_past_other_marks() {
let pin = |x| Mark::Pin {
at: (x, 0.5),
color: 0,
};
let pen = Mark::Pen {
points: vec![(0.0, 0.0)],
color: 0,
};
assert_eq!(
pin_numbers(&[pin(0.1), pen, pin(0.2)]),
[Some(1), None, Some(2)]
);
}
}
+10
View File
@@ -1,9 +1,19 @@
mod annotate;
mod crop;
mod cropper;
mod marks;
mod thumbnail;
mod turn;
mod upload;
mod viewer;
#[cfg(all(test, feature = "test-support"))]
mod tests;
pub use annotate::ImageAnnotator;
pub use crop::Crop;
pub use cropper::ImageCropper;
pub use marks::{Mark, Tool};
pub use thumbnail::ImageThumbnail;
pub use upload::ImageUpload;
pub use viewer::ImageViewer;
+309
View File
@@ -0,0 +1,309 @@
use std::path::PathBuf;
use gpui::{
Context, Entity, IntoElement, Modifiers, MouseButton, ParentElement, Pixels, Point, Render,
Styled, TestAppContext, VisualTestContext, Window, div, point, px,
};
use super::{picture, press, settle, setup};
use crate::media::{Crop, ImageAnnotator, ImageCropper, ImageUpload, Mark};
const HALF: Crop = Crop {
x: 0.25,
y: 0.25,
w: 0.5,
h: 0.5,
};
fn near(a: Crop, b: Crop) -> bool {
[(a.x, b.x), (a.y, b.y), (a.w, b.w), (a.h, b.h)]
.iter()
.all(|(a, b)| (a - b).abs() < 1e-3)
}
/// Where a share of the picture sits in the window.
fn spot(selector: &'static str, share: (f32, f32), cx: &mut VisualTestContext) -> Point<Pixels> {
let picture = cx.debug_bounds(selector).expect("the picture draws");
picture.origin + point(picture.size.width * share.0, picture.size.height * share.1)
}
fn picture_width(cx: &mut VisualTestContext) -> f32 {
f32::from(
cx.debug_bounds("image-cropper")
.expect("the picture draws")
.size
.width,
)
}
/// A drag from `from` through `path`, the pointer let go at the last place.
fn drag(from: Point<Pixels>, path: &[Point<Pixels>], cx: &mut VisualTestContext) {
let none = Modifiers::none();
cx.simulate_mouse_down(from, MouseButton::Left, none);
for at in path {
cx.simulate_mouse_move(*at, MouseButton::Left, none);
}
cx.simulate_mouse_up(*path.last().unwrap_or(&from), MouseButton::Left, none);
settle(cx);
}
/// A cropper 400 wide over a picture twice as wide as tall; with `editable`, it keeps each crop it reports.
struct Cropping {
path: PathBuf,
crop: Crop,
aspect: Option<f32>,
editable: bool,
}
impl Render for Cropping {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let owner = cx.entity();
let cropper =
ImageCropper::new("crop", self.path.clone(), 2.0, self.crop).aspect(self.aspect);
let cropper = if self.editable {
cropper.on_change(move |crop, _, cx| {
owner.update(cx, |view, cx| {
view.crop = crop;
cx.notify();
})
})
} else {
cropper
};
div().w(px(400.0)).child(cropper)
}
}
fn cropping(
crop: Crop,
aspect: Option<f32>,
editable: bool,
cx: &mut TestAppContext,
) -> (Entity<Cropping>, &mut VisualTestContext) {
setup(cx);
let path = picture("crop", 200, 100);
let (view, cx) = cx.add_window_view(|_, _| Cropping {
path,
crop,
aspect,
editable,
});
cx.update(|window, _| window.activate_window());
settle(cx);
(view, cx)
}
#[gpui::test]
fn a_corner_drag_sizes_the_crop_and_stops_at_the_picture_edge(cx: &mut TestAppContext) {
let (view, cx) = cropping(HALF, None, true, cx);
let corner = spot("image-cropper", (0.75, 0.75), cx);
drag(
corner,
&[
corner + point(px(20.0), px(10.0)),
corner + point(px(600.0), px(600.0)),
],
cx,
);
let crop = view.read_with(cx, |view, _| view.crop);
assert!(
near(
crop,
Crop {
w: 0.75,
h: 0.75,
..HALF
}
),
"{crop:?}"
);
}
#[gpui::test]
fn the_arrows_move_the_crop_a_hundredth_and_shift_a_tenth(cx: &mut TestAppContext) {
let (view, cx) = cropping(HALF, None, true, cx);
let middle = spot("image-cropper", (0.5, 0.5), cx);
cx.simulate_click(middle, Modifiers::none());
settle(cx);
press("right", cx);
press("shift-right", cx);
let crop = view.read_with(cx, |view, _| view.crop);
assert!(near(crop, Crop { x: 0.36, ..HALF }), "{crop:?}");
}
#[gpui::test]
fn a_shaped_crop_keeps_its_shape_as_a_corner_moves(cx: &mut TestAppContext) {
let square = Crop { w: 0.25, ..HALF };
let (view, cx) = cropping(square, Some(1.0), true, cx);
let corner = spot("image-cropper", (0.5, 0.75), cx);
drag(
corner,
&[
corner + point(px(20.0), px(0.0)),
corner + point(px(40.0), px(0.0)),
],
cx,
);
let (crop, width) = (view.read_with(cx, |view, _| view.crop), picture_width(cx));
let grown = 0.25 + 40.0 / width;
let shaped = Crop {
w: grown,
h: grown * 2.0,
..HALF
};
assert!(near(crop, shaped), "{crop:?}");
}
#[gpui::test]
fn a_cropper_with_no_handler_takes_no_tab(cx: &mut TestAppContext) {
let (_, cx) = cropping(HALF, None, false, cx);
cx.update(|window, _| window.focus_next());
assert!(cx.update(|window, cx| window.focused(cx)).is_none());
}
/// An upload over a picked picture, and how often it was emptied.
struct Uploading {
picked: Option<PathBuf>,
crop: Option<Crop>,
heard: usize,
removed: usize,
}
impl Render for Uploading {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (owner, cropped) = (cx.entity(), cx.entity());
div().w(px(420.0)).child(
ImageUpload::new("upload", self.picked.clone(), self.crop)
.aspect(Some(1.0))
.on_crop(move |crop, _, cx| {
cropped.update(cx, |view, cx| {
(view.crop, view.heard) = (Some(crop), view.heard + 1);
cx.notify();
})
})
.on_remove(move |_, cx| {
owner.update(cx, |view, cx| {
(view.picked, view.removed) = (None, view.removed + 1);
cx.notify();
})
}),
)
}
}
#[gpui::test]
fn a_picked_picture_crops_from_a_fitted_box_and_remove_empties_it(cx: &mut TestAppContext) {
setup(cx);
let picked = Some(picture("upload", 300, 100));
let (view, cx) = cx.add_window_view(|_, _| Uploading {
picked,
crop: None,
heard: 0,
removed: 0,
});
cx.update(|window, _| window.activate_window());
settle(cx);
let shown = cx
.debug_bounds("image-cropper")
.expect("the cropper draws")
.size;
let snapped = (shown.height - shown.width / 3.0).abs() <= px(0.5);
assert!(
snapped,
"the picture's own shape, to the half point: {shown:?}"
);
let (crop, heard) = view.read_with(cx, |view, _| (view.crop, view.heard));
let square = Crop {
x: 1.0 / 3.0,
y: 0.0,
w: 1.0 / 3.0,
h: 1.0,
};
let fitted = crop.is_some_and(|crop| near(crop, square));
assert!(fitted && heard == 1, "{crop:?} after {heard}");
cx.update(|window, _| {
for _ in 0..2 {
window.focus_next();
}
});
press("enter", cx);
assert_eq!(view.read_with(cx, |view, _| view.removed), 1);
}
/// An annotator 400 wide over a picture twice as wide as tall, and its marks.
struct Marking {
path: PathBuf,
marks: Vec<Mark>,
}
impl Render for Marking {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let owner = cx.entity();
div().w(px(400.0)).child(
ImageAnnotator::new("marks", self.path.clone(), 2.0, self.marks.clone()).on_change(
move |marks, _, cx| {
owner.update(cx, |view, cx| {
view.marks = marks.to_vec();
cx.notify();
})
},
),
)
}
}
fn marking(marks: Vec<Mark>, cx: &mut TestAppContext) -> (Entity<Marking>, &mut VisualTestContext) {
setup(cx);
let path = picture("marks", 200, 100);
let (view, cx) = cx.add_window_view(|_, _| Marking { path, marks });
cx.update(|window, _| window.activate_window());
settle(cx);
(view, cx)
}
#[gpui::test]
fn a_drag_with_the_box_tool_adds_a_box_and_a_tap_adds_nothing(cx: &mut TestAppContext) {
let (view, cx) = marking(Vec::new(), cx);
let tap = spot("image-annotator", (0.8, 0.8), cx);
cx.simulate_click(tap, Modifiers::none());
settle(cx);
assert!(
view.read_with(cx, |view, _| view.marks.is_empty()),
"a tap makes no box"
);
let from = spot("image-annotator", (0.1, 0.2), cx);
let to = spot("image-annotator", (0.4, 0.6), cx);
drag(from, &[from + point(px(10.0), px(10.0)), to], cx);
let marks = view.read_with(cx, |view, _| view.marks.clone());
let [Mark::Box { from, to, color: 0 }] = marks.as_slice() else {
panic!("one box: {marks:?}");
};
let close = |a: (f32, f32), b: (f32, f32)| (a.0 - b.0).abs() < 0.01 && (a.1 - b.1).abs() < 0.01;
assert!(
close(*from, (0.1, 0.2)) && close(*to, (0.4, 0.6)),
"{marks:?}"
);
}
#[gpui::test]
fn select_and_delete_remove_a_mark_and_undo_brings_it_back(cx: &mut TestAppContext) {
let pin = Mark::Pin {
at: (0.5, 0.5),
color: 1,
};
let (view, cx) = marking(vec![pin.clone()], cx);
let corner = spot("image-annotator", (0.1, 0.1), cx);
cx.simulate_click(corner, Modifiers::none());
settle(cx);
press("v", cx);
let middle = spot("image-annotator", (0.5, 0.5), cx);
cx.simulate_click(middle, Modifiers::none());
settle(cx);
press("backspace", cx);
assert!(
view.read_with(cx, |view, _| view.marks.is_empty()),
"the pin is gone"
);
press("cmd-z", cx);
assert_eq!(view.read_with(cx, |view, _| view.marks.clone()), [pin]);
}
@@ -1,48 +1,13 @@
use std::{cell::Cell, path::PathBuf, rc::Rc};
use gpui::{
Bounds, Context, InteractiveElement, IntoElement, KeyBinding, KeyUpEvent, Keystroke, Modifiers,
MouseButton, MouseDownEvent, MouseUpEvent, ParentElement, Pixels, Render, ScrollDelta,
ScrollWheelEvent, Styled, TestAppContext, TouchPhase, VisualTestContext, Window, div, point,
px,
Bounds, Context, InteractiveElement, IntoElement, Modifiers, MouseButton, MouseDownEvent,
MouseUpEvent, ParentElement, Pixels, Render, ScrollDelta, ScrollWheelEvent, Styled,
TestAppContext, TouchPhase, VisualTestContext, Window, div, point, px,
};
use super::{ImageThumbnail, ImageViewer};
use crate::{primitives::FocusNext, theme::Theme};
fn setup(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::init(cx);
Theme::update(cx, |theme| theme.reduced_motion = true);
cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]);
});
}
fn settle(cx: &mut VisualTestContext) {
for _ in 0..4 {
cx.run_until_parked();
cx.update(|window, _| window.refresh());
}
cx.run_until_parked();
}
fn press(key: &str, cx: &mut VisualTestContext) {
cx.simulate_keystrokes(key);
settle(cx);
cx.simulate_event(KeyUpEvent {
keystroke: Keystroke::parse(key).expect("a key"),
});
settle(cx);
}
/// A picture `w` by `h` written to a file named for its test, the same file each run.
fn picture(name: &str, w: u32, h: u32) -> PathBuf {
let path = std::env::temp_dir().join(format!("ely-media-{name}.png"));
image::RgbaImage::from_pixel(w, h, image::Rgba([90, 120, 150, 255]))
.save(&path)
.expect("the picture writes");
path
}
use super::{picture, press, settle, setup};
use crate::media::{ImageThumbnail, ImageViewer};
/// A viewer 400 by 300 over a picture the test may change.
struct Viewing(PathBuf);
@@ -287,7 +252,7 @@ fn a_picture_that_cannot_open_says_so(cx: &mut TestAppContext) {
assert!(focused.is_none(), "with nothing to show, nothing takes Tab");
}
/// Two thumbnails, the second with no handler; the opens the first heard.
/// Two thumbnails, the second with no handler, and how often the first opened.
struct Strip(Rc<Cell<usize>>, PathBuf);
impl Render for Strip {
+42
View File
@@ -0,0 +1,42 @@
use std::path::PathBuf;
use gpui::{KeyBinding, KeyUpEvent, Keystroke, TestAppContext, VisualTestContext};
use crate::{primitives::FocusNext, theme::Theme};
mod edit;
mod images;
pub(super) fn setup(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::init(cx);
Theme::update(cx, |theme| theme.reduced_motion = true);
cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]);
});
}
pub(super) fn settle(cx: &mut VisualTestContext) {
for _ in 0..4 {
cx.run_until_parked();
cx.update(|window, _| window.refresh());
}
cx.run_until_parked();
}
pub(super) fn press(key: &str, cx: &mut VisualTestContext) {
cx.simulate_keystrokes(key);
settle(cx);
cx.simulate_event(KeyUpEvent {
keystroke: Keystroke::parse(key).expect("a key"),
});
settle(cx);
}
/// A picture `w` by `h` written to a file named for its test, the same file each run.
pub(super) fn picture(name: &str, w: u32, h: u32) -> PathBuf {
let path = std::env::temp_dir().join(format!("ely-media-{name}.png"));
image::RgbaImage::from_pixel(w, h, image::Rgba([90, 120, 150, 255]))
.save(&path)
.expect("the picture writes");
path
}
+213
View File
@@ -0,0 +1,213 @@
use std::{path::PathBuf, rc::Rc};
use gpui::{
App, ElementId, ImageAssetLoader, ImageSource, IntoElement, ParentElement, RenderOnce,
Resource, SharedString, Styled, Window, div, prelude::*,
};
use super::{Crop, ImageCropper, cropper::OnCrop};
use crate::{
buttons::{Button, ButtonVariant},
forms::{DropZone, PICTURES, Run, browse},
motion::Spinner,
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, TextSize},
typography::Ellipsis,
};
type OnPath = Rc<dyn Fn(PathBuf, &mut Window, &mut App)>;
/// A picture to send: a drop zone until one is chosen, then the picture to crop, its name, Replace and Remove. Only pictures are taken; the picture's shape comes from the file. The host keeps the file and its crop; with none yet, the upload reports the largest centered box of its aspect once the picture opens.
#[derive(IntoElement)]
pub struct ImageUpload {
id: ElementId,
picked: Option<PathBuf>,
crop: Option<Crop>,
aspect: Option<f32>,
on_pick: Option<OnPath>,
on_crop: Option<OnCrop>,
on_remove: Option<Run>,
}
impl ImageUpload {
/// `picked` is the chosen file, none before one is; `crop` is none until the upload first reports one.
pub fn new(id: impl Into<ElementId>, picked: Option<PathBuf>, crop: Option<Crop>) -> Self {
Self {
id: id.into(),
picked,
crop,
aspect: None,
on_pick: None,
on_crop: None,
on_remove: None,
}
}
/// The crop's width over its height, in pixels.
pub fn aspect(mut self, aspect: Option<f32>) -> Self {
self.aspect = aspect;
self
}
/// Gets a picture dropped or chosen, to keep in place of any before it.
pub fn on_pick(mut self, handler: impl Fn(PathBuf, &mut Window, &mut App) + 'static) -> Self {
self.on_pick = Some(Rc::new(handler));
self
}
pub fn on_crop(mut self, handler: impl Fn(Crop, &mut Window, &mut App) + 'static) -> Self {
self.on_crop = Some(Rc::new(handler));
self
}
pub fn on_remove(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_remove = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ImageUpload {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let pick = self.on_pick.clone();
let Some(path) = self.picked else {
return DropZone::new((self.id.clone(), "drop"))
.hint("PNG, JPEG, GIF or WebP")
.kinds(&PICTURES)
.when_some(pick, |zone, pick| {
zone.on_drop(move |mut paths, window, cx| pick(paths.remove(0), window, cx))
})
.into_any_element();
};
let decoded =
window.use_asset::<ImageAssetLoader>(&Resource::Path(path.clone().into()), cx);
let failed = window.use_keyed_state(
(self.id.clone(), path.to_string_lossy().into_owned()),
cx,
|_, _| false,
);
let name = path
.file_name()
.expect("a picked file has a name")
.to_string_lossy()
.into_owned();
let opened = match &decoded {
Some(Ok(image)) => {
let size = image.size(0);
let ratio = size.width.0 as f32 / size.height.0 as f32;
let fitted = self
.aspect
.map_or(Crop::WHOLE, |aspect| Crop::WHOLE.fitted(aspect, ratio));
Some((ratio, fitted))
}
_ => None,
};
if let (Some((_, fitted)), None, Some(on_crop)) = (opened, self.crop, self.on_crop.clone())
{
log::info!("image upload: {name} opened, crop fitted to {fitted:?}");
window.defer(cx, move |window, cx| on_crop(fitted, window, cx));
}
if let Some(Err(error)) = &decoded
&& !*failed.read(cx)
{
log::error!("image upload: {name} can't be opened: {error}");
failed.update(cx, |failed, _| *failed = true);
}
let theme = cx.theme();
let colors = theme.colors.clone();
let replace = pick.map(|pick| {
let what = SharedString::from(format!("image upload {:?}", self.id));
Button::new((self.id.clone(), "replace"), "Replace")
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
let pick = pick.clone();
browse(
what.clone(),
false,
&PICTURES,
window,
cx,
move |mut paths, window, cx| pick(paths.remove(0), window, cx),
);
})
});
let remove = self.on_remove.map(|remove| {
Button::new((self.id.clone(), "remove"), "Remove")
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
log::info!("image upload: removed");
remove(window, cx)
})
});
let body = match decoded {
Some(Ok(image)) => {
let (ratio, fitted) = opened.expect("an opened picture has its shape");
let cropper = ImageCropper::new(
(self.id.clone(), "crop"),
ImageSource::Render(image),
ratio,
self.crop.unwrap_or(fitted),
)
.aspect(self.aspect);
match self.on_crop {
Some(on_crop) => {
cropper.on_change(move |crop, window, cx| on_crop(crop, window, cx))
}
None => cropper,
}
.into_any_element()
}
Some(Err(_)) => div()
.flex()
.items_center()
.gap_2()
.py_6()
.justify_center()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.danger)
.child(
Icon::new(IconName::ImageOff)
.size(IconSize::Md)
.color(colors.danger),
)
.child("This picture can't be opened")
.into_any_element(),
None => div()
.flex()
.justify_center()
.py_6()
.child(Spinner::new((self.id.clone(), "loading")).size(IconSize::Md))
.into_any_element(),
};
div()
.w_full()
.flex()
.flex_col()
.gap_3()
.child(body)
.child(
div()
.flex()
.flex_wrap()
.items_center()
.gap_2()
.child(
div()
.flex_1()
.min_w(theme.label_width())
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child(Ellipsis::new(name)),
)
.child(
div()
.flex_none()
.flex()
.gap_2()
.children(replace)
.children(remove),
),
)
.into_any_element()
}
}