// Copyright (c) 2023-2024 Sophie Herold // Copyright (c) 2023 Alice Mikhaylenko // Copyright (c) 2023 Christopher Davis // Copyright (c) 2023 Lubosz Sarnecki // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . // // SPDX-License-Identifier: GPL-3.0-or-later //! A sliding view widget for images //! //! This widget it similar to `AdwCarousel`. use std::cell::{Cell, OnceCell, RefCell}; use adw::prelude::*; use adw::subclass::prelude::*; use glib::Properties; use indexmap::IndexMap; use log::error; use once_cell::sync::Lazy; use crate::deps::*; use crate::widgets::LpImagePage; const SCROLL_DAMPING_RATIO: f64 = 1.; const SCROLL_MASS: f64 = 0.5; const SCROLL_STIFFNESS: f64 = 500.; /// Duration for cards animation const STEP_DURATION: u32 = 250; /// Offset of card at the beginning in app pixels const STEP_PIXEL_SHIFT: f32 = 100.; /// Progress point at which the move-out animation starts. /// The progress is a value between 0.0 and 1.0 const STEP_MOVE_OUT_DELAY: f32 = 0.5; /// Space between images in application pixels /// /// This is combined with the percent component const PAGE_SPACING_FIXED: f32 = 25.; /// Space between images as factor of width const PAGE_SPACING_PERCENT: f32 = 0.02; #[derive(Debug)] enum PositionTracking { Position(f64), StackedCards(StackedCards), } impl Default for PositionTracking { fn default() -> Self { PositionTracking::Position(0.) } } impl PositionTracking { fn position(&self) -> f64 { match self { Self::Position(position) => *position, // TODO: This could return something useful Self::StackedCards(_) => 0., } } fn set_position(&mut self, position: f64) { *self = Self::Position(position); } fn set_progress(&mut self, progress: f64) { match self { Self::Position(_) => { log::error!("Trying to set StackedCards progress while not in animation state.") } Self::StackedCards(cards) => cards.progress = progress, }; } } #[derive(Debug)] struct StackedCards { prev_image: LpImagePage, progress: f64, } impl StackedCards { fn new(prev_image: LpImagePage) -> Self { Self { prev_image, progress: 0., } } } mod imp { use glib::subclass::Signal; use super::*; #[derive(Default, Properties)] #[properties(wrapper_type = super::LpSlidingView)] pub struct LpSlidingView { /// Pages that can be slided through pub(super) pages: RefCell>, /// Page that is currently shown or the animation is animating towards #[property(get)] pub(super) current_page: RefCell>, /// Animatable position, 0.0 first image, 1.0 second image etc pub(super) position_tracking: RefCell, /// Move position to not break animations when pages are removed/added pub(super) position_shift: Cell, /// The animation used to animate image changes pub(super) scroll_animation: OnceCell, pub(super) step_animation: OnceCell, /// Implements swiping pub(super) swipe_tracker: OnceCell, } #[glib::object_subclass] impl ObjectSubclass for LpSlidingView { const NAME: &'static str = "LpSlidingView"; type Type = super::LpSlidingView; type ParentType = gtk::Widget; type Interfaces = (adw::Swipeable,); } impl ObjectImpl for LpSlidingView { fn properties() -> &'static [glib::ParamSpec] { Self::derived_properties() } fn property(&self, id: usize, pspec: &glib::ParamSpec) -> glib::Value { self.derived_property(id, pspec) } fn signals() -> &'static [Signal] { static SIGNALS: Lazy> = Lazy::new(|| vec![Signal::builder("target-page-reached").build()]); SIGNALS.as_ref() } fn constructed(&self) { let obj = self.obj(); self.parent_constructed(); self.obj().set_overflow(gtk::Overflow::Hidden); let swipe_tracker = adw::SwipeTracker::builder() .swipeable(&*self.obj()) .reversed(self.is_rtl()) .lower_overshoot(true) .upper_overshoot(true) .build(); swipe_tracker.connect_begin_swipe(glib::clone!(@weak obj => move |_| { obj.scroll_animation().pause(); obj.step_animation().pause(); })); swipe_tracker.connect_update_swipe(glib::clone!(@weak obj => move |_, position| { obj.set_position(position); })); swipe_tracker.connect_end_swipe(glib::clone!(@weak obj => move |_, velocity, to| { if let Some(page) = obj.page_at(to) { obj.scroll_to(&page, velocity); } })); self.swipe_tracker.set(swipe_tracker).unwrap(); // Avoid propagating scroll events to AdwFlap if at beginning or end let scroll_controller = gtk::EventControllerScroll::new(gtk::EventControllerScrollFlags::HORIZONTAL); scroll_controller.connect_scroll( glib::clone!(@weak obj => @default-return glib::Propagation::Proceed, move |_, x, _| { let direction_sign = if obj.imp().is_rtl() { -1. } else { 1. }; if x * direction_sign > 0. { // check end if let Some(max) = obj.imp().snap_points().last() { if obj.position() >= *max { glib::Propagation::Stop } else { glib::Propagation::Proceed } } else { glib::Propagation::Stop } } else { //check beginning if let Some(min) = obj.imp().snap_points().first() { if obj.position() <= *min { glib::Propagation::Stop } else { glib::Propagation::Proceed } } else { glib::Propagation::Stop } } }), ); obj.add_controller(scroll_controller); } fn dispose(&self) { let obj = self.obj(); while let Some(child) = obj.first_child() { child.unparent(); } } } impl WidgetImpl for LpSlidingView { fn size_allocate(&self, width: i32, height: i32, _baseline: i32) { for (page_index, page) in self.pages.borrow().iter().enumerate() { match *self.position_tracking.borrow() { PositionTracking::Position(_) => { self.size_allocate_position(page_index, page, width, height) } PositionTracking::StackedCards(ref stacked_cards) => { self.size_allocate_stacked_cards(page, stacked_cards, width, height) } } if !page.scrolled_window().is_mapped() { // SVG needs to know a widget size before rendering something in LpImage page.scrolled_window().allocate(width, height, 0, None); } } } fn measure(&self, orientation: gtk::Orientation, for_size: i32) -> (i32, i32, i32, i32) { let obj = self.obj(); for page in self.pages.borrow().iter() { if Some(page) != obj.current_page().as_ref() { page.measure(orientation, for_size); } } if let Some(current_page) = obj.current_page() { current_page.measure(orientation, for_size) } else { (0, 0, -1, -1) } } fn direction_changed(&self, _previous_direction: gtk::TextDirection) { self.swipe_tracker .get() .unwrap() .set_reversed(self.is_rtl()); } } impl SwipeableImpl for LpSlidingView { fn progress(&self) -> f64 { self.obj().position() } fn distance(&self) -> f64 { let obj = self.obj(); let width = obj.width(); width as f64 + self.page_spacing(width) as f64 } fn snap_points(&self) -> Vec { let obj = self.obj(); (0..obj.n_pages()) .map(|i| i as f64 - obj.position_shift()) .collect() } fn cancel_progress(&self) -> f64 { let obj = self.obj(); let snap_points = self.snap_points(); if let (Some(min), Some(max)) = (snap_points.first(), snap_points.last()) { obj.position().round().clamp(*min, *max) } else { 0. } } } impl LpSlidingView { /// Usualy allocate or during scroll animation fn size_allocate_position( &self, page_index: usize, page: &LpImagePage, width: i32, height: i32, ) { let scroll_position = self.obj().position() as f32; let position_shift = self.position_shift.get() as f32; let page_position = page_index as f32; // reverse page order for RTL languages let direction_sign = if self.is_rtl() { -1. } else { 1. }; // This positions the pages within the carousel and shifts them // according to the position that should currently be shown. let x = direction_sign * (page_position - scroll_position - position_shift) * (width as f32 + self.page_spacing(width)); // Only show visible images if page_position == (scroll_position + position_shift).floor() || page_position == (scroll_position + position_shift).ceil() { page.set_child_visible(true); let transform = gsk::Transform::new().translate(&graphene::Point::new(x, 0.)); page.allocate(width, height, 0, Some(transform)); } else { page.set_child_visible(false); } } /// Allocate during stacked card animation fn size_allocate_stacked_cards( &self, page: &LpImagePage, stacked_cards: &StackedCards, width: i32, height: i32, ) { let obj = self.obj().to_owned(); if let Some(new_page) = obj.current_page() { let old_page = &stacked_cards.prev_image; let Some(old_index) = obj.index_of(old_page) else { return; }; let Some(new_index) = obj.index_of(&new_page) else { return; }; let mut direction_sign = if new_index > old_index { 1. } else { -1. }; if self.is_rtl() { direction_sign *= -1.; } let progress = stacked_cards.progress; // Only show visible images if page == &new_page { page.set_child_visible(true); // Ensure that new image is on top page.unparent(); page.insert_after(&obj, Some(old_page)); let x = direction_sign * STEP_PIXEL_SHIFT * (1. - progress as f32); let transform = gsk::Transform::new().translate(&graphene::Point::new(x, 0.)); page.allocate(width, height, 0, Some(transform)); page.set_opacity(progress); } else if page == old_page { page.set_child_visible(true); let x = -direction_sign * f32::max( 0., (progress as f32 - STEP_MOVE_OUT_DELAY) * STEP_PIXEL_SHIFT, ); let transform = gsk::Transform::new().translate(&graphene::Point::new(x, 0.)); page.allocate(width, height, 0, Some(transform)); } else { page.set_child_visible(false); } } } fn page_spacing(&self, width: i32) -> f32 { width as f32 * PAGE_SPACING_PERCENT + PAGE_SPACING_FIXED } fn is_rtl(&self) -> bool { let obj = self.obj(); obj.direction() == gtk::TextDirection::Rtl } } } glib::wrapper! { pub struct LpSlidingView(ObjectSubclass) @extends gtk::Widget, @implements gtk::Buildable, adw::Swipeable; } impl LpSlidingView { /// Returns a struct that allows lazy updates /// /// Lazy updates don't apply their notifications directly but only when the /// editor struct is dropped. pub fn editor(&self) -> SlidingViewEditor { SlidingViewEditor::new(self.clone()) } /// Add page to the end pub fn append(&self, page: &LpImagePage) { self.insert(page, self.n_pages()); } /// Add page to the front pub fn prepend(&self, page: &LpImagePage) { self.insert(page, 0); } /// Add page to the specified position pub fn insert(&self, page: &LpImagePage, pos: usize) { if pos > self.n_pages() { log::error!("Invalid insert position {pos} for {}", page.file().uri()); return; } let current_index = self.current_index(); let previous_sibling = self.imp().pages.borrow().get(pos).cloned(); self.imp().pages.borrow_mut().insert(pos, page.clone()); page.insert_after(self, previous_sibling.as_ref()); self.shift_position(current_index); if self.n_pages() == 1 { self.set_current_page(Some(page)); } } /// Reorders the pages by moving the specified page to the new index pub fn move_to(&self, page: &LpImagePage, new_index: usize) { if let Some(index) = self.index_of(page) { let current_index = self.current_index(); { let mut vec = self.imp().pages.borrow_mut(); let page = vec.remove(index); vec.insert(new_index, page); } self.shift_position(current_index); } else { log::error!("Page to move not found: {}", page.file().uri()); } } /// Removes the page fn remove(&self, page: &LpImagePage, lazy: bool) { let current_index = self.current_index(); if let Some(index) = self.index_of(page) { self.imp().pages.borrow_mut().remove(index); page.unparent(); self.queue_allocate(); self.shift_position(current_index); if self.is_empty() && !lazy { self.clear(false); } } else { log::error!("Trying to remove non-existent page."); } } /// Signal that fires when the animation for scrolling to new page ends pub fn connect_target_page_reached(&self, f: impl Fn() + 'static) { self.connect_local("target-page-reached", false, move |_| { f(); None }); } /// Removes all pages fn clear(&self, lazy: bool) { self.scroll_animation().pause(); self.step_animation().pause(); for page in self.imp().pages.borrow().iter() { page.unparent(); } self.imp().pages.borrow_mut().clear(); self.set_position(0.); self.imp().position_shift.set(0.); if !lazy { self.set_current_page(None); } } /// Gives all pages with hash access via their path pub fn pages(&self) -> IndexMap { self.imp() .pages .borrow() .iter() .cloned() .map(|x| (x.file().uri(), x)) .collect() } pub fn get(&self, file: &gio::File) -> Option { let uri = file.uri(); self.imp() .pages .borrow() .iter() .find(|x| x.file().uri() == uri) .cloned() } /// Move to specified page with animation pub fn animate_to(&self, page: &LpImagePage) { if let Some(index) = self.index_of(page) { if self .current_index() .map_or(false, |x| (x as i64 - index as i64).abs() == 1) { self.step_to(page); } else { self.scroll_to(page, 0.); } } else { log::error!("Page not in LpSlidingView {}", page.file().uri()); } } /// Move to image with cards animation fn step_to(&self, page: &LpImagePage) { let animation = self.step_animation(); if let Some(prev_image) = self.current_page() { *self.imp().position_tracking.borrow_mut() = PositionTracking::StackedCards(StackedCards::new(prev_image)); animation.play(); } self.set_current_page(Some(page)); } /// Move to image with scroll animation fn scroll_to(&self, page: &LpImagePage, initial_velocity: f64) { if let Some(index) = self.index_of(page) { let animation = self.scroll_animation(); animation.set_value_from(self.position()); animation.set_value_to(index as f64 - self.position_shift()); animation.set_initial_velocity(initial_velocity); animation.play(); self.set_current_page(Some(page)); } else { log::error!("Page not in LpSlidingView {}", page.file().uri()); } } /// Animates removal of current page /// /// Moves to next page if possible, or to previous, or immediately removes /// the current page if last remaining. pub fn scroll_to_neighbor(&self) { if let Some(next_page) = self.next_page() { self.animate_to(&next_page); } else if let Some(prev_page) = self.prev_page() { self.animate_to(&prev_page); } else if let Some(current_page) = self.current_page() { self.remove(¤t_page, false); } } fn next_page(&self) -> Option { let current_index = self.current_index()?; let next_index = current_index.checked_add(1)?; self.imp().pages.borrow().get(next_index).cloned() } fn prev_page(&self) -> Option { let current_index = self.current_index()?; let prev_index = current_index.checked_sub(1)?; self.imp().pages.borrow().get(prev_index).cloned() } fn page_at(&self, position: f64) -> Option { let index = (position + self.position_shift()) as usize; self.imp().pages.borrow().get(index).cloned() } fn n_pages(&self) -> usize { self.imp().pages.borrow().len() } fn is_empty(&self) -> bool { self.imp().pages.borrow().is_empty() } fn index_of(&self, page: &LpImagePage) -> Option { self.imp().pages.borrow().iter().position(|x| x == page) } fn position(&self) -> f64 { self.imp().position_tracking.borrow().position() } fn position_shift(&self) -> f64 { self.imp().position_shift.get() } /// Sets position shift to correct page insert or removal /// /// Takes the old position of the current page and calculates and sets the /// shift to keep the current page optically at the same position. fn shift_position(&self, old_index: Option) { if let (Some(old_index), Some(new_index)) = (old_index, self.current_index()) { let shift = new_index as f64 - old_index as f64; self.imp().position_shift.set(self.position_shift() + shift); } } fn set_position(&self, position: f64) { self.imp() .position_tracking .borrow_mut() .set_position(position); self.queue_allocate(); } fn set_current_page(&self, page: Option<&LpImagePage>) { self.imp().current_page.replace(page.cloned()); self.notify("current-page"); } fn current_index(&self) -> Option { self.imp() .current_page .borrow() .as_ref() .and_then(|x| self.index_of(x)) } fn scroll_animation(&self) -> &adw::SpringAnimation { self.imp().scroll_animation.get_or_init(|| { let target = adw::CallbackAnimationTarget::new( glib::clone!(@weak self as obj => move |position| obj.set_position(position)), ); let animation = adw::SpringAnimation::builder() .spring_params(&adw::SpringParams::new( SCROLL_DAMPING_RATIO, SCROLL_MASS, SCROLL_STIFFNESS, )) .widget(self) .target(&target) .build(); animation.connect_done(glib::clone!(@weak self as obj => move |_| { obj.emit_by_name("target-page-reached", &[]) })); animation }) } fn step_animation(&self) -> &adw::TimedAnimation { self.imp().step_animation.get_or_init(|| { let target: adw::CallbackAnimationTarget = adw::CallbackAnimationTarget::new( glib::clone!(@weak self as obj => move |progress| { obj.imp().position_tracking.borrow_mut().set_progress(progress); obj.queue_allocate(); }), ); let animation = adw::TimedAnimation::builder() .duration(STEP_DURATION) .value_from(0.) .value_to(1.) .widget(self) .target(&target) .build(); animation.connect_done(glib::clone!(@weak self as obj => move |_| { if let Some(current_index) = obj.current_index() { let imp = obj.imp(); imp.position_tracking.borrow_mut().set_progress(1.); imp.position_tracking.borrow_mut().set_position(current_index as f64 - obj.position_shift()); obj.queue_allocate(); } else { error!("No current page at end of animation"); } obj.emit_by_name("target-page-reached", &[]) })); animation }) } } /// See [`LpSlidingView::editor`](LpSlidingView::editor) pub struct SlidingViewEditor { sliding_view: LpSlidingView, } impl SlidingViewEditor { fn new(sliding_view: LpSlidingView) -> Self { Self { sliding_view } } /// Removes the page giving notifications later pub fn remove_lazy(&self, page: &LpImagePage) { self.remove(page, true) } /// Removes all pages giving notifications later pub fn clear_lazy(&self) { self.clear(true) } } impl std::ops::Deref for SlidingViewEditor { type Target = LpSlidingView; fn deref(&self) -> &LpSlidingView { &self.sliding_view } } impl Drop for SlidingViewEditor { fn drop(&mut self) { // Trigger everything that hasn't been done by lazy updates if self.is_empty() { self.clear(false); } } }