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