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
This commit is contained in:
Sophie Herold 2022-11-10 19:39:23 +01:00
parent 73a1702c47
commit 029c0c9244
9 changed files with 952 additions and 155 deletions

View file

@ -161,6 +161,8 @@ impl LpApplication {
self.set_accels_for_action("win.show-help-overlay", &["<Primary>question"]);
self.set_accels_for_action("win.toggle-fullscreen", &["F11"]);
self.set_accels_for_action("window.close", &["<Primary>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) {

View file

@ -1,4 +1,3 @@
// image.rs
//
// Copyright 2021 Christopher Davis <christopherdavis@gnome.org>
//
@ -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<f64>,
// Animates the `rotation` property
pub rotation_animation: RefCell<Option<adw::TimedAnimation>>,
pub rotation_animation: OnceCell<adw::TimedAnimation>,
/// Mirrored presentation of original image
pub mirrored: Cell<bool>,
/// Displayed zoom level
pub zoom: Cell<f64>,
pub zoom_animation: OnceCell<adw::TimedAnimation>,
/// Targeted zoom level, might differ from `zoom` when animation is running
pub zoom_target: Cell<f64>,
/// Current animation is transitioning from having horizontal scrollbars
/// to not having them or vice versa.
pub zoom_hscrollbar_transition: Cell<bool>,
/// Same but vor vertical
pub zoom_vscrollbar_transition: Cell<bool>,
/// Always fit image into window, causes `zoom` to change automatically
pub best_fit: Cell<bool>,
/// Horizontal scrolling
pub hadjustment: RefCell<Option<gtk::Adjustment>>,
/// Vertical scrolling
pub vadjustment: RefCell<Option<gtk::Adjustment>>,
/// Currently EXIF data
pub image_metadata: RefCell<ImageMetadata>,
/// Current pointer position
pub pointer_position: Cell<Option<(f64, f64)>>,
/// 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<Option<(f64, f64)>>,
/// Required for calculating delta while moving window around
pub last_drag_value: Cell<Option<(f64, f64)>>,
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::<gio::File>("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::<gtk::Scrollable>("hadjustment"),
glib::ParamSpecOverride::for_interface::<gtk::Scrollable>("vadjustment"),
glib::ParamSpecOverride::for_interface::<gtk::Scrollable>("hscroll-policy"),
glib::ParamSpecOverride::for_interface::<gtk::Scrollable>("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: &gtk::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<imp::LpImage>) @extends gtk::Widget;
pub struct LpImage(ObjectSubclass<imp::LpImage>)
@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<gio::File> {
@ -231,29 +522,383 @@ impl LpImage {
imp.file.borrow().clone()
}
/// Texture that contains the original image
pub fn texture(&self) -> Option<gdk::Texture> {
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<gtk::Adjustment>) {
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<gtk::Adjustment>) {
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();
}
}

View file

@ -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<adw::StatusPage>,
#[template_child]
pub(super) scrolled_window: TemplateChild<gtk::ScrolledWindow>,
#[template_child]
pub(super) image: TemplateChild<LpImage>,
#[template_child]
pub(super) popover: TemplateChild<gtk::PopoverMenu>,
@ -70,6 +72,23 @@ mod imp {
}
impl ObjectImpl for LpImagePage {
fn properties() -> &'static [glib::ParamSpec] {
static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
vec![glib::ParamSpecObject::builder::<LpImage>("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) => {

View file

@ -85,13 +85,14 @@ mod imp {
impl ObjectImpl for LpImageView {
fn properties() -> &'static [glib::ParamSpec] {
static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
vec![glib::ParamSpecObject::new(
"active-file",
"",
"",
gio::File::static_type(),
glib::ParamFlags::READABLE,
)]
vec![
glib::ParamSpecObject::builder::<gio::File>("active-file")
.read_only()
.build(),
glib::ParamSpecObject::builder::<LpImagePage>("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<LpImagePage> {
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<LpImagePage> {
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<url::Url> {
self.active_file()
.and_then(|f| url::Url::parse(&f.uri()).ok())

View file

@ -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::<LpImagePage>("image")
.chain_property::<LpImage>("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"),