From fc3d4298d24a64b02c3a3659588631d84de0df9a Mon Sep 17 00:00:00 2001 From: Pol Rivero <65060696+pol-rivero@users.noreply.github.com> Date: Sat, 28 Dec 2024 20:04:48 +0100 Subject: [PATCH 1/5] Refactor crop code and improve UX --- src/femtovg_area/imp.rs | 2 +- src/tools/crop.rs | 153 ++++++++++++++++++++++------------------ 2 files changed, 84 insertions(+), 71 deletions(-) diff --git a/src/femtovg_area/imp.rs b/src/femtovg_area/imp.rs index 7ca45d6..1bc0303 100644 --- a/src/femtovg_area/imp.rs +++ b/src/femtovg_area/imp.rs @@ -283,7 +283,7 @@ impl FemtoVgAreaMut { .crop_tool .borrow() .get_crop() - .and_then(|c| c.get_rectangle()) + .map(|c| c.get_rectangle()) .unwrap_or(( Vec2D::zero(), Vec2D::new( diff --git a/src/tools/crop.rs b/src/tools/crop.rs index 2ea192b..043ab2e 100644 --- a/src/tools/crop.rs +++ b/src/tools/crop.rs @@ -13,7 +13,7 @@ use super::{Drawable, Tool, ToolUpdateResult}; #[derive(Debug, Clone)] pub struct Crop { pos: Vec2D, - size: Option, + size: Vec2D, active: bool, } @@ -27,6 +27,10 @@ impl Crop { const HANDLE_RADIUS: f32 = 5.0; const HANDLE_BORDER: f32 = 2.0; + fn new(pos: Vec2D) -> Self { + Self { pos, size: Vec2D::zero(), active: true } + } + fn draw_single_handle( canvas: &mut femtovg::Canvas, center: Vec2D, @@ -50,9 +54,43 @@ impl Crop { canvas.stroke_path(&path, &border_paint); } - pub fn get_rectangle(&self) -> Option<(Vec2D, Vec2D)> { - self.size - .map(|size| math::rect_ensure_positive_size(self.pos, size)) + pub fn get_rectangle(&self) -> (Vec2D, Vec2D) { + math::rect_ensure_positive_size(self.pos, self.size) + } + + fn get_handle_pos(crop_pos: Vec2D, crop_size: Vec2D, handle: CropHandle) -> Vec2D { + match handle { + CropHandle::TopLeftCorner => crop_pos, + CropHandle::TopEdge => crop_pos + Vec2D::new(crop_size.x / 2.0, 0.0), + CropHandle::TopRightCorner => crop_pos + Vec2D::new(crop_size.x, 0.0), + CropHandle::RightEdge => crop_pos + Vec2D::new(crop_size.x, crop_size.y / 2.0), + CropHandle::BottomRightCorner => crop_pos + Vec2D::new(crop_size.x, crop_size.y), + CropHandle::BottomEdge => crop_pos + Vec2D::new(crop_size.x / 2.0, crop_size.y), + CropHandle::BottomLeftCorner => crop_pos + Vec2D::new(0.0, crop_size.y), + CropHandle::LeftEdge => crop_pos + Vec2D::new(0.0, crop_size.y / 2.0), + } + } + fn get_closest_handle(&self, mouse_pos: Vec2D) -> (CropHandle, f32) { + let mut min_distance_squared = f32::MAX; + let mut closest_handle = CropHandle::TopLeftCorner; + for h in CropHandle::all() { + let handle_pos = Self::get_handle_pos(self.pos, self.size, h); + let distance_squared = (handle_pos - mouse_pos).norm2(); + if distance_squared < min_distance_squared { + min_distance_squared = distance_squared; + closest_handle = h; + } + } + (closest_handle, min_distance_squared) + } + fn test_handle_hit(&self, mouse_pos: Vec2D, margin2: f32) -> Option { + const HANDLE_SIZE: f32 = Crop::HANDLE_RADIUS + Crop::HANDLE_BORDER; + const HANDLE_SIZE2: f32 = HANDLE_SIZE * HANDLE_SIZE; + let allowed_distance2 = HANDLE_SIZE2 + margin2; + + let (handle, distance2) = self.get_closest_handle(mouse_pos); + if distance2 < allowed_distance2 { Some(handle) } + else { None } } } @@ -62,11 +100,7 @@ impl Drawable for Crop { canvas: &mut femtovg::Canvas, _font: femtovg::FontId, ) -> Result<()> { - let size = match self.size { - Some(s) => s, - None => return Ok(()), // early exit if none - }; - + let size = self.size; let scale = canvas.transform().average_scale(); let dimensions = Vec2D::new( canvas.width() as f32 / scale, @@ -156,55 +190,28 @@ impl CropHandle { } impl CropTool { - fn get_handle_pos(crop_pos: Vec2D, crop_size: Vec2D, handle: CropHandle) -> Vec2D { - match handle { - CropHandle::TopLeftCorner => crop_pos, - CropHandle::TopEdge => crop_pos + Vec2D::new(crop_size.x / 2.0, 0.0), - CropHandle::TopRightCorner => crop_pos + Vec2D::new(crop_size.x, 0.0), - CropHandle::RightEdge => crop_pos + Vec2D::new(crop_size.x, crop_size.y / 2.0), - CropHandle::BottomRightCorner => crop_pos + Vec2D::new(crop_size.x, crop_size.y), - CropHandle::BottomEdge => crop_pos + Vec2D::new(crop_size.x / 2.0, crop_size.y), - CropHandle::BottomLeftCorner => crop_pos + Vec2D::new(0.0, crop_size.y), - CropHandle::LeftEdge => crop_pos + Vec2D::new(0.0, crop_size.y / 2.0), - } - } - fn test_handle_hit(&self, mouse_pos: Vec2D) -> Option<(CropHandle, Vec2D, Vec2D)> { - let crop = self.crop.as_ref()?; - - let crop_size = crop.size?; - let crop_pos = crop.pos; - - const MAX_DISTANCE2: f32 = (Crop::HANDLE_BORDER + Crop::HANDLE_RADIUS) - * (Crop::HANDLE_RADIUS + Crop::HANDLE_BORDER); - - for h in CropHandle::all() { - if (Self::get_handle_pos(crop_pos, crop_size, h) - mouse_pos).norm2() < MAX_DISTANCE2 { - return Some((h, crop_pos, crop_size)); - } - } - None - } + const HANDLE_MARGIN_IN_2: f32 = 15.0 * 15.0; + const HANDLE_MARGIN_OUT: f32 = 40.0; - fn test_inside_crop(&self, mouse_pos: Vec2D) -> bool { + fn test_inside_crop(&self, mouse_pos: Vec2D, margin: f32) -> bool { let crop = match &self.crop { Some(c) => c, None => return false, }; - let crop_size = match crop.size { - Some(s) => s, - None => return false, - }; - - let (mut min_x, mut max_x) = (crop.pos.x, crop.pos.x + crop_size.x); + let (mut min_x, mut max_x) = (crop.pos.x, crop.pos.x + crop.size.x); if min_x > max_x { (min_x, max_x) = (max_x, min_x); } + min_x -= margin; + max_x += margin; - let (mut min_y, mut max_y) = (crop.pos.y, crop.pos.y + crop_size.y); + let (mut min_y, mut max_y) = (crop.pos.y, crop.pos.y + crop.size.y); if min_y > max_y { (min_y, max_y) = (max_y, min_y); } + min_y -= margin; + max_y += margin; min_x < mouse_pos.x && mouse_pos.x < max_x && min_y < mouse_pos.y && mouse_pos.y < max_y } @@ -249,34 +256,40 @@ impl CropTool { // convert back and save crop.pos = tl; - crop.size = Some(br - tl); + crop.size = br - tl; } fn begin_drag(&mut self, pos: Vec2D) -> ToolUpdateResult { - if let Some((handle, pos, size)) = self.test_handle_hit(pos) { - let top_left_start = pos; - let bottom_right_start = pos + size; - self.action = Some(CropToolAction::DragHandle(DragHandleState { - handle, - top_left_start, - bottom_right_start, - })); - } else { - // only start a new crop if none exists - match &self.crop { - None => { - self.crop = Some(Crop { - pos, - size: None, - active: true, - }); + match &self.crop { + None => { + // No crop exists, create a new one + self.crop = Some(Crop::new(pos)); + self.action = Some(CropToolAction::NewCrop); + } + Some(c) => { + if let Some(handle) = c.test_handle_hit(pos, CropTool::HANDLE_MARGIN_IN_2) { + // Crop exists and we are near a handle, drag it + self.action = Some(CropToolAction::DragHandle(DragHandleState { + handle, + top_left_start: c.pos, + bottom_right_start: c.pos + c.size, + })); + } else if self.test_inside_crop(pos, 0.0) { + // Crop exists and we are inside it, move it + self.action = Some(CropToolAction::Move(MoveState { start: c.pos })); + } else if self.test_inside_crop(pos, CropTool::HANDLE_MARGIN_OUT) { + // Crop exists and we are near the edge, drag from the closest handle + let (handle, _) = c.get_closest_handle(pos); + self.action = Some(CropToolAction::DragHandle(DragHandleState { + handle, + top_left_start: c.pos, + bottom_right_start: c.pos + c.size, + })); + } else { + // Crop exists, but we far outside from it, create a new one + self.crop = Some(Crop::new(pos)); self.action = Some(CropToolAction::NewCrop); } - Some(c) => { - if self.test_inside_crop(pos) { - self.action = Some(CropToolAction::Move(MoveState { start: c.pos })); - } - } } } ToolUpdateResult::Redraw @@ -295,7 +308,7 @@ impl CropTool { match action { CropToolAction::NewCrop => { - crop.size = Some(direction); + crop.size = direction; ToolUpdateResult::Redraw } CropToolAction::DragHandle(state) => { @@ -322,7 +335,7 @@ impl CropTool { // crop never returns "commit" because nothing gets // committed to the drawables stack CropToolAction::NewCrop => { - crop.size = Some(direction); + crop.size = direction; self.action = None; ToolUpdateResult::Redraw } From 8b31358e5589b303d37c316318cce6c586745434 Mon Sep 17 00:00:00 2001 From: Pol Rivero <65060696+pol-rivero@users.noreply.github.com> Date: Sat, 28 Dec 2024 20:25:40 +0100 Subject: [PATCH 2/5] Ensure crop area is inside bounds When moving the crop area outside the bounds, output only the in-bounds part of the image instead of a black background --- src/femtovg_area/imp.rs | 5 +++-- src/math.rs | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/femtovg_area/imp.rs b/src/femtovg_area/imp.rs index 1bc0303..8afa694 100644 --- a/src/femtovg_area/imp.rs +++ b/src/femtovg_area/imp.rs @@ -19,7 +19,7 @@ use relm4::{gtk, Sender}; use resource::resource; use crate::{ - math::Vec2D, + math::{rect_ensure_in_bounds, Vec2D}, sketch_board::{Action, SketchBoardInput}, tools::{CropTool, Drawable, Tool}, APP_CONFIG, @@ -278,12 +278,13 @@ impl FemtoVgAreaMut { canvas: &mut femtovg::Canvas, font: FontId, ) -> anyhow::Result> { + let bounds = (Vec2D::zero(), Vec2D::new(self.background_image.width() as f32, self.background_image.height() as f32)); // get offset and size of the area in question let (pos, size) = self .crop_tool .borrow() .get_crop() - .map(|c| c.get_rectangle()) + .map(|c| rect_ensure_in_bounds(c.get_rectangle(), bounds)) .unwrap_or(( Vec2D::zero(), Vec2D::new( diff --git a/src/math.rs b/src/math.rs index f41a6b1..128ea3a 100644 --- a/src/math.rs +++ b/src/math.rs @@ -119,3 +119,27 @@ pub fn rect_ensure_positive_size(pos: Vec2D, size: Vec2D) -> (Vec2D, Vec2D) { (Vec2D::new(pos_x, pos_y), Vec2D::new(size_x, size_y)) } + +pub fn rect_ensure_in_bounds(rect: (Vec2D, Vec2D), bounds: (Vec2D, Vec2D)) -> (Vec2D, Vec2D) { + let (mut pos, mut size) = rect; + + if pos.x < bounds.0.x { + pos.x = bounds.0.x; + size.x -= bounds.0.x - pos.x; + } + + if pos.y < bounds.0.y { + pos.y = bounds.0.y; + size.y -= bounds.0.y - pos.y; + } + + if pos.x + size.x > bounds.1.x { + size.x = bounds.1.x - pos.x; + } + + if pos.y + size.y > bounds.1.y { + size.y = bounds.1.y - pos.y; + } + + (pos, size) +} From e259b72bf0f4824a075386fe076e6ab07e602861 Mon Sep 17 00:00:00 2001 From: Pol Rivero <65060696+pol-rivero@users.noreply.github.com> Date: Sat, 28 Dec 2024 21:25:20 +0100 Subject: [PATCH 3/5] Fix blurry images when using crop --- src/femtovg_area/imp.rs | 3 ++- src/math.rs | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/femtovg_area/imp.rs b/src/femtovg_area/imp.rs index 8afa694..6128b37 100644 --- a/src/femtovg_area/imp.rs +++ b/src/femtovg_area/imp.rs @@ -19,7 +19,7 @@ use relm4::{gtk, Sender}; use resource::resource; use crate::{ - math::{rect_ensure_in_bounds, Vec2D}, + math::{rect_ensure_in_bounds, rect_round, Vec2D}, sketch_board::{Action, SketchBoardInput}, tools::{CropTool, Drawable, Tool}, APP_CONFIG, @@ -285,6 +285,7 @@ impl FemtoVgAreaMut { .borrow() .get_crop() .map(|c| rect_ensure_in_bounds(c.get_rectangle(), bounds)) + .map(rect_round) .unwrap_or(( Vec2D::zero(), Vec2D::new( diff --git a/src/math.rs b/src/math.rs index 128ea3a..2cbbb0a 100644 --- a/src/math.rs +++ b/src/math.rs @@ -143,3 +143,14 @@ pub fn rect_ensure_in_bounds(rect: (Vec2D, Vec2D), bounds: (Vec2D, Vec2D)) -> (V (pos, size) } + +pub fn rect_round(rect: (Vec2D, Vec2D)) -> (Vec2D, Vec2D) { + let (mut pos, mut size) = rect; + + pos.x = pos.x.round(); + pos.y = pos.y.round(); + size.x = size.x.round(); + size.y = size.y.round(); + + (pos, size) +} From 1fccf521f62dbf226c89cc4a029857e0dc03eca9 Mon Sep 17 00:00:00 2001 From: Pol Rivero <65060696+pol-rivero@users.noreply.github.com> Date: Sun, 29 Dec 2024 14:21:03 +0100 Subject: [PATCH 4/5] Run cargo fmt --- src/femtovg_area/imp.rs | 8 +++++++- src/tools/crop.rs | 13 ++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/femtovg_area/imp.rs b/src/femtovg_area/imp.rs index 6128b37..e5f4b5c 100644 --- a/src/femtovg_area/imp.rs +++ b/src/femtovg_area/imp.rs @@ -278,7 +278,13 @@ impl FemtoVgAreaMut { canvas: &mut femtovg::Canvas, font: FontId, ) -> anyhow::Result> { - let bounds = (Vec2D::zero(), Vec2D::new(self.background_image.width() as f32, self.background_image.height() as f32)); + let bounds = ( + Vec2D::zero(), + Vec2D::new( + self.background_image.width() as f32, + self.background_image.height() as f32, + ), + ); // get offset and size of the area in question let (pos, size) = self .crop_tool diff --git a/src/tools/crop.rs b/src/tools/crop.rs index 043ab2e..3219103 100644 --- a/src/tools/crop.rs +++ b/src/tools/crop.rs @@ -28,7 +28,11 @@ impl Crop { const HANDLE_BORDER: f32 = 2.0; fn new(pos: Vec2D) -> Self { - Self { pos, size: Vec2D::zero(), active: true } + Self { + pos, + size: Vec2D::zero(), + active: true, + } } fn draw_single_handle( @@ -89,8 +93,11 @@ impl Crop { let allowed_distance2 = HANDLE_SIZE2 + margin2; let (handle, distance2) = self.get_closest_handle(mouse_pos); - if distance2 < allowed_distance2 { Some(handle) } - else { None } + if distance2 < allowed_distance2 { + Some(handle) + } else { + None + } } } From 0df085bcb53940215e3a0691e2b0f26082d5eed0 Mon Sep 17 00:00:00 2001 From: Pol Rivero <65060696+pol-rivero@users.noreply.github.com> Date: Mon, 30 Dec 2024 16:45:20 +0100 Subject: [PATCH 5/5] Check empty crop size --- src/femtovg_area/imp.rs | 12 ++++-------- src/math.rs | 4 ++++ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/femtovg_area/imp.rs b/src/femtovg_area/imp.rs index a1946d2..534f433 100644 --- a/src/femtovg_area/imp.rs +++ b/src/femtovg_area/imp.rs @@ -291,15 +291,11 @@ impl FemtoVgAreaMut { .crop_tool .borrow() .get_crop() - .map(|c| rect_ensure_in_bounds(c.get_rectangle(), bounds)) + .map(|c| c.get_rectangle()) + .map(|rect| rect_ensure_in_bounds(rect, bounds)) .map(rect_round) - .unwrap_or(( - Vec2D::zero(), - Vec2D::new( - self.background_image.width() as f32, - self.background_image.height() as f32, - ), - )); + .filter(|(_, size)| !size.is_zero()) + .unwrap_or(bounds); // create render-target let image_id = canvas.create_image_empty( diff --git a/src/math.rs b/src/math.rs index 2cbbb0a..7be1960 100644 --- a/src/math.rs +++ b/src/math.rs @@ -54,6 +54,10 @@ impl Vec2D { Vec2D::new(-a, -b) } } + + pub fn is_zero(&self) -> bool { + self.x.abs() < f32::EPSILON && self.y.abs() < f32::EPSILON + } } impl Add for Vec2D {