diff --git a/po/POTFILES.in b/po/POTFILES.in index 3bac502..7d5f361 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -18,6 +18,17 @@ src/util/gettext.rs src/util/mod.rs src/widgets/drag_overlay.rs src/widgets/image.rs +src/widgets/image/drag.rs +src/widgets/image/guess_background_color.rs +src/widgets/image/input_handling.rs +src/widgets/image/loading.rs +src/widgets/image/metadata.rs +src/widgets/image/pan.rs +src/widgets/image/printing.rs +src/widgets/image/rendering.rs +src/widgets/image/rotation.rs +src/widgets/image/scrollable.rs +src/widgets/image/zoom.rs src/widgets/image_page.rs src/widgets/image_page.ui src/widgets/image_view.rs diff --git a/src/widgets/image.rs b/src/widgets/image.rs index 5092a66..7fb64bd 100644 --- a/src/widgets/image.rs +++ b/src/widgets/image.rs @@ -1,6 +1,5 @@ -// Copyright (c) 2021-2023 Christopher Davis +// 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 // @@ -30,11 +29,23 @@ //! //! [`decoder`]: crate::decoder +mod drag; +mod guess_background_color; +mod input_handling; +mod loading; +mod metadata; +mod pan; +mod printing; +mod rendering; +mod rotation; +mod scrollable; +mod zoom; + use crate::deps::*; use crate::decoder::tiling::FrameBufferExt; use crate::decoder::{self, tiling, Decoder, DecoderUpdate}; -use crate::metadata::{self, Metadata}; +use crate::metadata::Metadata; use crate::util::Gesture; use adw::prelude::*; @@ -203,8 +214,8 @@ mod imp { /// Gesture, zoom or rotate, used for the duration of the gesture pub(super) locked_gestured: Cell>, - widget_dimensions: Cell<(i32, i32)>, - scale_factor: Cell, + pub(super) widget_dimensions: Cell<(i32, i32)>, + pub(super) scale_factor: Cell, } #[glib::object_subclass] @@ -247,8 +258,7 @@ mod imp { self.best_fit.set(true); self.scale_factor.set(obj.scale_factor()); - self.connect_controllers(); - self.connect_gestures(); + self.connect_input_handling(); obj.connect_scale_factor_notify(|obj| { obj.queue_resize(); @@ -277,510 +287,6 @@ mod imp { .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_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 - } - } - - 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 obj = self.obj(); - let widget_width = obj.width() as f64; - let widget_height = obj.height() as f64; - let display_width = obj.image_displayed_width(); - let display_height = obj.image_displayed_height(); - - // make sure the scrollbars are correct - obj.configure_adjustments(); - - let applicable_zoom = obj.applicable_zoom(); - - let scaling_filter = if obj.metadata().format().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: obj.scale_factor(), - background_color: Some(obj.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( - &obj.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 = obj.hadjustment(); - let x = -(hadj.value() - (hadj.upper() - display_width) / 2.); - snapshot.translate(&graphene::Point::new(obj.round_f64(x) as f32, 0.)); - - let vadj = obj.vadjustment(); - let y = -(vadj.value() - (vadj.upper() - display_height) / 2.); - snapshot.translate(&graphene::Point::new(0., obj.round_f64(y) as f32)); - - // Centering in widget when no scrolling (black bars around image) - let x = obj.round_f64(f64::max((widget_width - display_width) / 2.0, 0.)); - let y = obj.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( - obj.round_f64(x) as f32, - obj.round_f64(y) as f32, - )); - - // Apply rotation and mirroring - obj.snapshot_rotate_mirror( - snapshot, - obj.rotation() as f32, - obj.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! { @@ -788,1089 +294,3 @@ glib::wrapper! { @extends gtk::Widget, @implements gtk::Scrollable; } - -impl LpImage { - pub fn init(&self, file: &gio::File) { - self.imp().file.replace(Some(file.clone())); - } - - pub async fn load(&self, file: &gio::File) { - let imp = self.imp(); - log::debug!("Loading file {}", file.uri()); - - imp.metadata.replace(Metadata::default()); - self.emmit_metadata_changed(); - self.set_file_loaded(file).await; - - 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(), self.metadata().mime_type(), 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"); - }); - - imp.decoder.replace(Some(Arc::new(decoder))); - } - - // Set filename when (re)loading image - async fn set_file_loaded(&self, file: &gio::File) { - let imp = self.imp(); - - // Do not check if same file since it might be set via initialized with - // `Self::init` before - imp.file.replace(Some(file.clone())); - self.reload_file_info().await; - self.setup_file_monitor().await; - } - - /// Set filename after filename changed - async fn set_file_changed(&self, file: &gio::File) { - let imp = self.imp(); - let prev_mime_type = self.metadata().mime_type(); - - imp.file.replace(Some(file.clone())); - self.reload_file_info().await; - - // Reload if mime type changed since other decoding path might be responsible - // now - if self.metadata().mime_type() != prev_mime_type { - self.load(file).await; - return; - } - - self.setup_file_monitor().await; - } - - async fn reload_file_info(&self) { - let imp = self.imp(); - - if let Some(file) = self.file() { - let file_info = metadata::FileInfo::new(&file).await; - match file_info { - Ok(file_info) => imp.metadata.borrow_mut().set_file_info(file_info), - Err(err) => log::warn!("Failed to load file information: {err}"), - } - self.emmit_metadata_changed(); - } - } - - async fn setup_file_monitor(&self) { - let imp = self.imp(); - - if let Some(file) = self.file() { - let monitor = - file.monitor_file(gio::FileMonitorFlags::WATCH_MOVES, gio::Cancellable::NONE); - if let Ok(m) = &monitor { - m.connect_changed( - glib::clone!(@weak self as obj => move |_, file_a, file_b, event| { - obj.file_changed(event, file_a, file_b); - }), - ); - } - - imp.file_monitor.replace(monitor.ok()); - } - } - - /// 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.metadata.borrow_mut().merge(metadata); - self.emmit_metadata_changed(); - - 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.metadata.borrow_mut().set_format(format); - self.emmit_metadata_changed(); - } - 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)); - let obj = self.clone(); - if file_replace { - log::debug!("Image got replaced {}", file.uri()); - // TODO: error handling is missing - glib::spawn_future_local(async move { - obj.load(&file).await; - }); - } else { - glib::spawn_future_local(async move { - obj.set_file_changed(&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.metadata().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().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())) - } - - 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 - .metadata() - .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()? - } - - pub fn file(&self) -> Option { - self.imp().file.borrow().clone() - } - - pub fn metadata(&self) -> impl Deref + '_ { - self.imp().metadata.borrow() - } - - pub fn connect_changed(&self, f: impl Fn() + 'static) { - self.connect_local("metadata-changed", false, move |_| { - f(); - None - }); - } - - fn emmit_metadata_changed(&self) { - self.emit_by_name::<()>("metadata-changed", &[]); - } -} diff --git a/src/widgets/image/drag.rs b/src/widgets/image/drag.rs new file mode 100644 index 0000000..103e3e3 --- /dev/null +++ b/src/widgets/image/drag.rs @@ -0,0 +1,57 @@ +// Copyright (c) 2023 Sophie Herold +// +// 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 + +use super::*; + +impl LpImage { + /// 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())) + } + + /// 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) + } +} diff --git a/src/widgets/image/guess_background_color.rs b/src/widgets/image/guess_background_color.rs new file mode 100644 index 0000000..f229302 --- /dev/null +++ b/src/widgets/image/guess_background_color.rs @@ -0,0 +1,170 @@ +// Copyright (c) 2023 Sophie Herold +// +// 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 + +use super::*; + +impl LpImage { + /// 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 + .metadata() + .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()? + } +} diff --git a/src/widgets/image/input_handling.rs b/src/widgets/image/input_handling.rs new file mode 100644 index 0000000..c7195e6 --- /dev/null +++ b/src/widgets/image/input_handling.rs @@ -0,0 +1,281 @@ +// Copyright (c) 2023 Sophie Herold +// +// 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 + +use super::*; + +impl imp::LpImage { + /// To be called by constructor + pub(super) fn connect_input_handling(&self) { + self.connect_controllers(); + self.connect_gestures(); + } + + 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); + } +} + +impl LpImage { + /// 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"); + } + } +} diff --git a/src/widgets/image/loading.rs b/src/widgets/image/loading.rs new file mode 100644 index 0000000..dae08d0 --- /dev/null +++ b/src/widgets/image/loading.rs @@ -0,0 +1,287 @@ +// Copyright (c) 2023 Sophie Herold +// +// 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 + +use super::*; + +impl LpImage { + pub fn init(&self, file: &gio::File) { + self.imp().file.replace(Some(file.clone())); + } + + pub async fn load(&self, file: &gio::File) { + let imp = self.imp(); + log::debug!("Loading file {}", file.uri()); + + imp.metadata.replace(Metadata::default()); + self.emmit_metadata_changed(); + self.set_file_loaded(file).await; + + 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(), self.metadata().mime_type(), 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"); + }); + + imp.decoder.replace(Some(Arc::new(decoder))); + } + + // Set filename when (re)loading image + async fn set_file_loaded(&self, file: &gio::File) { + let imp = self.imp(); + + // Do not check if same file since it might be set via initialized with + // `Self::init` before + imp.file.replace(Some(file.clone())); + self.reload_file_info().await; + self.setup_file_monitor().await; + } + + /// Set filename after filename changed + async fn set_file_changed(&self, file: &gio::File) { + let imp = self.imp(); + let prev_mime_type = self.metadata().mime_type(); + + imp.file.replace(Some(file.clone())); + self.reload_file_info().await; + + // Reload if mime type changed since other decoding path might be responsible + // now + if self.metadata().mime_type() != prev_mime_type { + self.load(file).await; + return; + } + + self.setup_file_monitor().await; + } + + async fn setup_file_monitor(&self) { + let imp = self.imp(); + + if let Some(file) = self.file() { + let monitor = + file.monitor_file(gio::FileMonitorFlags::WATCH_MOVES, gio::Cancellable::NONE); + if let Ok(m) = &monitor { + m.connect_changed( + glib::clone!(@weak self as obj => move |_, file_a, file_b, event| { + obj.file_changed(event, file_a, file_b); + }), + ); + } + + imp.file_monitor.replace(monitor.ok()); + } + } + + /// 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.metadata.borrow_mut().merge(metadata); + self.emmit_metadata_changed(); + + 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.metadata.borrow_mut().set_format(format); + self.emmit_metadata_changed(); + } + 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 + } + + fn preload_area(&self) -> graphene::Rect { + let viewport = self.viewport(); + viewport.inset_r(-viewport.width() / 3., -viewport.height() / 3.) + } + + pub(super) 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(), + }); + } + } + } + + /// 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)); + let obj = self.clone(); + if file_replace { + log::debug!("Image got replaced {}", file.uri()); + // TODO: error handling is missing + glib::spawn_future_local(async move { + obj.load(&file).await; + }); + } else { + glib::spawn_future_local(async move { + obj.set_file_changed(&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(); + } + _ => {} + } + } + + 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); + } + } + } +} diff --git a/src/widgets/image/metadata.rs b/src/widgets/image/metadata.rs new file mode 100644 index 0000000..f38a259 --- /dev/null +++ b/src/widgets/image/metadata.rs @@ -0,0 +1,105 @@ +// Copyright (c) 2023 Sophie Herold +// +// 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 + +use super::*; +use crate::metadata; + +impl LpImage { + /// Image size of original image with EXIF rotation applied + pub fn image_size(&self) -> (i32, i32) { + let orientation = self.imp().metadata.borrow().orientation(); + if orientation.rotation.abs() == 90. || orientation.rotation.abs() == 270. { + let (x, y) = self.original_dimensions(); + (y, x) + } else { + self.original_dimensions() + } + } + + pub(super) 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 + } + + pub fn dimension_details(&self) -> decoder::ImageDimensionDetails { + self.imp().dimension_details.borrow().clone() + } + + pub fn file(&self) -> Option { + self.imp().file.borrow().clone() + } + + pub fn metadata(&self) -> impl Deref + '_ { + self.imp().metadata.borrow() + } + + pub fn connect_changed(&self, f: impl Fn() + 'static) { + self.connect_local("metadata-changed", false, move |_| { + f(); + None + }); + } + + pub(super) fn emmit_metadata_changed(&self) { + self.emit_by_name::<()>("metadata-changed", &[]); + } + + pub(super) async fn reload_file_info(&self) { + let imp = self.imp(); + + if let Some(file) = self.file() { + let file_info = metadata::FileInfo::new(&file).await; + match file_info { + Ok(file_info) => imp.metadata.borrow_mut().set_file_info(file_info), + Err(err) => log::warn!("Failed to load file information: {err}"), + } + self.emmit_metadata_changed(); + } + } +} diff --git a/src/widgets/image/pan.rs b/src/widgets/image/pan.rs new file mode 100644 index 0000000..6121030 --- /dev/null +++ b/src/widgets/image/pan.rs @@ -0,0 +1,49 @@ +// Copyright (c) 2023 Sophie Herold +// +// 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 + +use super::*; + +impl LpImage { + /// 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); + } +} diff --git a/src/widgets/image/printing.rs b/src/widgets/image/printing.rs new file mode 100644 index 0000000..261f59d --- /dev/null +++ b/src/widgets/image/printing.rs @@ -0,0 +1,49 @@ +// Copyright (c) 2023 Sophie Herold +// +// 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 + +use super::*; + +impl LpImage { + 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)) + } +} diff --git a/src/widgets/image/rendering.rs b/src/widgets/image/rendering.rs new file mode 100644 index 0000000..96797b4 --- /dev/null +++ b/src/widgets/image/rendering.rs @@ -0,0 +1,291 @@ +// Copyright (c) 2023 Sophie Herold +// +// 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 + +use super::*; + +impl WidgetImpl for imp::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 obj = self.obj(); + let widget_width = obj.width() as f64; + let widget_height = obj.height() as f64; + let display_width = obj.image_displayed_width(); + let display_height = obj.image_displayed_height(); + + // make sure the scrollbars are correct + obj.configure_adjustments(); + + let applicable_zoom = obj.applicable_zoom(); + + let scaling_filter = if obj.metadata().format().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: obj.scale_factor(), + background_color: Some(obj.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( + &obj.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 = obj.hadjustment(); + let x = -(hadj.value() - (hadj.upper() - display_width) / 2.); + snapshot.translate(&graphene::Point::new(obj.round_f64(x) as f32, 0.)); + + let vadj = obj.vadjustment(); + let y = -(vadj.value() - (vadj.upper() - display_height) / 2.); + snapshot.translate(&graphene::Point::new(0., obj.round_f64(y) as f32)); + + // Centering in widget when no scrolling (black bars around image) + let x = obj.round_f64(f64::max((widget_width - display_width) / 2.0, 0.)); + let y = obj.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( + obj.round_f64(x) as f32, + obj.round_f64(y) as f32, + )); + + // Apply rotation and mirroring + obj.snapshot_rotate_mirror( + snapshot, + obj.rotation() as f32, + obj.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 LpImage { + /// Mirrors and rotates snapshot according to arguments + /// + /// After the operation the image is positioned such that it's origin + /// is a `(0, 0)` again. + pub(super) 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.), + )); + } + + /// Returns the area of the image that is visible in physical pixels + pub(super) 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) + } + + pub fn widget_height(&self) -> f64 { + self.height() as f64 + } + + pub fn widget_width(&self) -> f64 { + self.width() as f64 + } + + /// 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 + } +} diff --git a/src/widgets/image/rotation.rs b/src/widgets/image/rotation.rs new file mode 100644 index 0000000..fcc3b66 --- /dev/null +++ b/src/widgets/image/rotation.rs @@ -0,0 +1,84 @@ +// Copyright (c) 2023 Sophie Herold +// +// 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 + +use super::*; + +impl imp::LpImage { + 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(super) 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(); + } +} + +impl LpImage { + /// 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(); + } + } + + pub(super) 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() + }) + } +} diff --git a/src/widgets/image/scrollable.rs b/src/widgets/image/scrollable.rs new file mode 100644 index 0000000..c793faf --- /dev/null +++ b/src/widgets/image/scrollable.rs @@ -0,0 +1,118 @@ +// Copyright (c) 2023 Sophie Herold +// +// 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 + +use super::*; + +impl ScrollableImpl for imp::LpImage {} + +impl imp::LpImage { + pub 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(); + } + + pub 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_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 + } +} + +impl LpImage { + /// Configure scrollbars for current situation + pub(super) 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. + } +} diff --git a/src/widgets/image/zoom.rs b/src/widgets/image/zoom.rs new file mode 100644 index 0000000..22963d2 --- /dev/null +++ b/src/widgets/image/zoom.rs @@ -0,0 +1,327 @@ +// Copyright (c) 2023 Sophie Herold +// +// 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 + +use super::*; + +impl imp::LpImage { + /// 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. + pub(super) fn set_zoom(&self, zoom: f64) { + self.obj() + .set_zoom_aiming(zoom, self.pointer_position.get()) + } +} + +impl LpImage { + /// 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 + pub(super) 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 + pub(super) fn zoom_level_best_fit_for_rotation(&self, rotation: f64) -> f64 { + let rotated = rotation.to_radians().sin().abs(); + let (image_width, image_height) = ( + self.original_dimensions().0 as f64 / self.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 + pub(super) 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(); + } + } + + pub fn is_best_fit(&self) -> bool { + self.imp().best_fit.get() + } + + pub(super) fn applicable_zoom(&self) -> f64 { + decoder::tiling::zoom_normalize(self.zoom()) / self.scale_factor() as f64 + } + + /// Maximal zoom allowed for this image + pub(super) fn max_zoom(&self) -> f64 { + if self.metadata().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 + pub(super) 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(); + } + + pub(super) 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(); + } + } + + /// 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); + } + + pub(super) 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); + } + } + + /// Animation that makes larger zoom steps (from buttons etc) look smooth + pub(super) 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.) + } +}