diff --git a/crates/rnote-compose/src/ext.rs b/crates/rnote-compose/src/ext.rs index d41ba18f68..870a405056 100644 --- a/crates/rnote-compose/src/ext.rs +++ b/crates/rnote-compose/src/ext.rs @@ -467,6 +467,8 @@ where fn to_kurbo(self) -> kurbo::Affine; /// converting from kurbo affine fn from_kurbo(affine: kurbo::Affine) -> Self; + /// Transforms the Aabb vertices and calculates a new that contains them. + fn transform_aabb(&self, aabb: Aabb) -> Aabb; } impl Affine2Ext for na::Affine2 { @@ -490,6 +492,21 @@ impl Affine2Ext for na::Affine2 { )) .unwrap() } + + /// Transforms the Aabb and returns the minimum enclosing Aabb + fn transform_aabb(&self, aabb: Aabb) -> Aabb { + let p0 = self * na::point![aabb.mins[0], aabb.mins[1]]; + let p1 = self * na::point![aabb.mins[0], aabb.maxs[1]]; + let p2 = self * na::point![aabb.maxs[0], aabb.maxs[1]]; + let p3 = self * na::point![aabb.maxs[0], aabb.mins[1]]; + + let min_x = p0[0].min(p1[0]).min(p2[0]).min(p3[0]); + let min_y = p0[1].min(p1[1]).min(p2[1]).min(p3[1]); + let max_x = p0[0].max(p1[0]).max(p2[0]).max(p3[0]); + let max_y = p0[1].max(p1[1]).max(p2[1]).max(p3[1]); + + Aabb::new_positive(na::point![min_x, min_y], na::point![max_x, max_y]) + } } /// Extension trait for types that implement [kurbo::Shape]. diff --git a/crates/rnote-compose/src/transform/mod.rs b/crates/rnote-compose/src/transform/mod.rs index d6be392f15..d743b52150 100644 --- a/crates/rnote-compose/src/transform/mod.rs +++ b/crates/rnote-compose/src/transform/mod.rs @@ -5,7 +5,7 @@ mod transformable; pub use transformable::Transformable; // Imports -use crate::ext::{AabbExt, Affine2Ext}; +use crate::ext::Affine2Ext; use p2d::bounding_volume::Aabb; use serde::{Deserialize, Serialize}; @@ -87,17 +87,7 @@ impl Transform { /// Transforms the Aabb vertices and calculates a new that contains them. pub fn transform_aabb(&self, aabb: Aabb) -> Aabb { - let p0 = self.affine * na::point![aabb.mins[0], aabb.mins[1]]; - let p1 = self.affine * na::point![aabb.mins[0], aabb.maxs[1]]; - let p2 = self.affine * na::point![aabb.maxs[0], aabb.maxs[1]]; - let p3 = self.affine * na::point![aabb.maxs[0], aabb.mins[1]]; - - let min_x = p0[0].min(p1[0]).min(p2[0]).min(p3[0]); - let min_y = p0[1].min(p1[1]).min(p2[1]).min(p3[1]); - let max_x = p0[0].max(p1[0]).max(p2[0]).max(p3[0]); - let max_y = p0[1].max(p1[1]).max(p2[1]).max(p3[1]); - - Aabb::new_positive(na::point![min_x, min_y], na::point![max_x, max_y]) + self.affine.transform_aabb(aabb) } /// Append a translation to the transform. diff --git a/crates/rnote-engine/src/camera.rs b/crates/rnote-engine/src/camera.rs index a517612997..1ac3d8fa06 100644 --- a/crates/rnote-engine/src/camera.rs +++ b/crates/rnote-engine/src/camera.rs @@ -5,7 +5,7 @@ use crate::engine::{EngineTask, EngineTaskSender}; use crate::tasks::{OneOffTaskError, OneOffTaskHandle}; use crate::{Document, WidgetFlags}; use p2d::bounding_volume::Aabb; -use rnote_compose::ext::AabbExt; +use rnote_compose::ext::{AabbExt, Affine2Ext}; use serde::{Deserialize, Serialize}; use std::time::Duration; use tracing::error; @@ -34,6 +34,9 @@ pub struct Camera { /// The camera zoom, origin at (0.0, 0.0). #[serde(rename = "zoom")] zoom: f64, + /// The camera rotation in radians. + #[serde(skip)] + rotation: f64, /// The temporary zoom. Is used to overlay the "permanent" zoom. #[serde(skip)] temporary_zoom: f64, @@ -54,6 +57,7 @@ impl Default for Camera { offset: na::vector![-Self::OVERSHOOT_HORIZONTAL, -Self::OVERSHOOT_VERTICAL], size: na::vector![800.0, 600.0], zoom: 1.0, + rotation: 0.0, temporary_zoom: 1.0, scale_factor: 1.0, zoom_task_handle: None, @@ -67,6 +71,7 @@ impl Snapshotable for Camera { offset: self.offset, size: self.size, zoom: self.zoom, + rotation: self.rotation, ..Default::default() } } @@ -97,6 +102,11 @@ impl Camera { self } + pub fn with_rotation(mut self, rotation: f64) -> Self { + self.rotation = rotation; + self + } + /// The current viewport offset in surface coordinate space. pub fn offset(&self) -> na::Vector2 { self.offset @@ -104,45 +114,39 @@ impl Camera { pub fn set_offset(&mut self, offset: na::Vector2, doc: &Document) -> WidgetFlags { let mut widget_flags = WidgetFlags::default(); - let (lower, upper) = self.offset_lower_upper(doc); + + let (mins, maxs) = self.surface_mins_maxs(doc); + let offset_maxs = na::vector![ + (maxs.x - self.size.x).max(mins.x), + (maxs.y - self.size.y).max(mins.y) + ]; + self.offset = na::vector![ - offset[0].clamp(lower[0], upper[0]), - offset[1].clamp(lower[1], upper[1]) + offset.x.clamp(mins.x, offset_maxs.x), + offset.y.clamp(mins.y, offset_maxs.y) ]; widget_flags.view_modified = true; - widget_flags.resize = true; widget_flags } - /// The offset minimum and maximum values in surface coordinate space. - pub fn offset_lower_upper(&self, doc: &Document) -> (na::Vector2, na::Vector2) { - let total_zoom = self.total_zoom(); + /// The minimum and maximum surface bounds (document including overshoot) in surface coordinate space. + pub fn surface_mins_maxs(&self, doc: &Document) -> (na::Vector2, na::Vector2) { + let document_bounds = self + .document_to_view_transform() + .transform_aabb(doc.bounds()); - let (h_lower, h_upper) = match doc.config.layout { - Layout::FixedSize | Layout::ContinuousVertical => ( - doc.x * total_zoom - Self::OVERSHOOT_HORIZONTAL, - (doc.x + doc.width) * total_zoom + Self::OVERSHOOT_HORIZONTAL, - ), - Layout::SemiInfinite => ( - doc.x * total_zoom - Self::OVERSHOOT_HORIZONTAL, - (doc.x + doc.width) * total_zoom, - ), - Layout::Infinite => (doc.x * total_zoom, (doc.x + doc.width) * total_zoom), - }; - let (v_lower, v_upper) = match doc.config.layout { - Layout::FixedSize | Layout::ContinuousVertical => ( - doc.y * total_zoom - Self::OVERSHOOT_VERTICAL, - (doc.y + doc.height) * total_zoom + Self::OVERSHOOT_VERTICAL, - ), - Layout::SemiInfinite => ( - doc.y * total_zoom - Self::OVERSHOOT_VERTICAL, - (doc.y + doc.height) * total_zoom, - ), - Layout::Infinite => (doc.y * total_zoom, (doc.y + doc.height) * total_zoom), + let bounds = match doc.config.layout { + Layout::FixedSize | Layout::ContinuousVertical | Layout::SemiInfinite => { + document_bounds.extend_by(na::vector![ + Self::OVERSHOOT_HORIZONTAL, + Self::OVERSHOOT_VERTICAL + ]) + } + Layout::Infinite => document_bounds, }; - (na::vector![h_lower, v_lower], na::vector![h_upper, v_upper]) + (bounds.mins.coords, bounds.maxs.coords) } /// The current viewport size in surface coordinate space. @@ -168,7 +172,47 @@ impl Camera { pub fn zoom_to(&mut self, zoom: f64) -> WidgetFlags { let mut widget_flags = WidgetFlags::default(); self.zoom = zoom.clamp(Self::ZOOM_MIN, Self::ZOOM_MAX); - widget_flags.zoomed = true; + widget_flags.view_modified = true; + widget_flags.resize = true; + widget_flags.refresh_canvasmenu = true; + widget_flags.update_old_viewport = true; + widget_flags + } + + /// The camera rotation in radians. + pub fn rotation(&self) -> f64 { + self.rotation + } + + fn snap_angle(angle: f64, step: f64) -> f64 { + const SNAP_EPS: f64 = 1_f64.to_radians(); + + let k = (angle / step).round(); + let snapped_angle = k * step; + + if (angle - snapped_angle).abs() <= SNAP_EPS { + snapped_angle + } else { + angle + } + } + + /// Normalizes angle to (-pi, pi]. + fn normalize_angle(angle: f64) -> f64 { + std::f64::consts::PI - (std::f64::consts::PI - angle).rem_euclid(std::f64::consts::TAU) + } + + pub fn set_rotation(&mut self, rotation: f64) -> WidgetFlags { + let mut widget_flags = WidgetFlags::default(); + + // snap angle to nearest 45 degrees and normalize it + // angle must not be close to zero, because it causes major rendering issues in GTK + self.rotation = + Self::normalize_angle(Self::snap_angle(rotation, std::f64::consts::FRAC_PI_4)); + + widget_flags.view_modified = true; + widget_flags.resize = true; + widget_flags.refresh_canvasmenu = true; widget_flags } @@ -182,7 +226,9 @@ impl Camera { let mut widget_flags = WidgetFlags::default(); self.temporary_zoom = temporary_zoom.clamp(Camera::ZOOM_MIN / self.zoom, Camera::ZOOM_MAX / self.zoom); - widget_flags.zoomed_temporarily = true; + widget_flags.view_modified = true; + widget_flags.resize = true; + widget_flags.refresh_canvasmenu = true; widget_flags } @@ -249,18 +295,19 @@ impl Camera { } /// The viewport in document coordinate space. + /// + /// Returns the Aabb enclosing the (potentially rotated) viewport. pub fn viewport(&self) -> Aabb { - let total_zoom = self.total_zoom(); - - Aabb::new_positive( - (self.offset / total_zoom).into(), - ((self.offset + self.size) / total_zoom).into(), - ) + let viewport_surface = Aabb::new(na::point![0.0, 0.0], na::Point2::from(self.size)); + self.transform().inverse().transform_aabb(viewport_surface) } /// The current viewport center in document coordinate space. pub fn viewport_center(&self) -> na::Vector2 { - (self.offset + self.size * 0.5) / self.total_zoom() + self.transform() + .inverse() + .transform_point(&na::Point2::from(self.size * 0.5)) + .coords } /// Set the viewport center. @@ -268,32 +315,46 @@ impl Camera { /// `center` must be in document coordinate space. pub fn set_viewport_center(&mut self, center: na::Vector2) -> WidgetFlags { let mut widget_flags = WidgetFlags::default(); - self.offset = center * self.total_zoom() - self.size * 0.5; + + self.offset = self + .document_to_view_transform() + .transform_point(&na::Point2::from(center)) + .coords + - self.size * 0.5; + widget_flags.view_modified = true; - widget_flags.resize = true; widget_flags } - /// Transform Aabb from document coords to surface coords. - pub fn transform_bounds(&self, bounds: Aabb) -> Aabb { - bounds.scale(self.total_zoom()).translate(-self.offset) + /// The transform from document coords to view coords (rotation + scale only). + /// + /// To get the inverse, call `.inverse()`. + pub fn document_to_view_transform(&self) -> na::Affine2 { + let total_zoom = self.total_zoom(); + + na::try_convert( + // LHS is applied onto RHS: rotate -> scale + na::Scale2::from(na::Vector2::from_element(total_zoom)).to_homogeneous() + * na::Rotation2::new(self.rotation).to_homogeneous(), + ) + .unwrap() } - /// Transform Aabb from surface coords to document coords. - pub fn transform_inv_bounds(&self, bounds: Aabb) -> Aabb { - bounds.translate(self.offset).scale(1.0 / self.total_zoom()) + /// The transform from view coords to surface coords (translation only). + /// + /// To get the inverse, call `.inverse()`. + pub fn view_to_surface_transform(&self) -> na::Affine2 { + na::try_convert(na::Translation2::from(-self.offset).to_homogeneous()).unwrap() } - /// The transform from document coords to surface coords. + /// The transform from document coords to surface coords (full). /// /// To get the inverse, call `.inverse()`. pub fn transform(&self) -> na::Affine2 { - let total_zoom = self.total_zoom(); - na::try_convert( - // LHS is applied onto RHS, so the order is scaling by zoom -> Translation by offset - na::Translation2::from(-self.offset).to_homogeneous() - * na::Scale2::from(na::Vector2::from_element(total_zoom)).to_homogeneous(), + // LHS is applied onto RHS: document -> view -> surface + self.view_to_surface_transform().to_homogeneous() + * self.document_to_view_transform().to_homogeneous(), ) .unwrap() } @@ -301,7 +362,9 @@ impl Camera { /// The gsk transform for the GTK snapshot function. /// /// GTKs transformations are applied on its coordinate system, - /// so we need to reverse the transformation order (translate, then scale). + /// so we need to reverse the transformation order (translate, then scale, then rotate). + /// Small rotation angles seem to cause major rendering issues. + /// /// To get the inverse, call .invert(). #[cfg(feature = "ui")] pub fn transform_for_gtk_snapshot(&self) -> gtk4::gsk::Transform { @@ -313,16 +376,20 @@ impl Camera { -self.offset[1] as f32, )) .scale(total_zoom as f32, total_zoom as f32) + .rotate(self.rotation.to_degrees() as f32) } /// Detects if a nudge is needed, meaning: the position is close to an edge of the current viewport. pub fn detect_nudge_needed(&self, pos: na::Vector2) -> Option { const NUDGE_VIEWPORT_DIST: f64 = 10.0; - let viewport = self.viewport(); - let nudge_north = pos[1] <= viewport.mins[1] + NUDGE_VIEWPORT_DIST; - let nudge_east = pos[0] >= viewport.maxs[0] - NUDGE_VIEWPORT_DIST; - let nudge_south = pos[1] >= viewport.maxs[1] - NUDGE_VIEWPORT_DIST; - let nudge_west = pos[0] <= viewport.mins[0] + NUDGE_VIEWPORT_DIST; + + // Transform position into surface coordinates and compare against the surface viewport. + let pos_surface = self.transform().transform_point(&na::Point2::from(pos)); + + let nudge_north = pos_surface.y <= NUDGE_VIEWPORT_DIST; + let nudge_east = pos_surface.x >= self.size[0] - NUDGE_VIEWPORT_DIST; + let nudge_south = pos_surface.y >= self.size[1] - NUDGE_VIEWPORT_DIST; + let nudge_west = pos_surface.x <= NUDGE_VIEWPORT_DIST; match (nudge_north, nudge_east, nudge_south, nudge_west) { (true, false, _, false) => Some(NudgeDirection::North), diff --git a/crates/rnote-engine/src/document/mod.rs b/crates/rnote-engine/src/document/mod.rs index 84b360e1ed..f5c57d6cc7 100644 --- a/crates/rnote-engine/src/document/mod.rs +++ b/crates/rnote-engine/src/document/mod.rs @@ -9,6 +9,7 @@ pub use background::Background; pub use config::DocumentConfig; pub use format::Format; pub use layout::Layout; +use na::SimdPartialOrd; // Imports use crate::engine::EngineConfig; @@ -62,7 +63,7 @@ impl Document { a: 0.35, }; - pub(crate) fn bounds(&self) -> Aabb { + pub fn bounds(&self) -> Aabb { Aabb::new( na::point![self.x, self.y], na::point![self.x + self.width, self.y + self.height], @@ -250,20 +251,26 @@ impl Document { ) -> bool { let padding_horizontal = self.config.format.width() * 2.0; let padding_vertical = self.config.format.height() * 2.0; + let padding = na::vector![padding_horizontal, padding_vertical]; - let mut new_bounds = self.bounds().merged( - &viewport.extend_right_and_bottom_by(na::vector![padding_horizontal, padding_vertical]), - ); + let mut new_bounds = self.bounds(); + let mut minimum_bounds = viewport.extend_right_and_bottom_by(padding); + minimum_bounds.mins = minimum_bounds.mins.simd_max(new_bounds.mins); + + if !new_bounds.contains(&minimum_bounds) { + // Extend the bounds further than necessary, so that we don't trigger + // a resize again immediately when the viewport is slightly moved + new_bounds.merge(&minimum_bounds.extend_right_and_bottom_by(padding)); + } if include_content { let keys = store.stroke_keys_as_rendered(); let content_bounds = if let Some(content_bounds) = store.bounds_for_strokes(&keys) { - content_bounds - .extend_right_and_bottom_by(na::vector![padding_horizontal, padding_vertical]) + content_bounds.extend_right_and_bottom_by(padding) } else { // If doc is empty, resize to one page with the format size Aabb::new(na::point![0.0, 0.0], self.config.format.size().into()) - .extend_right_and_bottom_by(na::vector![padding_horizontal, padding_vertical]) + .extend_right_and_bottom_by(padding) }; new_bounds.merge(&content_bounds); } @@ -295,19 +302,24 @@ impl Document { ) -> bool { let padding_horizontal = self.config.format.width() * 2.0; let padding_vertical = self.config.format.height() * 2.0; + let padding = na::vector![padding_horizontal, padding_vertical]; - let mut new_bounds = self - .bounds() - .merged(&viewport.extend_by(na::vector![padding_horizontal, padding_vertical])); + let mut new_bounds = self.bounds(); + let minimum_bounds = viewport.extend_by(padding); + + if !new_bounds.contains(&minimum_bounds) { + // Extend the bounds further than necessary, so that we don't trigger + // a resize again immediately when the viewport is slightly moved + new_bounds.merge(&minimum_bounds.extend_by(padding)); + } if include_content { let keys = store.stroke_keys_as_rendered(); let content_bounds = if let Some(content_bounds) = store.bounds_for_strokes(&keys) { - content_bounds.extend_by(na::vector![padding_horizontal, padding_vertical]) + content_bounds.extend_by(padding) } else { // If doc is empty, resize to one page with the format size - Aabb::new(na::point![0.0, 0.0], self.config.format.size().into()) - .extend_by(na::vector![padding_horizontal, padding_vertical]) + Aabb::new(na::point![0.0, 0.0], self.config.format.size().into()).extend_by(padding) }; new_bounds.merge(&content_bounds); } diff --git a/crates/rnote-engine/src/drawable.rs b/crates/rnote-engine/src/drawable.rs index 23e03cf07a..53f9e262be 100644 --- a/crates/rnote-engine/src/drawable.rs +++ b/crates/rnote-engine/src/drawable.rs @@ -63,13 +63,13 @@ pub trait DrawableOnDoc { if let Some(bounds) = self.bounds_on_doc(engine_view) { let viewport = engine_view.camera.viewport(); + // Restrict to viewport as maximum bounds, else cairo is very unperformant // and will even crash for very large bounds let bounds = bounds.clamp(None, Some(viewport)); - let mut bounds_on_surface = bounds - .scale(engine_view.camera.total_zoom()) - .translate(-engine_view.camera.offset()) - .ceil(); + + let mut bounds_on_surface = + engine_view.camera.transform().transform_aabb(bounds).ceil(); bounds_on_surface.ensure_positive(); bounds_on_surface.assert_valid()?; diff --git a/crates/rnote-engine/src/engine/mod.rs b/crates/rnote-engine/src/engine/mod.rs index c3c50a602c..c6a4ad7601 100644 --- a/crates/rnote-engine/src/engine/mod.rs +++ b/crates/rnote-engine/src/engine/mod.rs @@ -632,7 +632,7 @@ impl Engine { -Document::SHADOW_WIDTH * zoom ] }; - self.camera_set_offset_expand(new_offset) + self.camera_set_rotation(0.0) | self.camera_set_offset_expand(new_offset) } /// Resize the doc when in autoexpanding layouts. called e.g. when finishing a new stroke. @@ -704,11 +704,16 @@ impl Engine { self.camera.set_size(size) } - /// Update the viewport size of the camera. + /// The minimum and maximum surface bounds (document including overshoot) in surface coordinate space. + pub fn camera_surface_mins_maxs(&self) -> (na::Vector2, na::Vector2) { + self.camera.surface_mins_maxs(&self.document) + } + + /// Update the viewport rotation of the camera. /// /// Background and content rendering then need to be updated. - pub fn camera_offset_mins_maxs(&self) -> (na::Vector2, na::Vector2) { - self.camera.offset_lower_upper(&self.document) + pub fn camera_set_rotation(&mut self, rotation: f64) -> WidgetFlags { + self.camera.set_rotation(rotation) } /// Update the current pen with the current engine state. diff --git a/crates/rnote-engine/src/widgetflags.rs b/crates/rnote-engine/src/widgetflags.rs index d348063a9c..85b7b536df 100644 --- a/crates/rnote-engine/src/widgetflags.rs +++ b/crates/rnote-engine/src/widgetflags.rs @@ -13,9 +13,9 @@ pub struct WidgetFlags { /// Update the current view offsets and size. pub view_modified: bool, /// Indicates that the camera has changed it's temporary zoom. - pub zoomed_temporarily: bool, + pub refresh_canvasmenu: bool, /// Indicates that the camera has changed it's permanent zoom. - pub zoomed: bool, + pub update_old_viewport: bool, /// Deselect the elements of the global color picker. pub deselect_color_setters: bool, /// Is Some when undo button visibility should be changed. Is None if should not be changed. @@ -36,8 +36,8 @@ impl Default for WidgetFlags { refresh_ui: false, store_modified: false, view_modified: false, - zoomed_temporarily: false, - zoomed: false, + refresh_canvasmenu: false, + update_old_viewport: false, deselect_color_setters: false, hide_undo: None, hide_redo: None, @@ -62,8 +62,8 @@ impl std::ops::BitOrAssign for WidgetFlags { self.refresh_ui |= rhs.refresh_ui; self.store_modified |= rhs.store_modified; self.view_modified |= rhs.view_modified; - self.zoomed_temporarily |= rhs.zoomed_temporarily; - self.zoomed |= rhs.zoomed; + self.refresh_canvasmenu |= rhs.refresh_canvasmenu; + self.update_old_viewport |= rhs.update_old_viewport; self.deselect_color_setters |= rhs.deselect_color_setters; if rhs.hide_undo.is_some() { self.hide_undo = rhs.hide_undo diff --git a/crates/rnote-ui/data/icons/scalable/actions/rotate-left-symbolic.svg b/crates/rnote-ui/data/icons/scalable/actions/rotate-left-symbolic.svg new file mode 100644 index 0000000000..2ee2cac110 --- /dev/null +++ b/crates/rnote-ui/data/icons/scalable/actions/rotate-left-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/crates/rnote-ui/data/icons/scalable/actions/rotate-right-symbolic.svg b/crates/rnote-ui/data/icons/scalable/actions/rotate-right-symbolic.svg new file mode 100644 index 0000000000..57c7fa81f9 --- /dev/null +++ b/crates/rnote-ui/data/icons/scalable/actions/rotate-right-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/crates/rnote-ui/data/meson.build b/crates/rnote-ui/data/meson.build index 4d348358ca..1a5c93d998 100644 --- a/crates/rnote-ui/data/meson.build +++ b/crates/rnote-ui/data/meson.build @@ -242,6 +242,8 @@ rnote_ui_gresources_icons_files = files( 'icons/scalable/actions/resize-to-fit-content-symbolic.svg', 'icons/scalable/actions/restore-symbolic.svg', 'icons/scalable/actions/return-origin-page-symbolic.svg', + 'icons/scalable/actions/rotate-left-symbolic.svg', + 'icons/scalable/actions/rotate-right-symbolic.svg', 'icons/scalable/actions/save-symbolic.svg', 'icons/scalable/actions/selection-deselect-all-symbolic.svg', 'icons/scalable/actions/selection-duplicate-symbolic.svg', diff --git a/crates/rnote-ui/data/resources.gresource.xml b/crates/rnote-ui/data/resources.gresource.xml index 5532364b4b..d85f48762c 100644 --- a/crates/rnote-ui/data/resources.gresource.xml +++ b/crates/rnote-ui/data/resources.gresource.xml @@ -113,6 +113,8 @@ icons/scalable/actions/resize-to-fit-content-symbolic.svg icons/scalable/actions/restore-symbolic.svg icons/scalable/actions/return-origin-page-symbolic.svg + icons/scalable/actions/rotate-left-symbolic.svg + icons/scalable/actions/rotate-right-symbolic.svg icons/scalable/actions/save-symbolic.svg icons/scalable/actions/selection-deselect-all-symbolic.svg icons/scalable/actions/selection-duplicate-symbolic.svg diff --git a/crates/rnote-ui/data/ui/canvasmenu.ui b/crates/rnote-ui/data/ui/canvasmenu.ui index 364da8ba07..3c03556cc3 100644 --- a/crates/rnote-ui/data/ui/canvasmenu.ui +++ b/crates/rnote-ui/data/ui/canvasmenu.ui @@ -116,6 +116,40 @@ + + + + horizontal + true + + + rotate-left-symbolic + Rotate counterclockwise + 50 + false + win.rotate-ccw + + + + + win.rotation-reset + Reset Rotation + true + + + + + rotate-right-symbolic + Rotate clockwise + 50 + false + win.rotate-cw + + + + horizontal diff --git a/crates/rnote-ui/src/appwindow/actions.rs b/crates/rnote-ui/src/appwindow/actions.rs index a9175fe917..f564082344 100644 --- a/crates/rnote-ui/src/appwindow/actions.rs +++ b/crates/rnote-ui/src/appwindow/actions.rs @@ -72,6 +72,12 @@ impl RnAppWindow { self.add_action(&action_zoomin); let action_zoomout = gio::SimpleAction::new("zoom-out", None); self.add_action(&action_zoomout); + let action_rotation_reset = gio::SimpleAction::new("rotation-reset", None); + self.add_action(&action_rotation_reset); + let action_rotate_ccw = gio::SimpleAction::new("rotate-ccw", None); + self.add_action(&action_rotate_ccw); + let action_rotate_cw = gio::SimpleAction::new("rotate-cw", None); + self.add_action(&action_rotate_cw); let action_add_page_to_doc = gio::SimpleAction::new("add-page-to-doc", None); self.add_action(&action_add_page_to_doc); let action_remove_page_from_doc = gio::SimpleAction::new("remove-page-from-doc", None); @@ -683,6 +689,64 @@ impl RnAppWindow { } )); + // Rotation reset + action_rotation_reset.connect_activate(clone!( + #[weak(rename_to=appwindow)] + self, + move |_, _| { + let Some(canvas) = appwindow.active_tab_canvas() else { + return; + }; + let viewport_center = canvas.engine_ref().camera.viewport_center(); + let mut widget_flags = canvas.engine_mut().camera_set_rotation(0.0); + widget_flags |= canvas + .engine_mut() + .camera + .set_viewport_center(viewport_center); + appwindow.handle_widget_flags(widget_flags, &canvas) + } + )); + + // Rotate counterclockwise + action_rotate_ccw.connect_activate(clone!( + #[weak(rename_to=appwindow)] + self, + move |_, _| { + let Some(canvas) = appwindow.active_tab_canvas() else { + return; + }; + let viewport_center = canvas.engine_ref().camera.viewport_center(); + let new_rotation = + canvas.engine_ref().camera.rotation() - RnCanvas::ROTATION_SCROLL_STEP; + let mut widget_flags = canvas.engine_mut().camera_set_rotation(new_rotation); + widget_flags |= canvas + .engine_mut() + .camera + .set_viewport_center(viewport_center); + appwindow.handle_widget_flags(widget_flags, &canvas) + } + )); + + // Rotate clockwise + action_rotate_cw.connect_activate(clone!( + #[weak(rename_to=appwindow)] + self, + move |_, _| { + let Some(canvas) = appwindow.active_tab_canvas() else { + return; + }; + let viewport_center = canvas.engine_ref().camera.viewport_center(); + let new_rotation = + canvas.engine_ref().camera.rotation() + RnCanvas::ROTATION_SCROLL_STEP; + let mut widget_flags = canvas.engine_mut().camera_set_rotation(new_rotation); + widget_flags |= canvas + .engine_mut() + .camera + .set_viewport_center(viewport_center); + appwindow.handle_widget_flags(widget_flags, &canvas) + } + )); + // Add page to doc in fixed size mode action_add_page_to_doc.connect_activate(clone!( #[weak(rename_to=appwindow)] diff --git a/crates/rnote-ui/src/appwindow/mod.rs b/crates/rnote-ui/src/appwindow/mod.rs index 88d89f1d31..06dc2652cc 100644 --- a/crates/rnote-ui/src/appwindow/mod.rs +++ b/crates/rnote-ui/src/appwindow/mod.rs @@ -292,30 +292,23 @@ impl RnAppWindow { canvas.set_empty(false); } if widget_flags.view_modified { - let widget_size = canvas.widget_size(); - let offset_mins_maxs = canvas.engine_ref().camera_offset_mins_maxs(); - let offset = canvas.engine_ref().camera.offset(); - // Keep the adjustments configuration in sync - canvas.configure_adjustments(widget_size, offset_mins_maxs, offset); - canvas.queue_resize(); + canvas.queue_allocate(); } - if widget_flags.zoomed_temporarily { + if widget_flags.refresh_canvasmenu { let total_zoom = canvas.engine_ref().camera.total_zoom(); + let rotation = canvas.engine_ref().camera.rotation(); self.main_header() .canvasmenu() .refresh_zoom_reset_label(total_zoom); - canvas.queue_resize(); - } - if widget_flags.zoomed { - let total_zoom = canvas.engine_ref().camera.total_zoom(); - let viewport = canvas.engine_ref().camera.viewport(); - canvas.canvas_layout_manager().update_old_viewport(viewport); self.main_header() .canvasmenu() - .refresh_zoom_reset_label(total_zoom); - canvas.queue_resize(); + .refresh_rotation_reset_label(rotation); + } + if widget_flags.update_old_viewport { + let viewport = canvas.engine_ref().camera.viewport(); + canvas.canvas_layout_manager().update_old_viewport(viewport); } if widget_flags.deselect_color_setters { self.overlays().colorpicker().deselect_setters(); @@ -722,6 +715,7 @@ impl RnAppWindow { let pen_sounds = canvas.engine_ref().pen_sounds(); let snap_positions = self.engine_config().read().snap_positions; let total_zoom = canvas.engine_ref().camera.total_zoom(); + let rotation = canvas.engine_ref().camera.rotation(); let can_undo = canvas.engine_ref().can_undo(); let can_redo = canvas.engine_ref().can_redo(); let visual_debug = self.engine_config().read().visual_debug; @@ -737,6 +731,9 @@ impl RnAppWindow { self.main_header() .canvasmenu() .refresh_zoom_reset_label(total_zoom); + self.main_header() + .canvasmenu() + .refresh_rotation_reset_label(rotation); self.set_pen_style(pen_style); self.set_pen_sounds(pen_sounds); self.set_snap_positions(snap_positions); diff --git a/crates/rnote-ui/src/canvas/canvaslayout.rs b/crates/rnote-ui/src/canvas/canvaslayout.rs index 26a661b1eb..e8db1d484a 100644 --- a/crates/rnote-ui/src/canvas/canvaslayout.rs +++ b/crates/rnote-ui/src/canvas/canvaslayout.rs @@ -5,7 +5,7 @@ use gtk4::{ }; use p2d::bounding_volume::{Aabb, BoundingVolume}; use rnote_compose::ext::AabbExt; -use rnote_engine::{Camera, image}; +use rnote_engine::image; use std::cell::Cell; mod imp { @@ -45,40 +45,37 @@ mod imp { _for_size: i32, ) -> (i32, i32, i32, i32) { let canvas = widget.downcast_ref::().unwrap(); - let total_zoom = canvas.engine_ref().camera.total_zoom(); - let document = canvas.engine_ref().document.clone(); - if orientation == Orientation::Horizontal { - let natural_width = (document.width * total_zoom - + 2.0 * Camera::OVERSHOOT_HORIZONTAL) - .ceil() as i32; + let (surface_mins, surface_maxs) = canvas.engine_ref().camera_surface_mins_maxs(); + let surface_size = surface_maxs - surface_mins; + if orientation == Orientation::Horizontal { + let natural_width = surface_size.x.ceil() as i32; (0, natural_width, -1, -1) } else { - let natural_height = - (document.height * total_zoom + 2.0 * Camera::OVERSHOOT_VERTICAL).ceil() as i32; - + let natural_height = surface_size.y.ceil() as i32; (0, natural_height, -1, -1) } } fn allocate(&self, widget: &Widget, width: i32, height: i32, _baseline: i32) { let canvas = widget.downcast_ref::().unwrap(); - let hadj = canvas.hadjustment().unwrap(); - let vadj = canvas.vadjustment().unwrap(); - let hadj_value = hadj.value(); - let vadj_value = vadj.value(); + let new_size = na::vector![width as f64, height as f64]; - let offset_mins_maxs = canvas.engine_ref().camera_offset_mins_maxs(); - let new_offset = na::vector![hadj_value, vadj_value]; - let old_viewport = self.old_viewport.get(); - let new_viewport = canvas.engine_ref().camera.viewport(); + let _ = canvas.engine_mut().camera_set_size(new_size); - canvas.configure_adjustments(new_size, offset_mins_maxs, new_offset); + // Configure adjustments using new size + let (surface_mins, surface_maxs) = canvas.engine_ref().camera_surface_mins_maxs(); + let offset = canvas.engine_ref().camera.offset(); - // Update the camera - let _ = canvas.engine_mut().camera_set_offset(new_offset); - let _ = canvas.engine_mut().camera_set_size(new_size); + let adjustment_maxs = RnCanvas::surface_to_adjustment(surface_maxs, surface_mins); + let adjustment_value = RnCanvas::surface_to_adjustment(offset, surface_mins); + + canvas.configure_adjustments(new_size, adjustment_maxs, adjustment_value); + + // Calculate new viewport from the updated camera state + let old_viewport = self.old_viewport.get(); + let new_viewport = canvas.engine_ref().camera.viewport(); // We only extend the viewport by a (tweakable) fraction of the margin, because we want to trigger rendering // before we reach it. This has two advantages: Strokes that might take longer to render have a head start diff --git a/crates/rnote-ui/src/canvas/mod.rs b/crates/rnote-ui/src/canvas/mod.rs index e1e67fc3f0..dd57097100 100644 --- a/crates/rnote-ui/src/canvas/mod.rs +++ b/crates/rnote-ui/src/canvas/mod.rs @@ -14,8 +14,8 @@ use futures::StreamExt; use gettextrs::gettext; use gtk4::{ Adjustment, DropTarget, EventControllerKey, EventControllerLegacy, IMMulticontext, - PropagationPhase, Scrollable, ScrollablePolicy, Widget, gdk, gio, glib, glib::clone, graphene, - prelude::*, subclass::prelude::*, + PropagationPhase, Scrollable, ScrollablePolicy, ScrolledWindow, Widget, gdk, gio, glib, + glib::clone, graphene, prelude::*, subclass::prelude::*, }; use notify::EventKind; use notify::event::{AccessKind, AccessMode, ModifyKind, RenameMode}; @@ -24,7 +24,6 @@ use once_cell::sync::Lazy; use p2d::bounding_volume::Aabb; use rnote_compose::ext::AabbExt; use rnote_compose::penevent::PenState; -use rnote_engine::Camera; use rnote_engine::ext::GraphenePointExt; use rnote_engine::ext::GrapheneRectExt; use rnote_engine::{Engine, WidgetFlags}; @@ -37,6 +36,8 @@ use tracing::{debug, error, warn}; struct Connections { hadjustment: Option, vadjustment: Option, + hadjustment_upper: Option, + vadjustment_upper: Option, tab_page_output_file: Option, tab_page_unsaved_changes: Option, tab_page_invalidate_thumbnail: Option, @@ -60,6 +61,7 @@ mod imp { pub(super) connections: RefCell, pub(crate) hadjustment: RefCell>, pub(crate) vadjustment: RefCell>, + pub(crate) workaround_kinetic_scrolling_pending: Cell, pub(crate) hscroll_policy: Cell, pub(crate) vscroll_policy: Cell, pub(crate) regular_cursor_icon_name: RefCell, @@ -154,6 +156,7 @@ mod imp { hadjustment: RefCell::new(None), vadjustment: RefCell::new(None), + workaround_kinetic_scrolling_pending: Cell::new(false), hscroll_policy: Cell::new(ScrollablePolicy::Minimum), vscroll_policy: Cell::new(ScrollablePolicy::Minimum), regular_cursor: RefCell::new(regular_cursor), @@ -550,92 +553,162 @@ mod imp { fn set_hadjustment_prop(&self, hadj: Option) { let obj = self.obj(); + let scroller = obj + .parent() + .and_then(|parent| parent.downcast::().ok()); - let hadj_value = self - .hadjustment - .borrow() - .as_ref() - .map(|adj| adj.value()) - .unwrap_or(-Camera::OVERSHOOT_HORIZONTAL); - let vadj_value = self - .vadjustment - .borrow() - .as_ref() - .map(|adj| adj.value()) - .unwrap_or(-Camera::OVERSHOOT_VERTICAL); let widget_size = obj.widget_size(); - let offset_mins_maxs = obj.engine_ref().camera_offset_mins_maxs(); + let offset = obj.engine_ref().camera.offset(); + + let (surface_mins, surface_maxs) = obj.engine_ref().camera_surface_mins_maxs(); + let adjustment_maxs = + super::RnCanvas::surface_to_adjustment(surface_maxs, surface_mins); + let adjustment_value = super::RnCanvas::surface_to_adjustment(offset, surface_mins); if let Some(signal_id) = self.connections.borrow_mut().hadjustment.take() { let old_adj = self.hadjustment.borrow().as_ref().unwrap().clone(); old_adj.disconnect(signal_id); } + if let Some(signal_id) = self.connections.borrow_mut().hadjustment_upper.take() { + let old_adj = self.hadjustment.borrow().as_ref().unwrap().clone(); + old_adj.disconnect(signal_id); + } if let Some(ref hadj) = hadj { + let upper_signal_id = hadj.connect_notify_local( + Some("upper"), + clone!( + #[weak(rename_to=canvas)] + obj, + #[strong] + scroller, + move |_adj: &Adjustment, _| { + // restore kinetic scrolling after the canvas has been resized, + // workaround for https://gitlab.gnome.org/GNOME/gtk/-/issues/1494 + canvas.workaround_restore_kinetic_scrolling(scroller.as_ref()); + } + ), + ); + let signal_id = hadj.connect_value_changed(clone!( #[weak(rename_to=canvas)] obj, - move |_| { - // this triggers a canvaslayout allocate() call, - // where the camera and content rendering is updated based on some conditions - canvas.queue_resize(); + #[strong] + scroller, + move |hadj_signal| { + // Apply scroll input from adjustment to camera + let (surface_mins, _) = canvas.engine_ref().camera_surface_mins_maxs(); + let offset = canvas.engine_ref().camera.offset(); + + let new_offset = na::vector![ + super::RnCanvas::adjustment_to_surface( + hadj_signal.value(), + surface_mins.x + ), + offset.y + ]; + + let widget_flags = canvas.engine_mut().camera_set_offset_expand(new_offset); + + if widget_flags.resize { + // disable kinetic scrolling when the canvas is about to resize (i.e. when it was expanded), + // workaround for https://gitlab.gnome.org/GNOME/gtk/-/issues/1494 + canvas.workaround_disable_kinetic_scrolling(scroller.as_ref()); + } + + canvas.emit_handle_widget_flags(widget_flags); } )); self.connections.borrow_mut().hadjustment.replace(signal_id); + self.connections + .borrow_mut() + .hadjustment_upper + .replace(upper_signal_id); } self.hadjustment.replace(hadj); - obj.configure_adjustments( - widget_size, - offset_mins_maxs, - na::vector![hadj_value, vadj_value], - ); + obj.configure_adjustments(widget_size, adjustment_maxs, adjustment_value); } fn set_vadjustment_prop(&self, vadj: Option) { let obj = self.obj(); + let scroller = obj + .parent() + .and_then(|parent| parent.downcast::().ok()); - let hadj_value = self - .hadjustment - .borrow() - .as_ref() - .map(|adj| adj.value()) - .unwrap_or(-Camera::OVERSHOOT_HORIZONTAL); - let vadj_value = self - .vadjustment - .borrow() - .as_ref() - .map(|adj| adj.value()) - .unwrap_or(-Camera::OVERSHOOT_VERTICAL); let widget_size = obj.widget_size(); - let offset_mins_maxs = obj.engine_ref().camera_offset_mins_maxs(); + let offset = obj.engine_ref().camera.offset(); + + let (surface_mins, surface_maxs) = obj.engine_ref().camera_surface_mins_maxs(); + let adjustment_maxs = + super::RnCanvas::surface_to_adjustment(surface_maxs, surface_mins); + let adjustment_value = super::RnCanvas::surface_to_adjustment(offset, surface_mins); if let Some(signal_id) = self.connections.borrow_mut().vadjustment.take() { let old_adj = self.vadjustment.borrow().as_ref().unwrap().clone(); old_adj.disconnect(signal_id); } + if let Some(signal_id) = self.connections.borrow_mut().vadjustment_upper.take() { + let old_adj = self.vadjustment.borrow().as_ref().unwrap().clone(); + old_adj.disconnect(signal_id); + } if let Some(ref vadj) = vadj { + let upper_signal_id = vadj.connect_notify_local( + Some("upper"), + clone!( + #[weak(rename_to=canvas)] + obj, + #[strong] + scroller, + move |_adj: &Adjustment, _| { + // restore kinetic scrolling after the canvas has been resized, + // workaround for https://gitlab.gnome.org/GNOME/gtk/-/issues/1494 + canvas.workaround_restore_kinetic_scrolling(scroller.as_ref()); + } + ), + ); + let signal_id = vadj.connect_value_changed(clone!( #[weak(rename_to=canvas)] obj, - move |_| { - // this triggers a canvaslayout allocate() call, - // where the camera and content rendering is updated based on some conditions - canvas.queue_resize(); + #[strong] + scroller, + move |vadj_signal| { + // Apply scroll input from adjustment to camera + let (surface_mins, _) = canvas.engine_ref().camera_surface_mins_maxs(); + let offset = canvas.engine_ref().camera.offset(); + + let new_offset = na::vector![ + offset.x, + super::RnCanvas::adjustment_to_surface( + vadj_signal.value(), + surface_mins.y + ) + ]; + + let widget_flags = canvas.engine_mut().camera_set_offset_expand(new_offset); + + if widget_flags.resize { + // disable kinetic scrolling when the canvas is about to resize (i.e. when it was expanded), + // workaround for https://gitlab.gnome.org/GNOME/gtk/-/issues/1494 + canvas.workaround_disable_kinetic_scrolling(scroller.as_ref()); + } + + canvas.emit_handle_widget_flags(widget_flags); } )); self.connections.borrow_mut().vadjustment.replace(signal_id); + self.connections + .borrow_mut() + .vadjustment_upper + .replace(upper_signal_id); } self.vadjustment.replace(vadj); - obj.configure_adjustments( - widget_size, - offset_mins_maxs, - na::vector![hadj_value, vadj_value], - ); + obj.configure_adjustments(widget_size, adjustment_maxs, adjustment_value); } } } @@ -660,6 +733,8 @@ pub(crate) static OUTPUT_FILE_NEW_SUBTITLE: once_cell::sync::Lazy = impl RnCanvas { // Sets the canvas zoom scroll step in % for one unit of the event controller delta pub(crate) const ZOOM_SCROLL_STEP: f64 = 0.1; + // Sets the canvas rotation scroll step in radians for one unit of the event controller delta + pub(crate) const ROTATION_SCROLL_STEP: f64 = 5_f64.to_radians(); pub(crate) fn new() -> Self { glib::Object::new() @@ -794,20 +869,34 @@ impl RnCanvas { .unwrap() } + #[inline] + pub(crate) fn surface_to_adjustment(offset: T, surface_min: T) -> T + where + T: std::ops::Sub, + { + offset - surface_min + } + + #[inline] + pub(crate) fn adjustment_to_surface(offset: T, surface_min: T) -> T + where + T: std::ops::Add, + { + offset + surface_min + } + pub(crate) fn configure_adjustments( &self, widget_size: na::Vector2, - offset_mins_maxs: (na::Vector2, na::Vector2), - offset: na::Vector2, + adjustment_upper: na::Vector2, + adjustment_value: na::Vector2, ) { - let (offset_mins, offset_maxs) = offset_mins_maxs; - if let Some(hadj) = self.hadjustment() { hadj.configure( // This gets clamped to the lower and upper values - offset[0], - offset_mins[0], - offset_maxs[0], + adjustment_value[0], + 0.0, + adjustment_upper[0].max(widget_size[0]), 0.1 * widget_size[0], 0.9 * widget_size[0], widget_size[0], @@ -817,9 +906,9 @@ impl RnCanvas { if let Some(vadj) = self.vadjustment() { vadj.configure( // This gets clamped to the lower and upper values - offset[1], - offset_mins[1], - offset_maxs[1], + adjustment_value[1], + 0.0, + adjustment_upper[1].max(widget_size[1]), 0.1 * widget_size[1], 0.9 * widget_size[1], widget_size[1], @@ -829,6 +918,29 @@ impl RnCanvas { self.queue_resize(); } + fn workaround_disable_kinetic_scrolling(&self, scroller: Option<&ScrolledWindow>) { + if let Some(scroller) = scroller + && scroller.is_kinetic_scrolling() + { + scroller.set_kinetic_scrolling(false); + self.imp().workaround_kinetic_scrolling_pending.set(true); + } + } + + fn workaround_restore_kinetic_scrolling(&self, scroller: Option<&ScrolledWindow>) { + if !self + .imp() + .workaround_kinetic_scrolling_pending + .replace(false) + { + return; + } + + if let Some(scroller) = scroller { + scroller.set_kinetic_scrolling(true); + } + } + pub(crate) fn widget_size(&self) -> na::Vector2 { na::vector![self.width() as f64, self.height() as f64] } diff --git a/crates/rnote-ui/src/canvasmenu.rs b/crates/rnote-ui/src/canvasmenu.rs index 79ec2ad863..e0d8bc6a85 100644 --- a/crates/rnote-ui/src/canvasmenu.rs +++ b/crates/rnote-ui/src/canvasmenu.rs @@ -27,6 +27,8 @@ mod imp { #[template_child] pub(crate) zoom_fit_width_button: TemplateChild