diff --git a/apps/desktop-gpui/src/screenshot_annotations.rs b/apps/desktop-gpui/src/screenshot_annotations.rs index e74e8530787..f63ff4b562d 100644 --- a/apps/desktop-gpui/src/screenshot_annotations.rs +++ b/apps/desktop-gpui/src/screenshot_annotations.rs @@ -545,6 +545,115 @@ pub fn normalized_rect(annotation: &Annotation) -> Rect { } } +pub fn rect_from_points(a: (f64, f64), b: (f64, f64)) -> Rect { + Rect { + x: a.0.min(b.0), + y: a.1.min(b.1), + width: (a.0 - b.0).abs(), + height: (a.1 - b.1).abs(), + } +} + +pub fn crop_from_selection_rect( + selection: Rect, + image_rect: Rect, + image_size: (u32, u32), + existing_crop: Option<&Crop>, +) -> Option { + if image_rect.width <= 0. + || image_rect.height <= 0. + || image_size.0 == 0 + || image_size.1 == 0 + { + return None; + } + let (right_edge, bottom_edge) = ( + image_rect.x + image_rect.width, + image_rect.y + image_rect.height, + ); + let raw_left = selection.x.min(selection.x + selection.width); + let raw_right = selection.x.max(selection.x + selection.width); + let raw_top = selection.y.min(selection.y + selection.height); + let raw_bottom = selection.y.max(selection.y + selection.height); + let left = clamp_value(raw_left, image_rect.x, right_edge); + let right = clamp_value(raw_right, image_rect.x, right_edge); + let top = clamp_value(raw_top, image_rect.y, bottom_edge); + let bottom = clamp_value(raw_bottom, image_rect.y, bottom_edge); + let sel_width = (right - left).max(0.); + let sel_height = (bottom - top).max(0.); + if sel_width < MIN_SIZE || sel_height < MIN_SIZE { + return None; + } + + let (src_x, src_y, src_w, src_h) = match existing_crop { + Some(crop) => ( + f64::from(crop.position.x), + f64::from(crop.position.y), + f64::from(crop.size.x.max(1)), + f64::from(crop.size.y.max(1)), + ), + None => (0., 0., f64::from(image_size.0), f64::from(image_size.1)), + }; + + let norm_x = (left - image_rect.x) / image_rect.width; + let norm_y = (top - image_rect.y) / image_rect.height; + let norm_w = sel_width / image_rect.width; + let norm_h = sel_height / image_rect.height; + + let max_w = image_size.0; + let max_h = image_size.1; + let x = ((src_x + norm_x * src_w).round().max(0.) as u32).min(max_w.saturating_sub(1)); + let y = ((src_y + norm_y * src_h).round().max(0.) as u32).min(max_h.saturating_sub(1)); + let width = ((norm_w * src_w).round().max(1.) as u32).min(max_w.saturating_sub(x)); + let height = ((norm_h * src_h).round().max(1.) as u32).min(max_h.saturating_sub(y)); + if width == 0 || height == 0 { + return None; + } + Some(Crop { + position: cap_project::XY::new(x, y), + size: cap_project::XY::new(width, height), + }) +} + +pub fn selection_rect_from_crop( + crop: &Crop, + image_rect: Rect, + image_size: (u32, u32), + existing_crop: Option<&Crop>, +) -> Option { + if image_rect.width <= 0. + || image_rect.height <= 0. + || image_size.0 == 0 + || image_size.1 == 0 + || crop.size.x == 0 + || crop.size.y == 0 + { + return None; + } + let (src_x, src_y, src_w, src_h) = match existing_crop { + Some(existing) => ( + f64::from(existing.position.x), + f64::from(existing.position.y), + f64::from(existing.size.x.max(1)), + f64::from(existing.size.y.max(1)), + ), + None => (0., 0., f64::from(image_size.0), f64::from(image_size.1)), + }; + let left = f64::from(crop.position.x).clamp(src_x, src_x + src_w); + let top = f64::from(crop.position.y).clamp(src_y, src_y + src_h); + let right = f64::from(crop.position.x.saturating_add(crop.size.x)).clamp(src_x, src_x + src_w); + let bottom = f64::from(crop.position.y.saturating_add(crop.size.y)).clamp(src_y, src_y + src_h); + if right <= left || bottom <= top { + return None; + } + Some(Rect { + x: image_rect.x + ((left - src_x) / src_w) * image_rect.width, + y: image_rect.y + ((top - src_y) / src_h) * image_rect.height, + width: ((right - left) / src_w) * image_rect.width, + height: ((bottom - top) / src_h) * image_rect.height, + }) +} + /// The eight (or four, or two) grips `SelectionHandles` draws (`:935-1039`). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Handle { @@ -1048,6 +1157,10 @@ enum Gesture { Move(Dragging), /// `dragState.action === "resize"`. Resize(Dragging), + Crop { + start: (f64, f64), + current: (f64, f64), + }, } struct Creating { @@ -1200,6 +1313,7 @@ pub struct AnnotationState { /// which is what maps a pointer into frame space. layer: BoundsCell, gesture: Option, + pub(crate) crop_selection: Option, /// The annotation the pointer is over, for the text hover wash. hover: Option, hover_cursor: Option, @@ -1229,6 +1343,8 @@ struct LayerPaint { annotations: Vec, temp: Option, masks: Vec, + crop_selection: Option, + image_rect: Rect, selected: Option, editing: Option, hover: Option, @@ -1543,6 +1659,7 @@ impl ScreenshotEditorWindow { { let id = annotation.id.clone(); let original = annotation.clone(); + self.annotation_state.crop_selection = None; self.annotation_state.gesture = Some(Gesture::Resize(Dragging { id, handle: Some(handle), @@ -1558,6 +1675,7 @@ impl ScreenshotEditorWindow { } let annotation = annotation.clone(); + self.annotation_state.crop_selection = None; self.selected_annotation = Some(annotation.id.clone()); if click_count >= 2 && annotation.annotation_type == AnnotationType::Text { self.begin_text_edit(annotation.id, window, cx); @@ -1583,6 +1701,26 @@ impl ScreenshotEditorWindow { if self.selected_annotation.take().is_some() { cx.notify(); } + let rect = self.annotation_image_rect(); + if self.image_size.is_some() + && rect.width > 0. + && rect.height > 0. + && point.0 >= rect.x + && point.0 <= rect.x + rect.width + && point.1 >= rect.y + && point.1 <= rect.y + rect.height + { + self.annotation_state.crop_selection = None; + self.annotation_state.gesture = Some(Gesture::Crop { + start: point, + current: point, + }); + cx.notify(); + return true; + } + if self.annotation_state.crop_selection.take().is_some() { + cx.notify(); + } false } @@ -1694,6 +1832,26 @@ impl ScreenshotEditorWindow { self.drag_resize(&dragging, point, shift); Gesture::Resize(dragging) } + Gesture::Crop { start, .. } => { + let rect = self.annotation_image_rect(); + let clamped = ( + clamp_value(point.0, rect.x, rect.x + rect.width), + clamp_value(point.1, rect.y, rect.y + rect.height), + ); + let selection = rect_from_points(start, clamped); + self.annotation_state.crop_selection = self.image_size.and_then(|image_size| { + crop_from_selection_rect( + selection, + rect, + image_size, + self.project.background.crop.as_ref(), + ) + }); + Gesture::Crop { + start, + current: clamped, + } + } }; self.annotation_state.gesture = Some(gesture); self.refresh_mask_overlays(window, cx); @@ -1932,7 +2090,78 @@ impl ScreenshotEditorWindow { Gesture::Move(dragging) | Gesture::Resize(dragging) => { self.finish_drag(dragging, window, cx) } + Gesture::Crop { start, current } => { + self.annotation_state.crop_selection = if edit_text { + let selection = rect_from_points(start, current); + self.image_size.and_then(|image_size| { + crop_from_selection_rect( + selection, + self.annotation_image_rect(), + image_size, + self.project.background.crop.as_ref(), + ) + }) + } else { + None + }; + cx.notify(); + } + } + true + } + + pub(crate) fn cancel_crop_gesture(&mut self) -> bool { + let had_gesture = matches!(self.annotation_state.gesture, Some(Gesture::Crop { .. })); + if had_gesture { + self.annotation_state.gesture = None; + } + had_gesture + } + + pub(crate) fn clear_crop_selection(&mut self, cx: &mut Context) -> bool { + let had_gesture = self.cancel_crop_gesture(); + let had_selection = self.annotation_state.crop_selection.take().is_some(); + if had_gesture || had_selection { + cx.notify(); + return true; } + false + } + + pub(crate) fn take_pending_crop_bounds(&mut self) -> Option { + self.cancel_crop_gesture(); + self.annotation_state.crop_selection.take() + } + + pub(crate) fn apply_crop_selection( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> bool { + if matches!(self.annotation_state.gesture, Some(Gesture::Crop { .. })) { + self.finish_annotation_gesture(true, window, cx); + } + let Some(crop) = self.take_pending_crop_bounds() else { + return false; + }; + self.edit_project( + move |project| { + if project + .background + .crop + .as_ref() + .map(|c| (c.position, c.size)) + == Some((crop.position, crop.size)) + { + return false; + } + project.background.crop = Some(crop); + true + }, + window, + cx, + ); + window.refresh(); true } @@ -2398,7 +2627,7 @@ impl ScreenshotEditorWindow { pub(crate) fn render_annotation_layer( &self, content: (f32, f32), - _rect: Rect, + rect: Rect, cx: &mut Context, ) -> Option { if self.frame_size.0 <= 0. || content.0 <= 0. { @@ -2418,6 +2647,20 @@ impl ScreenshotEditorWindow { .iter() .filter_map(|entry| entry.value.clone()) .collect(), + crop_selection: self + .annotation_state + .crop_selection + .as_ref() + .zip(self.image_size) + .and_then(|(crop, image_size)| { + selection_rect_from_crop( + crop, + rect, + image_size, + self.project.background.crop.as_ref(), + ) + }), + image_rect: rect, selected: self.selected_annotation.clone(), editing: self .annotation_state @@ -3680,6 +3923,67 @@ fn paint_layer(paint: &LayerPaint, bounds: Bounds, window: &mut Window, paint_annotation(temp, origin, scale, window, cx); } } + + if let Some(crop_rect) = paint.crop_selection { + paint_crop_selection(crop_rect, paint.image_rect, origin, scale, window); + } +} + +fn paint_crop_selection( + crop_rect: Rect, + image_rect: Rect, + origin: Point, + scale: f32, + window: &mut Window, +) { + let dim = gpui::hsla(0., 0., 0., 0.45); + let img_right = image_rect.x + image_rect.width; + let img_bottom = image_rect.y + image_rect.height; + let crop_right = crop_rect.x + crop_rect.width; + let crop_bottom = crop_rect.y + crop_rect.height; + + let strips = [ + Rect { + x: image_rect.x, + y: image_rect.y, + width: image_rect.width, + height: (crop_rect.y - image_rect.y).max(0.), + }, + Rect { + x: image_rect.x, + y: crop_bottom, + width: image_rect.width, + height: (img_bottom - crop_bottom).max(0.), + }, + Rect { + x: image_rect.x, + y: crop_rect.y, + width: (crop_rect.x - image_rect.x).max(0.), + height: crop_rect.height, + }, + Rect { + x: crop_right, + y: crop_rect.y, + width: (img_right - crop_right).max(0.), + height: crop_rect.height, + }, + ]; + + for strip in strips { + if strip.width > 0. && strip.height > 0. { + let mut fill = PathBuilder::fill(); + push_rect(&mut fill, origin, scale, strip); + paint_fill(fill, dim, window); + } + } + + let mut stroke = PathBuilder::stroke(px(2.)); + push_rect(&mut stroke, origin, scale, crop_rect); + paint_fill( + stroke, + Hsla::from(gpui::rgb(SELECTION_BLUE)), + window, + ); } fn screen_rect(origin: Point, scale: f32, rect: Rect) -> Bounds { @@ -4708,6 +5012,71 @@ mod tests { } } + #[test] + fn crop_from_selection_rect_maps_frame_selection_into_source_image_bounds() { + let image_rect = Rect { + x: 100., + y: 50., + width: 500., + height: 250., + }; + let selection = rect_from_points((350., 300.), (100., 50.)); + let crop = crop_from_selection_rect(selection, image_rect, (2000, 1000), None) + .expect("valid crop"); + assert_eq!(crop.position.x, 0); + assert_eq!(crop.position.y, 0); + assert_eq!(crop.size.x, 1000); + assert_eq!(crop.size.y, 1000); + + let nested = crop_from_selection_rect( + Rect { + x: 350., + y: 50., + width: 250., + height: 250., + }, + image_rect, + (2000, 1000), + Some(&Crop { + position: cap_project::XY::new(200, 100), + size: cap_project::XY::new(1000, 500), + }), + ) + .expect("nested crop"); + assert_eq!(nested.position.x, 700); + assert_eq!(nested.position.y, 100); + assert_eq!(nested.size.x, 500); + assert_eq!(nested.size.y, 500); + + assert!( + crop_from_selection_rect( + Rect { + x: 110., + y: 60., + width: 3., + height: 3., + }, + image_rect, + (2000, 1000), + None, + ) + .is_none() + ); + + let moved_image_rect = Rect { + x: 200., + y: 120., + width: 800., + height: 400., + }; + let projected = + selection_rect_from_crop(&crop, moved_image_rect, (2000, 1000), None).expect("projected"); + assert_eq!(projected.x, 200.); + assert_eq!(projected.y, 120.); + assert_eq!(projected.width, 400.); + assert_eq!(projected.height, 400.); + } + #[test] fn duplicate_mask_ids_keep_both_regions_when_another_mask_is_deleted() { let frame = MaskFrame { diff --git a/apps/desktop-gpui/src/screenshot_crop.rs b/apps/desktop-gpui/src/screenshot_crop.rs index 90b9ba6db17..abb111dbc81 100644 --- a/apps/desktop-gpui/src/screenshot_crop.rs +++ b/apps/desktop-gpui/src/screenshot_crop.rs @@ -111,7 +111,10 @@ impl ScreenshotEditorWindow { return; }; - let initial = match &self.project.background.crop { + let initial = match self + .take_pending_crop_bounds() + .or_else(|| self.project.background.crop.clone()) + { Some(crop) => CropBounds::new( f64::from(crop.position.x), f64::from(crop.position.y), @@ -493,6 +496,10 @@ impl ScreenshotEditorWindow { self.cancel_crop_dialog("escape", window, cx); return true; } + if key == "enter" { + self.save_crop_dialog(window, cx); + return true; + } let Some(nudge) = engine::is_nudge_key(key) else { return false; }; diff --git a/apps/desktop-gpui/src/screenshot_editor.rs b/apps/desktop-gpui/src/screenshot_editor.rs index e0e92edc381..9b81274cf4b 100644 --- a/apps/desktop-gpui/src/screenshot_editor.rs +++ b/apps/desktop-gpui/src/screenshot_editor.rs @@ -1212,9 +1212,26 @@ impl ScreenshotEditorWindow { window: &mut Window, cx: &mut Context, ) { + let prev_crop = self + .project + .background + .crop + .as_ref() + .map(|c| (c.position, c.size)); if !change(&mut self.project) { return; } + self.cancel_crop_gesture(); + if self + .project + .background + .crop + .as_ref() + .map(|c| (c.position, c.size)) + != prev_crop + { + self.clear_crop_selection(cx); + } // Padding, crop and aspect all move the content rect underneath the // masks; the clamp rides along in the same history entry. self.clamp_masks(); @@ -1280,6 +1297,7 @@ impl ScreenshotEditorWindow { ) { self.project = config; self.bg_tab = BgTab::for_source(&self.project.background.source); + self.clear_crop_selection(cx); if let Some(id) = &self.selected_annotation && !self .project @@ -2606,6 +2624,7 @@ impl ScreenshotEditorWindow { fn set_tool(&mut self, tool: Tool, cx: &mut Context) { self.tool = tool; + self.clear_crop_selection(cx); if tool != Tool::Select { self.selected_annotation = None; } @@ -2740,10 +2759,18 @@ impl ScreenshotEditorWindow { cx.notify(); return; } + if self.clear_crop_selection(cx) { + return; + } self.set_tool(Tool::Select, cx); self.selected_annotation = None; cx.notify(); } + "enter" => { + if self.apply_crop_selection(window, cx) { + cx.stop_propagation(); + } + } "p" => self.toggle_popover(Popover::Padding, window, cx), "b" => self.toggle_popover(Popover::Background, window, cx), "h" => self.toggle_popover(Popover::Shadow, window, cx), diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index d7eb3c5772a..89462689ed2 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -216,7 +216,7 @@ pub enum CornerStyle { Rounded, } -#[derive(Type, Serialize, Deserialize, Clone, Debug, Default)] +#[derive(Type, Serialize, Deserialize, Clone, Debug, Default, PartialEq)] #[serde(rename_all = "camelCase")] pub struct Crop { pub position: XY,