diff --git a/AGENTS.md b/AGENTS.md index f4eac90..defcbed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - A drag that pans, turns or slides goes through gpui's drag, whose moves reach the owner in or out of its box; `on_mouse_move` stops at the box's edge. gpui sends a drag's moves to every listener of its type, so each payload carries its owner's `EntityId` and handlers check it. - gpui 0.2.2 paints pictures without a transform, so a picture turns by its pixels: `media::turn` turns decoded frames by quarter turns off the main thread, an asset keyed by the picture and the turn. - A component that takes a starting value from its owner keeps it in `layout::seeded`: a new value from the owner replaces local drags. -- The capture harness turns on reduced motion, so shots are still: repeating motion rests on one frame, and a shimmer shows no band. +- The capture harness turns on reduced motion, so shots are still: repeating motion rests on one frame, and a shimmer shows no band. gpui's mac window stops its display link while occluded, so a capture advances a moving picture only on the draws the harness forces. - Each capture pass starts its page fresh (`Gallery::fresh`): page state lives under the pass, so the dark pass runs its script from the same start as the light one. - The capture harness finds gpui windows by handle through `raw-window-handle`. AppKit popups, which gpui does not own, come from the window list by process and level. - Posted events cannot move a macOS window, so window drags are not scripted. `drag_region` follows Zed's title bar. @@ -79,11 +79,11 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - A picker hands focus to its popup on open and back to its trigger on pick or Escape; it closes once focus leaves both (`forms::date::picker`). - Motion for a value change keys its animation on `motion::changes`, so it replays per change and stays still on first paint. Key only the part that moves: a keyed ancestor gives every descendant a new id, and focused buttons lose focus. - A spotlight or tour takes its target's box from `primitives::Measure`; the lit box glides between targets. -- Thumbs stay inside their component's box: the track is padded by half a thumb (`Slider`, `ColorPicker`, `GradientEditor`). +- Thumbs stay inside their component's box: the track is padded by half a thumb (`Slider`, `ColorPicker`, `GradientEditor`, `media::ImageCropper`). - gpui's `Hsla::opacity` scales alpha; `alpha` sets it. - A rounded box does not clip its children. Each layer inside takes the radius itself; `checker` takes one. - A window keeps one `feedback::Toaster` and renders one `ToastViewport`; anything holding the entity pushes to it. Messages share `primitives::Severity`. -- Only gpui can build `ExternalPaths` with paths, so drop rules live in `forms::files::dropped`, where tests reach them. +- Only gpui can build `ExternalPaths` with paths, so drop rules live in `forms::files::dropped`, where tests reach them. `taken` adds file kinds by extension, for drops and the dialog alike (`DropZone::kinds`). - A stroke drawn with the pointer goes through `forms::pen`: a press starts it, a drag extends it, and the lift hands it over, in or out of the pad (`SignaturePad`, `generative::InpaintCanvas`). - gpui runs key bindings before key listeners. A container takes a child's bound key through the action with `capture_action`, as `Form` takes `Submit`. - `ScrollHandle::scroll_to_item` runs in the container's prepaint against last frame's bounds and overflow, so a call before a list's first frame does nothing. Call it from a child's prepaint once the handle has bounds, then request one frame (`navigation::editor_tabs`). A floating list reveals its cursor, and a strip its focused item, through `forms::options::reveal` and `revealer`. A focused Tab stop deeper in a scroll box sets the box's offset whole from its own painted bounds in prepaint, once per focus, through `layout::bring_into_view`. Never poll layout with `request_animation_frame`. @@ -146,7 +146,8 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - gpui takes a scroll box's reach from its direct children's bounds, and a block child stretches to the box. Content that scrolls sideways sets its least width on that child (`tables::body::least`). - gpui turns a wheel along the other axis onto a box that scrolls one way, and the page around it scrolls too. A box inside a scrolling page keeps its wheel to its axis with `layout::on_axis`, and so does the page. - The focus color belongs to the focus ring. A chosen or shown item takes accent, as `forms::glyphs` and `lists::ListItem` do. -- A box that shows a host's picture takes the picture's shape from the host (`ratio`), so nothing is cropped and a point given as a share lands on the picture. +- A box that shows a host's picture takes the picture's shape from the host (`ratio`), so nothing is cropped and a point given as a share lands on the picture. A focusable one keeps its focus border on an outer box, so the picture keeps that shape exactly (`media::ImageCropper`). +- A default only the component can work out, such as a crop fitted to a picture it decodes, reaches the owner once through `window.defer` after render (`media::ImageUpload`). ## Decisions diff --git a/assets/icons/pen-line.svg b/assets/icons/pen-line.svg new file mode 100644 index 0000000..656c6a4 --- /dev/null +++ b/assets/icons/pen-line.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/examples/gallery/pages/media/edit.rs b/examples/gallery/pages/media/edit.rs new file mode 100644 index 0000000..995431a --- /dev/null +++ b/examples/gallery/pages/media/edit.rs @@ -0,0 +1,173 @@ +use std::path::{Path, PathBuf}; + +use ely_gpui_component::{ + buttons::SegmentedControl, + media::{Crop, ImageAnnotator, ImageCropper, ImageUpload, Mark}, + theme::ActiveTheme, +}; +use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; + +use crate::{ + probe::probe, + ui::{keep, section, set}, +}; + +const ATRIUM: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/examples/gallery/assets/atrium.jpg" +); +const STAIR: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/examples/gallery/assets/atrium-stair.jpg" +); +const DUNES: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/examples/gallery/assets/dunes.jpg" +); + +/// Crop shapes to pick from, width over height; none is free. +const ASPECTS: [(&str, Option); 4] = [ + ("Free", None), + ("1:1", Some(1.0)), + ("4:3", Some(4.0 / 3.0)), + ("16:9", Some(16.0 / 9.0)), +]; + +pub fn cropper(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let kept = keep( + "media-crop", + || { + ( + Crop { + x: 0.12, + y: 0.1, + w: 0.7, + h: 0.75, + }, + "Free", + ) + }, + window, + cx, + ); + let (crop, shape) = *kept.read(cx); + let aspect = ASPECTS + .iter() + .find(|(name, _)| *name == shape) + .expect("a listed shape") + .1; + let (reshaped, moved) = (kept.clone(), kept.clone()); + let shapes = ASPECTS.iter().fold( + SegmentedControl::new("media-crop-shape", shape), + |control, (name, _)| control.segment(*name, *name, None), + ); + let readout = format!( + "{:.0}% across, {:.0}% down · {:.0}% by {:.0}%", + crop.x * 100.0, + crop.y * 100.0, + crop.w * 100.0, + crop.h * 100.0 + ); + section( + "ImageCropper", + "A box to crop a picture by. Drag it to move it and its handles to size it; with a shape, only its corners size it. The arrows move it, Shift further.", + cx, + ) + .child( + div() + .w(px(560.)) + .flex() + .flex_col() + .gap_3() + .child(shapes.on_change(move |name, _, cx| { + let (crop, _) = *reshaped.read(cx); + let name: &'static str = ASPECTS.iter().find(|(each, _)| *each == name.as_ref()).expect("a listed shape").0; + let aspect = ASPECTS.iter().find(|(each, _)| *each == name).expect("a listed shape").1; + let crop = aspect.map_or(crop, |aspect| crop.fitted(aspect, 1.5)); + set(&reshaped, (crop, name), cx); + })) + .child(probe( + "media-cropper", + div().w(px(560.)).child( + ImageCropper::new("media-cropper", Path::new(ATRIUM), 1.5, crop) + .aspect(aspect) + .on_change(move |crop, _, cx| { + let shape = moved.read(cx).1; + set(&moved, (crop, shape), cx) + }), + ), + )) + .child(div().text_size(cx.theme().text_size(ely_gpui_component::theme::TextSize::Sm)).text_color(cx.theme().colors.fg_muted).child(readout)), + ) +} + +pub fn upload(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let kept = keep( + "media-upload", + || (Some(PathBuf::from(DUNES)), None::), + window, + cx, + ); + let (picked, crop) = kept.read(cx).clone(); + let (pick, cropped, removed) = (kept.clone(), kept.clone(), kept.clone()); + section( + "ImageUpload", + "A picture to send: drop one or browse for it, then crop it. Replace picks another; Remove empties it.", + cx, + ) + .child(probe( + "media-upload", + div().w(px(420.)).child( + ImageUpload::new("media-upload", picked, crop) + .aspect(Some(1.0)) + .on_pick(move |path, _, cx| set(&pick, (Some(path), None), cx)) + .on_crop(move |crop, _, cx| { + let picked = cropped.read(cx).0.clone(); + set(&cropped, (picked, Some(crop)), cx) + }) + .on_remove(move |_, cx| set(&removed, (None, None), cx)), + ), + )) +} + +pub fn annotator(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let kept = keep( + "media-marks", + || { + vec![ + Mark::Box { + from: (0.52, 0.18), + to: (0.9, 0.86), + color: 0, + }, + Mark::Arrow { + from: (0.2, 0.3), + to: (0.36, 0.52), + color: 2, + }, + Mark::Pin { + at: (0.3, 0.72), + color: 1, + }, + ] + }, + window, + cx, + ); + let marks = kept.read(cx).clone(); + let count = SharedString::from(format!("{} marks", marks.len())); + let changed = kept.clone(); + section( + "ImageAnnotator", + "Marks on a picture: boxes, arrows, lines drawn freehand, numbered pins. Pick a tool and a color above; with Select, a press chooses a mark and Delete removes it. Cmd-Z undoes.", + cx, + ) + .child(probe( + "media-annotator", + div().w(px(560.)).child( + ImageAnnotator::new("media-annotator", Path::new(STAIR), 1.5, marks) + .on_change(move |marks, _, cx| set(&changed, marks.to_vec(), cx)), + ), + )) + .child(div().text_size(cx.theme().text_size(ely_gpui_component::theme::TextSize::Sm)).text_color(cx.theme().colors.fg_muted).child(count)) +} diff --git a/examples/gallery/pages/media/mod.rs b/examples/gallery/pages/media/mod.rs index 33452cb..66d7ee6 100644 --- a/examples/gallery/pages/media/mod.rs +++ b/examples/gallery/pages/media/mod.rs @@ -1,3 +1,4 @@ +mod edit; mod images; use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div}; @@ -30,11 +31,36 @@ const SCRIPT: &[Step] = &[ Step::Shot("lightbox"), Step::Key("escape"), Step::Wait(300), + Step::DownAt("media-cropper", 458.0, 317.0), + Step::DragTo("media-cropper", 400.0, 280.0), + Step::UpAt("media-cropper", 400.0, 280.0), + Step::Wait(200), + Step::Shot("cropped"), + Step::DownAt("media-annotator", 500.0, 80.0), + Step::UpAt("media-annotator", 500.0, 80.0), + Step::Key("v"), + Step::DownAt("media-annotator", 168.0, 310.0), + Step::UpAt("media-annotator", 168.0, 310.0), + Step::Wait(200), + Step::Shot("chosen"), + Step::Key("b"), + Step::DownAt("media-annotator", 60.0, 90.0), + Step::DragTo("media-annotator", 200.0, 200.0), + Step::UpAt("media-annotator", 200.0, 200.0), + Step::Wait(200), + Step::Shot("marked"), + Step::DownAt("media-upload", 388.0, 307.0), + Step::UpAt("media-upload", 388.0, 307.0), + Step::Wait(300), + Step::Shot("emptied"), ]; fn render(window: &mut Window, cx: &mut App) -> AnyElement { div() .child(images::viewer(cx)) .child(images::thumbnails(window, cx)) + .child(edit::cropper(window, cx)) + .child(edit::upload(window, cx)) + .child(edit::annotator(window, cx)) .into_any_element() } diff --git a/src/forms/files.rs b/src/forms/files.rs index 9a97bad..53f7732 100644 --- a/src/forms/files.rs +++ b/src/forms/files.rs @@ -35,6 +35,53 @@ pub(crate) fn dropped(paths: &[PathBuf], multiple: bool) -> Result, Ok(paths.to_vec()) } +/// Files a picture may be, by extension. +pub const PICTURES: [&str; 5] = ["png", "jpg", "jpeg", "gif", "webp"]; + +/// The files a drop or a dialog brings when each is of one of `kinds`, by extension in any case, or why they are refused; no kinds takes any file. +pub(crate) fn taken( + paths: &[PathBuf], + multiple: bool, + kinds: &[&str], +) -> Result, &'static str> { + let paths = dropped(paths, multiple)?; + let fits = |path: &PathBuf| { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + kinds + .iter() + .any(|kind| kind.eq_ignore_ascii_case(extension)) + }) + }; + if !kinds.is_empty() && !paths.iter().all(fits) { + return Err("not a kind this takes"); + } + Ok(paths) +} + +/// Opens the file dialog; what it brings goes to `then` once `taken` lets it through. +pub(crate) fn browse( + what: SharedString, + multiple: bool, + kinds: &'static [&'static str], + window: &mut Window, + cx: &mut App, + then: impl Fn(Vec, &mut Window, &mut App) + 'static, +) { + let refused = what.clone(); + choose( + what, + files(multiple), + window, + cx, + move |paths, window, cx| match taken(&paths, multiple, kinds) { + Ok(paths) => then(paths, window, cx), + Err(reason) => log::info!("{refused}: refused a choice: {reason}"), + }, + ); +} + /// A drop target while files hover: focus-tinted when it takes them, danger-tinted when not. pub(super) fn hovering(style: StyleRefinement, takes: bool, cx: &App) -> StyleRefinement { let colors = &cx.theme().colors; @@ -49,19 +96,19 @@ pub(super) fn hovering(style: StyleRefinement, takes: bool, cx: &App) -> StyleRe fn target( element: Stateful
, 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