From 81f2aed086ac98a08a24b37d67cb9db8028478c3 Mon Sep 17 00:00:00 2001 From: poire-z Date: Thu, 16 Feb 2023 23:30:58 +0100 Subject: [PATCH] ReaderRolling: quicker partial rerenderings with EPUBs Only available with EPUBs containing 2 or more fragments, and a file size large enough to ensure a cache file is used. The idea is simply, on any rendering setting change, to skip the rerendering of the full book and to defer any rerendering to the moment we draw a DocFragment, and render only it. So, on a setting change, only the fragment containing the current page will be rerendered, and the new fragments we may cross while turning pages. When having done so, KOReader is in a degraded state (the full page count is incorrect, the ToC is invalid...). So, a full rerendering is needed, and one will happen in the background, and when the user is idle, we reload seamlessly and quickly from the cache file it has made. ReaderFlipping will show some icons in the top left corner to let it know at which steps in this procress we are. --- .../apps/reader/modules/readerflipping.lua | 50 ++ .../apps/reader/modules/readerrolling.lua | 489 +++++++++++++++++- .../apps/reader/modules/readerthumbnail.lua | 3 + frontend/apps/reader/modules/readerview.lua | 11 +- frontend/apps/reader/readerui.lua | 23 +- frontend/document/credocument.lua | 30 +- frontend/ui/elements/reader_menu_order.lua | 2 + plugins/statistics.koplugin/main.lua | 22 + 8 files changed, 615 insertions(+), 15 deletions(-) diff --git a/frontend/apps/reader/modules/readerflipping.lua b/frontend/apps/reader/modules/readerflipping.lua index 6733e11d1..c3b8b7f85 100644 --- a/frontend/apps/reader/modules/readerflipping.lua +++ b/frontend/apps/reader/modules/readerflipping.lua @@ -6,6 +6,13 @@ local Screen = require("device").screen local ReaderFlipping = WidgetContainer:extend{ orig_reflow_mode = 0, + -- Icons to show during crengine partial rerendering automation + rolling_rendering_state_icons = { + PARTIALLY_RERENDERED = "cre.render.partial", + FULL_RENDERING_IN_BACKGROUND = "cre.render.working", + FULL_RENDERING_READY = "cre.render.ready", + RELOADING_DOCUMENT = "cre.render.reload", + }, } function ReaderFlipping:init() @@ -38,11 +45,54 @@ function ReaderFlipping:resetLayout() self[1].dimen.w = new_screen_width end +function ReaderFlipping:getRollingRenderingStateIconWidget() + if not self.rolling_rendering_state_widgets then + self.rolling_rendering_state_widgets = {} + end + local widget = self.rolling_rendering_state_widgets[self.ui.rolling.rendering_state] + if widget == nil then -- not met yet + local icon_size = Screen:scaleBySize(32) + for k, v in pairs(self.ui.rolling.RENDERING_STATE) do -- known states + if v == self.ui.rolling.rendering_state then -- current state + local icon = self.rolling_rendering_state_icons[k] -- our icon (or none) for this state + if icon then + self.rolling_rendering_state_widgets[v] = IconWidget:new{ + icon = icon, + width = icon_size, + height = icon_size, + alpha = not self.ui.rolling.cre_top_bar_enabled, + -- if top status bar enabled, have them opaque, as they + -- will be displayed over the bar + -- otherwise, keep their alpha so some bits of text is + -- visible if displayed over the text when small margins + } + else + self.rolling_rendering_state_widgets[v] = false + end + break + end + end + widget = self.rolling_rendering_state_widgets[self.ui.rolling.rendering_state] + end + return widget or nil -- return nil if cached widget is false +end + +function ReaderFlipping:onSetStatusLine() + -- Reset these widgets: we want new ones with proper alpha/opaque + self.rolling_rendering_state_widgets = nil +end + function ReaderFlipping:paintTo(bb, x, y) if self.ui.highlight.select_mode then if self[1][1] ~= self.select_mode_widget then self[1][1] = self.select_mode_widget end + elseif self.ui.rolling and self.ui.rolling.rendering_state then + local widget = self:getRollingRenderingStateIconWidget() + if self[1][1] ~= widget then + self[1][1] = widget + end + if not widget then return end -- nothing to get painted else if self[1][1] ~= self.flipping_widget then self[1][1] = self.flipping_widget diff --git a/frontend/apps/reader/modules/readerrolling.lua b/frontend/apps/reader/modules/readerrolling.lua index 9b140d77e..c95701770 100644 --- a/frontend/apps/reader/modules/readerrolling.lua +++ b/frontend/apps/reader/modules/readerrolling.lua @@ -9,6 +9,7 @@ local ReaderPanning = require("apps/reader/modules/readerpanning") local Size = require("ui/size") local UIManager = require("ui/uimanager") local bit = require("bit") +local ffiutil = require("ffi/util") local lfs = require("libs/libkoreader-lfs") local logger = require("logger") local time = require("ui/time") @@ -19,6 +20,18 @@ local T = require("ffi/util").template local band = bit.band +-- We need a small mmap'ped segment to exchange states with forked +-- subproceses doing background rerenderings. +-- Used as: +-- shared_state[0] = pid of current subprocess +-- shared_state[1] = 0 or 1, set by subprocess when rendering done, waiting to save cache +-- shared_state[2] = 0 or 1, set by main process when subprocess can go on saving cache +local ffi = require("ffi") +local shared_state_data = ffi.C.mmap(nil, 3*ffi.sizeof("uint32_t"), bit.bor(ffi.C.PROT_READ, ffi.C.PROT_WRITE), + bit.bor(ffi.C.MAP_SHARED, ffi.C.MAP_ANONYMOUS), -1, 0) +local shared_state = ffi.cast("uint32_t*", shared_state_data) +local koreader_pid = ffi.C.getpid() + --[[ Rolling is just like paging in page-based documents except that sometimes (in scroll mode) there is no concept of page number to indicate @@ -54,6 +67,18 @@ local ReaderRolling = InputContainer:extend{ -- same page when turning first 2-pages set of document) odd_or_even_first_page = 1, -- 1 = odd, 2 = even, nil or others = free hide_nonlinear_flows = nil, + + partial_rerendering = false, + nb_partial_rerenderings = 0, + rendering_state = nil, + RENDERING_STATE = { + FULLY_RENDERED = nil, + PARTIALLY_RERENDERED = 1, + FULL_RENDERING_IN_BACKGROUND = 2, + FULL_RENDERING_READY = 3, + RELOADING_DOCUMENT = 4, + DO_RELOAD_DOCUMENT = 5, + } } function ReaderRolling:init() @@ -61,8 +86,16 @@ function ReaderRolling:init() self.pan_interval = time.s(1 / self.pan_rate) table.insert(self.ui.postInitCallback, function() - self.rendering_hash = self.ui.document:getDocumentRenderingHash() + self.rendering_hash = self.ui.document:getDocumentRenderingHash(true) self.ui.document:_readMetadata() + if self.ui.document:hasCacheFile() and not self.ui.document:isCacheFileStale() then + -- We loaded from a valid cache file: remember its hash. It may allow not + -- having to do any background rerendering if the user somehow reverted + -- some setting changes before any background rerendering had completed + -- (ie. with autorotation, transitionning from portrait to landscape for + -- a few seconds, to then end up back in portrait). + self.valid_cache_rendering_hash = self.ui.document:getDocumentRenderingHash(false) + end end) table.insert(self.ui.postReaderCallback, function() self:updatePos() @@ -258,6 +291,13 @@ function ReaderRolling:onReadSettings(config) end self.ui.document:setHideNonlinearFlows(self.hide_nonlinear_flows) + -- Will be activated on ReaderReady + if config:has("partial_rerendering") then + self.partial_rerendering = config:isTrue("partial_rerendering") + else + self.partial_rerendering = G_reader_settings:nilOrTrue("cre_partial_rerendering") + end + -- Set a callback to allow showing load and rendering progress -- (this callback will be cleaned up by cre.cpp closeDocument(), -- no need to handle it in :onCloseDocument() here.) @@ -275,14 +315,19 @@ end -- in scroll mode percent_finished must be save before close document -- we cannot do it in onSaveSettings() because getLastPercent() uses self.ui.document function ReaderRolling:onCloseDocument() + self:tearDownRerenderingAutomation() self.current_header_height = nil -- show unload progress bar at top self.ui.doc_settings:saveSetting("percent_finished", self:getLastPercent()) + local cache_file_path = self.ui.document:getCacheFilePath() -- nil if no cache file self.ui.doc_settings:saveSetting("cache_file_path", cache_file_path) if self.ui.document:hasCacheFile() then -- also checks if DOM is coherent with styles; if not, invalidate the -- cache, so a new DOM is built on next opening - if self.ui.document:isBuiltDomStale() then + -- Don't check if we are reloading from a cache built in background: if + -- incoherent, the user will get the popup after the re-open, and can + -- decide to reload, or just revert his changes and avoid the reload. + if self.ui.document:isBuiltDomStale() and self.rendering_state ~= self.RENDERING_STATE.DO_RELOAD_DOCUMENT then logger.dbg("cre DOM may not be in sync with styles, invalidating cache file for a full reload at next opening") self.ui.document:invalidateCacheFile() end @@ -330,6 +375,7 @@ function ReaderRolling:onSaveSettings() end self.ui.doc_settings:saveSetting("visible_pages", self.visible_pages) self.ui.doc_settings:saveSetting("hide_nonlinear_flows", self.hide_nonlinear_flows) + self.ui.doc_settings:saveSetting("partial_rerendering", self.partial_rerendering) end function ReaderRolling:onReaderReady() @@ -338,6 +384,13 @@ function ReaderRolling:onReaderReady() self.ui.document:cacheFlows() end self.setupXpointer() + if self.partial_rerendering then + UIManager:nextTick(function() + if self.ui.document then -- (could have disappeared with unit tests) + self.ui.document:enablePartialRerendering(true) + end + end) + end end function ReaderRolling:setupTouchZones() @@ -423,6 +476,63 @@ function ReaderRolling:addToMainMenu(menu_items) help_text = hide_nonlinear_text, } end + menu_items.partial_rerendering = { + text = _("Enable partial renderings"), + enabled_func = function() + return self.ui.document:canBePartiallyRerendered() == true + end, + checked_func = function() + return self.ui.document:isPartialRerenderingEnabled() == true + end, + callback = function() + if self.ui.document:isPartialRerenderingEnabled() then + -- (Don't disable it if we are currently in a rerendering automation) + if not self.rendering_state then + self.partial_rerendering = false + if self.ui.document:enablePartialRerendering(false) then + -- Disabling returns true when some partial rerenderings had been done. + -- A full rerendering is needed to have a properly rendered document. + self:onUpdatePos() + end + end + else + self.ui.document:enablePartialRerendering(true) + self.partial_rerendering = self.ui.document:isPartialRerenderingEnabled() + end + end, + hold_callback = function() + local cre_partial_rerendering = G_reader_settings:nilOrTrue("cre_partial_rerendering") + local text = _([[ +With EPUB documents (having multiple fragments), text appearance adjustments can be made quicker by only rendering the current chapter. +After such partial renderings, the book and KOReader are in a degraded state: you can turn pages, but some info and features may be broken or disabled (ie. footer info, ToC, statistics…). +To get back to a sane state, a full rendering will happen in the background, get cached, and the document will be seamlessly reloaded after a brief period of inactivity.]]) + if cre_partial_rerendering then + text = text .. "\n\n" .. _("The current default (★) is to enable partial renderings when possible.") + else + text = text .. "\n\n" .. _("The current default (★) is to always do a full rendering.") + end + local MultiConfirmBox = require("ui/widget/multiconfirmbox") + UIManager:show(MultiConfirmBox:new{ + text = text, + -- This text is a bit long, and MultiConfirmBox currently doesn't adjust the + -- font size and may overflow the screen height: use a smaller font size + face = require("ui/font"):getFace("infofont", 20), + icon = "cre.render.partial", + choice1_text_func = function() + return cre_partial_rerendering and _("Disable") or _("Disable (★)") + end, + choice1_callback = function() + G_reader_settings:makeFalse("cre_partial_rerendering") + end, + choice2_text_func = function() + return cre_partial_rerendering and _("Enable (★)") or _("Enable") + end, + choice2_callback = function() + G_reader_settings:makeTrue("cre_partial_rerendering") + end, + }) + end, + } end function ReaderRolling:getLastPercent() @@ -951,10 +1061,19 @@ function ReaderRolling:updatePos(force) return end -- Check if the document has been re-rendered - local new_rendering_hash = self.ui.document:getDocumentRenderingHash() + local new_rendering_hash = self.ui.document:getDocumentRenderingHash(true) if new_rendering_hash ~= self.rendering_hash or force then logger.dbg("rendering hash changed:", self.rendering_hash, ">", new_rendering_hash) self.rendering_hash = new_rendering_hash + + if self.ui.document:isRerenderingDelayed(true) then + -- Partial rerendering is enabled, rerendering is delayed + logger.dbg(" but rendering delayed, will do partial renderings on draw") + self:handleRenderingDelayed() + return + end + + -- Full rerendering done. -- A few things like page numbers may have changed self.ui.document:resetCallCache() -- be really sure this cache is reset self.ui.document:_readMetadata() -- get updated document height and nb of pages @@ -1144,6 +1263,12 @@ function ReaderRolling:onSetVisiblePages(visible_pages) self.ui.document:setVisiblePageCount(visible_pages) local cur_visible_pages = self.ui.document:getVisiblePageCount() if cur_visible_pages ~= prev_visible_pages then + if self.ui.document:isPartialRerenderingEnabled() then + -- Page numbers have gone *2 or /2, we don't want drawCurrentViewByPage() + -- to ensure the current page as we know it, which would otherwise + -- led us *2 or /2 away or back in the book. + self.ui.document.no_page_sync = true + end self:onUpdatePos() end end @@ -1538,4 +1663,362 @@ function ReaderRolling:onToggleHideNonlinear() self:onUpdatePos(true) end + +-- Partial rerendering handling methods and automation. +-- This is a one way path: we start from normal, and on a setting +-- change, we are and stay in a degraded partial rendering mode, +-- that we only exit with reloadDocument(), which makes everything +-- sane by restarting with fresh Reader and crengine instances. + +function ReaderRolling:handleRenderingDelayed() + -- Partial rendering will happen on next drawing: let it be known. + -- Pages and ToC will be invalid, some modules may want to disable + -- some features (ie. reading statistics should stop accounting + -- pages read/total pages, as there are invalid, and bogus data + -- would stick in the stats db). + -- We don't send it on each partial rendering (which can happen as + -- we turn pages, only on setting changes (if this would be needed, + -- send this or another event in :handlePartialRerendering()). + local first_partial_rerender = self.rendering_state == nil + self.ui:handleEvent(Event:new("DocumentPartiallyRerendered", first_partial_rerender)) + -- Start the automation, ensuring we'll soon be rendering in background + self:setupRerenderingAutomation() + self._stepRerenderingAutomation(self.RENDERING_STATE.PARTIALLY_RERENDERED) + -- Have ReaderView draw the current page, which will provoke the partial rerendering + -- of the DocFragement the current page is from. + UIManager:setDirty(self.view.dialog, "partial") +end + +function ReaderRolling:handlePartialRerendering() + -- Called by ReaderView after crengine drawing, so we can notice if a partial + -- rerendering did happen or not + local nb_partial_rerenderings = self.ui.document:getPartialRerenderingsCount() + if nb_partial_rerenderings == self.nb_partial_rerenderings then + return -- no partial rerendering + end + logger.dbg("partial rerenderings done", self.nb_partial_rerenderings, ">", nb_partial_rerenderings) + self.nb_partial_rerenderings = nb_partial_rerenderings + + self.ui.document:resetCallCache() -- trash invalid cached info + + -- crengine should have handled repositionning to the initial page xpointer, + -- and redraw the page if needed. + -- But when tweaking settings multiple times (without changing page), crengine + -- will ensure this for each new displayed page top xpointer, and, the shifts + -- accumulating, we may end up a few pages behind the original page. + -- This is avoided with full rerendering by ensuring _gotoXPointer(self.xpointer) + -- in :updatePos() above. So, do the same if we notice it is needed. + local cur_page = self.ui.document:getCurrentPage() + local cur_xpointer_page = self.ui.document:getPageFromXPointer(self.xpointer) + if cur_page ~= cur_xpointer_page then + logger.dbg("not on the expected page: repositionning and redrawing") + self:_gotoXPointer(self.xpointer) + return true -- ReaderView will repaint + end + + -- Current page numbers, total pages and many other have changed. + -- Some may be gathered next time from crengine as we reset the cache, + -- but some are stored in Reader modules (ie. ToC) and won't be updated + -- (some updates may be costly, but mostly, we don't want to hunt them all). + -- At least, be sure everything knows about the current page, so we can + -- turn pages and scroll. + if self.view.view_mode == "page" then + self.ui:handleEvent(Event:new("PageUpdate", self.ui.document:getCurrentPage())) + else + self.current_page = self.ui.document:getCurrentPage() + self.ui:handleEvent(Event:new("PosUpdate", self.ui.document:getCurrentPos(), self.current_page)) + end +end + +function ReaderRolling:_waitOrKillCurrentRerenderingSubprocess(wait, kill) + -- No need for an asynchronous collector: we'll explicitely call this and wait + -- before going on, even when reloading, to avoid having multiple possibly huge + -- subprocesses at the same time. + -- Returns true if the process is no longer running. + -- We should only return false when wait=false provided (to know if still running). + -- If wait=true, don't return until process is done. + -- If kill=true, kill it, and wait for it to be gone. + if not self._current_rerendering_pid then + return true + end + if ffiutil.isSubProcessDone(self._current_rerendering_pid) then + self._current_rerendering_pid = nil + return true + end + if kill then + wait = true -- we want to be sure it is collected + ffiutil.terminateSubProcess(self._current_rerendering_pid) + end + if wait then + if ffiutil.isSubProcessDone(self._current_rerendering_pid, true) then + self._current_rerendering_pid = nil + return true + end + end + return false +end + +function ReaderRolling:setupRerenderingAutomation() + if self._stepRerenderingAutomation then + return + end + + -- Once this is created and called, we shouldn't stay long on the current document, + -- we will reload it soon. So, disable standby during the whole steps. + UIManager:preventStandby() + + -- Some states will step only when the user is idle + local last_input_event_time = UIManager:getTime() -- (we got here because of some input) + self._watchInputEvent = function() + -- :getTime(), although not accurate, should be good enough, see: + -- https://github.com/koreader/koreader/issues/9087#issuecomment-1419852952 + last_input_event_time = UIManager:getTime() + end + UIManager.event_hook:register("InputEvent", self._watchInputEvent) + + local next_step_not_before + self._stepRerenderingAutomation = function(next_step) + -- Ensure transitions between partial rerendering steps + logger.dbg("_stepRerenderingAutomation(", next_step, "), currently ", self.rendering_state) + UIManager:unschedule(self._stepRerenderingAutomation) + local reschedule = true + local prev_state = self.rendering_state + + if next_step == self.RENDERING_STATE.PARTIALLY_RERENDERED then + -- rendering changed: may be the first, or some setting were changed + -- before we ended all the steps and reload: cancel anything ongoing + self:_waitOrKillCurrentRerenderingSubprocess(true, true) + self.rendering_state = self.RENDERING_STATE.PARTIALLY_RERENDERED + -- Avoid setDirty("ui"), a "partial" has been issued by our caller: + prev_state = self.RENDERING_STATE.PARTIALLY_RERENDERED + next_step_not_before = false + + elseif next_step == self.RENDERING_STATE.FULL_RENDERING_IN_BACKGROUND then + self.rendering_state = self.RENDERING_STATE.FULL_RENDERING_IN_BACKGROUND + if self.valid_cache_rendering_hash + and self.valid_cache_rendering_hash == self.ui.document:getDocumentRenderingHash(false) then + -- No need to rerender, the current cache is valid, and we can just reload from it! + -- We'll still show the rendering icon to keep the icon workflow consistent, + -- it will just not display for long + logger.dbg("background rerendering not needed, current cache is usable") + self._current_rerendering_pid = nil -- have this mean no rerendering was needed + else + self._current_rerendering_pid = self:_rerenderInBackground() + end + + elseif next_step == self.RENDERING_STATE.FULL_RENDERING_READY then + -- Just show the new icon, work will happen at next schedule + self.rendering_state = self.RENDERING_STATE.FULL_RENDERING_READY + + elseif next_step == self.RENDERING_STATE.RELOADING_DOCUMENT then + -- Just show the new icon, work will happen at next schedule + self.rendering_state = self.RENDERING_STATE.RELOADING_DOCUMENT + + else -- (from reschedule, next_step=nil) + + if self.rendering_state == self.RENDERING_STATE.PARTIALLY_RERENDERED then + -- We want to launch a background rendering, but not before + -- the user has closed all menus, meaning he might be satisfied + -- enough with the partial result; as it may close them to see + -- more of the book; allow for a small delay, assuming that + -- if ReaderUI is still/again at the top, the user is done + -- tweaking settings (if he changes a setting before a reload + -- is triggered, we'll be go at step 1). + -- Let's go with a delay of 3s. + local top_widget = UIManager:getTopmostVisibleWidget() or {} + if top_widget.name == "ReaderUI" then + if not next_step_not_before then -- start counting from now + next_step_not_before = UIManager:getTime() + time.s(3) + else + if UIManager:getTime() >= next_step_not_before then + self._stepRerenderingAutomation(self.RENDERING_STATE.FULL_RENDERING_IN_BACKGROUND) + return + end + -- otherwise, recheck at next schedule + end + end + + elseif self.rendering_state == self.RENDERING_STATE.FULL_RENDERING_IN_BACKGROUND then + if self._current_rerendering_pid then + -- Be sure the subprocess is still running + if self:_waitOrKillCurrentRerenderingSubprocess(false) then + -- subprocess died... (self._current_rerendering_pid has then be reset to nil) + -- Step forward, as if no background rendering needed + self._stepRerenderingAutomation(self.RENDERING_STATE.FULL_RENDERING_READY) + return + else -- still running + if shared_state[1] ~= 0 then -- done rendering + self._stepRerenderingAutomation(self.RENDERING_STATE.FULL_RENDERING_READY) + return + end + -- otherwise, recheck at next schedule + end + else -- no background rendering needed, step forward + self._stepRerenderingAutomation(self.RENDERING_STATE.FULL_RENDERING_READY) + return + end + + elseif self.rendering_state == self.RENDERING_STATE.FULL_RENDERING_READY + or self.rendering_state == self.RENDERING_STATE.RELOADING_DOCUMENT then + -- We'll have to reload the document: we prefer doing it when the user is + -- idle "reading" (that is, ReaderUI is at top, and no input for some time) + -- to not surprise and interrupt him if he is turning pages, reading a dict + -- lookup result, or busy in some other widget or menu. + -- Let's go with 5s of idle time + local do_reload = false + local top_widget = UIManager:getTopmostVisibleWidget() or {} + if top_widget.name == "ReaderUI" then + do_reload = true + if self.ui.highlight.hold_pos or self.ui.highlight.select_mode then + -- Not if text selection in progress (to not reload while the user + -- is selecting, even if idle) + do_reload = false + elseif UIManager:getTime() < last_input_event_time + time.s(5) then + -- Not idle long enough + do_reload = false + end + end + if self.rendering_state == self.RENDERING_STATE.FULL_RENDERING_READY then + if do_reload then + -- Transition to show the reload icon for at least 1s if the + -- book reloads really fast. We'll redo the above conditions checks + -- in case things happened in the meantime. + self._stepRerenderingAutomation(self.RENDERING_STATE.RELOADING_DOCUMENT) + return + end + -- Otherwise, conditions not yet met + else -- self.RENDERING_STATE.RELOADING_DOCUMENT + if not do_reload then + -- Stuff happened: transition back to previous state + self._stepRerenderingAutomation(self.RENDERING_STATE.FULL_RENDERING_READY) + else + -- reload icon shown, ready to reload + if self._current_rerendering_pid and not self:_waitOrKillCurrentRerenderingSubprocess(false) then + -- Rendering subproces still alive, waiting for our signal to save its cache + -- We need to let the subprocess save the cache, and only then reloadDocument. + -- We need to block the UI so no other setting get changed, and the cache + -- is sane to reload from. + -- Let subprocess know it can save cache + shared_state[2] = 1 + -- And wait for it to end (successfully or not) + self:_waitOrKillCurrentRerenderingSubprocess(true) + end + -- Otherwise, no background rerendering needed, or the subprocess died: go on with the reload. + -- We're done with background stuff and icon animations: reallow standby + self.rendering_state = self.RENDERING_STATE.DO_RELOAD_DOCUMENT + self.ui:reloadDocument(nil, true) -- seamless reload (no infomsg, no flash) + end + return + end + end + end + if self.rendering_state ~= prev_state then + logger.dbg("_stepRerenderingAutomation", prev_state, ">", self.rendering_state) + -- Have ReaderView redraw and refresh ReaderFlipping and our state icon, avoiding flashes + UIManager:setDirty(self.view.dialog, "ui", self.view.flipping.dimen) + end + if reschedule then + UIManager:scheduleIn(1, self._stepRerenderingAutomation) + end + end +end + +function ReaderRolling:tearDownRerenderingAutomation() + if self._stepRerenderingAutomation then + UIManager:unschedule(self._stepRerenderingAutomation) + self._stepRerenderingAutomation = nil + UIManager:allowStandby() + end + if self._watchInputEvent then + UIManager.event_hook:unregister("InputEvent", self._watchInputEvent) + self._watchInputEvent = nil + end + -- Be sure we don't let any zombie + self:_waitOrKillCurrentRerenderingSubprocess(true, true) + Device:enableCPUCores(1) +end + +function ReaderRolling:_rerenderInBackground() + Device:enableCPUCores(2) + + -- Set up mmap segment to exchange signals between main and sub processes + shared_state[0] = 0 + shared_state[1] = 0 + shared_state[2] = 0 + + -- If the fork failed, self._current_rerendering_pid will be false, and the above + -- automation should do as if no rendering needed, and go on with the reload, + -- provoking a full rerendering on load + local child_pid = ffiutil.runInSubProcess(function(my_pid) + logger.dbg("background rerendering started for hash", self.rendering_hash) + -- Disable top left cre progress bar (not really needed, and may cause + -- a crash on SDL "XInitThreads not called") + self.ui.document:setCallback() + self.ui.document:enablePartialRerendering(false) -- we want a full render + + -- Rerender: this will take some time + self.ui.document._document:renderDocument() + + -- We could check if we would get "Styles have changed...fully reloading the document may be needed" + -- (which happens when CSS properties "display:" and "white-space:" have changed for some nodes, which + -- is rather rare with our style tweaks) here, and do the reload and rerendering in this same background + -- subprocess, and doing this would hide this whole thing from the user, making the UX seamless. + -- But this would need a lot more memory, as we would then have 2 independant DOM in memory. + -- Ie. with a big book and KOReader taking 120 MB, the subprocess would additionally use: + -- - 60 MB when doing a simple rerendering + -- - 130 MB when doing a full load+render + -- So, let's not, and let the user handle that after the coming up reload. + if false and self.ui.document:isBuiltDomStale() then + logger.info("background cre DOM may not be in sync with styles, doing a full reload") + self.ui.document:invalidateCacheFile() + -- Just doing this seems to work, we can keep the same LVDocView, no need for any + -- tedious cleanup: + self.ui.document._document:loadDocument(self.ui.document.file) + self.ui.document._document:renderDocument() + end + + -- Rebuild ToC and PageMap data (these are done when needed when requested by frontend, + -- but doing that now in the background will save that time on the next reload). + self.ui.document._document:updateTocAndPageMap() + + -- crengine is quite optimized regarding cache updates writes: it will compute hashes + -- of each block and avoid writing them to disk if not changed from its vision of + -- the existing cache at load time. Because of this, we don't want this background + -- subprocess to update the cache until we are sure we'll reload from it. + -- So, let the main process know we are done rendering and waiting for its + -- signal that we can write the cache. + + -- We take some precautions to avoid staying alive if nothing is waiting for us + if shared_state[0] ~= 0 and shared_state[0] ~= my_pid then + -- Parent process is no longer waiting for this child + os.exit(0) + end + shared_state[1] = 1 -- done rendering, let parent know, and wait before saving cache + shared_state[2] = 0 -- we're going to wait for this to become non-0 + while true do + ffiutil.usleep(250000) + if shared_state[0] ~= 0 and shared_state[0] ~= my_pid then + logger.info("rerendering subprocess: no longer waited, exiting") + os.exit(0) + end + if ffi.C.getppid() ~= koreader_pid then + -- new parent pid: main process exited/crashed + logger.info("rerendering subprocess: parent gone, exiting") + os.exit(0) + end + if shared_state[2] ~= 0 then + break -- we can go on + end + end + logger.dbg("rerendering subprocess: going on saving cache") + self.ui.document._document:close() -- should save the cache and clean things properly + -- ffiutil.sleep(1) + logger.dbg("rerendering subprocess done") + end) + -- If the fork failed, or in _stepRerenderingAutomation() when we notice the subprocess has died + -- (probably because of out of memory), should we disable partial rendering for this book? + shared_state[0] = child_pid or 0 + return child_pid +end + return ReaderRolling diff --git a/frontend/apps/reader/modules/readerthumbnail.lua b/frontend/apps/reader/modules/readerthumbnail.lua index 39c8e48e1..10db5a33b 100644 --- a/frontend/apps/reader/modules/readerthumbnail.lua +++ b/frontend/apps/reader/modules/readerthumbnail.lua @@ -407,10 +407,12 @@ function ReaderThumbnail:_getPageImage(page) if self.ui.view.highlight.lighten_factor < 0.3 then self.ui.view.highlight.lighten_factor = 0.3 -- make lighten highlight a bit darker end + self.ui.highlight.select_mode = false -- Remove any select mode icon if self.ui.rolling then -- CRE documents: pages all have the aspect ratio of our screen (alt top status bar -- will be croped out after drawing), we will show them just as rendered. + self.ui.rolling.rendering_state = nil -- Remove any partial rerendering icon self.ui.view:onSetViewMode("page") -- Get out of scroll mode if self.ui.font.gamma_index < 30 then -- Increase font gamma (if not already increased), self.ui.document:setGammaIndex(30) -- as downscaling will make text grayer @@ -503,6 +505,7 @@ end -- CRE: emitted after a re-rendering ReaderThumbnail.onDocumentRerendered = ReaderThumbnail.resetCache +ReaderThumbnail.onDocumentPartiallyRerendered = ReaderThumbnail.resetCache -- Emitted When adding/removing/updating bookmarks and highlights ReaderThumbnail.onBookmarkAdded = ReaderThumbnail.resetCachedPagesForBookmarks ReaderThumbnail.onBookmarkRemoved = ReaderThumbnail.resetCachedPagesForBookmarks diff --git a/frontend/apps/reader/modules/readerview.lua b/frontend/apps/reader/modules/readerview.lua index bc2696432..957c2f550 100644 --- a/frontend/apps/reader/modules/readerview.lua +++ b/frontend/apps/reader/modules/readerview.lua @@ -190,6 +190,14 @@ function ReaderView:paintTo(bb, x, y) elseif self.view_mode == "scroll" then self:drawScrollView(bb, x, y) end + local should_repaint = self.ui.rolling:handlePartialRerendering() + if should_repaint then + -- ReaderRolling may have repositionned on another page containing + -- the xpointer of the top of the original page: recalling this is + -- all there is to do. + self:paintTo(bb, x, y) + return + end end -- dim last read area @@ -226,7 +234,8 @@ function ReaderView:paintTo(bb, x, y) self.footer:paintTo(bb, x, y) end -- paint flipping or select mode sign - if self.flipping_visible or self.ui.highlight.select_mode then + if self.flipping_visible or self.ui.highlight.select_mode + or (self.ui.rolling and self.ui.rolling.rendering_state) then self.flipping:paintTo(bb, x, y) end for _, m in pairs(self.view_modules) do diff --git a/frontend/apps/reader/readerui.lua b/frontend/apps/reader/readerui.lua index 05f70b9fc..952ac4cab 100644 --- a/frontend/apps/reader/readerui.lua +++ b/frontend/apps/reader/readerui.lua @@ -63,7 +63,8 @@ local logger = require("logger") local time = require("ui/time") local util = require("util") local _ = require("gettext") -local Screen = require("device").screen +local Input = Device.input +local Screen = Device.screen local T = ffiUtil.template local ReaderUI = InputContainer:extend{ @@ -109,6 +110,7 @@ function ReaderUI:init() -- cap screen refresh on pan to 2 refreshes per second local pan_rate = Screen.low_pan_rate and 2.0 or 30.0 + Input:inhibitInput(true) -- Inhibit any past and upcoming input events. Device:setIgnoreInput(true) -- Avoid ANRs on Android with unprocessed events. self.postInitCallback = {} @@ -479,6 +481,7 @@ function ReaderUI:init() self.postReaderCallback = nil Device:setIgnoreInput(false) -- Allow processing of events (on Android). + Input:inhibitInputUntil(0.2) -- print("Ordered registered gestures:") -- for _, tzone in ipairs(self._ordered_touch_zones) do @@ -562,7 +565,7 @@ end --- @note: Will sanely close existing FileManager/ReaderUI instance for you! --- This is the *only* safe way to instantiate a new ReaderUI instance! --- (i.e., don't look at the testsuite, which resorts to all kinds of nasty hacks). -function ReaderUI:showReader(file, provider) +function ReaderUI:showReader(file, provider, seamless) logger.dbg("show reader ui") if lfs.attributes(file, "mode") ~= "file" then @@ -584,21 +587,22 @@ function ReaderUI:showReader(file, provider) UIManager:broadcastEvent(Event:new("ShowingReader")) provider = provider or DocumentRegistry:getProvider(file) if provider.provider then - self:showReaderCoroutine(file, provider) + self:showReaderCoroutine(file, provider, seamless) end end -function ReaderUI:showReaderCoroutine(file, provider) +function ReaderUI:showReaderCoroutine(file, provider, seamless) UIManager:show(InfoMessage:new{ text = T(_("Opening file '%1'."), BD.filepath(file)), timeout = 0.0, + invisible = seamless, }) -- doShowReader might block for a long time, so force repaint here UIManager:forceRePaint() UIManager:nextTick(function() logger.dbg("creating coroutine for showing reader") local co = coroutine.create(function() - self:doShowReader(file, provider) + self:doShowReader(file, provider, seamless) end) local ok, err = coroutine.resume(co) if err ~= nil or ok == false then @@ -612,7 +616,7 @@ function ReaderUI:showReaderCoroutine(file, provider) end) end -function ReaderUI:doShowReader(file, provider) +function ReaderUI:doShowReader(file, provider, seamless) logger.info("opening file", file) -- Only keep a single instance running if ReaderUI.instance then @@ -664,7 +668,7 @@ function ReaderUI:doShowReader(file, provider) FileManager.instance:onClose() end - UIManager:show(reader, "full") + UIManager:show(reader, seamless and "ui" or "full") end -- NOTE: The instance reference used to be stored in a private module variable, hence the getter method. @@ -832,7 +836,7 @@ function ReaderUI:onReload() self:reloadDocument() end -function ReaderUI:reloadDocument(after_close_callback) +function ReaderUI:reloadDocument(after_close_callback, seamless) local file = self.document.file local provider = getmetatable(self.document).__index @@ -842,6 +846,7 @@ function ReaderUI:reloadDocument(after_close_callback) self:handleEvent(Event:new("CloseReaderMenu")) self:handleEvent(Event:new("CloseConfigMenu")) + self:handleEvent(Event:new("PreserveCurrentSession")) -- don't reset statistics' start_current_period self.highlight:onClose() -- close highlight dialog if any self:onClose(false) if after_close_callback then @@ -849,7 +854,7 @@ function ReaderUI:reloadDocument(after_close_callback) after_close_callback(file, provider) end - self:showReader(file, provider) + self:showReader(file, provider, seamless) end function ReaderUI:switchDocument(new_file) diff --git a/frontend/document/credocument.lua b/frontend/document/credocument.lua index ac9bcf9d4..95943d5fd 100644 --- a/frontend/document/credocument.lua +++ b/frontend/document/credocument.lua @@ -322,9 +322,9 @@ function CreDocument:render() logger.dbg("CreDocument: rendering done.") end -function CreDocument:getDocumentRenderingHash() +function CreDocument:getDocumentRenderingHash(extended) if self.been_rendered then - return self._document:getDocumentRenderingHash() + return self._document:getDocumentRenderingHash(extended) end return 0 end @@ -1390,6 +1390,26 @@ function CreDocument:setCallback(func) return self._document:setCallback(func) end +function CreDocument:canBePartiallyRerendered() + return self._document:canBePartiallyRerendered() +end + +function CreDocument:isPartialRerenderingEnabled() + return self._document:isPartialRerenderingEnabled() +end + +function CreDocument:enablePartialRerendering(enable) + return self._document:enablePartialRerendering(enable) +end + +function CreDocument:getPartialRerenderingsCount() + return self._document:getPartialRerenderingsCount() +end + +function CreDocument:isRerenderingDelayed() + return self._document:isRerenderingDelayed() +end + function CreDocument:isBuiltDomStale() return self._document:isBuiltDomStale() end @@ -1398,6 +1418,10 @@ function CreDocument:hasCacheFile() return self._document:hasCacheFile() end +function CreDocument:isCacheFileStale() + return self._document:isCacheFileStale() +end + function CreDocument:invalidateCacheFile() self._document:invalidateCacheFile() end @@ -1794,6 +1818,8 @@ function CreDocument:setupCallCache() elseif name == "getPageFlow" then no_wrap = true elseif name == "getPageNumberInFlow" then no_wrap = true elseif name == "getTotalPagesLeft" then no_wrap = true + elseif name == "getDocumentRenderingHash" then no_wrap = true + elseif name == "getPartialRerenderingsCount" then no_wrap = true -- Some get* have different results by page/pos elseif name == "getLinkFromPosition" then cache_by_tag = true diff --git a/frontend/ui/elements/reader_menu_order.lua b/frontend/ui/elements/reader_menu_order.lua index 2e342f0bd..3e1d56712 100644 --- a/frontend/ui/elements/reader_menu_order.lua +++ b/frontend/ui/elements/reader_menu_order.lua @@ -83,6 +83,8 @@ local order = { "document_save", "document_end_action", "language_support", + "----------------------------", + "partial_rerendering", }, device = { "keyboard_layout", diff --git a/plugins/statistics.koplugin/main.lua b/plugins/statistics.koplugin/main.lua index b4b164efe..7a34f5fd2 100644 --- a/plugins/statistics.koplugin/main.lua +++ b/plugins/statistics.koplugin/main.lua @@ -62,6 +62,7 @@ local STATISTICS_SQL_BOOK_TOTALS_QUERY = [[ local ReaderStatistics = Widget:extend{ name = "statistics", start_current_period = 0, + preserved_start_current_period = nil, -- should stay a class property curr_page = 0, id_curr_book = nil, is_enabled = nil, @@ -120,6 +121,10 @@ function ReaderStatistics:init() } self.start_current_period = os.time() + if ReaderStatistics.preserved_start_current_period then + self.start_current_period = ReaderStatistics.preserved_start_current_period + ReaderStatistics.preserved_start_current_period = nil + end self:resetVolatileStats() self.settings = G_reader_settings:readSetting("statistics", self.default_settings) @@ -246,6 +251,23 @@ function ReaderStatistics:onDocumentRerendered() self.data.pages = new_pagecount end +function ReaderStatistics:onDocumentPartiallyRerendered(first_partial_rerender) + if not first_partial_rerender then return end -- already done + -- Override :onPageUpdate() to not account page changes from now on + self.onPageUpdate = function(this, pageno) + if pageno == false then -- happens from onCloseDocument + -- We need to call the original one to get saved previous statistics correct + return ReaderStatistics.onPageUpdate(this, false) + end + return + end +end + +function ReaderStatistics:onPreserveCurrentSession() + -- Can be called before ReaderUI:reloadDocument() to not reset the current session + ReaderStatistics.preserved_start_current_period = self.start_current_period +end + function ReaderStatistics:resetVolatileStats(now_ts) -- Computed by onPageUpdate self.pageturn_count = 0