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