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:
parent
73a1702c47
commit
029c0c9244
9 changed files with 952 additions and 155 deletions
|
|
@ -23,7 +23,7 @@ features = ["gtk4"]
|
|||
[dependencies.gtk]
|
||||
package = "gtk4"
|
||||
version = "0.5.1"
|
||||
features = ["v4_6"]
|
||||
features = ["v4_8"]
|
||||
|
||||
[dependencies.gio]
|
||||
package = "gio"
|
||||
|
|
|
|||
|
|
@ -38,6 +38,18 @@
|
|||
<property name="accelerator"><Primary>q</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="title" translatable="yes" context="shortcut window">Show with orginal scale</property>
|
||||
<property name="accelerator">1</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="title" translatable="yes" context="shortcut window">Show with 200% of orginal scale</property>
|
||||
<property name="accelerator">2</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
|
|
|
|||
|
|
@ -57,45 +57,49 @@
|
|||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="LpImage" id="image">
|
||||
<child>
|
||||
<object class="GtkGestureClick" id="click_gesture">
|
||||
<property name="button">3</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkGestureLongPress" id="press_gesture">
|
||||
<property name="touch-only">True</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkPopoverMenu" id="popover">
|
||||
<property name="has-arrow">False</property>
|
||||
<property name="menu-model">image_menu</property>
|
||||
<child type="rotate-left">
|
||||
<object class="GtkButton">
|
||||
<property name="icon-name">object-rotate-left-symbolic</property>
|
||||
<property name="tooltip-text" translatable="yes">Rotate Left</property>
|
||||
<property name="action-name">win.rotate</property>
|
||||
<property name="action-target">-90.0</property>
|
||||
<style>
|
||||
<class name="flat" />
|
||||
</style>
|
||||
<object class="GtkScrolledWindow" id="scrolled_window">
|
||||
<property name="child">
|
||||
<object class="LpImage" id="image">
|
||||
<child>
|
||||
<object class="GtkGestureClick" id="click_gesture">
|
||||
<property name="button">3</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="rotate-right">
|
||||
<object class="GtkButton">
|
||||
<property name="icon-name">object-rotate-right-symbolic</property>
|
||||
<property name="tooltip-text" translatable="yes">Rotate Right</property>
|
||||
<property name="action-name">win.rotate</property>
|
||||
<property name="action-target">90.0</property>
|
||||
<style>
|
||||
<class name="flat" />
|
||||
</style>
|
||||
<child>
|
||||
<object class="GtkGestureLongPress" id="press_gesture">
|
||||
<property name="touch-only">True</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkPopoverMenu" id="popover">
|
||||
<property name="has-arrow">False</property>
|
||||
<property name="menu-model">image_menu</property>
|
||||
<child type="rotate-left">
|
||||
<object class="GtkButton">
|
||||
<property name="icon-name">object-rotate-left-symbolic</property>
|
||||
<property name="tooltip-text" translatable="yes">Rotate Left</property>
|
||||
<property name="action-name">win.rotate</property>
|
||||
<property name="action-target">-90.0</property>
|
||||
<style>
|
||||
<class name="flat" />
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
<child type="rotate-right">
|
||||
<object class="GtkButton">
|
||||
<property name="icon-name">object-rotate-right-symbolic</property>
|
||||
<property name="tooltip-text" translatable="yes">Rotate Right</property>
|
||||
<property name="action-name">win.rotate</property>
|
||||
<property name="action-target">90.0</property>
|
||||
<style>
|
||||
<class name="flat" />
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
|
|
|
|||
|
|
@ -16,8 +16,9 @@
|
|||
</object>
|
||||
</child>
|
||||
<child type="overlay">
|
||||
<object class="GtkBox" id="controls_box">
|
||||
<object class="GtkBox" id="controls_box_start">
|
||||
<property name="valign">end</property>
|
||||
<property name="halign">start</property>
|
||||
<property name="margin-start">18</property>
|
||||
<property name="margin-end">18</property>
|
||||
<property name="margin-top">18</property>
|
||||
|
|
@ -46,9 +47,19 @@
|
|||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child type="overlay">
|
||||
<object class="GtkBox" id="controls_box_end">
|
||||
<property name="valign">end</property>
|
||||
<property name="halign">end</property>
|
||||
<property name="margin-start">18</property>
|
||||
<property name="margin-end">18</property>
|
||||
<property name="margin-top">18</property>
|
||||
<property name="margin-bottom">18</property>
|
||||
<property name="spacing">12</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="fullscreen_button">
|
||||
<property name="hexpand">True</property>
|
||||
<property name="halign">end</property>
|
||||
<binding name="icon-name">
|
||||
<closure type="gchararray" function="get_fullscreen_icon">
|
||||
|
|
@ -65,6 +76,28 @@
|
|||
</style>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="zoom_out">
|
||||
<property name="icon-name">zoom-out-symbolic</property>
|
||||
<property name="action-name">win.zoom-out</property>
|
||||
<property name="tooltip-text" translatable="yes">Zoom Out</property>
|
||||
<style>
|
||||
<class name="osd"/>
|
||||
<class name="circular"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="zoom_in">
|
||||
<property name="icon-name">zoom-in-symbolic</property>
|
||||
<property name="action-name">win.zoom-in</property>
|
||||
<property name="tooltip-text" translatable="yes">Zoom In</property>
|
||||
<style>
|
||||
<class name="osd"/>
|
||||
<class name="circular"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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: >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<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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue