Media: a shaped corner follows the pointer up and down; the fitted crop is told once
With an aspect, a corner drag sizes the crop by whichever side the pointer moved further, so a drag straight up or down resizes it too. The upload tells the host the fitted crop once per file, whether the host keeps it or not; a host that dropped it had set off a loop of reports. The annotator's Escape stops only when it lets a chosen mark go. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7d86c804d7
commit
cb28c0e305
5 files changed
+124
-26
No files matched your search
@@ -309,3 +309,4 @@ Per-component lines live in `tasks/`. Tags there:
|
|||||||
| T25f | 2 | PASS | |
|
| T25f | 2 | PASS | |
|
||||||
| T26a | 1 | FAIL | the viewer's picture had no id, so an animated picture never left its first frame |
|
| T26a | 1 | FAIL | the viewer's picture had no id, so an animated picture never left its first frame |
|
||||||
| T26a | 2 | PASS | |
|
| T26a | 2 | PASS | |
|
||||||
|
| T26b | 1 | FAIL | a shaped crop's corner ignored the pointer's move up or down |
|
||||||
@@ -407,10 +407,15 @@ impl RenderOnce for ImageAnnotator {
|
|||||||
match (stroke.key.as_str(), command, stroke.modifiers.shift) {
|
match (stroke.key.as_str(), command, stroke.modifiers.shift) {
|
||||||
("z", true, back) => keyed.step(!back, window, cx),
|
("z", true, back) => keyed.step(!back, window, cx),
|
||||||
("delete" | "backspace", false, _) => keyed.remove(window, cx),
|
("delete" | "backspace", false, _) => keyed.remove(window, cx),
|
||||||
("escape", false, _) => keyed.hand.update(cx, |hand, cx| {
|
("escape", false, _) => {
|
||||||
hand.chosen = None;
|
let chose = keyed.hand.update(cx, |hand, cx| {
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}),
|
hand.chosen.take().is_some()
|
||||||
|
});
|
||||||
|
if !chose {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
(key, false, false) => {
|
(key, false, false) => {
|
||||||
match TOOLS.iter().find(|(_, each, ..)| *each == key) {
|
match TOOLS.iter().find(|(_, each, ..)| *each == key) {
|
||||||
Some((tool, ..)) => keyed.pick(*tool, cx),
|
Some((tool, ..)) => keyed.pick(*tool, cx),
|
||||||
|
|||||||
+41
-2
@@ -72,7 +72,7 @@ pub(crate) fn grip_at(crop: Crop, at: (f32, f32), reach: (f32, f32), shaped: boo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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.
|
/// 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, sized by whichever side the pointer moved further.
|
||||||
pub(crate) fn dragged(
|
pub(crate) fn dragged(
|
||||||
crop: Crop,
|
crop: Crop,
|
||||||
grip: Grip,
|
grip: Grip,
|
||||||
@@ -108,7 +108,13 @@ pub(crate) fn dragged(
|
|||||||
if let Some(shape) = shape {
|
if let Some(shape) = shape {
|
||||||
let tall = if grip.top { y2 } else { 1.0 - y1 };
|
let tall = if grip.top { y2 } else { 1.0 - y1 };
|
||||||
let wide = if grip.left { x2 } else { 1.0 - x1 };
|
let wide = if grip.left { x2 } else { 1.0 - x1 };
|
||||||
let w = (x2 - x1)
|
let (across, down) = (x2 - x1, (y2 - y1) * shape);
|
||||||
|
let asked = if (across - crop.w).abs() >= (down - crop.w).abs() {
|
||||||
|
across
|
||||||
|
} else {
|
||||||
|
down
|
||||||
|
};
|
||||||
|
let w = asked
|
||||||
.max(least * shape.max(1.0))
|
.max(least * shape.max(1.0))
|
||||||
.min(tall * shape)
|
.min(tall * shape)
|
||||||
.min(wide);
|
.min(wide);
|
||||||
@@ -252,6 +258,39 @@ mod tests {
|
|||||||
assert_eq!(dragged(HALF, edge, (0.0, -0.1), 0.05, Some(1.0)), HALF);
|
assert_eq!(dragged(HALF, edge, (0.0, -0.1), 0.05, Some(1.0)), HALF);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_shaped_corner_follows_the_pointer_up_and_down_too() {
|
||||||
|
let bottom_right = Grip {
|
||||||
|
right: true,
|
||||||
|
bottom: true,
|
||||||
|
..Grip::default()
|
||||||
|
};
|
||||||
|
let down = dragged(HALF, bottom_right, (0.0, 0.2), 0.05, Some(1.0));
|
||||||
|
assert!(
|
||||||
|
near(
|
||||||
|
down,
|
||||||
|
Crop {
|
||||||
|
w: 0.7,
|
||||||
|
h: 0.7,
|
||||||
|
..HALF
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"straight down grows it: {down:?}"
|
||||||
|
);
|
||||||
|
let up = dragged(HALF, bottom_right, (0.0, -0.2), 0.05, Some(1.0));
|
||||||
|
assert!(
|
||||||
|
near(
|
||||||
|
up,
|
||||||
|
Crop {
|
||||||
|
w: 0.3,
|
||||||
|
h: 0.3,
|
||||||
|
..HALF
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"straight up shrinks it: {up:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_new_shape_takes_the_largest_centered_box_inside() {
|
fn a_new_shape_takes_the_largest_centered_box_inside() {
|
||||||
let wide = fitted(HALF, 2.0);
|
let wide = fitted(HALF, 2.0);
|
||||||
|
|||||||
+60
-16
@@ -1,8 +1,8 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Context, Entity, IntoElement, Modifiers, MouseButton, ParentElement, Pixels, Point, Render,
|
Context, Entity, InteractiveElement, IntoElement, Modifiers, MouseButton, ParentElement,
|
||||||
Styled, TestAppContext, VisualTestContext, Window, div, point, px,
|
Pixels, Point, Render, Styled, TestAppContext, VisualTestContext, Window, div, point, px,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{picture, press, settle, setup};
|
use super::{picture, press, settle, setup};
|
||||||
@@ -161,10 +161,11 @@ fn a_cropper_with_no_handler_takes_no_tab(cx: &mut TestAppContext) {
|
|||||||
assert!(cx.update(|window, cx| window.focused(cx)).is_none());
|
assert!(cx.update(|window, cx| window.focused(cx)).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An upload over a picked picture, and how often it was emptied.
|
/// An upload over a picked picture, the crops it heard, kept or not, and how often it was emptied.
|
||||||
struct Uploading {
|
struct Uploading {
|
||||||
picked: Option<PathBuf>,
|
picked: Option<PathBuf>,
|
||||||
crop: Option<Crop>,
|
crop: Option<Crop>,
|
||||||
|
keeps: bool,
|
||||||
heard: usize,
|
heard: usize,
|
||||||
removed: usize,
|
removed: usize,
|
||||||
}
|
}
|
||||||
@@ -177,7 +178,10 @@ impl Render for Uploading {
|
|||||||
.aspect(Some(1.0))
|
.aspect(Some(1.0))
|
||||||
.on_crop(move |crop, _, cx| {
|
.on_crop(move |crop, _, cx| {
|
||||||
cropped.update(cx, |view, cx| {
|
cropped.update(cx, |view, cx| {
|
||||||
(view.crop, view.heard) = (Some(crop), view.heard + 1);
|
view.heard += 1;
|
||||||
|
if view.keeps {
|
||||||
|
view.crop = Some(crop);
|
||||||
|
}
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -198,6 +202,7 @@ fn a_picked_picture_crops_from_a_fitted_box_and_remove_empties_it(cx: &mut TestA
|
|||||||
let (view, cx) = cx.add_window_view(|_, _| Uploading {
|
let (view, cx) = cx.add_window_view(|_, _| Uploading {
|
||||||
picked,
|
picked,
|
||||||
crop: None,
|
crop: None,
|
||||||
|
keeps: true,
|
||||||
heard: 0,
|
heard: 0,
|
||||||
removed: 0,
|
removed: 0,
|
||||||
});
|
});
|
||||||
@@ -230,32 +235,63 @@ fn a_picked_picture_crops_from_a_fitted_box_and_remove_empties_it(cx: &mut TestA
|
|||||||
assert_eq!(view.read_with(cx, |view, _| view.removed), 1);
|
assert_eq!(view.read_with(cx, |view, _| view.removed), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_host_that_keeps_no_crop_still_hears_the_fitted_one_once(cx: &mut TestAppContext) {
|
||||||
|
setup(cx);
|
||||||
|
let picked = Some(picture("ignored", 300, 100));
|
||||||
|
let (view, cx) = cx.add_window_view(|_, _| Uploading {
|
||||||
|
picked,
|
||||||
|
crop: None,
|
||||||
|
keeps: false,
|
||||||
|
heard: 0,
|
||||||
|
removed: 0,
|
||||||
|
});
|
||||||
|
settle(cx);
|
||||||
|
for _ in 0..3 {
|
||||||
|
view.update(cx, |_, cx| cx.notify());
|
||||||
|
settle(cx);
|
||||||
|
}
|
||||||
|
assert_eq!(view.read_with(cx, |view, _| view.heard), 1);
|
||||||
|
}
|
||||||
|
|
||||||
/// An annotator 400 wide over a picture twice as wide as tall, and its marks.
|
/// An annotator 400 wide over a picture twice as wide as tall, and its marks.
|
||||||
struct Marking {
|
struct Marking {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
marks: Vec<Mark>,
|
marks: Vec<Mark>,
|
||||||
|
escapes: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Render for Marking {
|
impl Render for Marking {
|
||||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let owner = cx.entity();
|
let (owner, page) = (cx.entity(), cx.entity());
|
||||||
div().w(px(400.0)).child(
|
div()
|
||||||
ImageAnnotator::new("marks", self.path.clone(), 2.0, self.marks.clone()).on_change(
|
.w(px(400.0))
|
||||||
move |marks, _, cx| {
|
.on_key_down(move |event, _, cx| {
|
||||||
owner.update(cx, |view, cx| {
|
if event.keystroke.key == "escape" {
|
||||||
view.marks = marks.to_vec();
|
page.update(cx, |view, _| view.escapes += 1);
|
||||||
cx.notify();
|
}
|
||||||
})
|
})
|
||||||
},
|
.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) {
|
fn marking(marks: Vec<Mark>, cx: &mut TestAppContext) -> (Entity<Marking>, &mut VisualTestContext) {
|
||||||
setup(cx);
|
setup(cx);
|
||||||
let path = picture("marks", 200, 100);
|
let path = picture("marks", 200, 100);
|
||||||
let (view, cx) = cx.add_window_view(|_, _| Marking { path, marks });
|
let (view, cx) = cx.add_window_view(|_, _| Marking {
|
||||||
|
path,
|
||||||
|
marks,
|
||||||
|
escapes: 0,
|
||||||
|
});
|
||||||
cx.update(|window, _| window.activate_window());
|
cx.update(|window, _| window.activate_window());
|
||||||
settle(cx);
|
settle(cx);
|
||||||
(view, cx)
|
(view, cx)
|
||||||
@@ -299,11 +335,19 @@ fn select_and_delete_remove_a_mark_and_undo_brings_it_back(cx: &mut TestAppConte
|
|||||||
let middle = spot("image-annotator", (0.5, 0.5), cx);
|
let middle = spot("image-annotator", (0.5, 0.5), cx);
|
||||||
cx.simulate_click(middle, Modifiers::none());
|
cx.simulate_click(middle, Modifiers::none());
|
||||||
settle(cx);
|
settle(cx);
|
||||||
|
press("escape", cx);
|
||||||
|
let escapes = view.read_with(cx, |view, _| view.escapes);
|
||||||
|
assert_eq!(escapes, 0, "Escape lets the chosen pin go and stops there");
|
||||||
|
cx.simulate_click(middle, Modifiers::none());
|
||||||
|
settle(cx);
|
||||||
press("backspace", cx);
|
press("backspace", cx);
|
||||||
assert!(
|
assert!(
|
||||||
view.read_with(cx, |view, _| view.marks.is_empty()),
|
view.read_with(cx, |view, _| view.marks.is_empty()),
|
||||||
"the pin is gone"
|
"the pin is gone"
|
||||||
);
|
);
|
||||||
|
press("escape", cx);
|
||||||
|
let escapes = view.read_with(cx, |view, _| view.escapes);
|
||||||
|
assert_eq!(escapes, 1, "with nothing chosen, Escape passes to the page");
|
||||||
press("cmd-z", cx);
|
press("cmd-z", cx);
|
||||||
assert_eq!(view.read_with(cx, |view, _| view.marks.clone()), [pin]);
|
assert_eq!(view.read_with(cx, |view, _| view.marks.clone()), [pin]);
|
||||||
}
|
}
|
||||||
+13
-4
@@ -17,6 +17,13 @@ use crate::{
|
|||||||
|
|
||||||
type OnPath = Rc<dyn Fn(PathBuf, &mut Window, &mut App)>;
|
type OnPath = Rc<dyn Fn(PathBuf, &mut Window, &mut App)>;
|
||||||
|
|
||||||
|
/// What the upload has said about a file: its fitted crop to the host, its failure to the log.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Told {
|
||||||
|
fitted: bool,
|
||||||
|
failed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// 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.
|
/// 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)]
|
#[derive(IntoElement)]
|
||||||
pub struct ImageUpload {
|
pub struct ImageUpload {
|
||||||
@@ -80,10 +87,10 @@ impl RenderOnce for ImageUpload {
|
|||||||
};
|
};
|
||||||
let decoded =
|
let decoded =
|
||||||
window.use_asset::<ImageAssetLoader>(&Resource::Path(path.clone().into()), cx);
|
window.use_asset::<ImageAssetLoader>(&Resource::Path(path.clone().into()), cx);
|
||||||
let failed = window.use_keyed_state(
|
let told = window.use_keyed_state(
|
||||||
(self.id.clone(), path.to_string_lossy().into_owned()),
|
(self.id.clone(), path.to_string_lossy().into_owned()),
|
||||||
cx,
|
cx,
|
||||||
|_, _| false,
|
|_, _| Told::default(),
|
||||||
);
|
);
|
||||||
let name = path
|
let name = path
|
||||||
.file_name()
|
.file_name()
|
||||||
@@ -102,15 +109,17 @@ impl RenderOnce for ImageUpload {
|
|||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
if let (Some((_, fitted)), None, Some(on_crop)) = (opened, self.crop, self.on_crop.clone())
|
if let (Some((_, fitted)), None, Some(on_crop)) = (opened, self.crop, self.on_crop.clone())
|
||||||
|
&& !told.read(cx).fitted
|
||||||
{
|
{
|
||||||
log::info!("image upload: {name} opened, crop fitted to {fitted:?}");
|
log::info!("image upload: {name} opened, crop fitted to {fitted:?}");
|
||||||
|
told.update(cx, |told, _| told.fitted = true);
|
||||||
window.defer(cx, move |window, cx| on_crop(fitted, window, cx));
|
window.defer(cx, move |window, cx| on_crop(fitted, window, cx));
|
||||||
}
|
}
|
||||||
if let Some(Err(error)) = &decoded
|
if let Some(Err(error)) = &decoded
|
||||||
&& !*failed.read(cx)
|
&& !told.read(cx).failed
|
||||||
{
|
{
|
||||||
log::error!("image upload: {name} can't be opened: {error}");
|
log::error!("image upload: {name} can't be opened: {error}");
|
||||||
failed.update(cx, |failed, _| *failed = true);
|
told.update(cx, |told, _| told.failed = true);
|
||||||
}
|
}
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
let colors = theme.colors.clone();
|
let colors = theme.colors.clone();
|
||||||
|
|||||||
Reference in new issue
Block a user