From d0165f8bd1a8335ffc8f3b1aa5b12cf86ae3c98b Mon Sep 17 00:00:00 2001 From: poire-z Date: Wed, 19 May 2021 22:57:54 +0200 Subject: [PATCH] Fix scrolling, add inertial scroll on non-eInk devices Add a new reader module: ReaderScrolling, that exposes some Scrolling options to the menu (which are to be used by and implemented in ReaderPaging and ReaderRolling themselves) and implement some inertial scrolling logic used by both of them. Default to "Classic scrolling" which is the expected behaviour on phones and tablets. The old CreDocument buggy behaviour is available as "Turbo scrolling" for both Paging and Rolling documents. Added a "On release scrolling" option that might be useful on eInk to avoid dynamic pan/scrolling. Try to avoid bad interactions between pan and swipe, cancelling unwanted panning if we ended up doing a swipe or multiswipe. --- frontend/apps/reader/modules/readerconfig.lua | 1 + frontend/apps/reader/modules/readerfooter.lua | 4 +- frontend/apps/reader/modules/readermenu.lua | 1 + frontend/apps/reader/modules/readerpaging.lua | 162 ++++++- .../apps/reader/modules/readerrolling.lua | 167 ++++++- .../apps/reader/modules/readerscrolling.lua | 419 ++++++++++++++++++ frontend/apps/reader/modules/readerview.lua | 5 +- frontend/apps/reader/readerui.lua | 8 + frontend/device/sdl/device.lua | 1 + frontend/ui/elements/reader_menu_order.lua | 1 + frontend/ui/uimanager.lua | 5 + plugins/gestures.koplugin/main.lua | 3 +- 12 files changed, 747 insertions(+), 30 deletions(-) create mode 100644 frontend/apps/reader/modules/readerscrolling.lua diff --git a/frontend/apps/reader/modules/readerconfig.lua b/frontend/apps/reader/modules/readerconfig.lua index 17638c890..c3a94651e 100644 --- a/frontend/apps/reader/modules/readerconfig.lua +++ b/frontend/apps/reader/modules/readerconfig.lua @@ -130,6 +130,7 @@ function ReaderConfig:onShowConfigMenu() -- show last used panel when opening config dialog self.config_dialog:onShowConfigPanel(self.last_panel_index) UIManager:show(self.config_dialog) + self.ui:handleEvent(Event:new("HandledAsSwipe")) -- cancel any pan scroll made return true end diff --git a/frontend/apps/reader/modules/readerfooter.lua b/frontend/apps/reader/modules/readerfooter.lua index 1b18b77dc..14dafd0a9 100644 --- a/frontend/apps/reader/modules/readerfooter.lua +++ b/frontend/apps/reader/modules/readerfooter.lua @@ -1978,11 +1978,11 @@ function ReaderFooter:_updateFooterText(force_repaint, force_recompute) UIManager:widgetRepaint(self.view.footer, 0, 0) -- We've painted it first to ensure self.footer_content.dimen is sane UIManager:setDirty(self.view.footer, function() - return "ui", self.footer_content.dimen + return self.view.currently_scrolling and "fast" or "ui", self.footer_content.dimen end) else UIManager:setDirty(self.view.dialog, function() - return "ui", refresh_dim + return self.view.currently_scrolling and "fast" or "ui", refresh_dim end) end end diff --git a/frontend/apps/reader/modules/readermenu.lua b/frontend/apps/reader/modules/readermenu.lua index 8a3de60e8..6d564efc5 100644 --- a/frontend/apps/reader/modules/readermenu.lua +++ b/frontend/apps/reader/modules/readermenu.lua @@ -437,6 +437,7 @@ function ReaderMenu:onSwipeShowMenu(ges) self.ui:handleEvent(Event:new("ShowConfigMenu")) end self.ui:handleEvent(Event:new("ShowMenu", self:_getTabIndexFromLocation(ges))) + self.ui:handleEvent(Event:new("HandledAsSwipe")) -- cancel any pan scroll made return true end end diff --git a/frontend/apps/reader/modules/readerpaging.lua b/frontend/apps/reader/modules/readerpaging.lua index 5abb7d0bb..da6212f6c 100644 --- a/frontend/apps/reader/modules/readerpaging.lua +++ b/frontend/apps/reader/modules/readerpaging.lua @@ -7,6 +7,7 @@ local Math = require("optmath") local MultiConfirmBox = require("ui/widget/multiconfirmbox") local Notification = require("ui/widget/notification") local ReaderZooming = require("apps/reader/modules/readerzooming") +local TimeVal = require("ui/timeval") local UIManager = require("ui/uimanager") local bit = require("bit") local logger = require("logger") @@ -32,7 +33,6 @@ local ReaderPaging = InputContainer:new{ pan_rate = 30, -- default 30 ops, will be adjusted in readerui current_page = 0, number_of_pages = 0, - last_pan_relative_y = 0, visible_area = nil, page_area = nil, show_overlap_enable = nil, @@ -41,7 +41,7 @@ local ReaderPaging = InputContainer:new{ inverse_reading_order = nil, page_flipping_mode = false, bookmark_flipping_mode = false, - flip_steps = {0,1,2,5,10,20,50,100} + flip_steps = {0,1,2,5,10,20,50,100}, } function ReaderPaging:init() @@ -101,6 +101,7 @@ function ReaderPaging:init() {"0"}, doc = "go to end", event = "GotoPercent", args = 100, } end + self.pan_interval = TimeVal:new{ usec = 1000000 / self.pan_rate } self.number_of_pages = self.ui.document.info.number_of_pages self.ui.menu:registerToMainMenu(self) end @@ -173,7 +174,6 @@ function ReaderPaging:setupTouchZones() { id = "paging_pan", ges = "pan", - rate = self.pan_rate, screen_zone = { ratio_x = 0, ratio_y = 0, ratio_w = 1, ratio_h = 1, }, @@ -422,7 +422,43 @@ function ReaderPaging:bookmarkFlipping(flipping_page, flipping_ges) UIManager:setDirty(self.view.dialog, "partial") end +function ReaderPaging:onScrollSettingsUpdated(scroll_method, inertial_scroll_enabled, scroll_activation_delay) + self.scroll_method = scroll_method + self.scroll_activation_delay = TimeVal:new{ usec = scroll_activation_delay * 1000 } + if inertial_scroll_enabled then + self.ui.scrolling:setInertialScrollCallbacks( + function(distance) -- do_scroll_callback + if not self.ui.document then + return false + end + UIManager.currently_scrolling = true + local top_page, top_position = self:getTopPage(), self:getTopPosition() + self:onPanningRel(distance) + return not (top_page == self:getTopPage() and top_position == self:getTopPosition()) + end, + function() -- scroll_done_callback + UIManager.currently_scrolling = false + UIManager:setDirty(self.view.dialog, "partial") + end + ) + else + self.ui.scrolling:setInertialScrollCallbacks(nil, nil) + end +end + function ReaderPaging:onSwipe(_, ges) + if self._pan_has_scrolled then + -- We did some panning but released after a short amount of time, + -- so this gesture ended up being a Swipe - and this swipe was + -- not handled by the other modules (so, not opening the menus). + -- Do as :onPanRelese() and ignore this swipe. + self:onPanRelease() -- no arg, so we know there we come from here + return true + else + self._pan_started = false + UIManager.currently_scrolling = false + self._pan_page_states_to_restore = nil + end local direction = BD.flipDirectionIfMirroredUILayout(ges.direction) if self.bookmark_flipping_mode then self:bookmarkFlipping(self.current_page, ges) @@ -461,16 +497,86 @@ function ReaderPaging:onPan(_, ges) self.view:PanningStart(-ges.relative.x, -ges.relative.y) end elseif ges.direction == "north" or ges.direction == "south" then - local relative_type = "relative" - if self.ui.gesture and self.ui.gesture.multiswipes_enabled then - relative_type = "relative_delayed" - end - -- this is only used when mouse wheel is used if ges.mousewheel_direction and not self.view.page_scroll then + -- Mouse wheel generates a Pan event: in page mode, move one + -- page per event. Scroll mode is handled in the 'else' branch + -- and use the wheeled distance. self:onGotoViewRel(-1 * ges.mousewheel_direction) - else - self:onPanningRel(self.last_pan_relative_y - ges[relative_type].y) - self.last_pan_relative_y = ges[relative_type].y + elseif self.view.page_scroll then + if not self._pan_started then + self._pan_started = true + -- Re-init state variables + self._pan_has_scrolled = false + self._pan_prev_relative_y = 0 + self._pan_to_scroll_later = 0 + self._pan_real_last_time = TimeVal.zero + if ges.mousewheel_direction then + self._pan_activation_time = false + else + self._pan_activation_time = ges.time + self.scroll_activation_delay + end + -- We will restore the previous position if this pan + -- ends up being a swipe or a multiswipe + -- Somehow, accumulating the distances scrolled in a self._pan_dist_to_restore + -- so we can scroll these back may not always put us back to the original + -- position (possibly because of these page_states?). It's safer + -- to remember the original page_states and restore that. We can keep + -- a reference to the original table as onPanningRel() will have this + -- table replaced. + self._pan_page_states_to_restore = self.view.page_states + end + local scroll_now = false + if self._pan_activation_time and ges.time >= self._pan_activation_time then + self._pan_activation_time = false -- We can go on, no need to check again + end + if not self._pan_activation_time and ges.time - self._pan_real_last_time >= self.pan_interval then + scroll_now = true + self._pan_real_last_time = ges.time + end + local scroll_dist = 0 + if self.scroll_method == self.ui.scrolling.SCROLL_METHOD_CLASSIC then + -- Scroll by the distance the finger moved since last pan event, + -- having the document follows the finger + scroll_dist = self._pan_prev_relative_y - ges.relative.y + self._pan_prev_relative_y = ges.relative.y + if not self._pan_has_scrolled then + -- Avoid checking this for each pan, no need once we have scrolled + if self.ui.scrolling:cancelInertialScroll() or self.ui.scrolling:cancelledByTouch() then + -- If this pan or its initial touch did cancel some inertial scrolling, + -- ignore activation delay to allow continuous scrolling + self._pan_activation_time = false + scroll_now = true + self._pan_real_last_time = ges.time + end + end + self.ui.scrolling:accountManualScroll(scroll_dist, ges.time) + elseif self.scroll_method == self.ui.scrolling.SCROLL_METHOD_TURBO then + -- Legacy scrolling "buggy" behaviour, that can actually be nice + -- Scroll by the distance from the initial finger position, this distance + -- controlling the speed of the scrolling) + if scroll_now then + scroll_dist = -ges.relative.y + end + -- We don't accumulate in _pan_to_scroll_later + elseif self.scroll_method == self.ui.scrolling.SCROLL_METHOD_ON_RELEASE then + self._pan_to_scroll_later = -ges.relative.y + if scroll_now then + self._pan_has_scrolled = true -- so we really apply it later + end + scroll_dist = 0 + scroll_now = false + end + if scroll_now then + local dist = self._pan_to_scroll_later + scroll_dist + self._pan_to_scroll_later = 0 + if dist ~= 0 then + self._pan_has_scrolled = true + UIManager.currently_scrolling = true + self:onPanningRel(dist) + end + else + self._pan_to_scroll_later = self._pan_to_scroll_later + scroll_dist + end end end return true @@ -484,12 +590,40 @@ function ReaderPaging:onPanRelease(_, ges) self.view:PanningStop() end else - self.last_pan_relative_y = 0 - -- trigger full refresh to clear ghosting generated by previous fast refresh - UIManager:setDirty(nil, "full") + if self._pan_has_scrolled and self._pan_to_scroll_later ~= 0 then + self:onPanningRel(self._pan_to_scroll_later) + end + self._pan_started = false + self._pan_page_states_to_restore = nil + UIManager.currently_scrolling = false + if self._pan_has_scrolled then + self._pan_has_scrolled = false + -- Don't do any inertial scrolling if pan events come from + -- a mousewheel (which may have itself some inertia) + if (ges and ges.from_mousewheel) or not self.ui.scrolling:startInertialScroll() then + UIManager:setDirty(self.view.dialog, "partial") + end + end end end +function ReaderPaging:onHandledAsSwipe() + if self._pan_started then + -- Restore original position as this pan we've started handling + -- has ended up being a multiswipe or handled as a swipe to open + -- top or bottom menus + if self._pan_has_scrolled then + self.view.page_states = self._pan_page_states_to_restore + self:_gotoPage(self.view.page_states[#self.view.page_states].page, "scrolling") + UIManager:setDirty(self.view.dialog, "ui") + end + self._pan_page_states_to_restore = nil + self._pan_started = false + self._pan_has_scrolled = false + UIManager.currently_scrolling = false + end + return true +end function ReaderPaging:onZoomModeUpdate(new_mode) -- we need to remember zoom mode to handle page turn event self.zoom_mode = new_mode diff --git a/frontend/apps/reader/modules/readerrolling.lua b/frontend/apps/reader/modules/readerrolling.lua index cd21b1786..d174178ba 100644 --- a/frontend/apps/reader/modules/readerrolling.lua +++ b/frontend/apps/reader/modules/readerrolling.lua @@ -116,6 +116,7 @@ function ReaderRolling:init() {"0"}, doc = "go to end", event = "GotoPercent", args = 100, } end + self.pan_interval = TimeVal:new{ usec = 1000000 / self.pan_rate } table.insert(self.ui.postInitCallback, function() self.rendering_hash = self.ui.document:getDocumentRenderingHash() @@ -377,12 +378,19 @@ function ReaderRolling:setupTouchZones() { id = "rolling_pan", ges = "pan", - rate = self.pan_rate, screen_zone = { ratio_x = 0, ratio_y = 0, ratio_w = 1, ratio_h = 1, }, handler = function(ges) return self:onPan(nil, ges) end, }, + { + id = "rolling_pan_release", + ges = "pan_release", + screen_zone = { + ratio_x = 0, ratio_y = 0, ratio_w = 1, ratio_h = 1, + }, + handler = function(ges) return self:onPanRelease(nil, ges) end, + }, }) end @@ -515,7 +523,45 @@ function ReaderRolling:getLastPercent() end end +function ReaderRolling:onScrollSettingsUpdated(scroll_method, inertial_scroll_enabled, scroll_activation_delay) + self.scroll_method = scroll_method + self.scroll_activation_delay = TimeVal:new{ usec = scroll_activation_delay * 1000 } + if inertial_scroll_enabled then + self.ui.scrolling:setInertialScrollCallbacks( + function(distance) -- do_scroll_callback + if not self.ui.document then + return false + end + UIManager.currently_scrolling = true + local prev_pos = self.current_pos + self:_gotoPos(prev_pos + distance) + return self.current_pos ~= prev_pos + end, + function() -- scroll_done_callback + UIManager.currently_scrolling = false + if self.ui.document then + self.xpointer = self.ui.document:getXPointer() + end + UIManager:setDirty(self.view.dialog, "partial") + end + ) + else + self.ui.scrolling:setInertialScrollCallbacks(nil, nil) + end +end + function ReaderRolling:onSwipe(_, ges) + if self._pan_has_scrolled then + -- We did some panning but released after a short amount of time, + -- so this gesture ended up being a Swipe - and this swipe was + -- not handled by the other modules (so, not opening the menus). + -- Do as :onPanRelese() and ignore this swipe. + self:onPanRelease() -- no arg, so we know there we come from here + return true + else + self._pan_started = false + UIManager.currently_scrolling = false + end local direction = BD.flipDirectionIfMirroredUILayout(ges.direction) if direction == "west" then if G_reader_settings:nilOrFalse("page_turns_disable_swipe") then @@ -539,19 +585,116 @@ function ReaderRolling:onSwipe(_, ges) end function ReaderRolling:onPan(_, ges) - if self.view.view_mode == "scroll" then - local distance_type = "distance" - if self.ui.gesture and self.ui.gesture.multiswipes_enabled then - distance_type = "distance_delayed" + if ges.direction == "north" or ges.direction == "south" then + if ges.mousewheel_direction and self.view.view_mode == "page" then + -- Mouse wheel generates a Pan event: in page mode, move one + -- page per event. Scroll mode is handled in the 'else' branch + -- and use the wheeled distance. + UIManager:broadcastEvent(Event:new("GotoViewRel", -1 * ges.mousewheel_direction)) + elseif self.view.view_mode == "scroll" then + if not self._pan_started then + self._pan_started = true + -- Re-init state variables + self._pan_has_scrolled = false + self._pan_prev_relative_y = 0 + self._pan_to_scroll_later = 0 + self._pan_real_last_time = TimeVal.zero + if ges.mousewheel_direction then + self._pan_activation_time = false + else + self._pan_activation_time = ges.time + self.scroll_activation_delay + end + -- We will restore the previous position if this pan + -- ends up being a swipe or a multiswipe + self._pan_pos_at_pan_start = self.current_pos + end + local scroll_now = false + if self._pan_activation_time and ges.time >= self._pan_activation_time then + self._pan_activation_time = false -- We can go on, no need to check again + end + if not self._pan_activation_time and ges.time - self._pan_real_last_time >= self.pan_interval then + scroll_now = true + self._pan_real_last_time = ges.time + end + local scroll_dist = 0 + if self.scroll_method == self.ui.scrolling.SCROLL_METHOD_CLASSIC then + -- Scroll by the distance the finger moved since last pan event, + -- having the document follows the finger + scroll_dist = self._pan_prev_relative_y - ges.relative.y + self._pan_prev_relative_y = ges.relative.y + if not self._pan_has_scrolled then + -- Avoid checking this for each pan, no need once we have scrolled + if self.ui.scrolling:cancelInertialScroll() or self.ui.scrolling:cancelledByTouch() then + -- If this pan or its initial touch did cancel some inertial scrolling, + -- ignore activation delay to allow continuous scrolling + self._pan_activation_time = false + scroll_now = true + self._pan_real_last_time = ges.time + end + end + self.ui.scrolling:accountManualScroll(scroll_dist, ges.time) + elseif self.scroll_method == self.ui.scrolling.SCROLL_METHOD_TURBO then + -- Legacy scrolling "buggy" behaviour, that can actually be nice + -- Scroll by the distance from the initial finger position, this distance + -- controlling the speed of the scrolling) + if scroll_now then + scroll_dist = -ges.relative.y + end + -- We don't accumulate in _pan_to_scroll_later + elseif self.scroll_method == self.ui.scrolling.SCROLL_METHOD_ON_RELEASE then + self._pan_to_scroll_later = -ges.relative.y + if scroll_now then + self._pan_has_scrolled = true -- so we really apply it later + end + scroll_dist = 0 + scroll_now = false + end + if scroll_now then + local dist = self._pan_to_scroll_later + scroll_dist + self._pan_to_scroll_later = 0 + if dist ~= 0 then + self._pan_has_scrolled = true + UIManager.currently_scrolling = true + self:_gotoPos(self.current_pos + dist) + -- (We'll update self.xpointer only when done moving, at + -- release/swipe time as it might be expensive) + end + else + self._pan_to_scroll_later = self._pan_to_scroll_later + scroll_dist + end end - if ges.direction == "north" then - self:_gotoPos(self.current_pos + ges[distance_type]) - elseif ges.direction == "south" then - self:_gotoPos(self.current_pos - ges[distance_type]) + end + return true +end + +function ReaderRolling:onPanRelease(_, ges) + if self._pan_has_scrolled and self._pan_to_scroll_later ~= 0 then + self:_gotoPos(self.current_pos + self._pan_to_scroll_later) + end + self._pan_started = false + UIManager.currently_scrolling = false + if self._pan_has_scrolled then + self._pan_has_scrolled = false + self.xpointer = self.ui.document:getXPointer() + -- Don't do any inertial scrolling if pan events come from + -- a mousewheel (which may have itself some inertia) + if (ges and ges.from_mousewheel) or not self.ui.scrolling:startInertialScroll() then + UIManager:setDirty(self.view.dialog, "partial") end - --this is only use when mouse wheel is used - elseif ges.mousewheel_direction and self.view.view_mode == "page" then - UIManager:broadcastEvent(Event:new("GotoViewRel", -1 * ges.mousewheel_direction)) + end +end + +function ReaderRolling:onHandledAsSwipe() + if self._pan_started then + -- Restore original position as this pan we've started handling + -- has ended up being a multiswipe or handled as a swipe to open + -- top or bottom menus + self:_gotoPos(self._pan_pos_at_pan_start) + self._pan_started = false + self._pan_has_scrolled = false + UIManager.currently_scrolling = false + -- No specific refresh: the swipe/multiswipe might show other stuff, + -- and we'd want to avoid a flashing refresh end return true end diff --git a/frontend/apps/reader/modules/readerscrolling.lua b/frontend/apps/reader/modules/readerscrolling.lua new file mode 100644 index 000000000..93848f11b --- /dev/null +++ b/frontend/apps/reader/modules/readerscrolling.lua @@ -0,0 +1,419 @@ +local Device = require("device") +local Event = require("ui/event") +local InputContainer = require("ui/widget/container/inputcontainer") +local TimeVal = require("ui/timeval") +local UIManager = require("ui/uimanager") +local logger = require("logger") +local _ = require("gettext") +local T = require("ffi/util").template +local Screen = Device.screen + +-- This module exposes Scrolling settings, and additionnally +-- handles inertial scrolling on non-eInk devices. + +local SCROLL_METHOD_CLASSIC = "classic" +local SCROLL_METHOD_TURBO = "turbo" +local SCROLL_METHOD_ON_RELEASE = "on_release" + +local ReaderScrolling = InputContainer:new{ + -- Available scrolling methods (make them available to other reader modules) + SCROLL_METHOD_CLASSIC = SCROLL_METHOD_CLASSIC, + SCROLL_METHOD_TURBO = SCROLL_METHOD_TURBO, + SCROLL_METHOD_ON_RELEASE = SCROLL_METHOD_ON_RELEASE, + + scroll_method = SCROLL_METHOD_CLASSIC, + scroll_activation_delay = 0, -- 0 ms + inertial_scroll = false, + + pan_rate = 30, -- default 30 ops, will be adjusted in readerui + scroll_friction = 0.2, -- the lower, the sooner inertial scrolling stops + -- go at ending scrolling soon when we reach steps smaller than this + end_scroll_dist = Screen:scaleBySize(10), + -- no inertial scrolling if 300ms pause without any movement before release + pause_before_release_cancel_duration = TimeVal:new{ sec = 0, usec = 300000 }, + + -- Callbacks to be updated by readerrolling or readerpaging + _do_scroll_callback = function(distance) return false end, + _scroll_done_callback = function() end, + + _inertial_scroll_supported = false, + _inertial_scroll_enabled = false, + _inertial_scroll_interval = 1 / 30, + _inertial_scroll_action_scheduled = false, + _just_reschedule = false, + _last_manual_scroll_dy = 0, + _velocity = 0, +} + +function ReaderScrolling:init() + if not Device:isTouchDevice() then + -- No scroll support, no menu + return + end + + -- The different scrolling methods are handled directly by readerpaging/readerrolling + self.scroll_method = G_reader_settings:readSetting("scroll_method") + + -- Keep inertial scrolling available on the emulator (which advertizes itself as eInk) + if not Device:hasEinkScreen() or Device:isEmulator() then + self._inertial_scroll_supported = true + end + + if self._inertial_scroll_supported then + self.inertial_scroll = G_reader_settings:nilOrTrue("inertial_scroll") + self._inertial_scroll_interval = 1 / self.pan_rate + -- Set this so we don't have to check for nil, and in case + -- we miss a first touch event. + -- We can keep it obsolete, which will result in a long + -- duration and a small/zero velocity that won't hurt. + self._last_manual_scroll_timev = TimeVal.zero + self:_setupAction() + end + + self.ui.menu:registerToMainMenu(self) +end + +function ReaderScrolling:getDefaultScrollActivationDelay() + if (self.ui.gestures and self.ui.gestures.multiswipes_enabled) + or G_reader_settings:readSetting("activate_menu") ~= "tap" then + -- If swipes to show menu or multiswipes are enabled, higher default + -- scroll activation delay to avoid scrolling and restoring when + -- doing swipes + return 500 -- 500ms + end + -- Otherwise, no need for any delay + return 0 +end + +function ReaderScrolling:addToMainMenu(menu_items) + menu_items.scrolling = { + text = _("Scrolling"), + enabled_func = function() + -- Make it only enabled when in continuous/scroll mode + -- (different setting in self.view whether rolling or paging document) + if self.view and (self.view.page_scroll or self.view.view_mode == "scroll") then + return true + end + return false + end, + sub_item_table = { + { + text = _("Classic scrolling"), + help_text = _([[Classic scrolling will move the document with your finger.]]), + checked_func = function() + return self.scroll_method == self.SCROLL_METHOD_CLASSIC + end, + callback = function() + if self.scroll_method ~= self.SCROLL_METHOD_CLASSIC then + self.scroll_method = self.SCROLL_METHOD_CLASSIC + self:applyScrollSettings() + end + end, + }, + { + text = _("Turbo scrolling"), + help_text = _([[ +Turbo scrolling will scroll the document, at each step, by the distance from your initial finger position (rather than by the distance from your previous finger position). +It allows for faster scrolling without the need to lift and reposition your finger.]]), + checked_func = function() + return self.scroll_method == self.SCROLL_METHOD_TURBO + end, + callback = function() + if self.scroll_method ~= self.SCROLL_METHOD_TURBO then + self.scroll_method = self.SCROLL_METHOD_TURBO + self:applyScrollSettings() + end + end, + }, + { + text = _("On-release scrolling"), + help_text = _([[ +On-release scrolling will scroll the document by the panned distance only on finger up. +This is interesting on eInk if you only pan to better adjust page vertical position.]]), + checked_func = function() + return self.scroll_method == self.SCROLL_METHOD_ON_RELEASE + end, + callback = function() + if self.scroll_method ~= self.SCROLL_METHOD_ON_RELEASE then + self.scroll_method = self.SCROLL_METHOD_ON_RELEASE + self:applyScrollSettings() + end + end, + separator = true, + }, + { + text_func = function() + return T(_("Activation delay: %1 ms"), self.scroll_activation_delay) + end, + keep_menu_open = true, + callback = function(touchmenu_instance) + local scroll_activation_delay_default = self:getDefaultScrollActivationDelay() + local SpinWidget = require("ui/widget/spinwidget") + local widget = SpinWidget:new{ + title_text = _("Scroll activation delay"), + info_text = T(_([[ +A delay can be used to avoid scrolling when swipes or multiswipes are intended. + +The delay value is in milliseconds and can range from 0 to 2000 (2 seconds). +Default value: %1 ms]]), scroll_activation_delay_default), + width = math.floor(Screen:getWidth() * 0.75), + value = self.scroll_activation_delay, + value_min = 0, + value_max = 2000, + value_step = 100, + value_hold_step = 500, + ok_text = _("Set delay"), + default_value = scroll_activation_delay_default, + callback = function(spin) + self.scroll_activation_delay = spin.value + self:applyScrollSettings() + if touchmenu_instance then touchmenu_instance:updateItems() end + end + } + UIManager:show(widget) + end, + }, + } + } + if self._inertial_scroll_supported then + -- Add it before "Activation delay" to keep checkboxes together + table.insert(menu_items.scrolling.sub_item_table, 4, { + text = _("Allow inertial scrolling"), + enabled_func = function() + return self.scroll_method == self.SCROLL_METHOD_CLASSIC + end, + checked_func = function() + return self.scroll_method == self.SCROLL_METHOD_CLASSIC and self.inertial_scroll + end, + callback = function() + self.inertial_scroll = not self.inertial_scroll + self:applyScrollSettings() + end, + }) + end +end + +function ReaderScrolling:onReaderReady() + -- We don't know if the gestures plugin is loaded in :init(), but we know it here + self.scroll_activation_delay = G_reader_settings:readSetting("scroll_activation_delay") + or self:getDefaultScrollActivationDelay() + self:applyScrollSettings() +end + +function ReaderScrolling:applyScrollSettings() + G_reader_settings:saveSetting("scroll_method", self.scroll_method) + G_reader_settings:saveSetting("inertial_scroll", self.inertial_scroll) + if self.scroll_activation_delay == self:getDefaultScrollActivationDelay() then + G_reader_settings:delSetting("scroll_activation_delay") + else + G_reader_settings:saveSetting("scroll_activation_delay", self.scroll_activation_delay) + end + if self.scroll_method == self.SCROLL_METHOD_CLASSIC then + self._inertial_scroll_enabled = self.inertial_scroll + else + self._inertial_scroll_enabled = false + end + self:setupTouchZones() + self.ui:handleEvent(Event:new("ScrollSettingsUpdated", self.scroll_method, + self._inertial_scroll_enabled, self.scroll_activation_delay)) +end + +function ReaderScrolling:setupTouchZones() + self.ges_events = {} + self.onGesture = nil + + local zones = { + { + id = "inertial_scrolling_touch", + ges = "touch", + screen_zone = { + ratio_x = 0, ratio_y = 0, ratio_w = 1, ratio_h = 1, + }, + handler = function(ges) + -- A touch might set the start of the first pan event, + -- that we need to compute its duration + self._last_manual_scroll_timev = ges.time + -- If we are scrolling, a touch cancels it. + -- We want its release (which will trigger a tap) to not change pages. + -- This also allows a pan following this touch to skip any scroll + -- activation delay + self._cancelled_by_touch = self._inertial_scroll_action + and self._inertial_scroll_action(false) + or false + end, + }, + { + id = "inertial_scrolling_tap", + ges = "tap", + screen_zone = { + ratio_x = 0, ratio_y = 0, ratio_w = 1, ratio_h = 1, + }, + overrides = { + "tap_forward", + "tap_backward", + "readermenu_tap", + "readermenu_ext_tap", + "readerconfigmenu_tap", + "readerconfigmenu_ext_tap", + "readerfooter_tap", + "readerhighlight_tap", + "tap_link", + }, + handler = function() + -- Ignore tap if cancelled by its initial touch + if self._cancelled_by_touch then + self._cancelled_by_touch = false + return true + end + -- Otherwise, let it be handled by other tap handlers + end, + }, + } + if self._inertial_scroll_enabled then + self.ui:registerTouchZones(zones) + else + self.ui:unRegisterTouchZones(zones) + end +end + +function ReaderScrolling:isInertialScrollingEnabled() + return self._inertial_scroll_enabled +end + +function ReaderScrolling:setInertialScrollCallbacks(do_scroll_callback, scroll_done_callback) + self._do_scroll_callback = do_scroll_callback + self._scroll_done_callback = scroll_done_callback +end + +function ReaderScrolling:startInertialScroll() + if not self._inertial_scroll_enabled then + return false + end + return self._inertial_scroll_action(true) +end + +function ReaderScrolling:cancelInertialScroll() + if not self._inertial_scroll_enabled then + return + end + return self._inertial_scroll_action(false) +end + +function ReaderScrolling:cancelledByTouch() + return self._cancelled_by_touch +end + +function ReaderScrolling:accountManualScroll(dy, timev) + if not self._inertial_scroll_enabled then + return + end + self._last_manual_scroll_dy = dy + self._last_manual_scroll_duration = timev - self._last_manual_scroll_timev + self._last_manual_scroll_timev = timev +end + +function ReaderScrolling:_setupAction() + self._inertial_scroll_action = function(action) + -- action can be: + -- - true: stop any previous ongoing inertial scroll, then start a new one + -- (returns true if we started one) + -- - false: just stop any previous ongoing inertial scroll + -- (returns true if we did cancel one) + if action ~= nil then + local cancelled = false + if self._inertial_scroll_action_scheduled then + UIManager:unschedule(self._inertial_scroll_action) + self._inertial_scroll_action_scheduled = false + cancelled = true + self._scroll_done_callback() + logger.dbg("inertial scrolling cancelled") + end + if action == false then + self._last_manual_scroll_dy = 0 + return cancelled + end + + -- Initiate inertial scrolling (action=true), unless we should not + if UIManager:getTime() - self._last_manual_scroll_timev >= self.pause_before_release_cancel_duration then + -- but not if no finger move for 0.3s before finger up + self._last_manual_scroll_dy = 0 + return false + end + if self._last_manual_scroll_duration:isZero() or self._last_manual_scroll_dy == 0 then + return false + end + + -- Initial velocity is the one of the last pan scroll given to accountManualScroll() + local delay = self._last_manual_scroll_duration:tousecs() + if delay < 1 then delay = 1 end -- safety check + self._velocity = self._last_manual_scroll_dy * 1000000 / delay + self._last_manual_scroll_dy = 0 + + self._inertial_scroll_action_scheduled = true + -- We'll keep re-scheduling this same action, which will do + -- alternatively thanks to the _just_reschedule flag: + -- * either, in _inertial_scroll_interval, do a scroll + -- * or, then, at next tick, reschedule 1) + -- This is needed as the first one will cause a repaint that + -- may take more than _inertial_scroll_interval, which if we + -- didn't do that could be run before we process any input, + -- not allowing us to interrupt this inertial scrolling. + self._just_reschedule = false + UIManager:scheduleIn(self._inertial_scroll_interval, self._inertial_scroll_action) + -- self._stats_scroll_iterations = 0 + -- self._stats_scroll_distance = 0 + logger.dbg("inertial scrolling started") + return true + end + if not self._inertial_scroll_action_scheduled then + -- Safety check, shouldn't happen + return + end + if not self.ui.document then + -- might happen if scheduled and run after document is closed + return + end + + if self._just_reschedule then + -- just re-schedule this, so a real scrolling is done after the delay + self._just_reschedule = false + UIManager:scheduleIn(self._inertial_scroll_interval, self._inertial_scroll_action) + return + end + + -- Decrease velocity at each step + self._velocity = self._velocity * math.pow(self.scroll_friction, self._inertial_scroll_interval) + local dist = math.floor(self._velocity * self._inertial_scroll_interval) + if math.abs(dist) < self.end_scroll_dist then + -- Decrease it even more so scrolling stops sooner + self._velocity = self._velocity / 1.5 + end + -- self._stats_scroll_iterations = self._stats_scroll_iterations + 1 + -- self._stats_scroll_distance = self._stats_scroll_distance + dist + + logger.dbg("inertial scrolling by", dist) + local did_scroll = self._do_scroll_callback(dist) + + if did_scroll and math.abs(dist) > 2 then + -- Schedule at next tick the real re-scheduling + self._just_reschedule = true + UIManager:nextTick(self._inertial_scroll_action) + return + end + + -- We're done + self._inertial_scroll_action_scheduled = false + self._scroll_done_callback() + logger.dbg("inertial scrolling ended") + + --[[ + local Notification = require("ui/widget/notification") + UIManager:show(Notification:new{ + text = string.format("%d iterations, %d px scrolled", + self._stats_scroll_iterations, self._stats_scroll_distance), + }) + ]]-- + end +end + +return ReaderScrolling diff --git a/frontend/apps/reader/modules/readerview.lua b/frontend/apps/reader/modules/readerview.lua index 68f1c9966..2aaaa9bde 100644 --- a/frontend/apps/reader/modules/readerview.lua +++ b/frontend/apps/reader/modules/readerview.lua @@ -73,6 +73,9 @@ local ReaderView = OverlapGroup:extend{ flipping_visible = false, -- to ensure periodic flush of settings settings_last_save_tv = nil, + -- might be directly updated by readerpaging/readerrolling when + -- they handle some panning/scrolling, to request "fast" refreshes + currently_scrolling = false, } function ReaderView:init() @@ -614,7 +617,7 @@ function ReaderView:recalculate() end -- Flag a repaint so self:paintTo will be called -- NOTE: This is also unfortunately called during panning, essentially making sure we'll never be using "fast" for pans ;). - UIManager:setDirty(self.dialog, "partial") + UIManager:setDirty(self.dialog, self.currently_scrolling and "fast" or "partial") end function ReaderView:PanningUpdate(dx, dy) diff --git a/frontend/apps/reader/readerui.lua b/frontend/apps/reader/readerui.lua index d561da097..f5d8bcaba 100644 --- a/frontend/apps/reader/readerui.lua +++ b/frontend/apps/reader/readerui.lua @@ -33,6 +33,7 @@ local ReaderFont = require("apps/reader/modules/readerfont") local ReaderGoto = require("apps/reader/modules/readergoto") local ReaderHinting = require("apps/reader/modules/readerhinting") local ReaderHighlight = require("apps/reader/modules/readerhighlight") +local ReaderScrolling = require("apps/reader/modules/readerscrolling") local ReaderKoptListener = require("apps/reader/modules/readerkoptlistener") local ReaderLink = require("apps/reader/modules/readerlink") local ReaderMenu = require("apps/reader/modules/readermenu") @@ -334,6 +335,13 @@ function ReaderUI:init() }) end self.disable_double_tap = G_reader_settings:nilOrTrue("disable_double_tap") + -- scrolling (scroll settings + inertial scrolling) + self:registerModule("scrolling", ReaderScrolling:new{ + pan_rate = pan_rate, + dialog = self.dialog, + ui = self, + view = self.view, + }) -- back location stack self:registerModule("back", ReaderBack:new{ ui = self, diff --git a/frontend/device/sdl/device.lua b/frontend/device/sdl/device.lua index 06862aeff..b67959ec3 100644 --- a/frontend/device/sdl/device.lua +++ b/frontend/device/sdl/device.lua @@ -217,6 +217,7 @@ function Device:init() relative_delayed = fake_ges.relative_delayed, pos = pos, time = ev.time, + from_mousewheel = true, } local fake_pan_ev = Event:new("Pan", nil, fake_ges) local fake_release_ev = Event:new("Gesture", fake_ges_release) diff --git a/frontend/ui/elements/reader_menu_order.lua b/frontend/ui/elements/reader_menu_order.lua index bd69c36b2..458571e47 100644 --- a/frontend/ui/elements/reader_menu_order.lua +++ b/frontend/ui/elements/reader_menu_order.lua @@ -134,6 +134,7 @@ local order = { "----------------------------", "menu_activate", "page_turns", + "scrolling", "ignore_hold_corners", "screen_disable_double_tab", }, diff --git a/frontend/ui/uimanager.lua b/frontend/ui/uimanager.lua index 103535c29..6aca33208 100644 --- a/frontend/ui/uimanager.lua +++ b/frontend/ui/uimanager.lua @@ -22,6 +22,7 @@ local UIManager = { FULL_REFRESH_COUNT = G_reader_settings:isTrue("night_mode") and G_reader_settings:readSetting("night_full_refresh_count") or G_reader_settings:readSetting("full_refresh_count") or DEFAULT_FULL_REFRESH_COUNT, refresh_count = 0, + currently_scrolling = false, -- How long to wait between ZMQ wakeups: 50ms. ZMQ_TIMEOUT = 50 * 1000, @@ -1293,6 +1294,10 @@ function UIManager:_refresh(mode, region, dither) return end end + -- Downgrade all refreshes to "fast" when ReaderPaging or ReaderScrolling have set this flag + if self.currently_scrolling then + mode = "fast" + end if not region and mode == "full" then self.refresh_count = 0 -- reset counter on explicit full refresh end diff --git a/plugins/gestures.koplugin/main.lua b/plugins/gestures.koplugin/main.lua index 2f43ea7e2..1dbbb0760 100644 --- a/plugins/gestures.koplugin/main.lua +++ b/plugins/gestures.koplugin/main.lua @@ -1103,7 +1103,8 @@ function Gestures:gestureAction(action, ges) or (ges.ges == "hold" and self.ignore_hold_corners) then return else - Dispatcher:execute(self.ui, action_list, ges) + self.ui:handleEvent(Event:new("HandledAsSwipe")) + Dispatcher:execute(self.ui, action_list, ges) end return true end