From 7e5b87844622dd28f358d9f18f663adee38debfd Mon Sep 17 00:00:00 2001 From: cocvn157 <120394358+cocvn157@users.noreply.github.com> Date: Fri, 6 Nov 2026 22:10:45 +0700 Subject: [PATCH 1/4] feat(desktop-gpui): allow dragging on screenshot preview to crop and Enter to apply --- .../src/screenshot_annotations.rs | 302 +++++++++++++++++- apps/desktop-gpui/src/screenshot_crop.rs | 9 +- apps/desktop-gpui/src/screenshot_editor.rs | 10 + 3 files changed, 317 insertions(+), 4 deletions(-) diff --git a/apps/desktop-gpui/src/screenshot_annotations.rs b/apps/desktop-gpui/src/screenshot_annotations.rs index e74e8530787..083c0c5ba14 100644 --- a/apps/desktop-gpui/src/screenshot_annotations.rs +++ b/apps/desktop-gpui/src/screenshot_annotations.rs @@ -545,6 +545,79 @@ pub fn normalized_rect(annotation: &Annotation) -> Rect { } } +/// Normalize two frame-space points into a non-negative `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(), + } +} + +/// Convert a frame-space selection rect inside `image_rect` into a source-image +/// [`Crop`], accounting for any crop already active on the project. +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), + }) +} + /// The eight (or four, or two) grips `SelectionHandles` draws (`:935-1039`). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Handle { @@ -1048,6 +1121,11 @@ enum Gesture { Move(Dragging), /// `dragState.action === "resize"`. Resize(Dragging), + /// Dragging on empty screenshot space in Select mode to define a crop rect. + Crop { + start: (f64, f64), + current: (f64, f64), + }, } struct Creating { @@ -1200,6 +1278,8 @@ pub struct AnnotationState { /// which is what maps a pointer into frame space. layer: BoundsCell, gesture: Option, + /// Pending crop selection in frame space created by dragging on empty image space. + pub(crate) crop_selection: Option, /// The annotation the pointer is over, for the text hover wash. hover: Option, hover_cursor: Option, @@ -1229,6 +1309,8 @@ struct LayerPaint { annotations: Vec, temp: Option, masks: Vec, + crop_selection: Option, + image_rect: Rect, selected: Option, editing: Option, hover: Option, @@ -1543,6 +1625,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 +1641,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); @@ -1578,11 +1662,32 @@ impl ScreenshotEditorWindow { } // `e.target === e.currentTarget` (`:168-173`): a press on bare canvas - // drops the selection. The `false` then lets the caller start the pan, - // which is `onBackgroundMouseDown` over there. + // drops the selection. Dragging on empty screenshot space starts an + // inline crop selection (`#3636`); pressing outside the image rect + // falls through to background pan. 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 +1799,21 @@ 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 = + (selection.width >= MIN_SIZE && selection.height >= MIN_SIZE) + .then_some(selection); + Gesture::Crop { + start, + current: clamped, + } + } }; self.annotation_state.gesture = Some(gesture); self.refresh_mask_overlays(window, cx); @@ -1932,7 +2052,68 @@ impl ScreenshotEditorWindow { Gesture::Move(dragging) | Gesture::Resize(dragging) => { self.finish_drag(dragging, window, cx) } + Gesture::Crop { start, current } => { + if edit_text { + let selection = rect_from_points(start, current); + self.annotation_state.crop_selection = + (selection.width >= MIN_SIZE && selection.height >= MIN_SIZE) + .then_some(selection); + } else { + self.annotation_state.crop_selection = None; + } + cx.notify(); + } + } + true + } + + pub(crate) fn clear_crop_selection(&mut self, cx: &mut Context) -> bool { + let had_gesture = matches!(self.annotation_state.gesture, Some(Gesture::Crop { .. })); + if had_gesture { + self.annotation_state.gesture = None; + } + 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 { + let selection = self.annotation_state.crop_selection.take()?; + let image_size = self.image_size?; + crop_from_selection_rect( + selection, + self.annotation_image_rect(), + image_size, + self.project.background.crop.as_ref(), + ) + } + + 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() == Some(&crop) { + return false; + } + project.background.crop = Some(crop); + true + }, + window, + cx, + ); + window.refresh(); true } @@ -2398,7 +2579,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 +2599,8 @@ impl ScreenshotEditorWindow { .iter() .filter_map(|entry| entry.value.clone()) .collect(), + crop_selection: self.annotation_state.crop_selection, + image_rect: rect, selected: self.selected_annotation.clone(), editing: self .annotation_state @@ -3680,6 +3863,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 +4952,58 @@ 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() + ); + } + #[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..cfec9bb59ce 100644 --- a/apps/desktop-gpui/src/screenshot_editor.rs +++ b/apps/desktop-gpui/src/screenshot_editor.rs @@ -1280,6 +1280,7 @@ impl ScreenshotEditorWindow { ) { self.project = config; self.bg_tab = BgTab::for_source(&self.project.background.source); + self.annotation_state.crop_selection = None; if let Some(id) = &self.selected_annotation && !self .project @@ -2606,6 +2607,7 @@ impl ScreenshotEditorWindow { fn set_tool(&mut self, tool: Tool, cx: &mut Context) { self.tool = tool; + self.annotation_state.crop_selection = None; if tool != Tool::Select { self.selected_annotation = None; } @@ -2740,10 +2742,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), From 6ff119ff7fe5db9ab0887c00f39350b53b4de8b1 Mon Sep 17 00:00:00 2001 From: cocvn157 <120394358+cocvn157@users.noreply.github.com> Date: Sat, 7 Nov 2026 07:12:40 +0700 Subject: [PATCH 2/4] fix(desktop-gpui): store pending screenshot crop in source-image bounds and cancel crop gesture on tool change --- .../src/screenshot_annotations.rs | 118 ++++++++++++++---- apps/desktop-gpui/src/screenshot_editor.rs | 11 +- 2 files changed, 100 insertions(+), 29 deletions(-) diff --git a/apps/desktop-gpui/src/screenshot_annotations.rs b/apps/desktop-gpui/src/screenshot_annotations.rs index 083c0c5ba14..541cc5e47a4 100644 --- a/apps/desktop-gpui/src/screenshot_annotations.rs +++ b/apps/desktop-gpui/src/screenshot_annotations.rs @@ -545,7 +545,6 @@ pub fn normalized_rect(annotation: &Annotation) -> Rect { } } -/// Normalize two frame-space points into a non-negative `Rect`. pub fn rect_from_points(a: (f64, f64), b: (f64, f64)) -> Rect { Rect { x: a.0.min(b.0), @@ -555,8 +554,6 @@ pub fn rect_from_points(a: (f64, f64), b: (f64, f64)) -> Rect { } } -/// Convert a frame-space selection rect inside `image_rect` into a source-image -/// [`Crop`], accounting for any crop already active on the project. pub fn crop_from_selection_rect( selection: Rect, image_rect: Rect, @@ -618,6 +615,45 @@ pub fn crop_from_selection_rect( }) } +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 { @@ -1121,7 +1157,6 @@ enum Gesture { Move(Dragging), /// `dragState.action === "resize"`. Resize(Dragging), - /// Dragging on empty screenshot space in Select mode to define a crop rect. Crop { start: (f64, f64), current: (f64, f64), @@ -1278,8 +1313,7 @@ pub struct AnnotationState { /// which is what maps a pointer into frame space. layer: BoundsCell, gesture: Option, - /// Pending crop selection in frame space created by dragging on empty image space. - pub(crate) crop_selection: Option, + pub(crate) crop_selection: Option, /// The annotation the pointer is over, for the text hover wash. hover: Option, hover_cursor: Option, @@ -1662,9 +1696,8 @@ impl ScreenshotEditorWindow { } // `e.target === e.currentTarget` (`:168-173`): a press on bare canvas - // drops the selection. Dragging on empty screenshot space starts an - // inline crop selection (`#3636`); pressing outside the image rect - // falls through to background pan. + // drops the selection. The `false` then lets the caller start the pan, + // which is `onBackgroundMouseDown` over there. if self.selected_annotation.take().is_some() { cx.notify(); } @@ -1806,9 +1839,14 @@ impl ScreenshotEditorWindow { clamp_value(point.1, rect.y, rect.y + rect.height), ); let selection = rect_from_points(start, clamped); - self.annotation_state.crop_selection = - (selection.width >= MIN_SIZE && selection.height >= MIN_SIZE) - .then_some(selection); + 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, @@ -2053,14 +2091,19 @@ impl ScreenshotEditorWindow { self.finish_drag(dragging, window, cx) } Gesture::Crop { start, current } => { - if edit_text { + self.annotation_state.crop_selection = if edit_text { let selection = rect_from_points(start, current); - self.annotation_state.crop_selection = - (selection.width >= MIN_SIZE && selection.height >= MIN_SIZE) - .then_some(selection); + 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 { - self.annotation_state.crop_selection = None; - } + None + }; cx.notify(); } } @@ -2081,14 +2124,10 @@ impl ScreenshotEditorWindow { } pub(crate) fn take_pending_crop_bounds(&mut self) -> Option { - let selection = self.annotation_state.crop_selection.take()?; - let image_size = self.image_size?; - crop_from_selection_rect( - selection, - self.annotation_image_rect(), - image_size, - self.project.background.crop.as_ref(), - ) + if matches!(self.annotation_state.gesture, Some(Gesture::Crop { .. })) { + self.annotation_state.gesture = None; + } + self.annotation_state.crop_selection.take() } pub(crate) fn apply_crop_selection( @@ -2599,7 +2638,19 @@ impl ScreenshotEditorWindow { .iter() .filter_map(|entry| entry.value.clone()) .collect(), - crop_selection: self.annotation_state.crop_selection, + 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 @@ -5002,6 +5053,19 @@ mod tests { ) .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] diff --git a/apps/desktop-gpui/src/screenshot_editor.rs b/apps/desktop-gpui/src/screenshot_editor.rs index cfec9bb59ce..851bef31956 100644 --- a/apps/desktop-gpui/src/screenshot_editor.rs +++ b/apps/desktop-gpui/src/screenshot_editor.rs @@ -1212,9 +1212,16 @@ impl ScreenshotEditorWindow { window: &mut Window, cx: &mut Context, ) { + let prev_crop = self.project.background.crop.clone(); if !change(&mut self.project) { return; } + if matches!(self.annotation_state.gesture, Some(Gesture::Crop { .. })) { + self.annotation_state.gesture = None; + } + if self.project.background.crop != 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,7 +1287,7 @@ impl ScreenshotEditorWindow { ) { self.project = config; self.bg_tab = BgTab::for_source(&self.project.background.source); - self.annotation_state.crop_selection = None; + self.clear_crop_selection(cx); if let Some(id) = &self.selected_annotation && !self .project @@ -2607,7 +2614,7 @@ impl ScreenshotEditorWindow { fn set_tool(&mut self, tool: Tool, cx: &mut Context) { self.tool = tool; - self.annotation_state.crop_selection = None; + self.clear_crop_selection(cx); if tool != Tool::Select { self.selected_annotation = None; } From a0fa687141bb47f8fc80f0d9b9877911989711ea Mon Sep 17 00:00:00 2001 From: cocvn157 <120394358+cocvn157@users.noreply.github.com> Date: Sun, 8 Nov 2026 08:27:55 +0700 Subject: [PATCH 3/4] fix(desktop-gpui): move crop gesture cancellation into screenshot_annotations helper --- apps/desktop-gpui/src/screenshot_annotations.rs | 11 +++++++---- apps/desktop-gpui/src/screenshot_editor.rs | 4 +--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/desktop-gpui/src/screenshot_annotations.rs b/apps/desktop-gpui/src/screenshot_annotations.rs index 541cc5e47a4..c70347ef359 100644 --- a/apps/desktop-gpui/src/screenshot_annotations.rs +++ b/apps/desktop-gpui/src/screenshot_annotations.rs @@ -2110,11 +2110,16 @@ impl ScreenshotEditorWindow { true } - pub(crate) fn clear_crop_selection(&mut self, cx: &mut Context) -> bool { + 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(); @@ -2124,9 +2129,7 @@ impl ScreenshotEditorWindow { } pub(crate) fn take_pending_crop_bounds(&mut self) -> Option { - if matches!(self.annotation_state.gesture, Some(Gesture::Crop { .. })) { - self.annotation_state.gesture = None; - } + self.cancel_crop_gesture(); self.annotation_state.crop_selection.take() } diff --git a/apps/desktop-gpui/src/screenshot_editor.rs b/apps/desktop-gpui/src/screenshot_editor.rs index 851bef31956..facd266f37f 100644 --- a/apps/desktop-gpui/src/screenshot_editor.rs +++ b/apps/desktop-gpui/src/screenshot_editor.rs @@ -1216,9 +1216,7 @@ impl ScreenshotEditorWindow { if !change(&mut self.project) { return; } - if matches!(self.annotation_state.gesture, Some(Gesture::Crop { .. })) { - self.annotation_state.gesture = None; - } + self.cancel_crop_gesture(); if self.project.background.crop != prev_crop { self.clear_crop_selection(cx); } From 12a46ee757152f4efbf69c77ef4b2a3e8cec41fb Mon Sep 17 00:00:00 2001 From: cocvn157 <120394358+cocvn157@users.noreply.github.com> Date: Sun, 8 Nov 2026 08:39:40 +0700 Subject: [PATCH 4/4] fix(desktop-gpui): compare crop position and size tuples and derive PartialEq on Crop --- apps/desktop-gpui/src/screenshot_annotations.rs | 8 +++++++- apps/desktop-gpui/src/screenshot_editor.rs | 16 ++++++++++++++-- crates/project/src/configuration.rs | 2 +- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/desktop-gpui/src/screenshot_annotations.rs b/apps/desktop-gpui/src/screenshot_annotations.rs index c70347ef359..f63ff4b562d 100644 --- a/apps/desktop-gpui/src/screenshot_annotations.rs +++ b/apps/desktop-gpui/src/screenshot_annotations.rs @@ -2146,7 +2146,13 @@ impl ScreenshotEditorWindow { }; self.edit_project( move |project| { - if project.background.crop.as_ref() == Some(&crop) { + if project + .background + .crop + .as_ref() + .map(|c| (c.position, c.size)) + == Some((crop.position, crop.size)) + { return false; } project.background.crop = Some(crop); diff --git a/apps/desktop-gpui/src/screenshot_editor.rs b/apps/desktop-gpui/src/screenshot_editor.rs index facd266f37f..9b81274cf4b 100644 --- a/apps/desktop-gpui/src/screenshot_editor.rs +++ b/apps/desktop-gpui/src/screenshot_editor.rs @@ -1212,12 +1212,24 @@ impl ScreenshotEditorWindow { window: &mut Window, cx: &mut Context, ) { - let prev_crop = self.project.background.crop.clone(); + 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 != prev_crop { + 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 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,