From 029c0c9244c710aba6a21ef3f48a6ab8fc52ed7f Mon Sep 17 00:00:00 2001 From: Sophie Herold Date: Thu, 10 Nov 2022 19:39:23 +0100 Subject: [PATCH] image: Add zoom and scrolling support Zoom via - Scroll event controller - Zoom gesture - Overlay buttons - Fixed zoom level shortcuts (1=100%, 2=200%) Scrolling via - Scrolled window - Drag gesture Changes - Implement best-fit via calculating a zoom level - Gesture for carousel no longer working because of scrolled window - Split control overlays into two to not steal inputs in area between Closes #17 --- Cargo.toml | 2 +- data/gtk/help_overlay.ui | 12 + data/gtk/image_page.ui | 72 ++-- data/gtk/image_view.ui | 37 +- src/application.rs | 2 + src/widgets/image.rs | 841 +++++++++++++++++++++++++++++++++----- src/widgets/image_page.rs | 23 +- src/widgets/image_view.rs | 75 +++- src/window.rs | 43 +- 9 files changed, 952 insertions(+), 155 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index f34d2cb..cc6351b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ features = ["gtk4"] [dependencies.gtk] package = "gtk4" version = "0.5.1" -features = ["v4_6"] +features = ["v4_8"] [dependencies.gio] package = "gio" diff --git a/data/gtk/help_overlay.ui b/data/gtk/help_overlay.ui index 13ff07f..7a3ad5d 100644 --- a/data/gtk/help_overlay.ui +++ b/data/gtk/help_overlay.ui @@ -38,6 +38,18 @@ <Primary>q + + + Show with orginal scale + 1 + + + + + Show with 200% of orginal scale + 2 + + diff --git a/data/gtk/image_page.ui b/data/gtk/image_page.ui index e5599ff..c19451c 100644 --- a/data/gtk/image_page.ui +++ b/data/gtk/image_page.ui @@ -57,45 +57,49 @@ - - - - 3 - - - - - True - - - - - False - image_menu - - - object-rotate-left-symbolic - Rotate Left - win.rotate - -90.0 - + + + + + + 3 - - - object-rotate-right-symbolic - Rotate Right - win.rotate - 90.0 - + + + True + + + + + False + image_menu + + + object-rotate-left-symbolic + Rotate Left + win.rotate + -90.0 + + + + + + object-rotate-right-symbolic + Rotate Right + win.rotate + 90.0 + + + - + diff --git a/data/gtk/image_view.ui b/data/gtk/image_view.ui index 397ed73..b41c111 100644 --- a/data/gtk/image_view.ui +++ b/data/gtk/image_view.ui @@ -16,8 +16,9 @@ - + end + start 18 18 18 @@ -46,9 +47,19 @@ + + + + + end + end + 18 + 18 + 18 + 18 + 12 - True end @@ -65,6 +76,28 @@ + + + zoom-out-symbolic + win.zoom-out + Zoom Out + + + + + + zoom-in-symbolic + win.zoom-in + Zoom In + + + diff --git a/src/application.rs b/src/application.rs index 402760d..fe1b08a 100644 --- a/src/application.rs +++ b/src/application.rs @@ -161,6 +161,8 @@ impl LpApplication { self.set_accels_for_action("win.show-help-overlay", &["question"]); self.set_accels_for_action("win.toggle-fullscreen", &["F11"]); self.set_accels_for_action("window.close", &["W"]); + self.set_accels_for_action("win.zoom-to(1.0)", &["1"]); + self.set_accels_for_action("win.zoom-to(2.0)", &["2"]); } pub fn show_about(&self) { diff --git a/src/widgets/image.rs b/src/widgets/image.rs index caf1ecd..483852c 100644 --- a/src/widgets/image.rs +++ b/src/widgets/image.rs @@ -1,4 +1,3 @@ -// image.rs // // Copyright 2021 Christopher Davis // @@ -23,10 +22,18 @@ use adw::{prelude::*, subclass::prelude::*}; use once_cell::sync::Lazy; +use once_cell::unsync::OnceCell; use std::cell::{Cell, RefCell}; use crate::image_metadata::ImageMetadata; +static ZOOM_ANIMATION_DURATION: u32 = 200; +static ROTATION_ANIMATION_DURATION: u32 = 200; + +static ZOOM_FACTOR_BUTTON: f64 = 1.5; +static ZOOM_FACTOR_WHEEL: f64 = 1.3; +static ZOOM_FACTOR_WHEEL_HI_RES: f64 = 0.1; + mod imp { use super::*; @@ -39,11 +46,44 @@ mod imp { /// Rotated presentation of original image in degrees clockwise pub rotation: Cell, // Animates the `rotation` property - pub rotation_animation: RefCell>, + pub rotation_animation: OnceCell, /// Mirrored presentation of original image pub mirrored: Cell, + + /// Displayed zoom level + pub zoom: Cell, + pub zoom_animation: OnceCell, + /// Targeted zoom level, might differ from `zoom` when animation is running + pub zoom_target: Cell, + /// Current animation is transitioning from having horizontal scrollbars + /// to not having them or vice versa. + pub zoom_hscrollbar_transition: Cell, + /// Same but vor vertical + pub zoom_vscrollbar_transition: Cell, + + /// Always fit image into window, causes `zoom` to change automatically + pub best_fit: Cell, + + /// Horizontal scrolling + pub hadjustment: RefCell>, + /// Vertical scrolling + pub vadjustment: RefCell>, + /// Currently EXIF data pub image_metadata: RefCell, + + /// Current pointer position + pub pointer_position: Cell>, + + /// Position of fingers during zoom gesture + /// + /// Required for calculating delta when moving window on touchscreen. + /// On touchpad this is only the initial value used as the zoom target. + pub zoom_gesture_center: Cell>, + /// Required for calculating delta while moving window around + pub last_drag_value: Cell>, + + widget_dimensions: Cell<(i32, i32)>, } #[glib::object_subclass] @@ -51,6 +91,7 @@ mod imp { const NAME: &'static str = "LpImage"; type ParentType = gtk::Widget; type Type = super::LpImage; + type Interfaces = (gtk::Scrollable,); } impl ObjectImpl for LpImage { @@ -60,8 +101,22 @@ mod imp { glib::ParamSpecObject::builder::("file") .read_only() .build(), - glib::ParamSpecDouble::builder("rotation").build(), - glib::ParamSpecBoolean::builder("mirrored").build(), + glib::ParamSpecDouble::builder("rotation") + .explicit_notify() + .build(), + glib::ParamSpecBoolean::builder("mirrored") + .explicit_notify() + .build(), + glib::ParamSpecDouble::builder("zoom") + .explicit_notify() + .build(), + glib::ParamSpecBoolean::builder("best-fit") + .explicit_notify() + .build(), + glib::ParamSpecOverride::for_interface::("hadjustment"), + glib::ParamSpecOverride::for_interface::("vadjustment"), + glib::ParamSpecOverride::for_interface::("hscroll-policy"), + glib::ParamSpecOverride::for_interface::("vscroll-policy"), ] }); @@ -74,7 +129,13 @@ mod imp { "file" => obj.file().to_value(), "rotation" => obj.rotation().to_value(), "mirrored" => obj.mirrored().to_value(), - _ => unimplemented!(), + "zoom" => obj.zoom().to_value(), + "best-fit" => obj.is_best_fit().to_value(), + // don't use getter functions here sicne they can return a fake adjustment + "hadjustment" => self.hadjustment.borrow().to_value(), + "vadjustment" => self.vadjustment.borrow().to_value(), + "hscroll-policy" | "vscroll-policy" => gtk::ScrollablePolicy::Minimum.to_value(), + name => unimplemented!("property {}", name), } } @@ -83,39 +144,39 @@ mod imp { match pspec.name() { "rotation" => obj.set_rotation(value.get().unwrap()), "mirrored" => obj.set_mirrored(value.get().unwrap()), + "zoom" => obj.set_zoom(value.get().unwrap()), + "best-fit" => obj.set_best_fit(value.get().unwrap()), + "hadjustment" => obj.set_hadjustment(value.get().unwrap()), + "vadjustment" => obj.set_vadjustment(value.get().unwrap()), + "hscroll-policy" | "vscroll-policy" => (), _ => unimplemented!(), } } fn constructed(&self) { + self.parent_constructed(); + let obj = self.instance(); obj.set_hexpand(true); obj.set_vexpand(true); + obj.set_overflow(gtk::Overflow::Hidden); - let rotation_animation = adw::TimedAnimation::builder() - .duration(200) - .widget(&*obj) - .target(&adw::PropertyAnimationTarget::new(&*obj, "rotation")) - .build(); - self.rotation_animation.replace(Some(rotation_animation)); + self.zoom.set(1.); + self.zoom_target.set(1.); + self.best_fit.set(true); - let rotation_gesture = gtk::GestureRotate::new(); - obj.add_controller(&rotation_gesture); - - rotation_gesture.connect_angle_changed(glib::clone!(@weak obj => move |_, angle, _| { - obj.set_rotation(obj.imp().rotation_target.get() + angle.to_degrees()); - })); - - rotation_gesture.connect_end(glib::clone!(@weak obj => move |_, _| { - let angle = (obj.rotation() / 90.).round() * 90. - obj.imp().rotation_target.get(); - obj.rotate(angle); - })); + self.connect_controllers(); + self.connect_gestures(); } fn dispose(&self) { let obj = self.instance(); - self.rotation_animation.replace(None); + // remove target fron zoom animation because it's property of this object + obj.rotation_animation() + .set_target(&adw::CallbackAnimationTarget::new(|_| {})); + obj.zoom_animation() + .set_target(&adw::CallbackAnimationTarget::new(|_| {})); while let Some(child) = obj.first_child() { child.unparent(); @@ -123,87 +184,278 @@ mod imp { } } + impl LpImage { + fn connect_controllers(&self) { + let obj = self.instance(); + + // Needed vor having the current cursor position available + let motion_controller = gtk::EventControllerMotion::new(); + motion_controller.connect_enter(glib::clone!(@weak obj => move |_, x, y| { + obj.imp().pointer_position.set(Some((x, y))); + })); + motion_controller.connect_motion(glib::clone!(@weak obj => move |_, x, y| { + obj.imp().pointer_position.set(Some((x, y))); + })); + motion_controller.connect_leave(glib::clone!(@weak obj => move |_| { + obj.imp().pointer_position.set(None); + })); + obj.add_controller(&motion_controller); + + // Zoom via scroll wheels etc + let scroll_controller = + gtk::EventControllerScroll::new(gtk::EventControllerScrollFlags::VERTICAL); + + scroll_controller.connect_scroll(glib::clone!(@weak obj => @default-return gtk::Inhibit(false), move |event, _, y| { + // use Ctrl key as modifier for vertical scrolling + if event.current_event_state() == gdk::ModifierType::CONTROL_MASK + || event.current_event_state() == gdk::ModifierType::SHIFT_MASK + { + // propagate event to scrolled window + return gtk::Inhibit(false); + } + + // touchpads do zoom via gestures only + if event.current_event_device().map(|x| x.source()) + == Some(gdk::InputSource::Touchpad) + { + // propagate event to scrolled window + return gtk::Inhibit(false); + } + + let (zoom_factor, animated) = match event.unit() { + gdk::ScrollUnit::Wheel => (y.abs() * ZOOM_FACTOR_WHEEL, true), + // TODO: completely untested + gdk::ScrollUnit::Surface => (y.abs() * ZOOM_FACTOR_WHEEL_HI_RES + 1.0, false), + unknown_unit => { + log::warn!("Ignoring unknown scroll unit: {unknown_unit}"); + (1., false) + } + }; + + let zoom = if y > 0. { + obj.imp().zoom_target.get() / zoom_factor + } else { + obj.imp().zoom_target.get() * zoom_factor + }; + + if zoom > obj.zoom_level_best_fit() { + obj.set_best_fit(false); + if animated { + obj.zoom_to(zoom); + } else { + obj.set_zoom(zoom); + } + } else { + obj.set_best_fit(true); + if animated { + obj.zoom_to(obj.zoom_level_best_fit()); + } else { + obj.set_zoom(obj.zoom_level_best_fit()); + } + } + + // do not propagate event to scrolled window + gtk::Inhibit(true) + })); + + obj.add_controller(&scroll_controller); + } + + fn connect_gestures(&self) { + let obj = self.instance(); + + // Drag for moving image around + let drag_gesture = gtk::GestureDrag::new(); + obj.add_controller(&drag_gesture); + + drag_gesture.connect_drag_begin(glib::clone!(@weak obj => move |gesture, _, _| { + if obj.is_hscrollable() || obj.is_vscrollable() { + obj.set_cursor(gdk::Cursor::from_name("grabbing", None).as_ref()); + obj.imp().last_drag_value.set(Some((0., 0.))); + } else { + // let drag and drop handle the events when not scrollable + gesture.set_state(gtk::EventSequenceState::Denied); + } + })); + + drag_gesture.connect_drag_update(glib::clone!(@weak obj => move |_, x1, y1| { + if let Some((x0, y0)) = obj.imp().last_drag_value.get() { + let hadjustment = obj.hadjustment(); + let vadjustment = obj.vadjustment(); + + hadjustment.set_value(hadjustment.value() - x1 + x0); + vadjustment.set_value(vadjustment.value() - y1 + y0); + } + + obj.imp().last_drag_value.set(Some((x1, y1))); + })); + + drag_gesture.connect_drag_end(glib::clone!(@weak obj => move |_, _, _| { + obj.set_cursor(None); + obj.imp().last_drag_value.set(None); + })); + + // Rotate + let rotation_gesture = gtk::GestureRotate::new(); + obj.add_controller(&rotation_gesture); + + rotation_gesture.connect_angle_changed(glib::clone!(@weak obj => move |_, angle, _| { + // offset for rotate gesture to take effect + if angle.abs().to_degrees() > 20. { + obj.set_rotation(obj.imp().rotation_target.get() + angle.to_degrees()); + } + })); + + rotation_gesture.connect_end(glib::clone!(@weak obj => move |_, _| { + log::debug!("Rotate gesture ended"); + + // Set best-fit here as well, since this can end before zoom gesture. + // The value of best-fit is relevant for `rotate_by` + obj.set_best_fit(obj.zoom() < obj.zoom_level_best_fit()); + + let angle = (obj.rotation() / 90.).round() * 90. - obj.imp().rotation_target.get(); + obj.rotate_by(angle); + })); + + // Zoom + let zoom_gesture = gtk::GestureZoom::new(); + obj.add_controller(&zoom_gesture); + + zoom_gesture.connect_begin(glib::clone!(@weak obj => move |gesture, _| { + obj.imp() + .zoom_gesture_center + .set(gesture.bounding_box_center()); + })); + + zoom_gesture.connect_scale_changed(glib::clone!(@weak obj => move |gesture, scale| { + let hadjustment = obj.hadjustment(); + let vadjustment = obj.vadjustment(); + let zoom = obj.imp().zoom_target.get() * scale; + + // move image with fingers on touchscreens + if gesture.device().map(|x| x.source()) == Some(gdk::InputSource::Touchscreen) { + if let p1 @ Some((x1, y1)) = gesture.bounding_box_center() { + if let Some((x0, y0)) = obj.imp().zoom_gesture_center.get() { + hadjustment.set_value(hadjustment.value() + x1 - x0); + vadjustment.set_value(vadjustment.value() + y1 - y0); + } else { + log::warn!("Zoom bounding box center: No previous value"); + } + + obj.imp().zoom_gesture_center.set(p1); + } + } + + obj.set_zoom_aiming(zoom, obj.imp().zoom_gesture_center.get()); + })); + + zoom_gesture.connect_end(glib::clone!(@weak obj => move |_, _| { + log::debug!("Zoom gesture ended"); + + let rotation_target = (obj.rotation() / 90.).round() * 90.; + if obj.zoom() < obj.zoom_level_best_fit_for_rotation(rotation_target) { + obj.set_best_fit(true); + obj.zoom_to(obj.zoom_level_best_fit_for_rotation(rotation_target)); + } else { + obj.set_best_fit(false); + obj.imp().zoom_target.set(obj.zoom()); + }; + })); + + zoom_gesture.group_with(&rotation_gesture); + } + } + impl WidgetImpl for LpImage { + // called when the widget size might have changed + fn size_allocate(&self, width: i32, height: i32, _baseline: i32) { + let widget = self.instance(); + + // ensure there is an actual size change + if self.widget_dimensions.get() != (width, height) { + self.widget_dimensions.set((width, height)); + // calculate new zoom value for best fit + if widget.is_best_fit() { + let best_fit_level = widget.zoom_level_best_fit(); + self.zoom.set(best_fit_level); + self.zoom_target.set(best_fit_level); + self.instance().zoom_animation().pause(); + } + } + + widget.configure_adjustments(); + } + + // called when the widget content should be re-rendered fn snapshot(&self, snapshot: >k::Snapshot) { if let Some(texture) = self.texture.borrow().as_ref() { let widget = self.instance(); let widget_width = widget.width() as f64; let widget_height = widget.height() as f64; - let texture_ratio = texture.intrinsic_aspect_ratio(); - let rotated = widget.rotation().to_radians().sin().abs(); - if texture_ratio == 0.0 { - // TODO: We probably need rotation etc on this? - // Maybe just set ratio to 1. or widget_width/widget_height and run code below? - texture.snapshot(snapshot, widget_width, widget_height); - } else { - let widget_ratio = widget_width / widget_height; + // make sure the scrollbars are correct + widget.configure_adjustments(); - // image dimension for default orientation - let (snapshot_width_default, snapshot_height_default) = - if texture_ratio > widget_ratio { - (widget_width, widget_width / texture_ratio) - } else { - (widget_height * texture_ratio, widget_height) - }; + snapshot.save(); - // image dimensions for 90deg rotated - let (snapshot_width_rotated, snapshot_height_rotated) = - if (1.0 / texture_ratio) > widget_ratio { - (widget_width * texture_ratio, widget_width) - } else { - (widget_height, widget_height / texture_ratio) - }; - - // interpolate between size computation during rotation - let mut snapshot_height = rotated * snapshot_height_rotated - + (1. - rotated) * snapshot_height_default; - let mut snapshot_width = - rotated * snapshot_width_rotated + (1. - rotated) * snapshot_width_default; - - // Don't make the image larger then default size - if snapshot_height > texture.height() as f64 - || snapshot_width > texture.width() as f64 - { - snapshot_height = texture.height() as f64; - snapshot_width = texture.width() as f64; - } - - snapshot.save(); - - // center image in widget - let x = (widget_width - snapshot_width) / 2.0; - let y = (widget_height - snapshot_height) / 2.0; - snapshot.translate(&graphene::Point::new(x as f32, y as f32)); - - // center image in coordinates to rotate origin - snapshot.translate(&graphene::Point::new( - (snapshot_width / 2.) as f32, - (snapshot_height / 2.) as f32, - )); - - // apply the transformations from properties - snapshot.rotate(widget.rotation() as f32); - if widget.mirrored() { - snapshot.scale(-1., 1.); - } - - // move back to original position - snapshot.translate(&graphene::Point::new( - (-snapshot_width / 2.) as f32, - (-snapshot_height / 2.) as f32, - )); - - texture.snapshot(snapshot, snapshot_width, snapshot_height); - snapshot.restore(); + // apply the scrolling position to the image + if let Some(adj) = self.hadjustment.borrow().as_ref() { + let x = -(adj.value() - (adj.upper() - widget_width) / 2.); + snapshot.translate(&graphene::Point::new(x as f32, 0.)); } + if let Some(adj) = self.vadjustment.borrow().as_ref() { + let y = -(adj.value() - (adj.upper() - widget_height) / 2.); + snapshot.translate(&graphene::Point::new(0., y as f32)); + } + + // Center image in coordinates. + // 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( + (widget_width / 2.) as f32, + (widget_height / 2.) as f32, + )); + + // vertical centering in widget when no scrolling + let x = f64::max((widget_width - widget.image_displayed_width()) / 2.0, 0.); + snapshot.translate(&graphene::Point::new(x as f32, 0.)); + + let y = f64::max((widget_height - widget.image_displayed_height()) / 2.0, 0.); + snapshot.translate(&graphene::Point::new(0., y as f32)); + + // apply the transformations from properties + snapshot.rotate(widget.rotation() as f32); + if widget.mirrored() { + snapshot.scale(-1., 1.); + } + snapshot.scale(widget.zoom() as f32, widget.zoom() as f32); + + // scale to actual pixel size + // this is needed since usually the texture would just fill the widget + snapshot.scale( + (texture.width() as f64 / widget_width) as f32, + (texture.height() as f64 / widget_height) as f32, + ); + + // move back to original position + snapshot.translate(&graphene::Point::new( + (-widget_width / 2.) as f32, + (-widget_height / 2.) as f32, + )); + + texture.snapshot(snapshot, widget_width, widget_height); + snapshot.restore(); } } } + + impl ScrollableImpl for LpImage {} } glib::wrapper! { - pub struct LpImage(ObjectSubclass) @extends gtk::Widget; + pub struct LpImage(ObjectSubclass) + @extends gtk::Widget, + @implements gtk::Scrollable; } impl LpImage { @@ -222,7 +474,46 @@ impl LpImage { imp.rotation.set(-orientation.rotation); imp.mirrored.set(orientation.mirrored); + self.configure_adjustments(); + self.queue_draw(); + self.queue_allocate(); + self.queue_resize(); + } + + /// Zoom level that makes the image fit in widget + /// + /// During image rotation the image does not actually fit into widget. + /// Instead the level is interpolated between zoom levels + fn zoom_level_best_fit(&self) -> f64 { + self.zoom_level_best_fit_for_rotation(self.rotation()) + } + + /// Same, but not for current rotation target + /// + /// Used for calculating the required zoom level after rotation + fn zoom_level_best_fit_for_rotation(&self, rotation: f64) -> f64 { + if let Some(texture) = self.texture() { + let rotated = rotation.to_radians().sin().abs(); + let texture_aspect_ratio = texture.width() as f64 / texture.height() as f64; + let widget_aspect_ratio = self.width() as f64 / self.height() as f64; + + let default_zoom = if texture_aspect_ratio > widget_aspect_ratio { + (self.width() as f64 / texture.width() as f64).min(1.) + } else { + (self.height() as f64 / texture.height() as f64).min(1.) + }; + + let rotated_zoom = if 1. / texture_aspect_ratio > widget_aspect_ratio { + (self.width() as f64 / texture.height() as f64).min(1.) + } else { + (self.height() as f64 / texture.width() as f64).min(1.) + }; + + rotated * rotated_zoom + (1. - rotated) * default_zoom + } else { + 1. + } } pub fn file(&self) -> Option { @@ -231,29 +522,383 @@ impl LpImage { imp.file.borrow().clone() } + /// Texture that contains the original image pub fn texture(&self) -> Option { let imp = self.imp(); imp.texture.borrow().clone() } + fn mirrored(&self) -> bool { + self.imp().mirrored.get() + } + + fn set_mirrored(&self, mirrored: bool) { + if mirrored == self.mirrored() { + return; + } + + self.imp().mirrored.set(mirrored); + self.notify("mirrored"); + self.queue_draw(); + } + pub fn rotation(&self) -> f64 { self.imp().rotation.get() } pub fn set_rotation(&self, rotation: f64) { + if rotation == self.rotation() { + return; + } + self.imp().rotation.set(rotation); + self.notify("rotation"); self.queue_draw(); } - pub fn mirrored(&self) -> bool { - self.imp().mirrored.get() + pub fn rotate_by(&self, angle: f64) { + log::debug!("Rotate by {} degrees", angle); + let target = &self.imp().rotation_target; + target.set(target.get() + angle); + + let animation = self.rotation_animation(); + + animation.set_value_from(self.rotation()); + animation.set_value_to(target.get()); + animation.play(); + + if self.is_best_fit() { + let animation = self.zoom_animation(); + + animation.set_value_from(self.zoom()); + animation.set_value_to(self.zoom_level_best_fit_for_rotation(target.get())); + animation.play(); + } } - pub fn set_mirrored(&self, mirrored: bool) { - self.imp().mirrored.set(mirrored); + fn rotation_animation(&self) -> &adw::TimedAnimation { + self.imp().rotation_animation.get_or_init(|| { + adw::TimedAnimation::builder() + .duration(ROTATION_ANIMATION_DURATION) + .widget(self) + .target(&adw::PropertyAnimationTarget::new(self, "rotation")) + .build() + }) + } + + pub fn is_best_fit(&self) -> bool { + self.imp().best_fit.get() + } + + pub fn set_best_fit(&self, best_fit: bool) { + if best_fit == self.is_best_fit() { + return; + } + + self.imp().best_fit.set(best_fit); + self.notify("best-fit"); + } + + /// Current zoom level + pub fn zoom(&self) -> f64 { + self.imp().zoom.get() + } + + /// Set zoom level aiming for cursor position or center if not available + /// + /// Aiming means that the scrollbars are adjust such that the same point + /// of the image remains under the cursor after changing the zoom level. + fn set_zoom(&self, zoom: f64) { + self.set_zoom_aiming(zoom, self.imp().pointer_position.get()) + } + + /// Set zoom level aiming for given position or center if not available + fn set_zoom_aiming(&self, zoom: f64, aiming: Option<(f64, f64)>) { + if zoom == self.zoom() || zoom <= 0. { + return; + } + + let zoom_ratio = self.imp().zoom.get() / zoom; + + self.imp().zoom.set(zoom); + + self.configure_adjustments(); + + let center_x = self.widget_width() / 2.; + let center_y = self.widget_height() / 2.; + + let (x, y) = aiming.unwrap_or((center_x, center_y)); + + if self.imp().zoom_hscrollbar_transition.get() { + if zoom_ratio < 1. { + self.hadjustment() + .set_value(self.max_hadjustment_value() / 2.); + } else { + // move towards center + self.hadjustment() + .set_value(self.hadjustment_corrected_for_zoom(zoom_ratio, center_x)); + } + } else { + self.hadjustment() + .set_value(self.hadjustment_corrected_for_zoom(zoom_ratio, x)); + } + + if self.imp().zoom_vscrollbar_transition.get() { + if zoom_ratio < 1. { + self.vadjustment() + .set_value(self.max_vadjustment_value() / 2.); + } else { + // move towards center + self.vadjustment() + .set_value(self.vadjustment_corrected_for_zoom(zoom_ratio, center_y)); + } + } else { + self.vadjustment() + .set_value(self.vadjustment_corrected_for_zoom(zoom_ratio, y)); + } + + self.notify("zoom"); self.queue_draw(); } + /// Animation that makes larger zoom steps (from buttons etc) look smooth + fn zoom_animation(&self) -> &adw::TimedAnimation { + self.imp().zoom_animation.get_or_init(|| { + let animation = adw::TimedAnimation::builder() + .duration(ZOOM_ANIMATION_DURATION) + .widget(self) + .target(&adw::PropertyAnimationTarget::new(self, "zoom")) + .build(); + + animation.connect_done(glib::clone!(@weak self as obj => move |_| { + dbg!("animation done"); + obj.imp().zoom_hscrollbar_transition.set(false); + obj.imp().zoom_vscrollbar_transition.set(false); + })); + + animation + }) + } + + /// Required scrollbar change to keep aiming + /// + /// When zooming by a ratio of `zoom_delta` and wanting to keep position `x` + /// in the image at the same place in the widget, the returned value is + /// the correct value for hadjustment to achive that. + pub fn hadjustment_corrected_for_zoom(&self, zoom_delta: f64, x: f64) -> f64 { + let adj = self.hadjustment(); + // Width of bars to the left and right of the image + let border = if self.widget_width() > self.image_displayed_width() { + (self.widget_width() - self.image_displayed_width()) / 2. + } else { + 0. + }; + + f64::max((x + adj.value() - border) / zoom_delta - x, 0.) + } + + /// Same but for vertical adjustment + pub fn vadjustment_corrected_for_zoom(&self, zoom_delta: f64, y: f64) -> f64 { + let adj = self.vadjustment(); + // Width of bars to the top and bottom of the image + let border = if self.widget_height() > self.image_displayed_height() { + (self.widget_height() - self.image_displayed_height()) / 2. + } else { + 0. + }; + + f64::max((y + adj.value() - border) / zoom_delta - y, 0.) + } + + /// Zoom in a step with animation + /// + /// Used by buttons + pub fn zoom_in(&self) { + let zoom = self.zoom() * ZOOM_FACTOR_BUTTON; + + self.set_best_fit(false); + + self.zoom_to(zoom); + } + + /// Zoom out a step with animation + /// + /// Used by buttons + pub fn zoom_out(&self) { + let zoom = (self.zoom() / ZOOM_FACTOR_BUTTON).max(self.zoom_level_best_fit()); + + self.set_best_fit(zoom == self.zoom_level_best_fit()); + + self.zoom_to(zoom); + } + + /// Zoom to specific level with animation + pub fn zoom_to(&self, zoom: f64) { + log::debug!("Zoom to {zoom:.3}"); + + // abort if already at correct zoom level + if zoom == self.zoom() && zoom == self.imp().zoom_target.get() { + log::debug!("Already at correct zoom level"); + return; + } + + if let Some(texture) = self.texture() { + let current_hborder = self.widget_width() - self.image_displayed_width(); + let target_hborder = self.widget_width() - texture.width() as f64 * zoom; + + self.imp() + .zoom_hscrollbar_transition + .set(current_hborder.signum() != target_hborder.signum() && current_hborder != 0.); + + let current_vborder = self.widget_height() - self.image_displayed_height(); + let target_vborder = self.widget_height() - texture.height() as f64 * zoom; + + self.imp() + .zoom_hscrollbar_transition + .set(current_vborder.signum() != target_vborder.signum() && current_vborder != 0.); + + self.set_best_fit(zoom <= self.zoom_level_best_fit()); + + let animation = self.zoom_animation(); + + self.imp().zoom_target.set(zoom); + + animation.set_value_from(self.zoom()); + animation.set_value_to(zoom); + animation.play(); + } else { + log::error!("No texture to zoom"); + } + } + + /// Image width with current zoom factor and rotation + /// + /// During rotation it is an interpolated size that does not + /// represent the actual size. The size returned might well be + /// larger than what can actually be displayed within the widget. + pub fn image_displayed_width(&self) -> f64 { + if let Some(texture) = self.texture() { + let rotated = self.rotation().to_radians().sin().abs(); + + ((1. - rotated) * texture.width() as f64 + rotated * texture.height() as f64) + * self.zoom() + } else { + 0. + } + } + + pub fn image_displayed_height(&self) -> f64 { + if let Some(texture) = self.texture() { + let rotated = self.rotation().to_radians().sin().abs(); + + ((1. - rotated) * texture.height() as f64 + rotated * texture.width() as f64) + * self.zoom() + } else { + 0. + } + } + + fn hadjustment(&self) -> gtk::Adjustment { + if let Some(adj) = self.imp().hadjustment.borrow().as_ref() { + adj.clone() + } else { + log::debug!("Hadjustment not set yet: Using fake object"); + gtk::Adjustment::default() + } + } + + fn set_hadjustment(&self, adjustment: Option) { + if let Some(adj) = &adjustment { + adj.connect_value_changed(glib::clone!(@weak self as obj => move |_| { + obj.queue_draw(); + })); + // TODO: needed? + self.queue_allocate(); + } + + self.imp().hadjustment.replace(adjustment); + self.configure_adjustments(); + } + + fn vadjustment(&self) -> gtk::Adjustment { + if let Some(adj) = self.imp().vadjustment.borrow().as_ref() { + adj.clone() + } else { + log::debug!("Vadjustment not set yet: Using fake object"); + gtk::Adjustment::default() + } + } + + fn set_vadjustment(&self, adjustment: Option) { + if let Some(adj) = &adjustment { + adj.connect_value_changed(glib::clone!(@weak self as obj => move |_| { + obj.queue_draw(); + })); + self.queue_allocate(); + } + + self.imp().vadjustment.replace(adjustment); + self.configure_adjustments(); + } + + /// Configure scrollbars for current situation + fn configure_adjustments(&self) { + let hadjustment = self.hadjustment(); + let content_width = self.image_displayed_width(); + let widget_width = self.widget_width(); + + hadjustment.configure( + hadjustment.value().clamp(0., self.max_hadjustment_value()), + 0., + content_width, + // arrow button step (probably irrelevant) + widget_width * 0.1, + // page up/down step + widget_width * 0.9, + widget_width.min(content_width), + ); + + let vadjustment = self.vadjustment(); + let content_height = self.image_displayed_height(); + let widget_height = self.widget_height(); + + vadjustment.configure( + vadjustment.value().clamp(0., self.max_vadjustment_value()), + 0., + content_height, + // arrow button step (probably irrelevant) + widget_height * 0.1, + // page up/down step + widget_height * 0.9, + widget_height.min(content_height), + ); + } + + pub fn max_hadjustment_value(&self) -> f64 { + f64::max(self.image_displayed_width() - self.widget_width(), 0.) + } + + pub fn max_vadjustment_value(&self) -> f64 { + f64::max(self.image_displayed_height() - self.widget_height(), 0.) + } + + pub fn is_hscrollable(&self) -> bool { + self.max_hadjustment_value() != 0. + } + + pub fn is_vscrollable(&self) -> bool { + self.max_vadjustment_value() != 0. + } + + pub fn widget_height(&self) -> f64 { + self.height() as f64 + } + + pub fn widget_width(&self) -> f64 { + self.width() as f64 + } + + /// Drag and drop content provider pub fn content_provider(&self) -> gdk::ContentProvider { let imp = self.imp(); let mut contents = vec![]; @@ -271,16 +916,4 @@ impl LpImage { gdk::ContentProvider::new_union(contents.as_slice()) } - - pub fn rotate(&self, angle: f64) { - let target = &self.imp().rotation_target; - target.set(target.get() + angle); - - let borrow = self.imp().rotation_animation.borrow(); - let animation = borrow.as_ref().unwrap(); - - animation.set_value_from(self.rotation()); - animation.set_value_to(target.get()); - animation.play(); - } } diff --git a/src/widgets/image_page.rs b/src/widgets/image_page.rs index 1559da8..1a24873 100644 --- a/src/widgets/image_page.rs +++ b/src/widgets/image_page.rs @@ -26,7 +26,7 @@ use gtk::CompositeTemplate; use gtk_macros::spawn; -use once_cell::sync::OnceCell; +use once_cell::sync::{Lazy, OnceCell}; use crate::widgets::LpImage; @@ -43,6 +43,8 @@ mod imp { #[template_child] pub(super) error_page: TemplateChild, #[template_child] + pub(super) scrolled_window: TemplateChild, + #[template_child] pub(super) image: TemplateChild, #[template_child] pub(super) popover: TemplateChild, @@ -70,6 +72,23 @@ mod imp { } impl ObjectImpl for LpImagePage { + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![glib::ParamSpecObject::builder::("image") + .read_only() + .build()] + }); + + PROPERTIES.as_ref() + } + + fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + let obj = self.instance(); + match pspec.name() { + "image" => obj.image().to_value(), + name => unimplemented!("property {name}"), + } + } fn constructed(&self) { let obj = self.instance(); @@ -114,7 +133,7 @@ impl LpImagePage { match load_texture_from_file(&file).await { Ok(texture) => { imp.image.set_texture_with_file(texture, &file); - imp.stack.set_visible_child(&*imp.image); + imp.stack.set_visible_child(&*imp.scrolled_window); imp.spinner.set_spinning(false); }, Err(e) => { diff --git a/src/widgets/image_view.rs b/src/widgets/image_view.rs index d0a4241..c2d6ae3 100644 --- a/src/widgets/image_view.rs +++ b/src/widgets/image_view.rs @@ -85,13 +85,14 @@ mod imp { impl ObjectImpl for LpImageView { fn properties() -> &'static [glib::ParamSpec] { static PROPERTIES: Lazy> = Lazy::new(|| { - vec![glib::ParamSpecObject::new( - "active-file", - "", - "", - gio::File::static_type(), - glib::ParamFlags::READABLE, - )] + vec![ + glib::ParamSpecObject::builder::("active-file") + .read_only() + .build(), + glib::ParamSpecObject::builder::("current-page-strict") + .read_only() + .build(), + ] }); PROPERTIES.as_ref() @@ -101,6 +102,7 @@ mod imp { let obj = self.instance(); match psec.name() { "active-file" => obj.active_file().to_value(), + "current-page-strict" => obj.current_page_strict().to_value(), _ => unimplemented!(), } } @@ -114,8 +116,18 @@ mod imp { source.set_exclusive(true); source.connect_prepare( - glib::clone!(@weak obj => @default-return None, move |_, _, _| { - obj.current_page().map(|p| p.content_provider()) + glib::clone!(@weak obj => @default-return None, move |gesture, _, _| { + let is_scrollable = obj + .current_page() + .map(|p| p.image().is_hscrollable() || p.image().is_vscrollable()); + + // do scrolling if scrollable + if is_scrollable == Some(true) { + gesture.set_state(gtk::EventSequenceState::Denied); + None + } else { + obj.current_page().map(|p| p.content_provider()) + } }), ); @@ -131,6 +143,7 @@ mod imp { self.carousel .connect_position_notify(clone!(@weak obj => move |_| { obj.notify("active-file"); + obj.notify("current-page-strict"); })); } } @@ -156,6 +169,7 @@ impl LpImageView { self.build_model_from_file(file)?; self.notify("active-file"); + self.notify("current-page-strict"); Ok(()) } @@ -385,9 +399,27 @@ impl LpImageView { } } + pub fn zoom_out(&self) { + if let Some(current_page) = self.current_page() { + current_page.image().zoom_out(); + } + } + + pub fn zoom_in(&self) { + if let Some(current_page) = self.current_page() { + current_page.image().zoom_in(); + } + } + + pub fn zoom_to(&self, level: f64) { + if let Some(current_page) = self.current_page() { + current_page.image().zoom_to(level); + } + } + pub fn rotate_image(&self, angle: f64) { if let Some(current_page) = self.current_page() { - current_page.image().rotate(angle); + current_page.image().rotate_by(angle); } } @@ -482,16 +514,37 @@ impl LpImageView { Ok(()) } + // TODO: check how often we actually want to use this pub fn current_page(&self) -> Option { let carousel = &self.imp().carousel; let pos = carousel.position().round() as u32; - if carousel.n_pages() > 0 { + if carousel.n_pages() > 0 && pos < carousel.n_pages() { carousel.nth_page(pos).downcast().ok() } else { None } } + /// Returns `None` during animations instead of returning the closest image + pub fn current_page_strict(&self) -> Option { + let carousel = &self.imp().carousel; + + if carousel.position().fract() != 0. { + // there is no current page during animations + None + } else { + let pos = carousel.position().round() as u32; + if pos >= carousel.n_pages() { + log::warn!("Hit AdwCarousel bug"); + None + } else if carousel.n_pages() > 0 { + carousel.nth_page(pos).downcast().ok() + } else { + None + } + } + } + pub fn uri(&self) -> Option { self.active_file() .and_then(|f| url::Url::parse(&f.uri()).ok()) diff --git a/src/window.rs b/src/window.rs index 5d7e43d..ab1905d 100644 --- a/src/window.rs +++ b/src/window.rs @@ -32,7 +32,7 @@ use gtk_macros::spawn; use crate::config; use crate::util; -use crate::widgets::{LpImageView, LpPropertiesView}; +use crate::widgets::{LpImage, LpImagePage, LpImageView, LpPropertiesView}; mod imp { use super::*; @@ -94,6 +94,18 @@ mod imp { win.toggle_fullscreen(!win.is_fullscreened()); }); + klass.install_action("win.zoom-out", None, move |win, _, _| { + win.zoom_out(); + }); + + klass.install_action("win.zoom-in", None, move |win, _, _| { + win.zoom_in(); + }); + + klass.install_action("win.zoom-to", Some("d"), move |win, _, level| { + win.zoom_to(level.unwrap().get().unwrap()); + }); + klass.install_action("win.open", None, move |win, _, _| { win.pick_file(); }); @@ -141,6 +153,23 @@ mod imp { } obj.set_actions_enabled(false); + self.image_view + .property_expression("current-page-strict") + .chain_property::("image") + .chain_property::("best-fit") + .watch( + glib::Object::NONE, + glib::clone!(@weak obj => move || { + let enabled = obj + .imp() + .image_view + .current_page() + .map(|page| !page.image().is_best_fit()) + .unwrap_or_default(); + + obj.action_set_enabled("win.zoom-out", enabled); + }), + ); self.status_page .set_icon_name(Some(&format!("{}-symbolic", config::APP_ID))); @@ -235,6 +264,18 @@ impl LpWindow { self.set_fullscreened(fullscreen); } + fn zoom_out(&self) { + self.imp().image_view.zoom_out(); + } + + fn zoom_in(&self) { + self.imp().image_view.zoom_in(); + } + + fn zoom_to(&self, level: f64) { + self.imp().image_view.zoom_to(level); + } + fn pick_file(&self) { let chooser = gtk::FileChooserNative::new( Some("Open Image"),