editing: Preview cropping/rotate/mirror operations

This commit is contained in:
Sophie Herold 2024-10-04 14:05:11 +02:00
parent e9869bcb40
commit 367fdf207d
14 changed files with 419 additions and 36 deletions

View file

@ -682,6 +682,7 @@ impl SharedFrameBuffer {
self.buffer.rcu(|tiling_store| {
let mut new_store = (**tiling_store).clone();
new_store.current().original_dimensions = Some(size);
new_store.original_dimensions();
Arc::new(new_store)
});

View file

@ -15,6 +15,8 @@
//
// SPDX-License-Identifier: GPL-3.0-or-later
pub mod preview;
use std::sync::Arc;
use arc_swap::ArcSwap;

138
src/editing/preview.rs Normal file
View file

@ -0,0 +1,138 @@
use crate::deps::*;
use glycin::{Operation, Operations};
use gtk::prelude::*;
use gufo_common::orientation::Rotation;
pub fn apply_operations(
node: gsk::RenderNode,
width: u32,
height: u32,
operations: &Operations,
snapshot: &gtk::Snapshot,
scale: f64,
) -> Result<(u32, u32), EditingError> {
let os = OperationsSnapshot::new(node, width, height, scale as f32);
os.apply(operations, snapshot)
}
struct OperationsSnapshot {
width: u32,
height: u32,
current_node: gsk::RenderNode,
scale: f32,
}
impl OperationsSnapshot {
fn new(node: gsk::RenderNode, width: u32, height: u32, scale: f32) -> Self {
Self {
width,
height,
current_node: node,
scale,
}
}
fn apply(
mut self,
operations: &Operations,
snapshot: &gtk::Snapshot,
) -> Result<(u32, u32), EditingError> {
for operation in operations.operations() {
let operation_result = match operation {
Operation::Clip(rect) => self.clip(*rect),
Operation::Rotate(rotation) => self.rotate(*rotation),
Operation::MirrorHorizontally => self.mirror_horizontally(),
unsupported_operation => {
return Err(EditingError::UnsupportedOperation(
unsupported_operation.clone(),
));
}
};
if let Err(err) = operation_result {
match err {
OperationError::EmptyNode => {
return Err(EditingError::EmptyNode(operation.clone()))
}
}
}
}
snapshot.append_node(self.current_node);
Ok((self.width, self.height))
}
fn clip(&mut self, (x, y, width, height): (u32, u32, u32, u32)) -> Result<(), OperationError> {
let snapshot = gtk::Snapshot::new();
self.width = width;
self.height = height;
let x = x as f32 * self.scale;
let y = y as f32 * self.scale;
let width = width as f32 * self.scale;
let height = height as f32 * self.scale;
snapshot.translate(&graphene::Point::new(-x, -y));
snapshot.push_clip(&graphene::Rect::new(x, y, width, height));
snapshot.append_node(self.current_node.clone());
snapshot.pop();
self.current_node = snapshot.to_node().ok_or(OperationError::EmptyNode)?;
Ok(())
}
fn mirror_horizontally(&mut self) -> Result<(), OperationError> {
let snapshot = gtk::Snapshot::new();
snapshot.translate(&graphene::Point::new(self.width as f32 * self.scale, 0.));
snapshot.scale(-1., 1.);
snapshot.append_node(self.current_node.clone());
self.current_node = snapshot.to_node().ok_or(OperationError::EmptyNode)?;
Ok(())
}
fn rotate(
&mut self,
rotation: gufo_common::orientation::Rotation,
) -> Result<(), OperationError> {
let snapshot = gtk::Snapshot::new();
let x_center = self.width as f32 * self.scale / 2.;
let y_center = self.height as f32 * self.scale / 2.;
snapshot.translate(&graphene::Point::new(y_center, x_center));
snapshot.rotate(rotation.degrees() as f32);
snapshot.translate(&graphene::Point::new(-x_center, -y_center));
snapshot.append_node(self.current_node.clone());
self.current_node = snapshot.to_node().ok_or(OperationError::EmptyNode)?;
if rotation == Rotation::_90 || rotation == Rotation::_270 {
std::mem::swap(&mut self.width, &mut self.height);
}
Ok(())
}
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum EditingError {
#[error("Node empty after the following operation: {0:?}")]
EmptyNode(Operation),
#[error("The following editing operation is not supported: {0:?}")]
UnsupportedOperation(Operation),
}
#[derive(Debug, Clone, thiserror::Error)]
enum OperationError {
#[error("Empty node")]
EmptyNode,
}

View file

@ -43,7 +43,7 @@ Simplified widget arrangement including the most important widgets.
[`LpPropertiesView`]: widgets::LpPropertiesView
[`LpSlidingView`]: widgets::LpSlidingView
[`LpWindowContents`]: widgets::LpWindowContents
[`LpWindow`]: window::LpWindow
[`LpWindow`]: widgets::LpWindow
*/
use gettextrs::*;

View file

@ -53,6 +53,8 @@ pub enum LpOrientation {
}
mod imp {
use glycin::Operation;
use super::*;
use crate::widgets::LpImage;
#[derive(Debug, Default, gtk::CompositeTemplate, glib::Properties)]
@ -63,6 +65,8 @@ mod imp {
image: TemplateChild<LpImage>,
#[template_child]
pub(super) selection: TemplateChild<LpEditCropSelection>,
#[template_child]
pub apply_crop: TemplateChild<gtk::Button>,
#[property(get, set, builder(LpAspectRatio::default()))]
aspect_ratio: Cell<LpAspectRatio>,
@ -87,6 +91,18 @@ mod imp {
klass.install_property_action("edit-crop.aspect-ratio", "aspect_ratio");
klass.install_property_action("edit-crop.orientation", "orientation");
klass.install_action("edit-crop.mirror", None, |obj, _, _| {
obj.imp().apply_mirror();
});
klass.install_action("edit-crop.rotate-cw", None, |obj, _, _| {
obj.imp().apply_rotate_cw();
});
klass.install_action("edit-crop.rotate-ccw", None, |obj, _, _| {
obj.imp().apply_rotate_ccw();
});
klass.install_action("edit-crop.reset", None, |obj, _, _| {
obj.imp().apply_reset();
});
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
@ -104,6 +120,48 @@ mod imp {
obj.child().set_parent(obj);
self.image.duplicate_from(&obj.original_image());
self.apply_crop.connect_clicked(glib::clone!(
#[weak]
obj,
move |_| {
obj.imp().apply_crop();
}
));
// Selection changed notifications
self.selection.connect_crop_x_notify(glib::clone!(
#[weak]
obj,
move |_| {
obj.imp().selection_changed();
}
));
self.selection.connect_crop_y_notify(glib::clone!(
#[weak]
obj,
move |_| {
obj.imp().selection_changed();
}
));
self.selection.connect_crop_width_notify(glib::clone!(
#[weak]
obj,
move |_| {
obj.imp().selection_changed();
}
));
self.selection.connect_crop_height_notify(glib::clone!(
#[weak]
obj,
move |_| {
obj.imp().selection_changed();
}
));
}
fn dispose(&self) {
@ -115,7 +173,7 @@ mod imp {
fn size_allocate(&self, width: i32, height: i32, baseline: i32) {
let obj = self.obj();
let image: &TemplateChild<LpImage> = &self.image;
let image = &self.image;
obj.child().allocate(width, height, baseline, None);
@ -128,20 +186,6 @@ mod imp {
self.selection.ensure_initialized(x, y, width, height);
self.selection.set_size(x, y, width, height);
dbg!(self.image.widget_to_img_coord((
self.image.image_rendering_x() + self.selection.crop_x() as f64,
self.image.image_rendering_y() + self.selection.crop_y() as f64
)));
dbg!(self.image.widget_to_img_coord((
self.image.image_rendering_x()
+ self.selection.crop_x() as f64
+ self.selection.crop_width() as f64,
self.image.image_rendering_y()
+ self.selection.crop_y() as f64
+ self.selection.crop_height() as f64
)));
}
fn measure(&self, orientation: gtk::Orientation, for_size: i32) -> (i32, i32, i32, i32) {
@ -149,6 +193,80 @@ mod imp {
}
}
impl BinImpl for LpEditCrop {}
impl LpEditCrop {
fn selection_changed(&self) {
let apply_sensitive = self.selection.is_copped();
self.apply_crop.set_sensitive(apply_sensitive);
}
fn crop_area_image_coord(&self) -> Option<(u32, u32, u32, u32)> {
if !self.selection.is_copped() {
return None;
}
let (x1, y1) = self.image.widget_to_img_coord((
self.image.image_rendering_x() + self.selection.crop_x() as f64,
self.image.image_rendering_y() + self.selection.crop_y() as f64,
));
let (x2, y2) = self.image.widget_to_img_coord((
self.image.image_rendering_x()
+ self.selection.crop_x() as f64
+ self.selection.crop_width() as f64,
self.image.image_rendering_y()
+ self.selection.crop_y() as f64
+ self.selection.crop_height() as f64,
));
Some((
x1.round() as u32,
y1.round() as u32,
(x2 - x1).round() as u32,
(y2 - y1).round() as u32,
))
}
fn reset_selection(&self) {
let image = &self.image;
let (x, y, width, height) = (
image.image_rendering_x(),
image.image_rendering_y(),
image.image_rendering_width(),
image.image_rendering_height(),
);
self.selection.reset(x, y, width, height);
}
fn apply_crop(&self) {
if let Some(crop) = self.crop_area_image_coord() {
self.image.add_operation(Operation::Clip(crop));
self.reset_selection();
}
}
fn apply_mirror(&self) {
self.image.add_operation(Operation::MirrorHorizontally);
self.reset_selection();
}
fn apply_rotate_cw(&self) {
self.image
.add_operation(Operation::Rotate(gufo_common::orientation::Rotation::_90));
self.reset_selection();
}
fn apply_rotate_ccw(&self) {
self.image
.add_operation(Operation::Rotate(gufo_common::orientation::Rotation::_270));
self.reset_selection();
}
fn apply_reset(&self) {
self.image.set_operations(None);
self.reset_selection();
}
}
}
glib::wrapper! {

View file

@ -71,11 +71,15 @@
</object>
</child>
<child type="overlay">
<object class="GtkButton">
<object class="GtkButton" id="apply_crop">
<property name="label">Apply</property>
<property name="action-name">edit.apply-crop</property>
<property name="halign">end</property>
<property name="valign">start</property>
<style>
<class name="osd" />
<class name="pill" />
</style>
</object>
</child>
</object>
@ -144,6 +148,7 @@
<child>
<object class="GtkButton">
<property name="icon-name">edit-mirror-symbolic</property>
<property name="action-name">edit-crop.mirror</property>
<property name="valign">start</property>
<style>
<class name="flat" />
@ -160,6 +165,7 @@
<child>
<object class="GtkButton">
<property name="icon-name">object-rotate-left-symbolic</property>
<property name="action-name">edit-crop.rotate-ccw</property>
<property name="valign">start</property>
<style>
<class name="flat" />
@ -169,6 +175,7 @@
<child>
<object class="GtkButton">
<property name="icon-name">object-rotate-right-symbolic</property>
<property name="action-name">edit-crop.rotate-cw</property>
<property name="valign">start</property>
<style>
<class name="flat" />
@ -185,6 +192,7 @@
<child>
<object class="GtkButton">
<property name="label">Reset</property>
<property name="action-name">edit-crop.reset</property>
<property name="valign">start</property>
<style>
<class name="flat" />

View file

@ -767,6 +767,12 @@ impl LpEditCropSelection {
imp.initialized.set(true);
self.reset(x, y, width, height);
}
pub fn reset(&self, x: f64, y: f64, width: f64, height: f64) {
let imp = self.imp();
self.set_margin_start(x as i32);
self.set_margin_top(y as i32);
self.set_total_width(width as i32);
@ -791,4 +797,12 @@ impl LpEditCropSelection {
self.set_total_width(width as i32);
self.set_total_height(height as i32);
}
pub fn is_copped(&self) -> bool {
let untouched = self.crop_x() == 0.
&& self.crop_y() == 0.
&& self.crop_width() as i32 == self.total_width()
&& self.crop_height() as i32 == self.total_height();
!untouched
}
}

View file

@ -32,6 +32,7 @@
mod background_color;
mod drag;
mod editing;
mod input_handling;
mod loading;
mod metadata;
@ -132,6 +133,7 @@ pub enum FitMode {
mod imp {
use decoder::DecoderError;
use glycin::Operations;
use super::*;
use crate::decoder::tiling::SharedFrameBuffer;
@ -164,6 +166,7 @@ mod imp {
pub(super) frame_buffer: Arc<SharedFrameBuffer>,
pub(super) previous_frame_buffer: SharedFrameBuffer,
pub(super) decoder: RefCell<Option<Arc<Decoder>>>,
pub(super) overwrite_dimensions: Cell<Option<(u32, u32)>>,
/// Rotation final value (can differ from `rotation` during animation)
pub(super) rotation_target: Cell<f64>,
@ -244,6 +247,7 @@ mod imp {
/// Editing queue
pub(super) editing_queue: editing::Queue,
pub(super) operations: RefCell<Option<Operations>>,
}
#[glib::object_subclass]

View file

@ -70,7 +70,7 @@ impl imp::LpImage {
return Some(Self::default_background_color());
}
let (width, height) = self.original_dimensions();
let (width, height) = self.untransformed_dimensions();
let max_size = i32::max(width, height);
// Only use max 200px size scaled image for analysis

View file

@ -0,0 +1,73 @@
use glycin::Operations;
use tiling::RenderOptions;
use super::*;
impl imp::LpImage {
pub(super) fn apply_operations(
&self,
node: gsk::RenderNode,
snapshot: &gtk::Snapshot,
) -> Result<(), crate::editing::preview::EditingError> {
if let Some(operations) = self.operations.borrow().as_ref() {
let (width, height) = self.original_dimensions();
let new_dimensions = crate::editing::preview::apply_operations(
node,
width as u32,
height as u32,
operations,
snapshot,
self.applicable_zoom(),
)?;
self.overwrite_dimensions.set(Some(new_dimensions));
}
Ok(())
}
}
impl LpImage {
pub fn set_operations(&self, operations: Option<glycin::Operations>) {
let imp = self.imp();
if operations.is_none() {
imp.overwrite_dimensions.set(None);
}
imp.operations.replace(operations);
let tmp_snapshot = gtk::Snapshot::new();
imp.active_frame_buffer().add_to_snapshot(
&tmp_snapshot,
1.,
&RenderOptions {
background_color: None,
scaling: 1.,
scaling_filter: gsk::ScalingFilter::Nearest,
},
);
if let Some(node) = tmp_snapshot.to_node() {
imp.apply_operations(node, &gtk::Snapshot::new());
} else {
log::error!("Render node is empty");
}
imp.configure_best_fit();
self.queue_draw();
}
pub fn add_operation(&self, operation: glycin::Operation) {
let imp = self.imp();
let mut operations = imp
.operations
.borrow()
.as_ref()
.map(|x| x.operations().to_vec())
.unwrap_or_default();
operations.push(operation);
self.set_operations(Some(Operations::new(operations)));
}
}

View file

@ -81,7 +81,7 @@ impl imp::LpImage {
obj.reset_rotation();
}
DecoderUpdate::Dimensions => {
log::debug!("Received dimensions: {:?}", self.original_dimensions());
log::debug!("Received dimensions: {:?}", self.untransformed_dimensions());
obj.notify_image_size_available();
self.configure_best_fit();
self.request_tiles();

View file

@ -29,6 +29,14 @@ impl imp::LpImage {
}
}
pub(super) fn untransformed_dimensions(&self) -> (i32, i32) {
if let Some((width, height)) = self.overwrite_dimensions.get() {
(width as i32, height as i32)
} else {
self.original_dimensions()
}
}
/// Image width with current zoom factor and rotation
///
/// During rotation it is an interpolated size that does not
@ -43,7 +51,7 @@ impl imp::LpImage {
}
pub fn image_width(&self, zoom: f64) -> f64 {
let (width, height) = self.original_dimensions();
let (width, height) = self.untransformed_dimensions();
let rotated = self.obj().rotation().to_radians().sin().abs();
@ -51,7 +59,7 @@ impl imp::LpImage {
}
pub fn image_height(&self, zoom: f64) -> f64 {
let (width, height) = self.original_dimensions();
let (width, height) = self.untransformed_dimensions();
let rotated = self.obj().rotation().to_radians().sin().abs();
@ -104,7 +112,7 @@ impl LpImage {
let (x, y) = self.imp().original_dimensions();
(y, x)
} else {
self.imp().original_dimensions()
self.imp().untransformed_dimensions()
}
}
}

View file

@ -15,6 +15,8 @@
//
// SPDX-License-Identifier: GPL-3.0-or-later
use tiling::FrameBuffer;
use super::*;
impl WidgetImpl for imp::LpImage {
@ -121,17 +123,21 @@ impl WidgetImpl for imp::LpImage {
);
// Add texture(s)
let frame_buffer = self.frame_buffer.load();
// Don't use current frame buffer if it empty during an image reload
let use_frame_buffer = if frame_buffer.is_empty() {
// Fallback to previous buffer to show previous image until new one is available
self.previous_frame_buffer.load()
} else {
frame_buffer
};
let frame_buffer = self.active_frame_buffer();
use_frame_buffer.add_to_snapshot(snapshot, applicable_zoom, &render_options);
if self.operations.borrow().is_some() {
let tmp_snapshot = gtk::Snapshot::new();
frame_buffer.add_to_snapshot(&tmp_snapshot, applicable_zoom, &render_options);
if let Some(node) = tmp_snapshot.to_node() {
self.apply_operations(node, snapshot);
} else {
log::error!("Render node is empty");
}
} else {
frame_buffer.add_to_snapshot(snapshot, applicable_zoom, &render_options);
}
snapshot.restore();
@ -230,7 +236,7 @@ impl imp::LpImage {
return;
}
let (original_width, original_height) = self.original_dimensions();
let (untransformed_width, untransformed_height) = self.untransformed_dimensions();
let width = self.image_width(zoom) as f32;
let height = self.image_height(zoom) as f32;
@ -250,8 +256,8 @@ impl imp::LpImage {
// Needed for rotating around the center of the image, and
// mirroring the image does not put it to a completely different position.
snapshot.translate(&graphene::Point::new(
-self.round_f32(original_width as f32 * zoom as f32 / 2.),
-self.round_f32(original_height as f32 * zoom as f32 / 2.),
-self.round_f32(untransformed_width as f32 * zoom as f32 / 2.),
-self.round_f32(untransformed_height as f32 * zoom as f32 / 2.),
));
}
@ -268,6 +274,17 @@ impl imp::LpImage {
graphene::Rect::new(x, y, width, height)
}
pub(super) fn active_frame_buffer(&self) -> Arc<FrameBuffer> {
let frame_buffer = self.frame_buffer.load_full();
if frame_buffer.is_empty() {
// Fallback to previous buffer to show previous image until new one is available
self.previous_frame_buffer.load_full()
} else {
frame_buffer
}
}
pub fn widget_width(&self) -> f64 {
self.obj().width() as f64
}

View file

@ -40,8 +40,8 @@ impl imp::LpImage {
pub(super) fn zoom_level_best_fit_for_rotation(&self, rotation: f64) -> f64 {
let rotated = rotation.to_radians().sin().abs();
let (image_width, image_height) = (
self.original_dimensions().0 as f64 / self.scaling(),
self.original_dimensions().1 as f64 / self.scaling(),
self.untransformed_dimensions().0 as f64 / self.scaling(),
self.untransformed_dimensions().1 as f64 / self.scaling(),
);
let texture_aspect_ratio = image_width / image_height;
let widget_aspect_ratio = self.widget_width() / self.widget_height();
@ -93,7 +93,7 @@ impl imp::LpImage {
let obj = self.obj();
if obj.metadata().is_svg() {
let (width, height) = self.original_dimensions();
let (width, height) = self.untransformed_dimensions();
// Avoid division by 0
let long_side = f64::max(1., i32::max(width, height) as f64);
// Limit to maz size supported by rsvg