T26b Media: crop and annotate

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

No files matched your search

+93 -14
View File
@@ -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(&note);
assert_eq!(
taken(lone, false, &[]),
Ok(lone.to_vec()),
"no kinds takes any file"
);
fs::remove_dir_all(dir).unwrap();
}
}
+3 -3
View File
@@ -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;
+5 -1
View File
@@ -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;
};
+8
View File
@@ -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);