// Copyright (c) 2021-2023 Christopher Davis
// Copyright (c) 2022-2023 Sophie Herold
// Copyright (c) 2022 Maximiliano Sandoval R
// Copyright (c) 2023 Julian Hofer
// Copyright (c) 2023 Lubosz Sarnecki
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see .
//
// SPDX-License-Identifier: GPL-3.0-or-later
//! A widget that shows images and handles control inputs
//!
//! This widget handles showing the right section of the image
//! with a spicfic zoom level and rotation. It also handles control
//! input that change those properties.
//!
//! While this widget logically coposes the image, decoding images
//! and composing the textures happens in [`decoder`].
//!
//! [`decoder`]: crate::decoder
use crate::deps::*;
use crate::decoder::tiling::FrameBufferExt;
use crate::decoder::{self, tiling, Decoder, DecoderUpdate};
use crate::image_metadata::LpImageMetadata;
use crate::util::Gesture;
use adw::prelude::*;
use adw::subclass::prelude::*;
use arc_swap::ArcSwap;
use futures::prelude::*;
use once_cell::sync::Lazy;
use std::cell::{Cell, OnceCell, RefCell};
use std::marker::PhantomData;
use std::sync::Arc;
/// Default background color around images and behind transparent images
/// `#242424`
static BACKGROUND_COLOR_DEFAULT: Lazy =
Lazy::new(|| gdk::RGBA::new(36. / 255., 36. / 255., 36. / 255., 1.));
/// Background color if the default does not give enough contrast for
/// transparent images `#e8e7e6`
static BACKGROUND_COLOR_ALTERNATE: Lazy =
Lazy::new(|| gdk::RGBA::new(232. / 255., 231. / 255., 230. / 255., 1.));
static BACKGROUND_COLOR_DEFAULT_LIGHT_MODE: Lazy =
Lazy::new(|| gdk::RGBA::new(250. / 255., 250. / 255., 250. / 255., 1.));
static BACKGROUND_COLOR_ALTERNATE_LIGHT_MODE: Lazy =
Lazy::new(|| gdk::RGBA::new(103. / 255., 101. / 255., 110. / 255., 1.));
/// Consider 3.5:1 contrast and worse to be bad contrast for a pixel
static BACKGROUND_GUESS_LOW_CONTRAST_RATIO: f32 = 3.5;
/// Consider transparent images with more than 90% pixels bad contrast as bad
/// contrast
///
/// Bad contrast image will use the `BACKGROUND_COLOR_ALTERNATE`.
static BACKGROUND_GUESS_LOW_CONTRAST_TRHESHOLD: f64 = 0.90;
/// Milliseconds
const ZOOM_ANIMATION_DURATION: u32 = 200;
/// Milliseconds
const ROTATION_ANIMATION_DURATION: u32 = 200;
/// Relative to current zoom level
const ZOOM_FACTOR_BUTTON: f64 = 1.5;
/// Zoom 30% per scroll wheel detent
const ZOOM_FACTOR_SCROLL_WHEEL: f64 = 1.3;
/// Zoom 0.5% per pixel
///
/// This is for scrolling devices that might not exist
const ZOOM_FACTOR_SCROLL_SURFACE: f64 = 1.005;
/// Relative to best-fit level
const ZOOM_FACTOR_DOUBLE_TAP: f64 = 2.5;
/// Relative to best-fit and `MAX_ZOOM_LEVEL`
const ZOOM_FACTOR_MAX_RUBBERBAND: f64 = 2.;
/// Smaller values make the band feel stiffer
const RUBBERBANDING_EXPONENT: f64 = 0.3;
/// When this scale factor is reached, rotate is deactivated
const ZOOM_GESTURE_LOCK_THRESHOLD: f64 = 1.2;
/// When this rotate angle is reached, zoom is deactivated
const ROTATE_GESTURE_LOCK_THRESHOLD: f64 = 15.;
/// Max zoom level 2000%
const MAX_ZOOM_LEVEL: f64 = 20.0;
/// Thumbnail size in application pixels
///
/// The thumbnail is currently used for drag and drop.
const THUMBNAIL_SIZE: f32 = 128.;
mod imp {
use super::*;
use glib::Properties;
#[derive(Debug, Default, Properties)]
#[properties(wrapper_type = super::LpImage)]
pub struct LpImage {
#[property(get, set = LpImage::set_file, explicit_notify)]
pub(super) file: RefCell>,
#[property(get)]
pub(super) is_deleted: Cell,
#[property(get)]
pub(super) is_unsupported: Cell,
/// Set to true when image is ready for displaying
#[property(get)]
pub(super) is_loaded: Cell,
/// Set if an error has occurred, shown on error_page
#[property(get)]
pub(super) error: RefCell>,
#[property(name = "format-name", type = Option, get = Self::format_name)]
pub(super) format: RefCell>,
pub(super) background_color: RefCell >,
/// Track changes to this image
pub(super) file_monitor: RefCell >,
pub(super) frame_buffer: Arc>,
pub(super) decoder: RefCell>>,
/// Rotation final value (can differ from `rotation` during animation)
pub(super) rotation_target: Cell,
/// Rotated presentation of original image in degrees clockwise
#[property(get, set = Self::set_rotation, explicit_notify)]
pub(super) rotation: Cell,
// Animates the `rotation` property
pub(super) rotation_animation: OnceCell,
/// Mirrored presentation of original image
#[property(get, set = Self::set_mirrored, explicit_notify)]
pub(super) mirrored: Cell,
/// Displayed zoom level
#[property(get, set = Self::set_zoom, explicit_notify)]
pub(super) zoom: Cell,
pub(super) zoom_animation: OnceCell,
/// Targeted zoom level, might differ from `zoom` when animation is
/// running
pub(super) zoom_target: Cell,
/// Current animation is transitioning from having horizontal scrollbars
/// to not having them or vice versa.
pub(super) zoom_hscrollbar_transition: Cell,
/// Same but for vertical
pub(super) zoom_vscrollbar_transition: Cell,
/// Always fit image into window, causes `zoom` to change automatically
#[property(get, set)]
pub(super) best_fit: Cell,
/// Max zoom level is reached, stored to only send signals on change
#[property(get, set)]
pub(super) is_max_zoom: Cell,
/// Horizontal scrolling
#[property(override_interface = gtk::Scrollable, get , set = Self::set_hadjustment)]
pub(super) hadjustment: RefCell,
/// Vertical scrolling
#[property(override_interface = gtk::Scrollable, get , set = Self::set_vadjustment)]
pub(super) vadjustment: RefCell,
#[property(override_interface = gtk::Scrollable, get = Self::scroll_policy, set = Self::set_ignore_scroll_policy)]
pub(super) _hscroll_policy: PhantomData,
#[property(override_interface = gtk::Scrollable, get = Self::scroll_policy, set = Self::set_ignore_scroll_policy)]
pub(super) _vscroll_policy: PhantomData,
/// Currently EXIF data
#[property(name = "metadata", get)]
pub(super) image_metadata: RefCell,
/// Image dimension details for SVGs
pub(super) dimension_details: RefCell,
#[property(get)]
_image_size_available: bool,
/// Current pointer position
pub(super) 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(super) zoom_gesture_center: Cell >,
/// Required for calculating delta while moving window around
pub(super) last_drag_value: Cell >,
/// Ticks callback for animated image formats
pub(super) tick_callback: RefCell >,
/// Frame block time for currently shown frame
pub(super) last_animated_frame: Cell,
/// Gesture, zoom or rotate, used for the duration of the gesture
pub(super) locked_gestured: Cell>,
widget_dimensions: Cell<(i32, i32)>,
scale_factor: Cell,
}
#[glib::object_subclass]
impl ObjectSubclass for LpImage {
const NAME: &'static str = "LpImage";
type ParentType = gtk::Widget;
type Type = super::LpImage;
type Interfaces = (gtk::Scrollable,);
}
impl ObjectImpl for LpImage {
fn properties() -> &'static [glib::ParamSpec] {
Self::derived_properties()
}
fn set_property(&self, id: usize, value: &glib::Value, pspec: &glib::ParamSpec) {
self.derived_set_property(id, value, pspec)
}
fn property(&self, id: usize, pspec: &glib::ParamSpec) -> glib::Value {
self.derived_property(id, pspec)
}
fn constructed(&self) {
self.parent_constructed();
let obj = self.obj();
obj.set_hexpand(true);
obj.set_vexpand(true);
obj.set_overflow(gtk::Overflow::Hidden);
self.zoom.set(1.);
self.zoom_target.set(1.);
self.best_fit.set(true);
self.scale_factor.set(obj.scale_factor());
self.connect_controllers();
self.connect_gestures();
obj.connect_scale_factor_notify(|obj| {
obj.queue_resize();
});
adw::StyleManager::default().connect_dark_notify(glib::clone!(@weak obj => move |_| {
glib::spawn_future_local(async move {
let color = obj.background_color_guess().await;
obj.set_background_color(color);
if obj.is_mapped() {
obj.queue_draw();
}
});
}));
}
fn dispose(&self) {
let obj = self.obj();
log::debug!("Disposing LpImage");
// remove target from 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(|_| {}));
}
}
impl LpImage {
fn connect_controllers(&self) {
let obj = self.obj();
// Needed for 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 glib::Propagation::Proceed, move |event, _, y| {
// use Ctrl key as modifier for vertical scrolling
if event.current_event_state().contains(gdk::ModifierType::CONTROL_MASK)
|| event.current_event_state().contains(gdk::ModifierType::SHIFT_MASK)
{
// propagate event to scrolled window
return glib::Propagation::Proceed;
}
// 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 glib::Propagation::Proceed;
}
// Use exponential scaling since zoom is always multiplicative with the existing value
// This is the right thing since `exp(n/2)^2 == exp(n)` (two small steps are the same as one larger step)
let (zoom_factor, animated) = match event.unit() {
gdk::ScrollUnit::Wheel => (f64::exp( - y * f64::ln(ZOOM_FACTOR_SCROLL_WHEEL)), y.abs() >= 1.),
gdk::ScrollUnit::Surface => (f64::exp( - y * f64::ln(ZOOM_FACTOR_SCROLL_SURFACE)), false),
unknown_unit => {
log::warn!("Ignoring unknown scroll unit: {unknown_unit}");
(1., false)
}
};
let zoom =
obj.imp().zoom_target.get() * zoom_factor;
if animated {
obj.zoom_to(zoom);
} else {
obj.zoom_to_full(zoom, false, false);
}
// do not propagate event to scrolled window
glib::Propagation::Stop
}));
obj.add_controller(scroll_controller);
}
fn connect_gestures(&self) {
let obj = self.obj();
// Double click for fullscreen (mouse/touchpad) or zoom (touch screen)
let left_click_gesture = gtk::GestureClick::builder().button(1).build();
obj.add_controller(left_click_gesture.clone());
left_click_gesture.connect_pressed(
glib::clone!(@weak obj => move |gesture, n_press, x, y| {
// only handle double clicks
if n_press != 2 {
return;
}
if gesture.device().map(|x| x.source()) == Some(gdk::InputSource::Touchscreen) {
// zoom
obj.imp().pointer_position.set(Some((x, y)));
if obj.is_best_fit() {
// zoom in
obj.zoom_to(ZOOM_FACTOR_DOUBLE_TAP * obj.zoom_level_best_fit());
} else {
// zoom back out
obj.zoom_best_fit();
}
} else {
// fullscreen
obj.activate_action("win.toggle-fullscreen", None).unwrap();
}
}),
);
// Drag for moving image around
let drag_gesture = gtk::GestureDrag::builder().button(0).build();
obj.add_controller(drag_gesture.clone());
drag_gesture.connect_drag_begin(glib::clone!(@weak obj => move |gesture, _, _| {
// Allow only left and middle button
if ![1, 2].contains(&gesture.current_button())
// Drag gesture for touchscreens is handled by ScrolledWindow
|| gesture.device().map(|x| x.source()) == Some(gdk::InputSource::Touchscreen)
{
gesture.set_state(gtk::EventSequenceState::Denied);
return;
}
if obj.is_hscrollable() || obj.is_vscrollable() {
obj.cancel_deceleration();
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.clone());
rotation_gesture
.connect_begin(glib::clone!(@weak obj => move |_,_|obj.cancel_deceleration()));
rotation_gesture.connect_angle_changed(
glib::clone!(@weak obj => move |gesture, _, _| {
let angle = gesture.angle_delta().to_degrees();
// Only reset rotation if scale gesture is locked in
if let Some(Gesture::Scale) = obj.imp().locked_gestured.get() {
obj.imp().rotation.set(obj.imp().rotation_target.get());
return;
}
// Correct angle by the the angle at the moment of passing the threshold.
// This stops the rotation from suddenly jumping when passing the threshold.
let correction =
if let Some(Gesture::Rotate(correction)) = obj.imp().locked_gestured.get() {
correction
} else if angle.abs() > ROTATE_GESTURE_LOCK_THRESHOLD {
let correction = angle.signum() * ROTATE_GESTURE_LOCK_THRESHOLD;
obj.imp()
.locked_gestured
.set(Some(Gesture::Rotate(correction)));
correction
} else {
return;
};
obj.set_rotation(obj.imp().rotation_target.get() + angle - correction);
}),
);
rotation_gesture.connect_end(glib::clone!(@weak obj => move |_, _| {
log::debug!("Rotate gesture ended");
let angle = (obj.rotation() / 90.).round() * 90. - obj.imp().rotation_target.get();
obj.rotate_by(angle);
obj.imp().locked_gestured.set(None);
}));
// Zoom
let zoom_gesture = gtk::GestureZoom::new();
obj.add_controller(zoom_gesture.clone());
zoom_gesture.connect_begin(glib::clone!(@weak obj => move |gesture, _| {
obj.cancel_deceleration();
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() + x0 - x1);
vadjustment.set_value(vadjustment.value() + y0 - y1);
} else {
log::warn!("Zoom bounding box center: No previous value");
}
obj.imp().zoom_gesture_center.set(p1);
}
}
let zoom_out_threshold = 1. / ZOOM_GESTURE_LOCK_THRESHOLD;
let zoom_in_threshold = ZOOM_GESTURE_LOCK_THRESHOLD;
if let Some(Gesture::Rotate(_)) = obj.imp().locked_gestured.get() {
// Do not zoom when rotate is locked in
return;
} else if !(zoom_out_threshold..zoom_in_threshold).contains(&scale) {
// Lock in scale when leaving the scale threshold
obj.imp().locked_gestured.set(Some(Gesture::Scale));
}
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.zoom_to(obj.zoom_level_best_fit_for_rotation(rotation_target));
} else {
// rubberband if over highest zoom level and sets `zoom_target`
obj.zoom_to(obj.zoom());
};
obj.imp().locked_gestured.set(None);
}));
zoom_gesture.group_with(&rotation_gesture);
}
fn set_file(&self, file: &gio::File) {
let obj = self.obj();
if obj.file().map_or(false, |x| file.equal(&x)) {
return;
}
self.file.replace(Some(file.clone()));
let monitor =
file.monitor_file(gio::FileMonitorFlags::WATCH_MOVES, gio::Cancellable::NONE);
if let Ok(m) = &monitor {
m.connect_changed(glib::clone!(@weak obj => move |_, file_a, file_b, event| {
obj.file_changed(event, file_a, file_b);
}));
}
self.file_monitor.replace(monitor.ok());
obj.notify_file();
}
fn set_mirrored(&self, mirrored: bool) {
let obj = self.obj();
if mirrored == obj.mirrored() {
return;
}
self.mirrored.set(mirrored);
obj.notify_mirrored();
obj.queue_draw();
}
/// 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.obj()
.set_zoom_aiming(zoom, self.pointer_position.get())
}
fn set_hadjustment(&self, hadjustment: Option) {
let obj = self.obj();
let adjustment = hadjustment.unwrap_or_default();
adjustment.connect_value_changed(glib::clone!(@weak obj => move |_| {
obj.request_tiles();
obj.queue_draw();
}));
self.hadjustment.replace(adjustment);
obj.configure_adjustments();
}
fn set_vadjustment(&self, vadjustment: Option) {
let obj = self.obj();
let adjustment = vadjustment.unwrap_or_default();
adjustment.connect_value_changed(glib::clone!(@weak obj => move |_| {
obj.request_tiles();
obj.queue_draw();
}));
self.vadjustment.replace(adjustment);
obj.configure_adjustments();
}
pub fn set_rotation(&self, rotation: f64) {
let obj = self.obj();
if rotation == obj.rotation() {
return;
}
self.rotation.set(rotation);
obj.notify_rotation();
obj.queue_draw();
}
pub fn set_ignore_scroll_policy(&self, scroll_policy: gtk::ScrollablePolicy) {
log::error!("Ignored setting new scroll policy {scroll_policy:?}");
}
pub fn scroll_policy(&self) -> gtk::ScrollablePolicy {
gtk::ScrollablePolicy::Minimum
}
/// Image format displayable name
pub fn format_name(&self) -> Option {
self.format.borrow().as_ref().map(|x| x.to_string())
}
}
impl WidgetImpl for LpImage {
// called when the widget size might have changed
fn size_allocate(&self, width: i32, height: i32, _baseline: i32) {
let obj = self.obj();
let (scale_changed, scale_change) = if obj.scale_factor() != self.scale_factor.get() {
let scale_change = obj.scale_factor() as f64 / self.scale_factor.get() as f64;
self.scale_factor.set(obj.scale_factor());
(true, scale_change)
} else {
(false, 1.)
};
if obj.is_best_fit() {
// ensure there is an actual size change
if self.widget_dimensions.get() != (width, height) || scale_changed {
obj.configure_best_fit();
}
} else if scale_changed {
// Show same area of the image when scale changes
let new_zoom = self.zoom_target.get() * scale_change;
obj.zoom_animation().pause();
self.zoom.set(new_zoom);
self.zoom_target.set(new_zoom);
}
self.widget_dimensions.set((width, height));
obj.configure_adjustments();
// Avoid updates for first size_allocate with zoom not set yet
if obj.is_loaded() {
// Get potentially missing tiles for enlarged viewing area
obj.request_tiles();
}
}
// called when the widget content should be re-rendered
fn snapshot(&self, snapshot: >k::Snapshot) {
let widget = self.obj();
let widget_width = widget.width() as f64;
let widget_height = widget.height() as f64;
let display_width = widget.image_displayed_width();
let display_height = widget.image_displayed_height();
// make sure the scrollbars are correct
widget.configure_adjustments();
let applicable_zoom = widget.applicable_zoom();
let scaling_filter = if self.format.borrow().as_ref().map_or(false, |x| x.is_svg()) {
// Looks better in SVG animations and avoids rendering issues
gsk::ScalingFilter::Linear
} else if applicable_zoom < 1. {
// Uses mipmaps to avoid moiré patterns
gsk::ScalingFilter::Trilinear
} else {
// Show pixels when zooming in because making images blurry looks worse
gsk::ScalingFilter::Nearest
};
let render_options = tiling::RenderOptions {
scaling_filter,
scale_factor: widget.scale_factor(),
background_color: Some(widget.background_color()),
};
// Operations on snapshots are coordinate transformations
// It might help to read the following code from bottom to top
snapshot.save();
// Add background
snapshot.append_color(
&widget.background_color(),
&graphene::Rect::new(0., 0., widget_width as f32, widget_height as f32),
);
// Apply the scrolling position to the image
let hadj: gtk::Adjustment = widget.hadjustment();
let x = -(hadj.value() - (hadj.upper() - display_width) / 2.);
snapshot.translate(&graphene::Point::new(widget.round_f64(x) as f32, 0.));
let vadj = widget.vadjustment();
let y = -(vadj.value() - (vadj.upper() - display_height) / 2.);
snapshot.translate(&graphene::Point::new(0., widget.round_f64(y) as f32));
// Centering in widget when no scrolling (black bars around image)
let x = widget.round_f64(f64::max((widget_width - display_width) / 2.0, 0.));
let y = widget.round_f64(f64::max((widget_height - display_height) / 2.0, 0.));
// Round to pixel values to not have a half pixel offset to physical pixels
// The offset would leading to a blurry output
snapshot.translate(&graphene::Point::new(
widget.round_f64(x) as f32,
widget.round_f64(y) as f32,
));
// Apply rotation and mirroring
widget.snapshot_rotate_mirror(
snapshot,
widget.rotation() as f32,
widget.mirrored(),
applicable_zoom,
);
// Add texture(s)
self.frame_buffer
.load()
.add_to_snapshot(snapshot, applicable_zoom, &render_options);
snapshot.restore();
}
fn measure(&self, orientation: gtk::Orientation, _for_size: i32) -> (i32, i32, i32, i32) {
let (image_width, image_height) = self.obj().image_size();
if image_width > 0 && image_height > 0 {
if let Some(display) = gdk::Display::default() {
if let Some(native) = self.obj().native() {
if let Some(monitor) = display.monitor_at_surface(&native.surface()) {
let hidpi_scale = self.obj().scale_factor() as f64;
let monitor_geometry = monitor.geometry();
// TODO: Per documentation those dimensions should not be physical
// pixels. But on Wayland they are physical
// pixels and on X11 not. Taking the version
// that works on Wayland for now.
let monitor_width = monitor_geometry.width() as f64 - 40.;
let monitor_height = monitor_geometry.height() as f64 - 60.;
// areas
let monitor_area = monitor_width * monitor_height;
let image_area = image_width as f64 * image_height as f64;
let occupy_area_factor = if monitor_area < 1024. * 768. {
// for small monitors occupy 80% of the area
0.8
} else {
// for large monitors occupy 30% of the area
0.3
};
// factor for width and height that will achieve the desired area
// occupation derived from:
// monitor_area * occupy_area_factor ==
// (image_width * size_scale) * (image_height * size_scale)
let size_scale =
f64::sqrt(monitor_area / image_area * occupy_area_factor);
// ensure that we never increase image size
let target_scale = f64::min(1.0, size_scale);
let mut nat_width = image_width as f64 * target_scale;
let mut nat_height = image_height as f64 * target_scale;
// scale down if targeted occupation does not fit in one direction
if nat_width > monitor_width {
nat_width = monitor_width;
nat_height = nat_height * monitor_width / nat_width;
}
// same for other direction
if nat_height > monitor_height {
nat_height = monitor_height;
nat_width = nat_width * monitor_height / nat_height;
}
let size = match orientation {
gtk::Orientation::Horizontal => (nat_width / hidpi_scale).round(),
gtk::Orientation::Vertical => (nat_height / hidpi_scale).round(),
_ => unreachable!(),
};
return (0, size as i32, -1, -1);
}
}
}
}
// fallback if monitor size or image size is not known:
// use original image size and hope for the best
let size = match orientation {
gtk::Orientation::Horizontal => image_width,
gtk::Orientation::Vertical => image_height,
_ => unreachable!(),
};
log::warn!("Not enough information available to calculate fitting window size");
(0, size, -1, -1)
}
}
impl ScrollableImpl for LpImage {}
}
glib::wrapper! {
pub struct LpImage(ObjectSubclass)
@extends gtk::Widget,
@implements gtk::Scrollable;
}
impl LpImage {
pub async fn load(&self, file: &gio::File) {
log::debug!("Loading file {}", file.uri());
self.set_file(file);
let tiles = &self.imp().frame_buffer;
// Delete all stored textures for reloads
tiles.reset();
// Reset background color for reloads
self.set_background_color(None);
let (decoder, mut decoder_update) = Decoder::new(file.clone(), tiles.clone()).await;
let weak_obj = self.downgrade();
glib::spawn_future_local(async move {
while let Some(update) = decoder_update.next().await {
if let Some(obj) = weak_obj.upgrade() {
obj.update(update);
}
}
log::debug!("Stopped listening to decoder since sender is gone");
});
self.imp().decoder.replace(Some(Arc::new(decoder)));
}
/// Called when decoder sends update
pub fn update(&self, update: DecoderUpdate) {
let imp = self.imp();
match update {
DecoderUpdate::Metadata(metadata) => {
log::debug!("Received metadata");
imp.image_metadata.replace(LpImageMetadata::from(metadata));
self.notify_metadata();
self.reset_rotation();
}
DecoderUpdate::Dimensions(dimension_details) => {
log::debug!("Received dimensions: {:?}", self.original_dimensions());
self.imp().dimension_details.replace(dimension_details);
self.notify_image_size_available();
self.configure_best_fit();
self.request_tiles();
}
DecoderUpdate::Redraw => {
self.set_loaded(true);
self.queue_draw();
imp.frame_buffer.rcu(|tiles| {
let mut new_tiles = (**tiles).clone();
new_tiles.cleanup(imp.zoom_target.get(), self.preload_area());
new_tiles
});
if imp.background_color.borrow().is_none() {
glib::spawn_future_local(glib::clone!(@weak self as obj => async move {
let color = obj.background_color_guess().await;
obj.set_background_color(color);
if obj.is_mapped() {
obj.queue_draw();
}
}));
}
}
DecoderUpdate::Error(err) => {
self.set_error(Some(err));
}
DecoderUpdate::Format(format) => {
imp.format.replace(Some(format));
self.notify_format_name();
}
DecoderUpdate::Animated => {
let callback_id = self
.add_tick_callback(glib::clone!(@weak self as obj => @default-return glib::ControlFlow::Break, move |_, clock| obj.tick_callback(clock)));
imp.tick_callback.replace(Some(callback_id));
}
DecoderUpdate::UnsupportedFormat => {
self.set_unsupported(true);
}
}
}
/// Roughly called for every frame if image is visible
///
/// We handle advancing to the next frame for animated GIFs etc here.
fn tick_callback(&self, clock: &gdk::FrameClock) -> glib::ControlFlow {
// Do not animate if not visible
if !self.is_mapped() {
return glib::ControlFlow::Continue;
}
let elapsed = clock.frame_time() - self.imp().last_animated_frame.get();
let duration = std::time::Duration::from_micros(elapsed as u64);
// Check if it's time to show the next frame
if self.imp().frame_buffer.frame_timeout(duration) {
// Just draw since frame_timeout updated to new frame
self.queue_draw();
self.imp().last_animated_frame.set(clock.frame_time());
if let Some(decoder) = self.imp().decoder.borrow().as_ref() {
// Decode new frame and load it into buffer
decoder.fill_frame_buffer();
}
}
glib::ControlFlow::Continue
}
/// 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 {
let rotated = rotation.to_radians().sin().abs();
let (image_width, image_height) = (
self.original_dimensions().0 as f64 / self.scale_factor() as f64,
self.original_dimensions().1 as f64 / self.scale_factor() as f64,
);
let texture_aspect_ratio = image_width / image_height;
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 / image_width).min(1.)
} else {
(self.height() as f64 / image_height).min(1.)
};
let rotated_zoom = if 1. / texture_aspect_ratio > widget_aspect_ratio {
(self.width() as f64 / image_height).min(1.)
} else {
(self.height() as f64 / image_width).min(1.)
};
rotated * rotated_zoom + (1. - rotated) * default_zoom
}
/// Sets respective output values if best-fit is active
fn configure_best_fit(&self) {
// calculate new zoom value for best fit
if self.is_best_fit() {
let best_fit_level = self.zoom_level_best_fit();
self.imp().zoom.set(best_fit_level);
self.set_zoom_target(best_fit_level);
self.zoom_animation().pause();
}
}
/// File changed on drive
fn file_changed(
&self,
event: gio::FileMonitorEvent,
file_a: &gio::File,
file_b: Option<&gio::File>,
) {
match event {
gio::FileMonitorEvent::Renamed => {
if let Some(file) = file_b.cloned() {
log::debug!("Moved to {}", file.uri());
// current file got replaced with a new one
let file_replace = self.file().map_or(false, |x| x.equal(&file));
self.set_file(&file);
if file_replace {
log::debug!("Image got replaced {}", file.uri());
let obj = self.clone();
// TODO: error handling is missing
glib::spawn_future_local(async move {
obj.load(&file).await;
});
}
}
}
gio::FileMonitorEvent::ChangesDoneHint => {
let obj = self.clone();
let file = file_a.clone();
log::debug!("Image was edited {}", file.uri());
// TODO: error handling is missing
glib::spawn_future_local(async move {
obj.load(&file).await;
});
}
gio::FileMonitorEvent::Deleted
| gio::FileMonitorEvent::MovedOut
| gio::FileMonitorEvent::Unmounted => {
log::debug!("File no longer available: {event:?} {}", file_a.uri());
self.imp().is_deleted.set(true);
self.notify_is_deleted();
}
_ => {}
}
}
/// Returns a thumbnail of the displated image
pub fn thumbnail(&self) -> Option {
let (width, height) = self.image_size();
let long_side = i32::max(width, height);
let orientation = self.metadata().orientation();
let scale = f32::min(1., THUMBNAIL_SIZE / long_side as f32);
let render_options = tiling::RenderOptions {
scaling_filter: gsk::ScalingFilter::Trilinear,
scale_factor: self.scale_factor(),
background_color: Some(self.background_color()),
};
let snapshot = gtk::Snapshot::new();
self.snapshot_rotate_mirror(
&snapshot,
-orientation.rotation as f32,
orientation.mirrored,
scale as f64,
);
self.imp()
.frame_buffer
.load()
.add_to_snapshot(&snapshot, scale as f64, &render_options);
snapshot.to_paintable(None)
}
pub fn print_data(&self, scale: f64) -> Option {
let orientation = self.metadata().orientation();
let render_options = tiling::RenderOptions {
scaling_filter: gsk::ScalingFilter::Trilinear,
background_color: None,
scale_factor: 1,
};
let snapshot = gtk::Snapshot::new();
self.snapshot_rotate_mirror(
&snapshot,
-orientation.rotation as f32,
orientation.mirrored,
scale,
);
self.imp()
.frame_buffer
.load()
.add_to_snapshot(&snapshot, scale, &render_options);
let node = snapshot.to_node()?;
let renderer = self.root()?.renderer();
Some(renderer.render_texture(&node, None))
}
/// Set rotation and mirroring to the state would have after loading
pub fn reset_rotation(&self) {
let orientation = self.metadata().orientation();
self.imp().rotation_target.set(-orientation.rotation);
self.set_mirrored(orientation.mirrored);
self.set_rotation(-orientation.rotation);
}
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();
}
}
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()
}
fn applicable_zoom(&self) -> f64 {
decoder::tiling::zoom_normalize(self.zoom()) / self.scale_factor() as f64
}
/// Maximal zoom allowed for this image
fn max_zoom(&self) -> f64 {
if self.format().map_or(false, |x| x.is_svg()) {
let (width, height) = self.original_dimensions();
// Avoid division by 0
let long_side = f64::max(1., i32::max(width, height) as f64);
// Limit to maz size supported by rsvg
f64::min(MAX_ZOOM_LEVEL, decoder::RSVG_MAX_SIZE as f64 / long_side)
} else {
MAX_ZOOM_LEVEL
}
}
/// Set zoom level aiming for given position or center if not available
fn set_zoom_aiming(&self, mut zoom: f64, aiming: Option<(f64, f64)>) {
let max_zoom = self.max_zoom();
// allow some deviation from max value for rubberbanding
if zoom > max_zoom {
let max_deviation = max_zoom * ZOOM_FACTOR_MAX_RUBBERBAND;
let deviation = zoom / max_zoom;
zoom = f64::min(
max_zoom * deviation.powf(RUBBERBANDING_EXPONENT),
max_deviation,
);
}
if zoom < self.zoom_level_best_fit() {
let minimum = self.zoom_level_best_fit();
let max_deviation = minimum / ZOOM_FACTOR_MAX_RUBBERBAND;
let deviation = zoom / minimum;
zoom = f64::max(
minimum * deviation.powf(RUBBERBANDING_EXPONENT),
max_deviation,
);
}
if zoom == self.zoom() {
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();
}
fn set_zoom_target(&self, zoom_target: f64) {
log::debug!("Setting zoom target {zoom_target}");
self.imp().zoom_target.set(zoom_target);
if self.zoom() == self.imp().zoom_target.get() {
self.request_tiles();
}
}
fn request_tiles(&self) {
if let Some(decoder) = self.imp().decoder.borrow().as_ref() {
if self.zoom_animation().state() != adw::AnimationState::Playing {
// Force minimum tile size of 1000x1000 since with smaller
// tiles the tiled rendering advantage disappears
let x_inset = f32::min(-3., (self.viewport().width() - 1000.) / 2.);
let y_inset = f32::min(-3., (self.viewport().height() - 1000.) / 2.);
decoder.request(crate::decoder::TileRequest {
viewport: self.viewport().inset_r(x_inset, y_inset),
area: self.preload_area(),
zoom: self.imp().zoom_target.get(),
});
}
}
}
/// Returns the area of the image that is visible in physical pixels
fn viewport(&self) -> graphene::Rect {
let scale_factor = self.scale_factor() as f32;
let x = self.hadjustment().value() as f32 * scale_factor;
let y = self.vadjustment().value() as f32 * scale_factor;
let width = self.width() as f32 * scale_factor;
let height = self.height() as f32 * scale_factor;
graphene::Rect::new(x, y, width, height)
}
fn preload_area(&self) -> graphene::Rect {
let viewport = self.viewport();
viewport.inset_r(-viewport.width() / 3., -viewport.height() / 3.)
}
/// 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 |_| {
obj.imp().zoom_hscrollbar_transition.set(false);
obj.imp().zoom_vscrollbar_transition.set(false);
obj.set_zoom_target(obj.imp().zoom_target.get());
}));
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 achieve 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.imp().zoom_target.get() * ZOOM_FACTOR_BUTTON;
self.zoom_to(zoom);
}
/// Zoom out a step with animation
///
/// Used by buttons
pub fn zoom_out(&self) {
let zoom = self.imp().zoom_target.get() / ZOOM_FACTOR_BUTTON;
self.zoom_to(zoom);
}
/// Zoom to best fit
///
/// Used by shortcut
pub fn zoom_best_fit(&self) {
self.zoom_to(self.zoom_level_best_fit());
}
/// Zoom to specific level with animation
pub fn zoom_to(&self, zoom: f64) {
self.zoom_to_full(zoom, true, true);
}
/// Zoom to specific level with animation not snapping to best-fit
///
/// Used for zooming to 100% or 200%
pub fn zoom_to_exact(&self, zoom: f64) {
self.zoom_to_full(zoom, true, false);
}
fn zoom_to_full(&self, mut zoom: f64, animated: bool, snap_best_fit: bool) {
let max_zoom = self.max_zoom();
if zoom >= max_zoom {
zoom = max_zoom;
self.set_is_max_zoom(true);
} else {
self.set_is_max_zoom(false);
}
let extended_best_fit_threshold = if snap_best_fit {
// If image is only 1/4 of a zoom step away from best-fit, also
// activate best-fit. This avoids bugs with floating point precision
// and removes awkward minimal zoom steps.
self.zoom_level_best_fit() * (1. + (ZOOM_FACTOR_BUTTON - 1.) / 4.)
} else {
self.zoom_level_best_fit()
};
if zoom <= extended_best_fit_threshold {
zoom = self.zoom_level_best_fit();
self.set_best_fit(true);
} else {
self.set_best_fit(false);
}
log::debug!("Zoom to {zoom:.3}");
self.set_zoom_target(zoom);
// abort if already at correct zoom level
if zoom == self.zoom() {
log::debug!("Already at correct zoom level");
return;
}
if animated {
// wild code
let current_hborder = self.widget_width() - self.image_displayed_width();
let target_hborder = self.widget_width() - self.image_size().0 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() - self.image_size().1 as f64 * zoom;
self.imp()
.zoom_hscrollbar_transition
.set(current_vborder.signum() != target_vborder.signum() && current_vborder != 0.);
let animation = self.zoom_animation();
animation.set_value_from(self.zoom());
animation.set_value_to(zoom);
animation.play();
} else {
self.set_zoom(zoom);
self.set_zoom_target(zoom);
}
}
/// Image size of original image with EXIF rotation applied
pub fn image_size(&self) -> (i32, i32) {
let orientation = self.imp().image_metadata.borrow().orientation();
if orientation.rotation.abs() == 90. || orientation.rotation.abs() == 270. {
let (x, y) = self.original_dimensions();
(y, x)
} else {
self.original_dimensions()
}
}
fn original_dimensions(&self) -> (i32, i32) {
if let Some((width, height)) = self.imp().frame_buffer.load().original_dimensions() {
(width as i32, height as i32)
} else {
(0, 0)
}
}
/// 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 {
self.image_width(self.applicable_zoom())
}
pub fn image_displayed_height(&self) -> f64 {
self.image_height(self.applicable_zoom())
}
pub fn image_width(&self, zoom: f64) -> f64 {
let (width, height) = self.original_dimensions();
let rotated = self.rotation().to_radians().sin().abs();
((1. - rotated) * width as f64 + rotated * height as f64) * zoom
}
pub fn image_height(&self, zoom: f64) -> f64 {
let (width, height) = self.original_dimensions();
let rotated = self.rotation().to_radians().sin().abs();
((1. - rotated) * height as f64 + rotated * width as f64) * zoom
}
/// Stepwise scrolls inside an image when zoomed in
pub fn pan(&self, direction: >k::PanDirection) {
let sign = match direction {
gtk::PanDirection::Left | gtk::PanDirection::Up => -1.,
gtk::PanDirection::Right | gtk::PanDirection::Down => 1.,
_ => {
log::error!("Unknown pan direction {direction:?}");
return;
}
};
let (adjustment, max) = match direction {
gtk::PanDirection::Left | gtk::PanDirection::Right => {
(self.hadjustment(), self.max_hadjustment_value())
}
gtk::PanDirection::Up | gtk::PanDirection::Down => {
(self.vadjustment(), self.max_vadjustment_value())
}
_ => {
log::error!("Unknown pan direction {direction:?}");
return;
}
};
let value = (adjustment.value() + sign * adjustment.step_increment()).clamp(0., max);
adjustment.set_value(value);
}
/// Configure scrollbars for current situation
fn configure_adjustments(&self) {
let hadjustment = self.hadjustment();
// round to application pixels to avoid tiny rounding errors from zoom
let content_width = self.round_f64(self.image_displayed_width());
let widget_width = self.widget_width();
hadjustment.configure(
// value
hadjustment.value().clamp(0., self.max_hadjustment_value()),
// lower
0.,
// upper
content_width,
// arrow button and shortcut step
widget_width * 0.1,
// page up/down step
widget_width * 0.9,
// page size
f64::min(widget_width, content_width),
);
let vadjustment = self.vadjustment();
// round to application pixels to avoid tiny rounding errors from zoom
let content_height = self.round_f64(self.image_displayed_height());
let widget_height = self.widget_height();
vadjustment.configure(
vadjustment.value().clamp(0., self.max_vadjustment_value()),
// lower
0.,
// upper
content_height,
// arrow button and shortcut step
widget_height * 0.1,
// page up/down step
widget_height * 0.9,
// page_size
f64::min(widget_height, 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.
}
/// Cancel kinetic scrolling movements, needed for some gestures
///
/// If deceleration is not canceled gestures become buggy.
fn cancel_deceleration(&self) {
if let Some(scrolled_window) = self
.parent()
.and_then(|x| x.downcast::().ok())
{
scrolled_window.set_kinetic_scrolling(false);
scrolled_window.set_kinetic_scrolling(true);
} else {
log::error!("Could not find GtkScrolledWindow parent to cancel deceleration");
}
}
pub fn widget_height(&self) -> f64 {
self.height() as f64
}
pub fn widget_width(&self) -> f64 {
self.width() as f64
}
/// Mirrors and rotates snapshot according to arguments
///
/// After the operation the image is positioned such that it's origin
/// is a `(0, 0)` again.
fn snapshot_rotate_mirror(
&self,
snapshot: >k::Snapshot,
rotation: f32,
mirrored: bool,
zoom: f64,
) {
if rotation == 0. && !mirrored {
return;
}
let (original_width, original_height) = self.original_dimensions();
let width = self.image_width(zoom) as f32;
let height = self.image_height(zoom) as f32;
// Put image origin at (0, 0) again with rotation
snapshot.translate(&graphene::Point::new(
self.round_f32(width / 2.),
self.round_f32(height / 2.),
));
// Apply the transformations from properties
snapshot.rotate(rotation);
if mirrored {
snapshot.scale(-1., 1.);
}
// 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(
-self.round_f32(original_width as f32 * zoom as f32 / 2.),
-self.round_f32(original_height as f32 * zoom as f32 / 2.),
));
}
pub fn dimension_details(&self) -> decoder::ImageDimensionDetails {
self.imp().dimension_details.borrow().clone()
}
/// Drag and drop content provider
pub fn content_provider(&self) -> Option {
let file = self.file()?;
let list = gdk::FileList::from_array(&[file]);
Some(gdk::ContentProvider::for_value(&list.to_value()))
}
/// Image format
pub fn format(&self) -> Option {
self.imp().format.borrow().clone()
}
fn set_error(&self, err: Option) {
log::debug!("Decoding error: {err:?}");
self.imp()
.error
.replace(err.as_ref().map(|x| x.to_string()));
self.notify_error();
if err.is_some() {
self.set_loaded(false);
}
}
fn set_unsupported(&self, is_unsupported: bool) {
if self.is_unsupported() != is_unsupported {
self.imp().is_unsupported.set(true);
self.notify_is_unsupported();
if is_unsupported {
self.set_loaded(false);
}
}
}
fn set_loaded(&self, is_loaded: bool) {
if self.is_loaded() != is_loaded {
self.imp().is_loaded.set(is_loaded);
self.notify_is_loaded();
if is_loaded {
self.set_error(None);
self.set_unsupported(false);
}
}
}
/// Returns scaling aware rounded application pixel
///
/// One physical pixel is 0.5 application pixels
pub fn round_f64(&self, number: f64) -> f64 {
// Do not round during animation to avoid wiggling around
if self.zoom_animation().state() == adw::AnimationState::Playing {
return number;
}
let scale = self.scale_factor() as f64;
(number * scale).round() / scale
}
pub fn round_f32(&self, number: f32) -> f32 {
if self.zoom_animation().state() == adw::AnimationState::Playing {
return number;
}
let scale = self.scale_factor() as f32;
(number * scale).round() / scale
}
/// Returns the background color that should be used with this image
///
/// Returns the default color if no one has been guessed yet
pub fn background_color(&self) -> gdk::RGBA {
(*self.imp().background_color.borrow()).unwrap_or_else(Self::default_background_color)
}
pub fn set_background_color(&self, color: Option) {
self.imp().background_color.replace(color);
}
pub fn default_background_color() -> gdk::RGBA {
if adw::StyleManager::default().is_dark() {
*BACKGROUND_COLOR_DEFAULT
} else {
*BACKGROUND_COLOR_DEFAULT_LIGHT_MODE
}
}
pub fn alternate_background_color() -> gdk::RGBA {
if adw::StyleManager::default().is_dark() {
*BACKGROUND_COLOR_ALTERNATE
} else {
*BACKGROUND_COLOR_ALTERNATE_LIGHT_MODE
}
}
/// Returns a background color that should give suitable contrast with
/// transparent images
///
/// For non-transparent images this always returns
/// `BACKGROUND_COLOR_DEFAULT`
pub async fn background_color_guess(&self) -> Option {
// Shortcut for formats that don't support transparency
if !self
.format()
.map_or(true, |x| x.is_potentially_transparent())
{
log::debug!("This format does not support transparency");
return Some(Self::default_background_color());
}
let (width, height) = self.original_dimensions();
let max_size = i32::max(width, height);
// Only use max 200px size scaled image for analysis
let zoom = f64::min(1., 200. / max_size as f64);
let snapshot = gtk::Snapshot::new();
let render_options = tiling::RenderOptions {
scaling_filter: gsk::ScalingFilter::Nearest,
background_color: None,
scale_factor: 1,
};
self.imp()
.frame_buffer
.load()
.add_to_snapshot(&snapshot, zoom, &render_options);
let node = snapshot.to_node()?;
let renderer = self.root()?.renderer();
if !renderer.is_realized() {
return None;
}
// Render the small version of the image and download to RAM
let texture = renderer.render_texture(node, None);
let mut downloader = gdk::TextureDownloader::new(&texture);
downloader.set_format(gdk::MemoryFormat::R8g8b8a8);
let (bytes, stride) = downloader.download_bytes();
// Get here because only available in main thread
let alternate_color = Self::alternate_background_color();
let default_color = Self::default_background_color();
gio::spawn_blocking(move || {
let mut has_transparency = false;
let mut bytes_iter = bytes.iter();
// Number of transparent pixels
let mut completely_transparent = 0;
// Number of non-transparent pixels with bad contrast
let mut bad_contrast = 0;
'img: loop {
for _ in 0..texture.width() {
let Some(r) = bytes_iter.next() else {
break 'img;
};
let Some(g) = bytes_iter.next() else {
break 'img;
};
let Some(b) = bytes_iter.next() else {
break 'img;
};
let Some(a) = bytes_iter.next() else {
break 'img;
};
if *a < 255 {
has_transparency = true;
}
if *a == 0 {
completely_transparent += 1;
} else {
let fg = gdk::RGBA::new(
*r as f32 / 255.,
*g as f32 / 255.,
*b as f32 / 255.,
*a as f32 / 255.,
);
let contrast = crate::util::contrast_ratio(&default_color, &fg);
if contrast < BACKGROUND_GUESS_LOW_CONTRAST_RATIO {
bad_contrast += 1;
}
}
}
let advance_by = stride - 4 * texture.width() as usize;
if advance_by > 0 {
bytes_iter.nth(advance_by - 1);
}
}
if !has_transparency {
log::debug!("This image does not have transparency");
return Some(default_color);
}
let n_pixels = texture.width() * texture.height();
let part_bad_contrast = if completely_transparent < n_pixels {
bad_contrast as f64 / (n_pixels as f64 - completely_transparent as f64)
} else {
1.
};
if part_bad_contrast > BACKGROUND_GUESS_LOW_CONTRAST_TRHESHOLD {
Some(alternate_color)
} else {
Some(default_color)
}
})
.await
.ok()?
}
}