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:
1 parent
4acedc60c0
commit
5cd7ee0ea8
21 files changed
+2305
-65
No files matched your search
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user