,
what: SharedString,
- multiple: bool,
+ (multiple, kinds): (bool, &'static [&'static str]),
take: OnPaths,
) -> Stateful
{
element
.drag_over::
(move |style, paths, _, cx| {
- hovering(style, dropped(paths.paths(), multiple).is_ok(), cx)
+ hovering(style, taken(paths.paths(), multiple, kinds).is_ok(), cx)
})
- .on_drop(
- move |paths: &ExternalPaths, window, cx| match dropped(paths.paths(), multiple) {
+ .on_drop(move |paths: &ExternalPaths, window, cx| {
+ match taken(paths.paths(), multiple, kinds) {
Ok(paths) => take(paths, window, cx),
Err(reason) => log::info!("{what}: refused a drop: {reason}"),
- },
- )
+ }
+ })
}
fn files(multiple: bool) -> PathPromptOptions {
@@ -177,7 +224,7 @@ impl RenderOnce for FileInput {
}),
)
});
- target(field, what, multiple, set)
+ target(field, what, (multiple, &[]), set)
}
}
@@ -187,6 +234,7 @@ pub struct DropZone {
id: ElementId,
hint: Option,
multiple: bool,
+ kinds: &'static [&'static str],
on_drop: Option,
}
@@ -196,10 +244,17 @@ impl DropZone {
id: id.into(),
hint: None,
multiple: false,
+ kinds: &[],
on_drop: None,
}
}
+ /// Takes only files of these kinds, by extension, such as `PICTURES`.
+ pub fn kinds(mut self, kinds: &'static [&'static str]) -> Self {
+ self.kinds = kinds;
+ self
+ }
+
/// A line under the prompt, such as the kinds and sizes taken.
pub fn hint(mut self, text: impl Into) -> Self {
self.hint = Some(text.into());
@@ -235,7 +290,8 @@ impl RenderOnce for DropZone {
};
let theme = cx.theme();
let colors = &theme.colors;
- let (multiple, browse, browse_what) = (self.multiple, take.clone(), what.clone());
+ let (multiple, kinds, chosen, browse_what) =
+ (self.multiple, self.kinds, take.clone(), what.clone());
let zone = div()
.id(self.id.clone())
.flex()
@@ -272,18 +328,19 @@ impl RenderOnce for DropZone {
Button::new((self.id, "browse"), "Browse…")
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
- let browse = browse.clone();
- choose(
+ let take = chosen.clone();
+ browse(
browse_what.clone(),
- files(multiple),
+ multiple,
+ kinds,
window,
cx,
- move |paths, window, cx| browse(paths, window, cx),
+ move |paths, window, cx| take(paths, window, cx),
);
}),
),
);
- target(zone, what, multiple, take)
+ target(zone, what, (multiple, kinds), take)
}
}
@@ -291,7 +348,7 @@ impl RenderOnce for DropZone {
mod tests {
use std::fs;
- use super::dropped;
+ use super::{PICTURES, dropped, taken};
#[test]
fn drops_take_files_and_refuse_folders_extras_and_nothing() {
@@ -314,4 +371,26 @@ mod tests {
assert_eq!(dropped(&[], true), Err("nothing was dropped"));
fs::remove_dir_all(dir).unwrap();
}
+
+ #[test]
+ fn a_zone_of_pictures_takes_pictures_in_any_case_and_refuses_the_rest() {
+ let dir = std::env::temp_dir().join(format!("ely-kinds-{}", std::process::id()));
+ fs::create_dir_all(&dir).unwrap();
+ let [photo, loud, note] = ["dunes.jpg", "STAIR.PNG", "notes.txt"].map(|name| {
+ let path = dir.join(name);
+ fs::write(&path, "x").unwrap();
+ path
+ });
+ let both = [photo.clone(), loud.clone()];
+ assert_eq!(taken(&both, true, &PICTURES), Ok(both.to_vec()));
+ let mixed = [photo, note.clone()];
+ assert_eq!(taken(&mixed, true, &PICTURES), Err("not a kind this takes"));
+ let lone = std::slice::from_ref(¬e);
+ assert_eq!(
+ taken(lone, false, &[]),
+ Ok(lone.to_vec()),
+ "no kinds takes any file"
+ );
+ fs::remove_dir_all(dir).unwrap();
+ }
}
diff --git a/src/forms/mod.rs b/src/forms/mod.rs
index 7b40b74..0e70198 100644
--- a/src/forms/mod.rs
+++ b/src/forms/mod.rs
@@ -63,8 +63,8 @@ pub use date::{
WeekPicker, YearPicker,
};
pub use expression::{ExpressionInput, evaluate, expression_highlights};
-pub(crate) use files::dropped;
-pub use files::{DropZone, FileInput};
+pub use files::{DropZone, FileInput, PICTURES};
+pub(crate) use files::{browse, dropped};
pub(crate) use glyphs::emoji_found;
pub use glyphs::{EmojiPicker, IconPicker};
pub use group::{InputAddon, InputGroup};
@@ -95,7 +95,7 @@ pub use rating::Rating;
pub use search::SearchInput;
pub use select::Select;
pub(crate) use select::{Listing, field_button, field_text, listing};
-pub(crate) use signature::{Drawing, pen};
+pub(crate) use signature::{Drawing, ink, pen};
pub use signature::{SignaturePad, Stroke};
pub use slider::{RangeSlider, Slider};
pub use stepper::Stepper;
diff --git a/src/forms/signature.rs b/src/forms/signature.rs
index c4c0637..227507d 100644
--- a/src/forms/signature.rs
+++ b/src/forms/signature.rs
@@ -53,7 +53,11 @@ fn local(bounds: Bounds, at: Point) -> Point {
}
/// A line through `points`, bent through their midpoints so it reads smooth.
-fn ink(points: &[Point], origin: Point, width: Pixels) -> Option> {
+pub(crate) fn ink(
+ points: &[Point],
+ origin: Point,
+ width: Pixels,
+) -> Option> {
let [first, .., last] = points else {
return None;
};
diff --git a/src/forms/text/edit.rs b/src/forms/text/edit.rs
index 0965aa8..954174f 100644
--- a/src/forms/text/edit.rs
+++ b/src/forms/text/edit.rs
@@ -121,6 +121,14 @@ impl History {
self.redo.clear();
}
+ pub fn can_undo(&self) -> bool {
+ !self.undo.is_empty()
+ }
+
+ pub fn can_redo(&self) -> bool {
+ !self.redo.is_empty()
+ }
+
pub fn undo(&mut self, current: S) -> Option {
let previous = self.undo.pop()?;
self.redo.push(current);
diff --git a/src/media/annotate.rs b/src/media/annotate.rs
new file mode 100644
index 0000000..3ea60cb
--- /dev/null
+++ b/src/media/annotate.rs
@@ -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;
+
+/// 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,
+ history: History>,
+}
+
+/// The marks, the hand, and the host to tell.
+#[derive(Clone)]
+struct Board {
+ marks: Entity>>,
+ hand: Entity,
+ on_change: Option,
+}
+
+impl Board {
+ /// Sets the marks, keeping the ones before for Undo, and tells the host.
+ fn commit(&self, next: Vec, 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, 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, at: gpui::Point) -> (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,
+ on_change: Option,
+}
+
+impl ImageAnnotator {
+ /// `ratio` is the picture's width over its height.
+ pub fn new(
+ id: impl Into,
+ source: impl Into,
+ ratio: f32,
+ marks: impl IntoIterator- ,
+ ) -> Self {
+ let marks: Vec
= 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)
+ }
+}
diff --git a/src/media/crop.rs b/src/media/crop.rs
new file mode 100644
index 0000000..fb338eb
--- /dev/null
+++ b/src/media/crop.rs
@@ -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 {
+ 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,
+) -> 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();
+ }
+}
diff --git a/src/media/cropper.rs b/src/media/cropper.rs
new file mode 100644
index 0000000..f403869
--- /dev/null
+++ b/src/media/cropper.rs
@@ -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;
+
+/// 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,
+ held: Option,
+ from: Point,
+}
+
+/// 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,
+ on_change: Option,
+}
+
+impl ImageCropper {
+ /// `ratio` is the picture's width over its height.
+ pub fn new(
+ id: impl Into,
+ source: impl Into,
+ 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) -> 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, 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);
+ })
+ })
+ }
+}
diff --git a/src/media/marks.rs b/src/media/marks.rs
new file mode 100644
index 0000000..ec04397
--- /dev/null
+++ b/src/media/marks.rs
@@ -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,
+ chosen: Option,
+ look: Look,
+ bounds: Bounds,
+ 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 {
+ 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 {
+ 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> {
+ 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)]
+ );
+ }
+}
diff --git a/src/media/mod.rs b/src/media/mod.rs
index d52f236..a368af8 100644
--- a/src/media/mod.rs
+++ b/src/media/mod.rs
@@ -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;
diff --git a/src/media/tests/edit.rs b/src/media/tests/edit.rs
new file mode 100644
index 0000000..ff51aa4
--- /dev/null
+++ b/src/media/tests/edit.rs
@@ -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 {
+ 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, path: &[Point], 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,
+ editable: bool,
+}
+
+impl Render for Cropping {
+ fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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,
+ editable: bool,
+ cx: &mut TestAppContext,
+) -> (Entity, &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,
+ crop: Option,
+ heard: usize,
+ removed: usize,
+}
+
+impl Render for Uploading {
+ fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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,
+}
+
+impl Render for Marking {
+ fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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, cx: &mut TestAppContext) -> (Entity, &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]);
+}
diff --git a/src/media/tests.rs b/src/media/tests/images.rs
similarity index 86%
rename from src/media/tests.rs
rename to src/media/tests/images.rs
index 1a9db47..c00d01a 100644
--- a/src/media/tests.rs
+++ b/src/media/tests/images.rs
@@ -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>, PathBuf);
impl Render for Strip {
diff --git a/src/media/tests/mod.rs b/src/media/tests/mod.rs
new file mode 100644
index 0000000..889ddc9
--- /dev/null
+++ b/src/media/tests/mod.rs
@@ -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
+}
diff --git a/src/media/upload.rs b/src/media/upload.rs
new file mode 100644
index 0000000..7cac689
--- /dev/null
+++ b/src/media/upload.rs
@@ -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;
+
+/// 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,
+ crop: Option,
+ aspect: Option,
+ on_pick: Option,
+ on_crop: Option,
+ on_remove: Option,
+}
+
+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, picked: Option, crop: Option) -> 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) -> 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::(&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()
+ }
+}
diff --git a/src/primitives/icon.rs b/src/primitives/icon.rs
index 52c9dd4..34c3bb9 100644
--- a/src/primitives/icon.rs
+++ b/src/primitives/icon.rs
@@ -209,6 +209,7 @@ icons! {
PanelRight => "panel-right",
Paperclip => "paperclip",
Pause => "pause",
+ PenLine => "pen-line",
PenTool => "pen-tool",
Pencil => "pencil",
PhoneOff => "phone-off",
diff --git a/src/theme/media.rs b/src/theme/media.rs
new file mode 100644
index 0000000..c549638
--- /dev/null
+++ b/src/theme/media.rs
@@ -0,0 +1,27 @@
+use gpui::Rems;
+
+use super::{Theme, tokens::px_to_rems};
+
+/// Media tools' measures.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct MediaSizes {
+ /// A crop box's handle, square.
+ pub crop_handle: Rems,
+ /// A mark's line on a picture.
+ pub mark_stroke: Rems,
+ /// An arrow's head, from its tip.
+ pub arrow_head: Rems,
+ /// A numbered pin, round.
+ pub pin: Rems,
+}
+
+impl Theme {
+ pub fn media(&self) -> MediaSizes {
+ MediaSizes {
+ crop_handle: px_to_rems(10.0),
+ mark_stroke: px_to_rems(2.0),
+ arrow_head: px_to_rems(12.0),
+ pin: px_to_rems(20.0),
+ }
+ }
+}
diff --git a/src/theme/mod.rs b/src/theme/mod.rs
index c6b9aeb..0b71b5d 100644
--- a/src/theme/mod.rs
+++ b/src/theme/mod.rs
@@ -1,5 +1,6 @@
mod chart;
mod generative;
+mod media;
mod palette;
mod tokens;
@@ -10,6 +11,7 @@ use gpui::{App, Global, SharedString, WindowAppearance};
use crate::motion;
pub use chart::ChartSizes;
pub use generative::GenerativeSizes;
+pub use media::MediaSizes;
pub use palette::{Mix, Palette, Syntax};
pub use tokens::{
AvatarSize, ContainerSize, ControlSize, Density, Elevation, IconSize, Radius, TextSize,
diff --git a/tasks/ch21-30.md b/tasks/ch21-30.md
index ae97499..5d09205 100644
--- a/tasks/ch21-30.md
+++ b/tasks/ch21-30.md
@@ -259,8 +259,8 @@
## T26b Media · Crop and annotate
-- [ ] ImageCropper — also T06e ImageUpload
-- [ ] ImageAnnotator
+- [x] ImageCropper — also T06e ImageUpload
+- [x] ImageAnnotator
## T26c Media · Video
|