diff --git a/frontend/apps/reader/modules/readerbookmark.lua b/frontend/apps/reader/modules/readerbookmark.lua index da8e9ad9b..a8aae328e 100644 --- a/frontend/apps/reader/modules/readerbookmark.lua +++ b/frontend/apps/reader/modules/readerbookmark.lua @@ -840,6 +840,7 @@ function ReaderBookmark:addBookmark(item) end end table.insert(self.bookmarks, _middle + direction, item) + self.ui:handleEvent(Event:new("BookmarkAdded", item)) self.view.footer:onUpdateFooter(self.view.footer_visible) end @@ -888,6 +889,7 @@ function ReaderBookmark:removeBookmark(item, reset_auto_text_only) v.text = nil end else + self.ui:handleEvent(Event:new("BookmarkRemoved", v)) table.remove(self.bookmarks, _middle) self.view.footer:onUpdateFooter(self.view.footer_visible) end @@ -911,6 +913,7 @@ function ReaderBookmark:removeBookmark(item, reset_auto_text_only) v.text = nil end else + self.ui:handleEvent(Event:new("BookmarkRemoved", v)) table.remove(self.bookmarks, i) self.view.footer:onUpdateFooter(self.view.footer_visible) end @@ -923,6 +926,7 @@ end function ReaderBookmark:updateBookmark(item) for i=1, #self.bookmarks do if item.datetime == self.bookmarks[i].datetime and item.page == self.bookmarks[i].page then + local bookmark_before = util.tableDeepCopy(self.bookmarks[i]) local is_auto_text = self:isBookmarkAutoText(self.bookmarks[i]) self.bookmarks[i].page = item.updated_highlight.pos0 self.bookmarks[i].pos0 = item.updated_highlight.pos0 @@ -933,6 +937,7 @@ function ReaderBookmark:updateBookmark(item) if is_auto_text then self.bookmarks[i].text = self:getBookmarkAutoText(self.bookmarks[i]) end + self.ui:handleEvent(Event:new("BookmarkUpdated", self.bookmarks[i], bookmark_before)) self:onSaveSettings() break end @@ -953,6 +958,7 @@ function ReaderBookmark:renameBookmark(item, from_highlight, is_new_note) bookmark = util.tableDeepCopy(bm) bookmark.text_orig = bm.text or bm.notes bookmark.mandatory = self:getBookmarkPageString(bm.page) + self.ui:handleEvent(Event:new("BookmarkEdited", bm)) break end end @@ -997,6 +1003,7 @@ function ReaderBookmark:renameBookmark(item, from_highlight, is_new_note) for __, bm in ipairs(self.bookmarks) do if bookmark.datetime == bm.datetime and bookmark.page == bm.page then bm.text = value + self.ui:handleEvent(Event:new("BookmarkEdited", bm)) -- A bookmark isn't necessarily a highlight (it doesn't have pboxes) if bookmark.pboxes then local setting = G_reader_settings:readSetting("save_document") @@ -1131,6 +1138,7 @@ end function ReaderBookmark:toggleBookmark(pn_or_xp) local index = self:getDogearBookmarkIndex(pn_or_xp) if index then + self.ui:handleEvent(Event:new("BookmarkRemoved", self.bookmarks[index])) table.remove(self.bookmarks, index) else -- build notes from TOC @@ -1297,6 +1305,26 @@ function ReaderBookmark:getBookmarkPageString(page) return tostring(page) end +function ReaderBookmark:getBookmarkedPages() + local pages = {} + for _, bm in ipairs(self.bookmarks) do + local page + if self.ui.rolling then + page = self.ui.document:getPageFromXPointer(bm.page) + else + page = bm.page + end + local btype = self:getBookmarkType(bm) + if not pages[page] then + pages[page] = {} + end + if not pages[page][btype] then + pages[page][btype] = true + end + end + return pages +end + function ReaderBookmark:getBookmarkAutoText(bookmark, force_auto_text) if G_reader_settings:nilOrTrue("bookmarks_items_auto_text") or force_auto_text then local page = self:getBookmarkPageString(bookmark.page) @@ -1309,7 +1337,7 @@ end --- Check if the 'text' field has not been edited manually function ReaderBookmark:isBookmarkAutoText(bookmark) - return (bookmark.text == nil) or (bookmark.text == bookmark.notes) + return (bookmark.text == nil) or (bookmark.text == "") or (bookmark.text == bookmark.notes) or (bookmark.text == self:getBookmarkAutoText(bookmark, true)) end @@ -1321,4 +1349,12 @@ function ReaderBookmark:getBookmarkNote(item) end end +function ReaderBookmark:getBookmarkForHighlight(item) + for i=1, #self.bookmarks do + if item.datetime == self.bookmarks[i].datetime and item.page == self.bookmarks[i].page then + return self.bookmarks[i] + end + end +end + return ReaderBookmark diff --git a/frontend/apps/reader/modules/readerhighlight.lua b/frontend/apps/reader/modules/readerhighlight.lua index 6d87de99e..7c5681d2b 100644 --- a/frontend/apps/reader/modules/readerhighlight.lua +++ b/frontend/apps/reader/modules/readerhighlight.lua @@ -1710,8 +1710,13 @@ function ReaderHighlight:editHighlightStyle(page, i) default_provider = self.view.highlight.saved_drawer or G_reader_settings:readSetting("highlight_drawing_style", "lighten"), callback = function(radio) - self.view.highlight.saved[page][i].drawer = radio.provider + item.drawer = radio.provider UIManager:setDirty(self.dialog, "ui") + self.ui:handleEvent(Event:new("BookmarkUpdated", + self.ui.bookmark:getBookmarkForHighlight({ + page = self.ui.paging and item.pos0.page or item.pos0, + datetime = item.datetime, + }))) end, }) end diff --git a/frontend/apps/reader/modules/readerlink.lua b/frontend/apps/reader/modules/readerlink.lua index 9620e274c..abf09c756 100644 --- a/frontend/apps/reader/modules/readerlink.lua +++ b/frontend/apps/reader/modules/readerlink.lua @@ -524,6 +524,21 @@ function ReaderLink:onClearLocationStack(show_notification) return true end +function ReaderLink:getPreviousLocationPages() + local previous_locations = {} + if #self.location_stack > 0 then + for num, location in ipairs(self.location_stack) do + if self.ui.rolling and location.xpointer then + previous_locations[self.ui.document:getPageFromXPointer(location.xpointer)] = num + end + if self.ui.paging and location[1] and location[1].page then + previous_locations[location[1].page] = num + end + end + end + return previous_locations +end + --- Goes to link. -- (This is called by other modules (highlight, search) to jump to a xpointer, -- they should not provide allow_footnote_popup=true) diff --git a/frontend/apps/reader/modules/readerthumbnail.lua b/frontend/apps/reader/modules/readerthumbnail.lua new file mode 100644 index 000000000..5ea944226 --- /dev/null +++ b/frontend/apps/reader/modules/readerthumbnail.lua @@ -0,0 +1,507 @@ +local Blitbuffer = require("ffi/blitbuffer") +local Cache = require("cache") +local Device = require("device") +local Geom = require("ui/geometry") +local InputContainer = require("ui/widget/container/inputcontainer") +local Persist = require("persist") +local RenderImage = require("ui/renderimage") +local TileCacheItem = require("document/tilecacheitem") +local UIManager = require("ui/uimanager") +local Screen = Device.screen +local ffiutil = require("ffi/util") +local logger = require("logger") +local util = require("util") +local _ = require("gettext") + +-- This ReaderThumbnail module provides a service for generating thumbnails +-- of book pages. +-- It handles launching via the menu or Dispatcher/Gestures two fullscreen +-- widgets related to showing pages and thumbnails that will make use of +-- its services: BookMap and PageBrowser. +local ReaderThumbnail = InputContainer:new{} + +function ReaderThumbnail:init() + if not Device:isTouchDevice() then + -- The BookMap and PageBrowser widgets depend too much on gestures, + -- making them work with keys would be hard and very limited, so + -- just don't make them available. + return + end + + self.ui.menu:registerToMainMenu(self) + + -- Use LuaJIT fast buffer.encode()/decode() when serializing BlitBuffer + -- for exchange between subprocess and parent. + self.codec = Persist.getCodec("luajit") + + self:setupColor() + self.thumbnails_requests = {} + self.current_target_size_tag = nil + + -- Ensure no multiple executions, and nextTick() the scheduleIn() + -- so we get a chance to process events in-between refreshes and + -- this can be interrupted (otherwise, something scheduleIn(0.1), + -- if a screen refresh is then done and taking longer than 0.1s, + -- would be executed immediately, without emptying any input event). + local schedule_step = 0 + self._ensureTileGeneration_action = function(restart) + if restart then + UIManager:unschedule(self._ensureTileGeneration_action) + schedule_step = 0 + end + if schedule_step == 0 then + schedule_step = 1 + UIManager:nextTick(self._ensureTileGeneration_action) + elseif schedule_step == 1 then + schedule_step = 2 + UIManager:scheduleIn(0.1, self._ensureTileGeneration_action) + else + schedule_step = 0 + self:ensureTileGeneration() + end + end +end + +function ReaderThumbnail:addToMainMenu(menu_items) + menu_items.book_map = { + text = _("Book map"), + callback = function() + self:onShowBookMap() + end, + } + menu_items.page_browser = { + text = _("Page browser"), + callback = function() + self:onShowPageBrowser() + end, + } +end + +function ReaderThumbnail:onShowBookMap() + local BookMapWidget = require("ui/widget/bookmapwidget") + UIManager:show(BookMapWidget:new{ + ui = self.ui, + }) + return true +end + +function ReaderThumbnail:onShowPageBrowser() + local PageBrowserWidget = require("ui/widget/pagebrowserwidget") + UIManager:show(PageBrowserWidget:new{ + ui = self.ui, + }) + return true +end + +-- This is made a module local so we can keep track of pids +-- to collect across multiple Reader instantiations +local pids_to_collect = {} + +function ReaderThumbnail:collectPids() + if #pids_to_collect == 0 then + return false + end + for i=#pids_to_collect, 1, -1 do + if ffiutil.isSubProcessDone(pids_to_collect[i]) then + table.remove(pids_to_collect, i) + end + end + return #pids_to_collect > 0 +end + +function ReaderThumbnail:setupColor() + self.bb_type = self.ui.document.render_color and self.ui.document.color_bb_type or Blitbuffer.TYPE_BB8 +end + +function ReaderThumbnail:setupCache() + if not self.tile_cache then + -- We want to allow browsing at least N pages worth of thumbnails + -- without cache trashing. A little more than N pages (because inter + -- thumbnail margins) will fit in N * screen size. + -- With N=5, this should use from 5 to 15 Mb on a classic eInk device. + local N = 5 + local max_bytes = math.ceil(N * Screen:getWidth() * Screen:getHeight() * Blitbuffer.TYPE_TO_BPP[self.bb_type] / 8) + -- We don't really care about limiting any number of slots, so allow + -- for at least 5 pages of 10x10 tiles + local avg_itemsize = math.ceil(max_bytes / 500) + self.tile_cache = Cache:new{ + size = max_bytes, + avg_itemsize = avg_itemsize, -- will make slots=500 + enable_eviction_cb = true, + } + end +end + +function ReaderThumbnail:logCacheSize() + logger.info(string.format("Thumbnails cache: %d/%d (%s/%s)", + self.tile_cache.cache.used_slots(), + self.tile_cache.slots, + util.getFriendlySize(self.tile_cache.cache.used_size()), + util.getFriendlySize(self.tile_cache.size))) +end + +function ReaderThumbnail:resetCache() + if self.tile_cache then + self.tile_cache:clear() + self.tile_cache = nil + end +end + +function ReaderThumbnail:removeFromCache(hash_subs, remove_only_non_matching) + -- Remove from cache all tiles matching any hash from hash_subs. + -- IF only_non_matching=true, keep those matching and remove all others. + if not self.tile_cache then + return + end + if type(hash_subs) ~= "table" then + hash_subs = { hash_subs } + end + local nb_removed, size_removed = 0, 0 + local to_remove = {} + for thash, tile in self.tile_cache.cache:pairs() do + local remove = remove_only_non_matching + for _, h in ipairs(hash_subs) do + if thash:find(h, 1, true) then -- plain text match (no pattern needed) + remove = not remove + break + end + end + if remove then + to_remove[thash] = true + nb_removed = nb_removed + 1 + size_removed = size_removed + tile.size + end + end + for thash, _ in pairs(to_remove) do + self.tile_cache.cache:delete(thash) + logger.dbg("removed cached thumbnail", thash) + end + return nb_removed, size_removed +end + +function ReaderThumbnail:resetCachedPagesForBookmarks(...) + -- Multiple bookmarks may be provided + local start_page, end_page + for _, bm in ipairs({...}) do + if self.ui.rolling then + -- Look at all properties that may be xpointers + for _, k in ipairs({"page", "pos0", "pos1"}) do + if bm[k] and type(bm[k]) == "string" then + local p = self.ui.document:getPageFromXPointer(bm[k]) + if not start_page or p < start_page then + start_page = p + end + if not end_page or p > end_page then + end_page = p + end + end + end + else + if bm.page and type(bm.page) == "number" then + local p = bm.page + if not start_page or p < start_page then + start_page = p + end + if not end_page or p > end_page then + end_page = p + end + end + end + end + if start_page and end_page then + local hash_subs_to_remove = {} + for p=start_page, end_page do + table.insert(hash_subs_to_remove, string.format("p%d-", p)) + end + self:removeFromCache(hash_subs_to_remove) + end +end + +function ReaderThumbnail:tidyCache() + if self.current_target_size_tag then + -- Remove all thumbnails generated for an older target size + self:removeFromCache("-"..self.current_target_size_tag, true) + end +end + +function ReaderThumbnail:cancelPageThumbnailRequests(batch_id) + if batch_id then + self.thumbnails_requests[batch_id] = nil + else + self.thumbnails_requests = {} + end + if self.req_in_progress and (not batch_id or self.req_in_progress.batch_id == batch_id) then + -- Kill any reference to the module cancelling it + self.req_in_progress.when_generated_callback = nil + end +end + +function ReaderThumbnail:getPageThumbnail(page, width, height, batch_id, when_generated_callback) + self:setupCache() + self.current_target_size_tag = string.format("w%d_h%d", width, height) + local hash = string.format("p%d-%s", page, self.current_target_size_tag) + local tile = self.tile_cache and self.tile_cache:check(hash) + if tile then + -- Cached: call callback and we're done. + when_generated_callback(tile, batch_id, false) + return false -- not delayed + end + if not self.thumbnails_requests[batch_id] then + self.thumbnails_requests[batch_id] = {} + end + table.insert(self.thumbnails_requests[batch_id], { + batch_id = batch_id, + hash = hash, + page = page, + width = width, + height = height, + when_generated_callback = when_generated_callback, + }) + -- Start tile generation, avoid multiple ones + self._ensureTileGeneration_action(true) + return true -- delayed +end + +function ReaderThumbnail:ensureTileGeneration() + local has_pids_still_to_collect = self:collectPids() + + local still_in_progress = false + if self.req_in_progress then + local pid_still_to_collect + still_in_progress, pid_still_to_collect = self:checkTileGeneration(self.req_in_progress) + if pid_still_to_collect then + has_pids_still_to_collect = true + end + end + if not still_in_progress then + self.req_in_progress = nil + while true do + local req_id, requests = next(self.thumbnails_requests) + if not req_id then -- no more requests + break + end + local req = table.remove(requests, 1) + if #requests == 0 then + self.thumbnails_requests[req_id] = nil + end + if req.when_generated_callback then -- not cancelled since queued + -- It might have been generated and cached by a previous batch + local tile = self.tile_cache and self.tile_cache:check(req.hash) + if tile then + req.when_generated_callback(tile, req.batch_id, true) + else + if self:startTileGeneration(req) then + self.req_in_progress = req + break + else + -- Failure starting it: let requester know in case it cares, and forget it + req.when_generated_callback(nil, req.batch_id, true) + end + end + end + end + end + if self.req_in_progress or has_pids_still_to_collect or next(self.thumbnails_requests) then + self._ensureTileGeneration_action() + end +end + +function ReaderThumbnail:startTileGeneration(request) + local pid, parent_read_fd = ffiutil.runInSubProcess(function(pid, child_write_fd) + -- Get page image as if drawn on the screen + local bb = self:_getPageImage(request.page) + -- Scale it to fit in the requested size + local scale_factor = math.min(request.width / bb:getWidth(), request.height / bb:getHeight()) + local target_w = math.floor(bb:getWidth() * scale_factor) + local target_h = math.floor(bb:getHeight() * scale_factor) + -- local TimeVal = require("ui/timeval") + -- local start_tv = TimeVal:now() + local tile = TileCacheItem:new{ + bb = RenderImage:scaleBlitBuffer(bb, target_w, target_h, true), + pageno = request.page, + } + tile.size = tonumber(tile.bb.stride) * tile.bb.h + -- logger.info("tile size", tile.bb.w, tile.bb.h, "=>", tile.size) + -- logger.info(string.format(" scaling took %.3f seconds, %d bpp", TimeVal:getDuration(start_tv), tile.bb:getBpp())) + -- bb:free() -- no need to spend time freeing, we're dying soon anyway! + + ffiutil.writeToFD(child_write_fd, self.codec.serialize(tile:totable()), true) + end, true) -- with_pipe = true + if pid then + -- Store these in the request object itself + request.pid = pid + request.parent_read_fd = parent_read_fd + return true + end + logger.warn("PageBrowserWidget thumbnail start failure:", parent_read_fd) + return false +end + +function ReaderThumbnail:checkTileGeneration(request) + local pid, parent_read_fd = request.pid, request.parent_read_fd + local stuff_to_read = ffiutil.getNonBlockingReadSize(parent_read_fd) ~= 0 + local subprocess_done = ffiutil.isSubProcessDone(pid) + logger.dbg("subprocess_done:", subprocess_done, " stuff_to_read:", stuff_to_read) + if stuff_to_read then + -- local TimeVal = require("ui/timeval") + -- local start_tv = TimeVal:now() + local result, err = self.codec.deserialize(ffiutil.readAllFromFD(parent_read_fd)) + if result then + local tile = TileCacheItem:new{} + tile:fromtable(result) + if self.tile_cache then + self.tile_cache:insert(request.hash, tile) + end + if request.when_generated_callback then -- not cancelled + request.when_generated_callback(tile, request.batch_id, true) + end + else + logger.warn("PageBrowserWidget thumbnail deserialize() failed:", err) + if request.when_generated_callback then -- not cancelled + request.when_generated_callback(nil, request.batch_id, true) + end + end + -- logger.info(string.format(" parsing result from subprocess took %.3f seconds", TimeVal:getDuration(start_tv))) + if not subprocess_done then + table.insert(pids_to_collect, pid) + return false, true + end + return false + elseif subprocess_done then + -- subprocess_done: process exited with no output + ffiutil.readAllFromFD(parent_read_fd) -- close our fd + return false + end + logger.dbg("process not yet done, will check again soon") + return true +end + +function ReaderThumbnail:_getPageImage(page) + -- This is run in a subprocess: we can tweak all document settings + -- to get an adequate image of the page. + -- No need to worry about the final state of things: this subprocess + -- will die just after drawing the page, and all will be forgotten, + -- without impact on the parent process. + + -- Be sure to limit our impact on the disk-saved book state + self.ui.saveSettings = function() end -- Be sure nothing is flushed + self.ui.statistics = nil -- Don't update statistics for pages we visit + + -- By default, our target page size is the current screen size + local target_w, target_h = Screen:getWidth(), Screen:getHeight() + + -- This was all mostly chosen by experimenting. + -- Be sure to call the innermost methods enough to get what we want, and + -- not upper event handlers that may trigger other unneeded events and stuff. + -- Especially, be sure to not trigger any paint on the screen buffer, or + -- any processing of input events. + -- No need to worry about UIManager:scheduleIn() or :nextTick(), as + -- we will die before the callback gets a chance to be run. + + -- Common to ReaderRolling and ReaderPaging + self.ui.view.footer_visible = false -- We want no footer on page image + 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 + + 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.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 + end + self.ui.document:setImageScaling(false) -- No need for smooth scaling as all will be downscaled + self.ui.document:setNightmodeImages(false) -- We don't invert page images even if nightmode set: keep images as-is + self.ui.view.state.page = page -- Be on requested page + self.ui.document:gotoPage(page) -- Current xpointer needs to be updated for some of what follows + self.ui.bookmark:onPageUpdate(page) -- Update dogear state for this page + self.ui.pagemap:onPageUpdate(page) -- Update pagemap labels for this page + end + + if self.ui.paging then + -- With PDF/DJVU/Pics, we will show the native page (no reflow, no crop, no zoom + -- to columns...). This makes thumbnail generation faster, and will allow the user + -- to get an overview of the book native pages to better decide which option will + -- be best to use for the book. + -- We also want to get a thumbnail with the aspect ratio of the native page + -- (so we don't get a native landscape page smallish and centered with blank above + -- and below in a portrait thumbnail, if the screen is in portrait mode). + + self.ui.view.hinting = false -- Disable hinting + self.ui.view.page_scroll = false -- Get out of scroll mode + self.ui.view.flipping_visible = false -- No page flipping icon + self.ui.document.configurable.text_wrap = false -- Get out of reflow mode + self.ui.document.configurable.trim_page = 3 -- Page crop: none + -- self.ui.document.configurable.trim_page = 1 -- Page crop: auto (very slower) + self.ui.document.configurable.auto_straighten = 0 -- No auto straighten + -- We can let dewatermark if the user has enabled it, it helps + -- limiting annoying eInk refreshes of light gray areas + -- self.ui.document.configurable.page_opt = 0 -- No dewatermark + -- We won't touch the contrast (to try making text less gray), as it applies on + -- images that could get too dark. + + -- Get native page dimensions, and update our target bb dimensions so it gets the + -- same aspect ratio (we don't use native dimensions as is, as they may get huge) + local dimen = self.ui.document:getPageDimensions(page, 1, 0) + local scale_factor = math.min(target_w / dimen.w, target_h / dimen.h) + target_w = math.floor(dimen.w * scale_factor) + target_h = math.floor(dimen.h * scale_factor) + dimen = Geom:new{ w=target_w, h=target_h } + -- logger.info("getPageImage", page, dimen, "=>", target_w, target_h, scale_factor) + + -- This seems to do it all well: + -- local Event = require("ui/event") + -- self.ui:handleEvent(Event:new("SetDimensions", dimen)) + -- self.ui.view.dogear[1].dimen.w = dimen.w -- (hack... its code uses the Screen width) + -- self.ui:handleEvent(Event:new("PageUpdate", page)) + -- self.ui:handleEvent(Event:new("SetZoomMode", "page")) + + -- Trying to do as little as needed, knowing the internals: + self.ui.view:onSetDimensions(dimen) + self.ui.view:onBBoxUpdate(nil) -- drop any bbox, draw native page + self.ui.view.state.page = page + self.ui.view.state.zoom = scale_factor + self.ui.view.state.rotation = 0 + self.ui.view:recalculate() + self.ui.view.dogear[1].dimen.w = dimen.w -- (hack... its code uses the Screen width) + self.ui.bookmark:onPageUpdate(page) -- Update dogear state for this page + end + + -- Draw the page on a new BB with the targetted size + local bb = Blitbuffer.new(target_w, target_h, self.bb_type) + self.ui.view:paintTo(bb, 0, 0) + + if self.ui.rolling then + -- Crop out the top alt status bar if enabled + local header_height = self.ui.document:getHeaderHeight() + if header_height > 0 then + bb = bb:viewport(0, header_height, bb.w, bb.h - header_height) + end + end + + return bb +end + +function ReaderThumbnail:onCloseDocument() + self:cancelPageThumbnailRequests() + if self.tile_cache then + self:logCacheSize() + self.tile_cache:clear() + self.tile_cache = nil + end +end + +function ReaderThumbnail:onColorRenderingUpdate() + self:setupColor() + self:resetCache() +end + +-- CRE: emitted after a re-rendering +ReaderThumbnail.onTocReset = ReaderThumbnail.resetCache +-- Emitted When adding/removing/updating bookmarks and highlights +ReaderThumbnail.onBookmarkAdded = ReaderThumbnail.resetCachedPagesForBookmarks +ReaderThumbnail.onBookmarkRemoved = ReaderThumbnail.resetCachedPagesForBookmarks +ReaderThumbnail.onBookmarkUpdated = ReaderThumbnail.resetCachedPagesForBookmarks + +return ReaderThumbnail diff --git a/frontend/apps/reader/modules/readertoc.lua b/frontend/apps/reader/modules/readertoc.lua index 09500b7c5..1b58fa597 100644 --- a/frontend/apps/reader/modules/readertoc.lua +++ b/frontend/apps/reader/modules/readertoc.lua @@ -170,6 +170,7 @@ function ReaderToc:validateAndFixToc() logger.dbg("validateAndFixToc(): quick scan") local has_bogus local cur_page = 0 + local max_depth = 0 for i = first, last do local page = toc[i].page if page < cur_page then @@ -177,15 +178,22 @@ function ReaderToc:validateAndFixToc() break end cur_page = page + -- Use this loop to compute max_depth here (if has_bogus, + -- we will recompute it in the loop below) + if toc[i].depth > max_depth then + max_depth = toc[i].depth + end end if not has_bogus then -- no TOC items, or all are valid logger.dbg("validateAndFixToc(): TOC is fine") + self.toc_depth = max_depth return end logger.dbg("validateAndFixToc(): TOC needs fixing") -- Bad ordering previously noticed: try to fix the wrong items' page -- by setting it to the previous or next good item page. + max_depth = 0 -- recompute this local nb_bogus = 0 local nb_fixed_pages = 0 -- We fix only one bogus item per loop, taking the option that @@ -198,6 +206,9 @@ function ReaderToc:validateAndFixToc() -- (These cases are met in the following code with cur_page=57 and page=6) cur_page = 0 for i = first, last do + if toc[i].depth > max_depth then + max_depth = toc[i].depth + end local page = toc[i].fixed_page or toc[i].page if page >= cur_page then cur_page = page @@ -265,6 +276,7 @@ function ReaderToc:validateAndFixToc() end end logger.info(string.format("TOC had %d bogus page numbers: fixed %d items to keep them ordered.", nb_bogus, nb_fixed_pages)) + self.toc_depth = max_depth end function ReaderToc:getTocIndexByPage(pn_or_xp, skip_ignored_ticks) @@ -635,10 +647,11 @@ function ReaderToc:onShowToc() self:fillToc() -- build menu items if #self.toc > 0 and not self.toc[1].text then + local has_hidden_flows = self.ui.document:hasHiddenFlows() for _, v in ipairs(self.toc) do v.text = self.toc_indent:rep(v.depth-1)..self:cleanUpTocTitle(v.title, true) v.mandatory = v.page - if self.ui.document:hasHiddenFlows() then + if has_hidden_flows then local flow = self.ui.document:getPageFlow(v.page) if v.orig_page then -- bogus page fixed: show original page number -- This is an ugly piece of code, which can result in an ugly TOC, diff --git a/frontend/apps/reader/readerui.lua b/frontend/apps/reader/readerui.lua index fa82976f5..32659965f 100644 --- a/frontend/apps/reader/readerui.lua +++ b/frontend/apps/reader/readerui.lua @@ -46,6 +46,7 @@ local ReaderRolling = require("apps/reader/modules/readerrolling") local ReaderSearch = require("apps/reader/modules/readersearch") local ReaderStatus = require("apps/reader/modules/readerstatus") local ReaderStyleTweak = require("apps/reader/modules/readerstyletweak") +local ReaderThumbnail = require("apps/reader/modules/readerthumbnail") local ReaderToc = require("apps/reader/modules/readertoc") local ReaderTypeset = require("apps/reader/modules/readertypeset") local ReaderTypography = require("apps/reader/modules/readertypography") @@ -375,6 +376,11 @@ function ReaderUI:init() document = self.document, view = self.view, }) + -- thumbnails service (book map, page browser) + self:registerModule("thumbnail", ReaderThumbnail:new{ + ui = self, + document = self.document, + }) -- file searcher self:registerModule("filesearcher", FileManagerFileSearcher:new{ dialog = self.dialog, diff --git a/frontend/dispatcher.lua b/frontend/dispatcher.lua index 2d79b76da..a2c940303 100644 --- a/frontend/dispatcher.lua +++ b/frontend/dispatcher.lua @@ -118,6 +118,8 @@ local settingsList = { follow_nearest_internal_link = {category="arg", event="GoToInternalPageLink", arg={pos={x=0,y=0}}, title=_("Follow nearest internal link"), reader=true}, clear_location_history = {category="none", event="ClearLocationStack", arg=true, title=_("Clear location history"), reader=true, separator=true}, toc = {category="none", event="ShowToc", title=_("Table of contents"), reader=true}, + book_map = {category="none", event="ShowBookMap", title=_("Book map"), reader=true, condition=Device:isTouchDevice()}, + page_browser = {category="none", event="ShowPageBrowser", title=_("Page browser"), reader=true, condition=Device:isTouchDevice()}, bookmarks = {category="none", event="ShowBookmark", title=_("Bookmarks"), reader=true}, bookmark_search = {category="none", event="SearchBookmark", title=_("Bookmark search"), reader=true}, book_status = {category="none", event="ShowBookStatus", title=_("Book status"), reader=true}, @@ -281,6 +283,8 @@ local dispatcher_menu_order = { "clear_location_history", "toc", + "book_map", + "page_browser", "bookmarks", "bookmark_search", diff --git a/frontend/ui/elements/reader_menu_order.lua b/frontend/ui/elements/reader_menu_order.lua index 79249d59c..4d96d95a0 100644 --- a/frontend/ui/elements/reader_menu_order.lua +++ b/frontend/ui/elements/reader_menu_order.lua @@ -13,13 +13,16 @@ local order = { navi = { "table_of_contents", "bookmarks", - "toggle_bookmark", + "toggle_bookmark", -- if not Device:isTouchDevice() "bookmark_browsing_mode", "navi_settings", "----------------------------", "page_map", "hide_nonlinear_flows", "----------------------------", + "book_map", -- if Device:isTouchDevice() + "page_browser", -- if Device:isTouchDevice() + "----------------------------", "go_to", "skim_to", "autoturn", @@ -64,7 +67,7 @@ local order = { "network", "screen", "----------------------------", - "taps_and_gestures", + "taps_and_gestures", -- if Device:isTouchDevice() "navigation", "document", "----------------------------", diff --git a/frontend/ui/widget/bookmapwidget.lua b/frontend/ui/widget/bookmapwidget.lua new file mode 100644 index 000000000..10c761a59 --- /dev/null +++ b/frontend/ui/widget/bookmapwidget.lua @@ -0,0 +1,1417 @@ +local BD = require("ui/bidi") +local Blitbuffer = require("ffi/blitbuffer") +local CenterContainer = require("ui/widget/container/centercontainer") +local Device = require("device") +local Font = require("ui/font") +local FrameContainer = require("ui/widget/container/framecontainer") +local Geom = require("ui/geometry") +local GestureRange = require("ui/gesturerange") +local HorizontalGroup = require("ui/widget/horizontalgroup") +local HorizontalSpan = require("ui/widget/horizontalspan") +local InfoMessage = require("ui/widget/infomessage") +local InputContainer = require("ui/widget/container/inputcontainer") +local LeftContainer = require("ui/widget/container/leftcontainer") +local Menu = require("ui/widget/menu") +local OverlapGroup = require("ui/widget/overlapgroup") +local RenderText = require("ui/rendertext") +local ScrollableContainer = require("ui/widget/container/scrollablecontainer") +local Size = require("ui/size") +local TextBoxWidget = require("ui/widget/textboxwidget") +local TextWidget = require("ui/widget/textwidget") +local TitleBar = require("ui/widget/titlebar") +local UIManager = require("ui/uimanager") +local VerticalGroup = require("ui/widget/verticalgroup") +local VerticalSpan = require("ui/widget/verticalspan") +local Widget = require("ui/widget/widget") +local Input = Device.input +local Screen = Device.screen +local logger = require("logger") +local util = require("util") +local _ = require("gettext") + +-- BookMapRow (reused by PageBrowserWidget) +local BookMapRow = InputContainer:new{ + width = nil, + height = nil, + pages_frame_border = Size.border.default, + toc_span_border = Size.border.thin, + -- pages_frame_border = 10, -- for debugging positionning + -- toc_span_border = 5, -- for debugging positionning + toc_items = nil, -- Arrays[levels] of arrays[items at this level to show as spans] + -- Many other options not described here, see BookMapWidget:update() + -- for the complete list. + + _mirroredUI = BD.mirroredUILayout(), +} + +function BookMapRow:getPageX(page, right_edge) + if right_edge then + if (not self._mirroredUI and page == self.end_page) or + (self._mirroredUI and page == self.start_page) then + return self.pages_frame_inner_width + else + if self._mirroredUI then + return self:getPageX(page-1) + else + return self:getPageX(page+1) + end + end + end + local slot_idx + if self._mirroredUI then + slot_idx = self.end_page - page + else + slot_idx = page - self.start_page + end + local x = slot_idx * self.page_slot_width + x = x + math.floor(self.page_slot_extra * slot_idx / self.nb_page_slots) + return x +end + +function BookMapRow:getPageAtX(x) + x = x - self.pages_frame_offset_x + if x < 0 or x >= self.pages_frame_inner_width then + return + end + -- Reverse of the computation in :getPageX(): + local slot_idx = math.floor(x / (self.page_slot_width + self.page_slot_extra / self.nb_page_slots)) + if self._mirroredUI then + return self.end_page - slot_idx + else + return self.start_page + slot_idx + end +end + +-- Helper function to be used before instantiating a BookMapRow instance, +-- to obtain the left_spacing equivalent to not showing nb_pages at start +-- of a row of pages_per_row items in a width of row_width +function BookMapRow:getLeftSpacingForNumberOfPageSlots(nb_pages, pages_per_row, row_width) + -- Bits of the computation done in :init() + local pages_frame_inner_width = row_width - 2*self.pages_frame_border + local page_slot_width = math.floor(pages_frame_inner_width / pages_per_row) + local page_slot_extra = pages_frame_inner_width - page_slot_width * pages_per_row + -- Bits of the computation done in :getPageX() + local x = nb_pages * page_slot_width + x = x + math.floor(page_slot_extra * nb_pages / pages_per_row) + return x - self.pages_frame_border +end + +function BookMapRow:init() + self.dimen = Geom:new{ w = self.width, h = self.height } + + -- Keep one span_height under baseline (frame bottom border) for indicators (current page, bookmarks) + self.pages_frame_height = self.height - self.span_height + + self.pages_frame_offset_x = self.left_spacing + self.pages_frame_border + self.pages_frame_width = self.width - self.left_spacing + self.pages_frame_inner_width = self.pages_frame_width - 2*self.pages_frame_border + self.page_slot_width = math.floor(self.pages_frame_inner_width / self.pages_per_row) + self.page_slot_extra = self.pages_frame_inner_width - self.page_slot_width * self.pages_per_row -- will be distributed + + -- Update the widths if this row contains fewer pages + self.nb_page_slots = self.end_page - self.start_page + 1 + if self.nb_page_slots ~= self.pages_per_row then + self.page_slot_extra = math.floor(self.page_slot_extra * self.nb_page_slots / self.pages_per_row) + self.pages_frame_inner_width = self.page_slot_width * self.nb_page_slots + self.page_slot_extra + self.pages_frame_width = self.pages_frame_inner_width + 2*self.pages_frame_border + end + + if self._mirroredUI then + self.pages_frame_offset_x = self.width - self.pages_frame_width - self.left_spacing + self.pages_frame_border + end + + -- We draw a frame container with borders for the book content, with + -- some space on the left for the start page number, and some space + -- below the bottom border as spacing before the next row (spacing + -- that we can use to show current position and hanging bookmarks + -- and highlights symbols) + self.pages_frame = OverlapGroup:new{ + dimen = Geom:new{ + w = self.pages_frame_width, + h = self.pages_frame_height, + }, + allow_mirroring = false, -- we handle mirroring ourselves below + FrameContainer:new{ + overlap_align = self._mirroredUI and "right" or "left", + margin = 0, + padding = 0, + bordersize = self.pages_frame_border, + -- color = Blitbuffer.COLOR_GRAY, -- for debugging positionning + Widget:new{ -- empty widget to give dimensions around which to draw borders + dimen = Geom:new{ + w = self.pages_frame_inner_width, + h = self.pages_frame_height - 2*self.pages_frame_border, + } + } + }, + } + + -- We won't add this margin to the FrameContainer: to be able + -- to tweak it on some sides, we'll just tweak its overlap + -- offsets and width to ensure the margins + local tspan_margin = Size.margin.tiny + local tspan_padding_h = Size.padding.tiny + local tspan_height = self.span_height - 2 * (tspan_margin + self.toc_span_border) + if self.toc_items then + for lvl, items in pairs(self.toc_items) do + local offset_y = self.pages_frame_border + self.span_height * (lvl - 1) + tspan_margin + local prev_p_start, same_p_start_offset_dx + for __, item in ipairs(items) do + local text = item.title + local p_start, p_end = item.p_start, item.p_end + local started_before, continues_after = item.started_before, item.continues_after + if self._mirroredUI then + -- Just flip these (beware below, we need to use item.p_start to get + -- the real start page to account in prev_p_start) + p_start, p_end = p_end, p_start + started_before, continues_after = continues_after, started_before + end + local offset_x = self:getPageX(p_start) + local width = self:getPageX(p_end, true) - offset_x + offset_x = offset_x + self.pages_frame_border + if prev_p_start == item.p_start then + -- Multiple TOC items starting on the same page slot: + -- shift and shorten 2nd++ ones so we see a bit of + -- the previous overwritten span and we can know this + -- page slot contains multiple chapters + if width > same_p_start_offset_dx then + if not self._mirroredUI then + offset_x = offset_x + same_p_start_offset_dx + end + width = width - same_p_start_offset_dx + same_p_start_offset_dx = same_p_start_offset_dx + self.toc_span_border * 2 + end + else + prev_p_start = item.p_start + same_p_start_offset_dx = self.toc_span_border * 2 + end + if started_before then + -- No left margin, have span border overlap with outer border + offset_x = offset_x - self.toc_span_border + width = width + self.toc_span_border + else + -- Add some left margin + offset_x = offset_x + tspan_margin + width = width - tspan_margin + end + if continues_after then + -- No right margin, have span border overlap with outer border + width = width + self.toc_span_border + else + -- Add some right margin + width = width - tspan_margin + end + local text_max_width = width - 2 * (self.toc_span_border + tspan_padding_h) + local text_widget = nil + if text_max_width > 0 then + text_widget = TextWidget:new{ + text = BD.auto(text), + max_width = text_max_width, + face = self.font_face, + padding = 0, + } + if text_widget:getWidth() > text_max_width then + -- May happen with very small max_width when smaller + -- than the truncation ellipsis + text_widget:free() + text_widget = nil + end + end + local span_w = FrameContainer:new{ + overlap_offset = {offset_x, offset_y}, + margin = 0, + padding = 0, + bordersize = self.toc_span_border, + background = Blitbuffer.COLOR_WHITE, + CenterContainer:new{ + dimen = Geom:new{ + w = width - 2 * self.toc_span_border, + h = tspan_height, + }, + text_widget or VerticalSpan:new{ width = 0 }, + } + } + table.insert(self.pages_frame, span_w) + end + end + end + + -- For page numbers: + self.smaller_font_face = Font:getFace(self.font_face.orig_font, self.font_face.orig_size - 4) + -- For current page triangle + self.larger_font_face = Font:getFace(self.font_face.orig_font, self.font_face.orig_size + 6) + + self.hgroup = HorizontalGroup:new{ + align = "top", + } + + if self.left_spacing > 0 then + local spacing = Size.padding.small + table.insert(self.hgroup, TextBoxWidget:new{ + text = self.start_page_text, + width = self.left_spacing - spacing, + face = self.smaller_font_face, + line_height = 0, -- no additional line height + alignment = self._mirroredUI and "left" or "right", + alignment_strict = true, + }) + table.insert(self.hgroup, HorizontalSpan:new{ width = spacing }) + end + table.insert(self.hgroup, self.pages_frame) + + -- Get hidden flows rectangle ready to be painted gray first as background + self.background_fillers = {} + if self.hidden_flows then + for _, flow_edges in ipairs(self.hidden_flows) do + local f_start, f_end = flow_edges[1], flow_edges[2] + if f_start <= self.end_page and f_end >= self.start_page then + local r_start = math.max(f_start, self.start_page) + local r_end = math.min(f_end, self.end_page) + local x, w + if self._mirroredUI then + x = self:getPageX(r_end) + w = self:getPageX(r_start, true) - x + else + x = self:getPageX(r_start) + w = self:getPageX(r_end, true) - x + end + table.insert(self.background_fillers, { + x = x, y = 0, + w = w, h = self.pages_frame_height, + color = Blitbuffer.COLOR_LIGHT_GRAY, + }) + end + end + end + + self[1] = LeftContainer:new{ -- needed only for auto UI mirroring + dimen = Geom:new{ + w = self.dimen.w, + h = self.hgroup:getSize().h, + }, + self.hgroup, + } + + -- Get read pages markers and other indicators ready to be drawn + self.pages_markers = {} + self.indicators = {} + self.bottom_texts = {} + local prev_page_was_read = true -- avoid one at start of row + local unread_marker_h = math.ceil(self.span_height * 0.05) + local read_min_h = math.max(math.ceil(self.span_height * 0.1), unread_marker_h+Size.line.thick) + if self.page_slot_width >= 5 * unread_marker_h then + -- If page slots are large enough, we can make unread markers a bit taller (so they + -- are noticable and won't be confused with read page slots) + unread_marker_h = unread_marker_h * 2 + end + for page = self.start_page, self.end_page do + if self.read_pages and self.read_pages[page] then + local x = self:getPageX(page) + local w = self:getPageX(page, true) - x + local h = math.ceil(self.read_pages[page][1] * self.span_height * 0.8) + h = math.max(h, read_min_h) -- so it's noticable + local y = self.pages_frame_height - self.pages_frame_border - h + 1 + if self.with_page_sep then + -- We put the blank at the start of a page slot + x = x + 1 + w = w - 1 + if w > 2 then + if page == self.end_page and not self._mirroredUI then + w = w - 1 -- some spacing before right border (like we had at start) + end + if page == self.start_page and self._mirroredUI then + w = w - 1 + end + end + end + local color = Blitbuffer.COLOR_BLACK + if self.current_session_duration and self.read_pages[page][2] < self.current_session_duration then + color = Blitbuffer.COLOR_DIM_GRAY + end + table.insert(self.pages_markers, { + x = x, y = y, + w = w, h = h, + color = color, + }) + prev_page_was_read = true + else + if self.with_page_sep and not prev_page_was_read then + local w = Size.line.thin + local x + if self._mirroredUI then + x = self:getPageX(page, true) - w + else + x = self:getPageX(page) + end + local y = self.pages_frame_height - self.pages_frame_border - unread_marker_h + 1 + table.insert(self.pages_markers, { + x = x, y = y, + w = w, h = unread_marker_h, + color = Blitbuffer.COLOR_BLACK, + }) + end + prev_page_was_read = false + end + -- Indicator for bookmark/highlight type, and current page + if self.bookmarked_pages[page] then + local page_bookmark_types = self.bookmarked_pages[page] + local x = self:getPageX(page) + local w = self:getPageX(page, true) - x + x = x + math.ceil(w/2) + local y = self.pages_frame_height + 1 + -- These 3 icons overlap quite ok, so no need for any shift + if page_bookmark_types["highlight"] then + table.insert(self.indicators, { + x = x, y = y, + c = 0x2592, -- medium shade + }) + end + if page_bookmark_types["note"] then + table.insert(self.indicators, { + x = x, y = y, + c = 0xF040, -- pencil + rotation = -90, + shift_x_pct = 0.2, -- 20% looks a bit better + -- This glyph is a pencil pointing to the bottom left, + -- so we make it point to the top left and align it so + -- it points to the page slot it is associated with + }) + end + if page_bookmark_types["bookmark"] then + table.insert(self.indicators, { + x = x, y = y, + c = 0xF097, -- empty bookmark + }) + end + end + -- Indicator for previous locations + if self.previous_locations[page] and page ~= self.cur_page then + local x = self:getPageX(page) + local w = self:getPageX(page, true) - x + x = x + math.ceil(w/2) + local y = self.pages_frame_height + 1 + if self.bookmarked_pages[page] then + -- Shift it a bit down to keep bookmark glyph(s) readable + y = y + math.floor(self.span_height / 3) + end + local num = self.previous_locations[page] + table.insert(self.indicators, { + c = 0x2775 + (num < 10 and num or 10), -- number in solid black circle + -- c = 0x245F + (num < 20 and num or 20), -- number in white circle + x = x, y = y, + }) + end + -- Extra indicator + if self.extra_symbols_pages and self.extra_symbols_pages[page] then + local x = self:getPageX(page) + local w = self:getPageX(page, true) - x + x = x + math.ceil(w/2) + local y = self.pages_frame_height + 1 + if self.bookmarked_pages[page] then + -- Shift it a bit down to keep bookmark glyph(s) readable + y = y + math.floor(self.span_height / 3) + end + table.insert(self.indicators, { + c = self.extra_symbols_pages[page], + x = x, y = y, + }) + end + -- Current page indicator + if page == self.cur_page then + local x = self:getPageX(page) + local w = self:getPageX(page, true) - x + x = x + math.ceil(w/2) + local y = self.pages_frame_height + 1 + if self.bookmarked_pages[page] then + -- Shift it a bit down to keep bookmark glyph(s) readable + y = y + math.floor(self.span_height / 3) + end + table.insert(self.indicators, { + c = 0x25B2, -- black up-pointing triangle + x = x, y = y, + face = self.larger_font_face, + }) + end + if self.page_texts and self.page_texts[page] then + -- These have been put on pages free from any other indicator, so + -- we can show the page number at the very bottom + local x = self:getPageX(page) + local w = self:getPageX(page, true) - x - Size.padding.tiny + table.insert(self.bottom_texts, { + text = self.page_texts[page].text, + x = x, + slot_width = w, + block = self.page_texts[page].block, + block_dx = self.page_texts[page].block_dx, + }) + end + end +end + +function BookMapRow:paintTo(bb, x, y) + -- Paint background fillers (which are not subwidgets) first + for _, filler in ipairs(self.background_fillers) do + bb:paintRect(x + self.pages_frame_offset_x + filler.x, y + filler.y, filler.w, filler.h, filler.color) + end + -- Paint regular sub widgets the classic way + InputContainer.paintTo(self, bb, x, y) + -- And explicitely paint read pages markers (which are not subwidgets) + for _, marker in ipairs(self.pages_markers) do + bb:paintRect(x + self.pages_frame_offset_x + marker.x, y + marker.y, marker.w, marker.h, marker.color) + end + -- And explicitely paint indicators (which are not subwidgets) + for _, indicator in ipairs(self.indicators) do + local glyph = RenderText:getGlyph(indicator.face or self.font_face, indicator.c) + local alt_bb + if indicator.rotation then + alt_bb = glyph.bb:rotatedCopy(indicator.rotation) + end + -- Glyph's bb fit the blackbox of the glyph, so there's no cropping + -- or complicated positionning to do + -- By default, just center the glyph at x + local d_x_pct = indicator.shift_x_pct or 0.5 + local d_x = math.floor(glyph.bb:getWidth() * d_x_pct) + bb:colorblitFrom( + alt_bb or glyph.bb, + x + self.pages_frame_offset_x + indicator.x - d_x, + y + indicator.y, + 0, 0, + glyph.bb:getWidth(), glyph.bb:getHeight(), + Blitbuffer.COLOR_BLACK) + if alt_bb then + alt_bb:free() + end + end + -- And explicitely paint bottom texts (which are not subwidgets) + for _, btext in ipairs(self.bottom_texts) do + local text_w = TextWidget:new{ + text = btext.text, + face = self.smaller_font_face, + padding = 0, + } + local d_y = self.height - math.ceil(text_w:getSize().h) + local d_x + local text_width = text_w:getWidth() + local d_width = btext.slot_width - text_width + if not btext.block then + -- no block constraint: can be centered + d_x = math.ceil(d_width / 2) + else + if d_width >= 2 * btext.block_dx then + -- small enough: can be centered + d_x = math.ceil(d_width / 2) + elseif btext.block == "left" then + d_x = btext.block_dx + else -- "right" + d_x = d_width - btext.block_dx + end + end + text_w:paintTo(bb, x + self.pages_frame_offset_x + btext.x + d_x, y + d_y) + text_w:free() + end +end + +-- BookMapWidget: shows a map of content, including TOC, boomarks, read pages, non-linear flows... +local BookMapWidget = InputContainer:new{ + title = _("Book map"), + -- Focus page: show the BookMapRow containing this page + -- in the middle of screen + focus_page = nil, + -- Should only be nil on the first launch via ReaderThumbnail + launcher = nil, + -- Extra symbols to show below pages + extra_symbols_pages = nil, + + _mirroredUI = BD.mirroredUILayout(), + + -- Make this local subwidget available for reuse by PageBrowser + BookMapRow = BookMapRow, +} + +function BookMapWidget:init() + -- Compute non-settings-dependant sizes and options + self.dimen = Geom:new{ + w = Screen:getWidth(), + h = Screen:getHeight(), + } + self.covers_fullscreen = true -- hint for UIManager:_repaint() + + if Device:hasKeys() then + self.key_events = { + Close = { {"Back"}, doc = "close page" }, + ScrollRowUp = {{"Up"}, doc = "scroll up"}, + ScrollRowDown = {{"Down"}, doc = "scrol down"}, + ScrollPageUp = {{Input.group.PgBack}, doc = "prev page"}, + ScrollPageDown = {{Input.group.PgFwd}, doc = "next page"}, + } + end + if Device:isTouchDevice() then + self.ges_events.Swipe = { + GestureRange:new{ + ges = "swipe", + range = self.dimen, + } + } + self.ges_events.MultiSwipe = { + GestureRange:new{ + ges = "multiswipe", + range = self.dimen, + } + } + self.ges_events.Tap = { + GestureRange:new{ + ges = "tap", + range = self.dimen, + } + } + self.ges_events.Pinch = { + GestureRange:new{ + ges = "pinch", + range = self.dimen, + } + } + self.ges_events.Spread = { + GestureRange:new{ + ges = "spread", + range = self.dimen, + } + } + -- No need for any long-press handler: page slots may be small and we can't + -- really target a precise page slot with our fat finger above it... + -- Tap will zoom the zone in a PageBrowserWidget where things will be clearer + -- and allow us to get where we want. + -- (Also, handling "hold" is a bit more complicated when we have our + -- ScrollableContainer that would also like to handle it.) + end + + -- No real need for any explicite edge and inter-row padding: + -- we use the scrollbar width on both sides for balance (we may put a start + -- page number on the left space), and each BookMapRow will have itself some + -- blank space at bottom below page slots (where we may put hanging markers + -- for current page and bookmark/highlights) + self.scrollbar_width = ScrollableContainer:getScrollbarWidth() + self.row_width = self.dimen.w - self.scrollbar_width + self.row_left_spacing = self.scrollbar_width + self.swipe_hint_bar_width = Screen:scaleBySize(6) + + self.title_bar = TitleBar:new{ + fullscreen = true, + title = self.title, + left_icon = "notice-info", + left_icon_tap_callback = function() self:showHelp() end, + close_callback = function() self:onClose() end, + hold_close_callback = function() self:onClose(true) end, + show_parent = self, + } + self.title_bar_h = self.title_bar:getHeight() + self.crop_height = self.dimen.h - self.title_bar_h - Size.margin.small - self.swipe_hint_bar_width + + -- Guess grid TOC span height from its font size + -- (it feels this font size does not need to be configurable: too large and + -- titles will be too easily truncated, too small and they will be unreadable) + self.toc_span_font_name = "infofont" + self.toc_span_font_size = 14 + self.toc_span_face = Font:getFace(self.toc_span_font_name, self.toc_span_font_size) + local test_w = TextWidget:new{ + text = "z", + face = self.toc_span_face, + } + self.span_height = test_w:getSize().h + BookMapRow.toc_span_border + test_w:free() + + -- Reference font size for flat TOC items, as set (or default) in ReaderToc + self.reader_toc_font_size = G_reader_settings:readSetting("toc_items_font_size") + or Menu.getItemFontSize(G_reader_settings:readSetting("toc_items_per_page") or self.ui.toc.toc_items_per_page_default) + + -- Our container of stacked BookMapRows (and TOC titles in flat map mode) + self.vgroup = VerticalGroup:new{ + align = "left", + } + -- We'll handle all events in this main BookMapWidget: none of the vgroup + -- children have any handler. Hack into vgroup so it doesn't propagate + -- events needlessly to its children (the slowness gets noticable when + -- we have many TOC items in flat map mode - the also needless :paintTo() + -- don't seen to cause such a noticable slowness) + self.vgroup.propagateEvent = function() return false end + + -- Our scrollable container needs to be known as widget.cropping_widget in + -- the widget that is passed to UIManager:show() for UIManager to ensure + -- proper interception of inner widget self repainting/invert (mostly used + -- when flashing for UI feedback that we want to limit to the cropped area). + self.cropping_widget = ScrollableContainer:new{ + dimen = Geom:new{ + w = self.dimen.w, + h = self.crop_height, + }, + show_parent = self, + ignore_events = {"swipe"}, + self.vgroup, + } + + self[1] = FrameContainer:new{ + width = self.dimen.w, + height = self.dimen.h, + padding = 0, + margin = 0, + bordersize = 0, + background = Blitbuffer.COLOR_WHITE, + VerticalGroup:new{ + align = "center", + self.title_bar, + self.cropping_widget, + } + } + + -- Note: some of these could be cached in ReaderThumbnail, and discarded/updated + -- on some events (ie. TocUpdated, PageUpdate, AddHhighlight...) + -- Get some info that shouldn't change across calls to update() + self.nb_pages = self.ui.document:getPageCount() + self.ui.toc:fillToc() + self.cur_page = self.ui.toc.pageno + self.max_toc_depth = self.ui.toc.toc_depth + -- Get bookmarks and highlights from ReaderBookmark + self.bookmarked_pages = self.ui.bookmark:getBookmarkedPages() + -- Get read page from the statistics plugin if enabled + self.statistics_enabled = self.ui.statistics and self.ui.statistics:isEnabled() + self.read_pages = self.ui.statistics and self.ui.statistics:getCurrentBookReadPages() + self.current_session_duration = self.ui.statistics and (os.time() - self.ui.statistics.start_current_period) + -- Hidden flows, for first page display, and to draw them gray + self.has_hidden_flows = self.ui.document:hasHiddenFlows() + if self.has_hidden_flows and #self.ui.document.flows > 0 then + self.hidden_flows = {} + -- Pick into credocument internal data to build a table + -- of {first_page_number, last_page_number) for each flow + for flow, tab in ipairs(self.ui.document.flows) do + table.insert(self.hidden_flows, { tab[1], tab[1]+tab[2]-1 }) + end + end + -- Reference page numbers, for first row page display + self.page_labels = nil + if self.ui.pagemap and self.ui.pagemap:wantsPageLabels() then + self.page_labels = self.ui.document:getPageMap() + end + -- Location stack + self.previous_locations = self.ui.link:getPreviousLocationPages() + + -- Compute settings-dependant sizes and options, and build the inner widgets + self:update() +end + +function BookMapWidget:update() + if not self.focus_page then -- Initial display + -- Focus (show at the middle of screen) on the BookMapRow that contains + -- current page + self.focus_page = self.cur_page + else + -- We have a previous focus page: if we have not scrolled around, keep + -- focusing on this one. Otherwise, use the start_page of the BookMapRow + -- at the middle of screen as the new focus page. + if self.initial_scroll_offset_y ~= self.cropping_widget._scroll_offset_y then + local h = math.min(self.vgroup:getSize().h, self.crop_height) + local row = self:getBookMapRowNearY(h/2) + if row then + self.focus_page = row.start_page + end + end + end + + -- Reset main widgets + self.vgroup:clear() + self.cropping_widget:reset() + + -- Flat book map has each TOC item on a new line, and pages graph underneath. + -- Non-flat book map shows a grid with TOC items following each others. + self.flat_map = self.ui.doc_settings:readSetting("book_map_flat", false) + self.toc_depth = self.ui.doc_settings:readSetting("book_map_toc_depth", self.max_toc_depth) + if self.flat_map then + self.nb_toc_spans = 0 -- no span shown in grid + else + self.nb_toc_spans = self.toc_depth + end + + self.flat_toc_depth_faces = nil + if self.flat_map then + self.flat_toc_depth_faces = {} + -- Use ReaderToc setting font size for items at the lowest depth + self.flat_toc_depth_faces[self.toc_depth] = Font:getFace(self.toc_span_font_name, self.reader_toc_font_size) + for lvl=self.toc_depth-1, 1, -1 do + -- But increase font size for each upper level + local inc = 2 * (self.toc_depth - lvl) + self.flat_toc_depth_faces[lvl] = Font:getFace(self.toc_span_font_name, self.reader_toc_font_size + inc) + end + -- Use 1.5em with the reference font size for indenting chapters and their BookMapRow + self.flat_toc_level_indent = Screen:scaleBySize(self.reader_toc_font_size * 1.5) + end + + -- Row will contain: nb_toc_spans + page slots + spacing (+ some borders) + local page_slots_height_ratio = 1 -- default to 1 * span_height + if not self.statistics_enabled then + -- If statistics are disabled, we won't show black page slots for read pages. + -- We can gain a bit of height by reducing the height reserved for these + -- (don't go too low: we need some height to show the page number on the left). + if self.flat_map or self.nb_toc_spans == 0 then + -- Enough to show 4 digits page numbers + page_slots_height_ratio = 0.7 + elseif self.nb_toc_spans > 0 then + -- Just enough to show page separators below toc spans + page_slots_height_ratio = 0.2 + end + end + self.row_height = math.ceil((self.nb_toc_spans + page_slots_height_ratio + 1) * self.span_height + 2*BookMapRow.pages_frame_border) + + if self.flat_map then + -- Max pages per row, when each page slots takes 1px + self.max_pages_per_row = math.floor(self.row_width - self.row_left_spacing - 2*Size.border.default + - Size.span.horizontal_default*self.toc_depth) + -- Find out the length of the largest chapter that we may show + local len + local max_len = 0 + local p = 1 + for _, item in ipairs(self.ui.toc.toc) do + if item.depth <= self.toc_depth then + len = item.page - p + 1 + if len > max_len then max_len = len end + p = item.page + end + end + len = self.nb_pages - p + 1 -- last chapter + if len > max_len then max_len = len end + self.fit_pages_per_row = max_len + else + -- Max pages per row, when each page slots takes 1px + self.max_pages_per_row = math.floor(self.row_width - self.row_left_spacing - 2*Size.border.default) + -- What can fit without scrollbar + local fit_nb_rows = math.floor(self.crop_height / self.row_height) + self.fit_pages_per_row = math.ceil(self.nb_pages / fit_nb_rows) + end + self.min_pages_per_row = 10 + -- If page slots are at least 4 pixels wide, we can steal one to act as a 1px blank separator + self.max_pages_per_row_with_sep = math.floor(self.max_pages_per_row / 4) + if self.fit_pages_per_row < self.min_pages_per_row then + self.fit_pages_per_row = self.min_pages_per_row + end + if self.fit_pages_per_row > self.max_pages_per_row then + self.fit_pages_per_row = self.max_pages_per_row + end + + -- Show the whole book without scrollbar initially + self.pages_per_row = self.ui.doc_settings:readSetting("book_map_pages_per_row", self.fit_pages_per_row) + self.page_slot_width = nil -- will be fetched from the first BookMapRow + + -- Build BookMapRows as we walk the ToC + local toc = self.ui.toc.toc + local toc_idx = 1 + local cur_toc_items = {} + local p_start = 1 + local cur_left_spacing = self.row_left_spacing -- updated when flat_map with previous TOC item indentation + local cur_page_label_idx = 1 + while true do + local p_max = p_start + self.pages_per_row - 1 -- max page number in this row + local p_end = math.min(p_max, self.nb_pages) -- last book page in this row + -- Find out the toc items that can be shown on this row + local row_toc_items = {} + while toc_idx <= #toc do + local item = toc[toc_idx] + if item.page > p_max then + -- This TOC item will close previous items and start on the next row + break + end + if item.depth <= self.toc_depth then -- ignore lower levels we won't show + if self.flat_map then + if item.page == p_start then + cur_left_spacing = self.row_left_spacing + self.flat_toc_level_indent * (item.depth-1) + local txt_max_width = self.row_width - cur_left_spacing + table.insert(self.vgroup, HorizontalGroup:new{ + HorizontalSpan:new{ + width = cur_left_spacing, + }, + TextBoxWidget:new{ + text = self.ui.toc:cleanUpTocTitle(item.title, true), + width = txt_max_width, + face = self.flat_toc_depth_faces[item.depth], + } + }) + -- Add a bit more spacing for the BookMapRow(s) underneath this Toc item title + -- (so the page number painted in this spacing feels included in the indentation) + cur_left_spacing = cur_left_spacing + Size.span.horizontal_default + -- Note: this variable indentation may make the page slot widths variable across + -- rows from different levels (and self.fit_pages_per_row not really accurate) :/ + -- Hopefully, it won't be noticable. + else + p_max = item.page - 1 + p_end = p_max + -- Will be reprocessed on a new row + break + end + else + -- An item at level N closes all previous items at level >= N + for lvl = item.depth, self.toc_depth do + local done_toc_item = cur_toc_items[lvl] + cur_toc_items[lvl] = nil + if done_toc_item then + done_toc_item.p_end = math.max(item.page - 1, done_toc_item.p_start) + if done_toc_item.p_end >= p_start then + -- Can go into row_toc_items[lvl] + if done_toc_item.p_start < p_start then + done_toc_item.p_start = p_start + done_toc_item.started_before = true -- no left margin + end + if not row_toc_items[lvl] then + row_toc_items[lvl] = {} + end + -- We're done with it, we can just move it + table.insert(row_toc_items[lvl], done_toc_item) + end + end + end + cur_toc_items[item.depth] = { + title = item.title, + p_start = item.page, + p_end = nil, + } + end + end + toc_idx = toc_idx + 1 + end + local is_last_row = p_end == self.nb_pages + -- We may have current toc_items that are active and may continue on next row + -- Add a slightly adjusted copy of the current ones to row_toc_items + for lvl = 1, self.nb_toc_spans do -- (no-op/no-loop if flat_map) + local active_toc_item = cur_toc_items[lvl] + if active_toc_item then + local copied_toc_item = {} + for k,v in next, active_toc_item, nil do copied_toc_item[k] = v end + if copied_toc_item.p_start < p_start then + copied_toc_item.p_start = p_start + copied_toc_item.started_before = true -- no left margin + end + copied_toc_item.p_end = p_end + copied_toc_item.continues_after = not is_last_row -- no right margin (except if last row) + -- Look at next TOC item to see if it would close this one + local coming_up_toc_item = toc[toc_idx] + if coming_up_toc_item and coming_up_toc_item.page == p_max+1 and coming_up_toc_item.depth <= lvl then + copied_toc_item.continues_after = false -- right margin + end + if not row_toc_items[lvl] then + row_toc_items[lvl] = {} + end + table.insert(row_toc_items[lvl], copied_toc_item) + end + end + + -- Get the page number to display at start of row + local start_page_text + if self.page_labels then + local label + for idx=cur_page_label_idx, #self.page_labels do + local item = self.page_labels[idx] + if item.page > p_start then + break + end + label = item.label + cur_page_label_idx = idx + end + if label then + start_page_text = self.ui.pagemap:cleanPageLabel(label) + end + elseif self.has_hidden_flows then + local flow = self.ui.document:getPageFlow(p_start) + if flow == 0 then + start_page_text = tostring(self.ui.document:getPageNumberInFlow(p_start)) + else + -- start_page_text = string.format("[%d]%d", self.ui.document:getPageNumberInFlow(p_start), self.ui.document:getPageFlow(p_start)) + -- start_page_text = string.format("/%d\\", self.ui.document:getPageFlow(p_start)) + -- Just don't display anything + start_page_text = nil + end + else + start_page_text = tostring(p_start) + end + if start_page_text then + start_page_text = table.concat(util.splitToChars(start_page_text), "\n") + else + start_page_text = "" + end + + local row = BookMapRow:new{ + height = self.row_height, + width = self.row_width, + show_parent = self, + left_spacing = cur_left_spacing, + nb_toc_spans = self.nb_toc_spans, + span_height = self.span_height, + font_face = self.toc_span_face, + start_page_text = start_page_text, + start_page = p_start, + end_page = p_end, + pages_per_row = self.pages_per_row, + cur_page = self.cur_page, + with_page_sep = self.pages_per_row < self.max_pages_per_row_with_sep, + toc_items = row_toc_items, + bookmarked_pages = self.bookmarked_pages, + previous_locations = self.previous_locations, + extra_symbols_pages = self.extra_symbols_pages, + hidden_flows = self.hidden_flows, + read_pages = self.read_pages, + current_session_duration = self.current_session_duration, + } + table.insert(self.vgroup, row) + if not self.page_slot_width then + self.page_slot_width = row.page_slot_width + end + if is_last_row then + break + end + p_start = p_max + 1 + end + + -- Have main VerticalGroup size and subwidgets' offsets computed + self.vgroup:getSize() + + -- Scroll so we get the focus page at the middle of screen + local row, row_idx, row_y, row_h = self:getMatchingVGroupRow(function(r, r_y, r_h) -- luacheck: no unused + return r.start_page and self.focus_page >= r.start_page and self.focus_page <= r.end_page + end) + if row_y then + local top_y = row_y + row_h/2 - self.crop_height/2 + -- Align it so that we don't see any truncated BookMapRow at top + row, row_idx, row_y, row_h = self:getMatchingVGroupRow(function(r, r_y, r_h) + return r_y < top_y and r_y + r_h > top_y + end) + if row then + if top_y - row_y > row_y + row_h - top_y then + -- Less adjustment if we scroll to align the next row + top_y = row_y + row_h + else + top_y = row_y + end + end + if top_y > 0 then + self.cropping_widget:initState() -- anticipate this (otherwise delayed and done at :paintTo() time) + if self.cropping_widget._is_scrollable then + self.cropping_widget:_scrollBy(0, top_y) + end + end + end + self.initial_scroll_offset_y = self.cropping_widget._scroll_offset_y + + UIManager:setDirty(self, function() + return "ui", self.dimen + end) +end + + +function BookMapWidget:showHelp() + UIManager:show(InfoMessage:new{ + text = [[ +Book map displays an overview of the book content. + +If statistics are enabled, black bars are shown for already read pages (gray for pages read in the current reading session). Their heights vary with the time spent reading the page. +Chapters are shown above their pages. +Under the pages can be found some indicators: +▲ current page +❶ ❷ … previous locations +▒ highlighted text + highlighted text with notes + bookmarked page +▢ focused page when coming from Pages browser + +Tap on a location in the book to browse thumbnails of the pages there. +Swipe along the left screen edge to change the level of chapters to include in the book map, and the type of book map (grid or flat) when crossing the level 0. +Swipe along the bottom screen edge to change the width of page slots. +Swipe or pan vertically on content to scroll. +Any multiswipe will close the book map. + +On a newly opened book, the book map will start in grid mode showing all chapter levels, fitting on a single screen, to give the best initial overview of the book's content.]], + }) +end + +function BookMapWidget:onClose(close_all_parents) + -- Close this widget + logger.dbg("closing BookMapWidget") + UIManager:close(self) + if self.launcher then + -- We were launched by a PageBrowserWidget, don't do any cleanup. + if close_all_parents then + -- The last one of these (which has no launcher attribute) + -- will do the cleanup below. + self.launcher:onClose(true) + end + else + -- Remove all thumbnails generated for a different target size than + -- the last one used (no need to keep old sizes if the user played + -- with nb_cols/nb_rows, as on next opening, we just need the ones + -- with the current size to be available) + self.ui.thumbnail:tidyCache() + -- Force a GC to free the memory used by the widgets and tiles + -- (delay it a bit so this pause is less noticable) + UIManager:scheduleIn(0.5, function() + collectgarbage() + collectgarbage() + end) + -- As we're getting back to Reader, do a full flashing refresh to remove + -- any ghost trace of thumbnails or black page slots + UIManager:setDirty(self.ui.dialog, "full") + end + return true +end + +function BookMapWidget:getMatchingVGroupRow(check_func) + -- Generic Vertical subwidget search function. + -- We use some of VerticalGroup's internal data, no need + -- to keep public copies of these data in here + for i=1, #self.vgroup do + local row = self.vgroup[i] + local y = self.vgroup._offsets[i].y + local h = (i < #self.vgroup and self.vgroup._offsets[i+1].y or self.vgroup._size.h) - y + if check_func(row, y, h) then + return row, i, y, h + end + end +end + +function BookMapWidget:getVGroupRowAtY(y) + -- y is expected relative to the ScrollableContainer crop top + -- (if y is from a screen coordinate, substract 'self.title_bar_h' before calling this) + y = y + self.cropping_widget._scroll_offset_y + return self:getMatchingVGroupRow(function(r, r_y, r_h) + return y >= r_y and y < r_y + r_h + end) +end + +function BookMapWidget:getBookMapRowNearY(y) + -- y is expected relative to the ScrollableContainer crop top + -- (if y is from a screen coordinate, substract 'self.title_bar_h' before calling this) + y = y + self.cropping_widget._scroll_offset_y + -- Return the BookMapRow at y, or if the vgroup element is a ToC + -- title (in flat_map mode), return the follow up BookMapRow + return self:getMatchingVGroupRow(function(r, r_y, r_h) + return y < r_y + r_h and r.start_page + end) +end + +function BookMapWidget:onScrollPageUp() + -- Show previous content, ensuring any truncated widget at top is now full at bottom + local scroll_offset_y = self.cropping_widget._scroll_offset_y + local row, row_idx, row_y, row_h = self:getVGroupRowAtY(-1) -- luacheck: no unused + local to_keep = 0 + if row then + to_keep = row_h - (scroll_offset_y - row_y) + end + self.cropping_widget:_scrollBy(0, -(self.crop_height - to_keep)) + return true +end + +function BookMapWidget:onScrollPageDown() + -- Show next content, ensuring any truncated widget at bottom is now full at top + local scroll_offset_y = self.cropping_widget._scroll_offset_y + local row, row_idx, row_y, row_h = self:getVGroupRowAtY(self.crop_height) -- luacheck: no unused + if row then + self.cropping_widget:_scrollBy(0, row_y - scroll_offset_y) + else + self.cropping_widget:_scrollBy(0, self.crop_height) + end + return true +end + +function BookMapWidget:onScrollRowUp() + local scroll_offset_y = self.cropping_widget._scroll_offset_y + local row, row_idx, row_y, row_h = self:getVGroupRowAtY(-1) -- luacheck: no unused + if row then + self.cropping_widget:_scrollBy(0, row_y - scroll_offset_y) + end + return true +end + +function BookMapWidget:onScrollRowDown() + local scroll_offset_y = self.cropping_widget._scroll_offset_y + local row, row_idx, row_y, row_h = self:getVGroupRowAtY(0) -- luacheck: no unused + if row then + self.cropping_widget:_scrollBy(0, row_y + row_h - scroll_offset_y) + end + return true +end + +function BookMapWidget:saveSettings(reset) + if reset then + self.flat_map = nil + self.toc_depth = nil + self.pages_per_row = nil + end + self.ui.doc_settings:saveSetting("book_map_flat", self.flat_map) + self.ui.doc_settings:saveSetting("book_map_toc_depth", self.toc_depth) + self.ui.doc_settings:saveSetting("book_map_pages_per_row", self.pages_per_row) +end + +function BookMapWidget:updateTocDepth(depth, flat) + -- if flat == nil, consider value relative, and allow toggling + -- flatness when crossing 0 + local new_toc_depth = self.toc_depth + local new_flat_map = self.flat_map + if flat == nil then + if self.flat_map then + -- Reverse increment if flat_map + new_toc_depth = new_toc_depth - depth + else + new_toc_depth = new_toc_depth + depth + end + if new_toc_depth < 0 then + new_toc_depth = - new_toc_depth + new_flat_map = not new_flat_map + end + else + new_toc_depth = depth + new_flat_map = flat + end + if new_toc_depth < 0 then + new_toc_depth = 0 + end + if new_toc_depth > self.max_toc_depth then + new_toc_depth = self.max_toc_depth + end + if new_toc_depth == self.toc_depth and new_flat_map == self.flat_map then + return false + end + self.toc_depth = new_toc_depth + self.flat_map = new_flat_map + self:saveSettings() + return true +end + +function BookMapWidget:updatePagesPerRow(value, relative) + local new_pages_per_row + if relative then + new_pages_per_row = self.pages_per_row + value + else + new_pages_per_row = value + end + if new_pages_per_row < self.min_pages_per_row then + new_pages_per_row = self.min_pages_per_row + end + if new_pages_per_row > self.max_pages_per_row then + new_pages_per_row = self.max_pages_per_row + end + if new_pages_per_row == self.pages_per_row then + return false + end + self.pages_per_row = new_pages_per_row + self:saveSettings() + return true +end + +function BookMapWidget:onSwipe(arg, ges) + local direction = BD.flipDirectionIfMirroredUILayout(ges.direction) + if (not self._mirroredUI and ges.pos.x < Screen:getWidth() * 1/8) or + (self._mirroredUI and ges.pos.x > Screen:getWidth() * 7/8) then + -- Swipe along the left screen edge: increase/decrease toc levels shown + if direction == "north" or direction == "south" then + local rel = direction == "south" and 1 or -1 + if self:updateTocDepth(rel, nil) then + self:update() + end + return true + end + end + if ges.pos.y > Screen:getHeight() * 7/8 then + -- Swipe along the bottom screen edge: increase/decrease pages per row + if direction == "west" or direction == "east" then + -- Have a swipe distance 0.8 x screen width do *2 or *1/2 + local ratio = ges.distance / Screen:getWidth() + local new_pages_per_row + if direction == "west" then -- increase pages per row + new_pages_per_row = math.ceil(self.pages_per_row * (1 + ratio)) + else + new_pages_per_row = math.floor(self.pages_per_row / (1 + ratio)) + end + -- If we are crossing the ideal fit_pages_per_row, stop on it + if (self.pages_per_row < self.fit_pages_per_row and new_pages_per_row > self.fit_pages_per_row) + or (self.pages_per_row > self.fit_pages_per_row and new_pages_per_row < self.fit_pages_per_row) then + new_pages_per_row = self.fit_pages_per_row + end + if self:updatePagesPerRow(new_pages_per_row) then + self:update() + end + return true + end + end + -- Let our MovableContainer handle other swipes: + -- return self.cropping_widget:onScrollableSwipe(arg, ges) + -- No, we prefer not to, and have swipe north/south do full prev/next page + -- rather than based on the swipe distance + if direction == "north" then + return self:onScrollPageDown() + elseif direction == "south" then + return self:onScrollPageUp() + elseif direction == "west" or direction == "east" then + return true + else -- diagonal swipe + -- trigger full refresh + UIManager:setDirty(nil, "full") + -- a long diagonal swipe may also be used for taking a screenshot, + -- so let it propagate + return false + end +end + +function BookMapWidget:onPinch(arg, ges) + local updated = false + if ges.direction == "horizontal" or ges.direction == "diagonal" then + local new_pages_per_row = math.ceil(self.pages_per_row * 1.5) + if (self.pages_per_row < self.fit_pages_per_row and new_pages_per_row > self.fit_pages_per_row) + or (self.pages_per_row > self.fit_pages_per_row and new_pages_per_row < self.fit_pages_per_row) then + new_pages_per_row = self.fit_pages_per_row + end + if self:updatePagesPerRow(new_pages_per_row) then + updated = true + end + end + if ges.direction == "vertical" or ges.direction == "diagonal" then + -- Keep current flat map mode + if self:updateTocDepth(self.toc_depth-1, self.flat_map) then + updated = true + else + -- Already at 0: toggle flat mode, stay at 0 (no visual feedback though...) + if self:updateTocDepth(0, not self.flat_map) then + updated = true + end + end + end + if updated then + self:update() + end + return true +end + +function BookMapWidget:onSpread(arg, ges) + local updated = false + if ges.direction == "horizontal" or ges.direction == "diagonal" then + local new_pages_per_row = math.floor(self.pages_per_row / 1.5) + if (self.pages_per_row < self.fit_pages_per_row and new_pages_per_row > self.fit_pages_per_row) + or (self.pages_per_row > self.fit_pages_per_row and new_pages_per_row < self.fit_pages_per_row) then + new_pages_per_row = self.fit_pages_per_row + end + if self:updatePagesPerRow(new_pages_per_row) then + updated = true + end + end + if ges.direction == "vertical" or ges.direction == "diagonal" then + if self:updateTocDepth(self.toc_depth+1, self.flat_map) then + updated = true + end + end + if updated then + self:update() + end + return true +end + +function BookMapWidget:onMultiSwipe(arg, ges) + -- Swipe south (the usual shortcut for closing a full screen window) + -- is used for navigation. Swipe left/right are free, but a little + -- unusual for the purpose of closing. + -- So, allow for quick closing with any multiswipe. + self:onClose() + return true +end + +function BookMapWidget:onTap(arg, ges) + if ges.pos:notIntersectWith(self.cropping_widget.dimen) then + return true + end + local x, y = ges.pos.x, ges.pos.y + local row, row_idx, row_y, row_h = self:getVGroupRowAtY(y-self.title_bar_h) -- luacheck: no unused + if not row or not row.start_page then + -- not a BookMapRow, probably a TOC title + return true + end + if self._mirroredUI then + x = x - self.scrollbar_width + end + local page = row:getPageAtX(x) + if page then + local PageBrowserWidget = require("ui/widget/pagebrowserwidget") + UIManager:show(PageBrowserWidget:new{ + launcher = self, + ui = self.ui, + focus_page = page, + }) + end + return true +end + +function BookMapWidget:paintTo(bb, x, y) + -- Paint regular sub widgets the classic way + InputContainer.paintTo(self, bb, x, y) + -- And explicitely paint "swipe" hints along the left and bottom borders + self:paintLeftVerticalSwipeHint(bb, x, y) + self:paintBottomHorizontalSwipeHint(bb, x, y) +end + +function BookMapWidget:paintLeftVerticalSwipeHint(bb, x, y) + -- Vertical bar with a part of it darker, as a scale showing + -- selected flat_map and toc_depth. In gray so it's visible + -- when you look at it, but not distracting when you don't. + local v = self.vs_hint_info + if not v then + -- Compute and remember sizes, positions and info + v = {} + v.width = self.swipe_hint_bar_width + if self._mirroredUI then + v.left = Screen:getWidth() - v.width + else + v.left = 0 + end + v.top = self.title_bar_h + math.floor(self.crop_height * 1/6) + v.height = math.floor(self.crop_height * 4/6) + v.nb_units = self.max_toc_depth * 2 + 1 + v.unit_h = math.floor(v.height / v.nb_units) + self.vs_hint_info = v + end + -- Paint a vertical light gray bar + bb:paintRect(v.left, v.top, v.width, v.height, Blitbuffer.COLOR_LIGHT_GRAY) + -- And paint a part of it in a darker gray + local unit_idx -- starts from 0 + if self.flat_map then -- upper part of the vertical bar + unit_idx = self.max_toc_depth - self.toc_depth + else -- lower part of the vertical bar + unit_idx = self.max_toc_depth + self.toc_depth + end + local dy = unit_idx * v.unit_h + if unit_idx == v.nb_units - 1 then + -- avoid possible rounding error for last unit + dy = v.height - v.unit_h + end + bb:paintRect(v.left, v.top + dy, v.width, v.unit_h, Blitbuffer.COLOR_DARK_GRAY) +end + +function BookMapWidget:paintBottomHorizontalSwipeHint(bb, x, y) + -- Horizontal bar with a part of it darker, as a scale showing + -- selected pages_per_row. + local h = self.hs_hint_info + if not h then + -- Compute and remember sizes, positions and info + h = {} + h.height = self.swipe_hint_bar_width + h.top = Screen:getHeight() - h.height + h.width = math.floor(Screen:getWidth() * 4/6) + h.left = math.floor(Screen:getWidth() * 1/6) + -- We show a fixed width handle with a granular dx + h.hint_w = math.floor(h.width / 8) + h.max_dx = h.width - h.hint_w + self.hs_hint_info = h + end + -- Paint a horizontal light gray bar + bb:paintRect(h.left, h.top, h.width, h.height, Blitbuffer.COLOR_LIGHT_GRAY) + -- And paint a part of it in a darker gray + -- (Somebody good at maths could probably do better than this... which + -- could be related to the increment/ratio we use in onSwipe) + local cur = self.pages_per_row - self.min_pages_per_row + local max = self.max_pages_per_row - self.min_pages_per_row + local dx = math.floor(h.max_dx*(1-math.log(1+cur)/math.log(1+max))) + if self._mirroredUI then + dx = h.max_dx - dx + end + bb:paintRect(h.left + dx, h.top, h.hint_w, h.height, Blitbuffer.COLOR_DARK_GRAY) +end + +return BookMapWidget diff --git a/frontend/ui/widget/container/scrollablecontainer.lua b/frontend/ui/widget/container/scrollablecontainer.lua index d900cedf6..cf2470407 100644 --- a/frontend/ui/widget/container/scrollablecontainer.lua +++ b/frontend/ui/widget/container/scrollablecontainer.lua @@ -242,6 +242,16 @@ function ScrollableContainer:onCloseWidget() end end +function ScrollableContainer:reset() + if self._bb then + self._bb:free() + self._bb = nil + end + self._is_scrollable = nil + self._scroll_offset_x = 0 + self._scroll_offset_y = 0 +end + function ScrollableContainer:paintTo(bb, x, y) if self[1] == nil then return @@ -255,6 +265,9 @@ function ScrollableContainer:paintTo(bb, x, y) if not self._is_scrollable then -- nothing to scroll: pass-through + if self._mirroredUI then -- behave as LeftContainer + x = x + (self.dimen.w - self[1]:getSize().w) + end self[1]:paintTo(bb, x, y) return end diff --git a/frontend/ui/widget/pagebrowserwidget.lua b/frontend/ui/widget/pagebrowserwidget.lua new file mode 100644 index 000000000..82827e2db --- /dev/null +++ b/frontend/ui/widget/pagebrowserwidget.lua @@ -0,0 +1,947 @@ +local BD = require("ui/bidi") +local Blitbuffer = require("ffi/blitbuffer") +local CenterContainer = require("ui/widget/container/centercontainer") +local Device = require("device") +local Event = require("ui/event") +local Font = require("ui/font") +local FrameContainer = require("ui/widget/container/framecontainer") +local Geom = require("ui/geometry") +local GestureRange = require("ui/gesturerange") +local ImageWidget = require("ui/widget/imagewidget") +local InfoMessage = require("ui/widget/infomessage") +local InputContainer = require("ui/widget/container/inputcontainer") +local OverlapGroup = require("ui/widget/overlapgroup") +local Size = require("ui/size") +local TextWidget = require("ui/widget/textwidget") +local TitleBar = require("ui/widget/titlebar") +local UIManager = require("ui/uimanager") +local VerticalGroup = require("ui/widget/verticalgroup") +local VerticalSpan = require("ui/widget/verticalspan") +local Input = Device.input +local Screen = Device.screen +local logger = require("logger") +local _ = require("gettext") + +-- We use the BookMapRow widget, a local widget defined in bookmapwidget.lua, +-- that we made available via BookMapWidget itself +local BookMapWidget = require("ui/widget/bookmapwidget") +local BookMapRow = BookMapWidget.BookMapRow + +-- PageBrowserWidget: shows thumbnails of pages +local PageBrowserWidget = InputContainer:new{ + title = _("Page browser"), + -- Focus page: will be put at the best place in the thumbnail grid + -- (that is, the grid will pick thumbnails from pages before and + -- after it, and more pages after than before) + focus_page = nil, + -- Should only be nil on the first launch via ReaderThumbnail + launcher = nil, + + _mirroredUI = BD.mirroredUILayout(), +} + +function PageBrowserWidget:init() + -- Compute non-settings-dependant sizes and options + self.dimen = Geom:new{ + w = Screen:getWidth(), + h = Screen:getHeight(), + } + self.covers_fullscreen = true -- hint for UIManager:_repaint() + + if Device:hasKeys() then + self.key_events = { + Close = { {"Back"}, doc = "close page" }, + ScrollRowUp = {{"Up"}, doc = "scroll up"}, + ScrollRowDown = {{"Down"}, doc = "scrol down"}, + ScrollPageUp = {{Input.group.PgBack}, doc = "prev page"}, + ScrollPageDown = {{Input.group.PgFwd}, doc = "next page"}, + } + end + if Device:isTouchDevice() then + self.ges_events.Swipe = { + GestureRange:new{ + ges = "swipe", + range = self.dimen, + } + } + self.ges_events.MultiSwipe = { + GestureRange:new{ + ges = "multiswipe", + range = self.dimen, + } + } + self.ges_events.Tap = { + GestureRange:new{ + ges = "tap", + range = self.dimen, + } + } + self.ges_events.Hold = { + GestureRange:new{ + ges = "hold", + range = self.dimen, + } + } + self.ges_events.Pinch = { + GestureRange:new{ + ges = "pinch", + range = self.dimen, + } + } + self.ges_events.Spread = { + GestureRange:new{ + ges = "spread", + range = self.dimen, + } + } + end + + -- Put the BookMapRow left and right border outside of screen + self.row_width = self.dimen.w + 2*BookMapRow.pages_frame_border + + self.title_bar = TitleBar:new{ + fullscreen = true, + title = self.title, + left_icon = "notice-info", + left_icon_tap_callback = function() self:showHelp() end, + close_callback = function() self:onClose() end, + hold_close_callback = function() self:onClose(true) end, + show_parent = self, + } + self.title_bar_h = self.title_bar:getHeight() + + -- Guess grid TOC span height from its font size + -- (it feels this font size does not need to be configurable: too large and + -- titles will be too easily truncated, too small and they will be unreadable) + self.toc_span_font_name = "infofont" + self.toc_span_font_size = 14 + self.toc_span_face = Font:getFace(self.toc_span_font_name, self.toc_span_font_size) + local test_w = TextWidget:new{ + text = "z", + face = self.toc_span_face, + } + self.span_height = test_w:getSize().h + BookMapRow.toc_span_border + test_w:free() + + self.min_nb_rows = 1 + self.max_nb_rows = 6 + self.min_nb_cols = 1 + self.max_nb_cols = 6 + + self.ui.toc:fillToc() + self.max_toc_depth = self.ui.toc.toc_depth + -- We show the toc depth chosen in BookMapWidget, or all of it if not set + -- (nothing in this PageBrowserWidget to allow changing it) + self.nb_toc_spans = self.ui.doc_settings:readSetting("book_map_toc_depth", self.max_toc_depth) + + -- Row will contain: nb_toc_spans + page slots + spacing (+ some borders) + self.statistics_enabled = self.ui.statistics and self.ui.statistics:isEnabled() + local page_slots_height_ratio = 1 -- default to 1 * span_height + if not self.statistics_enabled and self.nb_toc_spans > 0 then + -- Just enough to show page separators below toc spans + page_slots_height_ratio = 0.2 + end + self.row_height = math.ceil((self.nb_toc_spans + page_slots_height_ratio + 1) * self.span_height + 2*BookMapRow.pages_frame_border) + + self.grid_width = self.dimen.w + self.grid_height = self.dimen.h - self.title_bar_h - self.row_height + + -- We'll draw some kind of static transparent glass over the BookMapRow, + -- which should span over the page slots that get their thumbnails shown. + self.view_finder_r = Size.radius.window + self.view_finder_bw = Size.border.default + -- Have its top border noticable above the BookMapRow top border + self.view_finder_y = self.dimen.h - self.row_height - 2*self.view_finder_bw + -- And put its bottom rounded corner outside of screen + self.view_finder_h = self.row_height + 2*self.view_finder_bw + Size.radius.window + + self.grid = OverlapGroup:new{ + dimen = Geom:new{ + w = self.grid_width, + h = self.grid_height, + }, + allow_mirroring = false, + } + self.row = CenterContainer:new{ + dimen = Geom:new{ + w = self.dimen.w, + h = self.row_height, + }, + -- Will contain a BookMapRow wider, with l/r borders outside screen + } + + self[1] = FrameContainer:new{ + width = self.dimen.w, + height = self.dimen.h, + padding = 0, + margin = 0, + bordersize = 0, + background = Blitbuffer.COLOR_WHITE, + VerticalGroup:new{ + align = "center", + self.title_bar, + self.grid, + self.row, + } + } + + + -- Get some info that shouldn't change across calls to update() and updateLayout() + self.nb_pages = self.ui.document:getPageCount() + self.cur_page = self.ui.toc.pageno + -- Get bookmarks and highlights from ReaderBookmark + self.bookmarked_pages = self.ui.bookmark:getBookmarkedPages() + -- Get read page from the statistics plugin if enabled + self.read_pages = self.ui.statistics and self.ui.statistics:getCurrentBookReadPages() + self.current_session_duration = self.ui.statistics and (os.time() - self.ui.statistics.start_current_period) + -- Hidden flows, for first page display, and to draw them gray + self.has_hidden_flows = self.ui.document:hasHiddenFlows() + if self.has_hidden_flows and #self.ui.document.flows > 0 then + self.hidden_flows = {} + -- Pick into credocument internal data to build a table + -- of {first_page_number, last_page_number) for each flow + for flow, tab in ipairs(self.ui.document.flows) do + table.insert(self.hidden_flows, { tab[1], tab[1]+tab[2]-1 }) + end + end + -- Reference page numbers, for first row page display + self.page_labels = nil + if self.ui.pagemap and self.ui.pagemap:wantsPageLabels() then + self.page_labels = self.ui.document:getPageMap() + end + -- Location stack + self.previous_locations = self.ui.link:getPreviousLocationPages() + + -- Compute settings-dependant sizes and options, and build the inner widgets + -- (this will call self:update()) + self:updateLayout() +end + +function PageBrowserWidget:updateLayout() + self.nb_rows = self.ui.doc_settings:readSetting("page_browser_nb_rows") + or G_reader_settings:readSetting("page_browser_nb_rows") + self.nb_cols = self.ui.doc_settings:readSetting("page_browser_nb_cols") + or G_reader_settings:readSetting("page_browser_nb_cols") + if not self.nb_rows or not self.nb_cols then + -- 3 x 2 seems like a good default, in both portrait or landscape mode + self.nb_cols = 3 + self.nb_rows = 2 + end + self.nb_grid_items = self.nb_rows * self.nb_cols + -- Set our items target size + self.grid_item_margin = Screen:scaleBySize(10) -- borders will eat into this, it should be larger than borders thin+thick + self.grid_item_height = math.floor((self.grid_height - (self.nb_rows)*self.grid_item_margin) / self.nb_rows) -- no need for top margin, title bottom padding is enough + self.grid_item_width = math.floor((self.grid_width - (1+self.nb_cols)*self.grid_item_margin) / self.nb_cols) + self.grid_item_dimen = Geom:new{ + w = self.grid_item_width, + h = self.grid_item_height + } + + self.grid:clear() + + for idx = 1, self.nb_grid_items do + local row = math.floor((idx-1)/self.nb_cols) -- start from 0 + local col = (idx-1) % self.nb_cols + if self._mirroredUI then + col = self.nb_cols - col - 1 + end + local offset_x = self.grid_item_margin*(col+1) + self.grid_item_width*col + local offset_y = self.grid_item_margin*(row) + self.grid_item_height*row -- no need for 1st margin + local grid_item = CenterContainer:new{ + dimen = self.grid_item_dimen:copy(), + } + table.insert(self.grid, FrameContainer:new{ + overlap_offset = {offset_x, offset_y}, + margin = 0, + padding = 0, + bordersize = 0, + background = Blitbuffer.COLOR_WHITE, + grid_item, + }) + end + + -- Put the focused (requested) page at some appropriate place in the grid + if self.nb_rows > 1 then -- Multiple rows + -- Show the focus page at the rightmost position in the first row + self.focus_page_shift = self.nb_cols - 1 + else -- Single row + if self.nb_cols > 2 then -- 3+ columns: show one page behind only + self.focus_page_shift = 1 + else -- 1 or 2 columns: show it first + self.focus_page_shift = 0 + end + end + + -- Don't go with too small page slots + self.pages_per_row = math.max(self.nb_grid_items*3, 20) + -- We want our view finder centered over the BookMapRow + if self.pages_per_row % 2 ~= self.nb_grid_items % 2 then + self.pages_per_row = self.pages_per_row + 1 + end + + -- Update the BookMapRow and page thumbnails for the current view + self:update() +end + +function PageBrowserWidget:update() + if self.requests_batch_id then + self.ui.thumbnail:cancelPageThumbnailRequests(self.requests_batch_id) + end + self.requests_batch_id = "PageBrowserWidget"..tostring(os.time()) + + if not self.focus_page then + self.focus_page = self.cur_page or 1 + end + + local grid_page_start = self.focus_page - self.focus_page_shift + local grid_page_end = grid_page_start + self.nb_grid_items - 1 + + -- Get p_start so that our viewfinder is centered + local p_start = math.ceil(grid_page_start + self.nb_grid_items/2 - self.pages_per_row/2) + local p_end = p_start + self.pages_per_row - 1 + local blank_page_slots_before_start = 0 + local blank_page_slots_after_end = 0 -- used only when _mirroredUI + if p_end > self.nb_pages then + blank_page_slots_after_end = p_end - self.nb_pages + p_end = self.nb_pages + end + if p_start < 1 then + blank_page_slots_before_start = 1 - p_start + p_start = 1 + end + + -- Show the page number or label at the bottom page slot every N slots, with N + -- the nb of thumbnails so we get at least one page label in our viewport. + local page_texts_cycle = math.min(self.nb_grid_items, 10) -- but max 10 + local next_p = p_start + local cur_page_label_idx = 1 + local page_texts = {} + for p=p_start, p_end do + if p >= next_p then + -- Only show a page text if there is no indicator on that slot + if p ~= self.cur_page and not self.bookmarked_pages[p] and not self.previous_locations[p] then + local page_text + if self.page_labels then + local page_label + for idx=cur_page_label_idx, #self.page_labels do + local item = self.page_labels[idx] + if item.page >= p then + if item.page == p then + page_label = item.label + end + break + end + cur_page_label_idx = idx + end + if page_label then + page_text = self.ui.pagemap:cleanPageLabel(page_label) + end + elseif self.has_hidden_flows then + local flow = self.ui.document:getPageFlow(p) + if flow == 0 then + page_text = tostring(self.ui.document:getPageNumberInFlow(p)) + else + page_text = string.format("[%d]%d", self.ui.document:getPageNumberInFlow(p), self.ui.document:getPageFlow(p)) + end + else + page_text = tostring(p) + end + if page_text then + local page_block, page_block_dx -- centered by default + if p == p_start or p == grid_page_start or p == grid_page_end+1 then + page_block = "left" + page_block_dx = Size.padding.tiny + if p == grid_page_start then + page_block_dx = page_block_dx + self.view_finder_bw + 1 + end + elseif p == p_end or p == grid_page_end or p == grid_page_start-1 then + page_block = "right" + page_block_dx = Size.padding.tiny + if p == grid_page_end then + page_block_dx = page_block_dx + self.view_finder_bw + 1 + end + end + page_texts[p] = { + text = page_text, + block = page_block, + block_dx = page_block_dx, + } + next_p = p + page_texts_cycle + end + end + end + end + + -- We need to rebuilt the full set of toc spans that will be shown + -- Similar (but simplified) to what is done in BookMapWidget. + self.toc_depth = self.nb_toc_spans + local toc = self.ui.toc.toc + local cur_toc_items = {} + local row_toc_items = {} + local toc_idx = 1 + while toc_idx <= #toc do + -- Find out the toc items that can be shown on this row + local item = toc[toc_idx] + if item.page > p_end then + break + end + if item.depth <= self.toc_depth then -- ignore lower levels we won't show + -- An item at level N closes all previous items at level >= N + for lvl = item.depth, self.toc_depth do + local done_toc_item = cur_toc_items[lvl] + cur_toc_items[lvl] = nil + if done_toc_item then + done_toc_item.p_end = math.max(item.page - 1, done_toc_item.p_start) + if done_toc_item.p_end >= p_start then + -- Can go into row_toc_items[lvl] + if done_toc_item.p_start < p_start then + done_toc_item.p_start = p_start + done_toc_item.started_before = true -- no left margin + end + if not row_toc_items[lvl] then + row_toc_items[lvl] = {} + end + -- We're done with it, we can just move it + table.insert(row_toc_items[lvl], done_toc_item) + end + end + end + cur_toc_items[item.depth] = { + title = item.title, + p_start = item.page, + p_end = nil, + } + end + toc_idx = toc_idx + 1 + end + local is_last_row = p_end >= self.nb_pages + for lvl = 1, self.nb_toc_spans do -- (no-op/no-loop if flat_map) + local active_toc_item = cur_toc_items[lvl] + if active_toc_item then + if active_toc_item.p_start < p_start then + active_toc_item.p_start = p_start + active_toc_item.started_before = true -- no left margin + end + active_toc_item.p_end = p_end + active_toc_item.continues_after = not is_last_row -- no right margin (except if last row) + -- Look at next TOC item to see if it would close this one + local coming_up_toc_item = toc[toc_idx] + if coming_up_toc_item and coming_up_toc_item.page == p_end+1 and coming_up_toc_item.depth <= lvl then + active_toc_item.continues_after = false -- right margin + end + if not row_toc_items[lvl] then + row_toc_items[lvl] = {} + end + table.insert(row_toc_items[lvl], active_toc_item) + end + end + + local left_spacing = 0 + if blank_page_slots_before_start > 0 then + left_spacing = BookMapRow:getLeftSpacingForNumberOfPageSlots(blank_page_slots_before_start, self.pages_per_row, self.row_width) + end + local row = BookMapRow:new{ + height = self.row_height, + width = self.row_width, + show_parent = self, + left_spacing = left_spacing, + nb_toc_spans = self.nb_toc_spans, + span_height = self.span_height, + font_face = self.toc_span_face, + start_page_text = "", + start_page = p_start, + end_page = p_end, + pages_per_row = self.pages_per_row - blank_page_slots_before_start, + cur_page = self.cur_page, + with_page_sep = true, + toc_items = row_toc_items, + bookmarked_pages = self.bookmarked_pages, + previous_locations = self.previous_locations, + hidden_flows = self.hidden_flows, + read_pages = self.read_pages, + current_session_duration = self.current_session_duration, + page_texts = page_texts, + } + self.row[1] = row + + if self._mirroredUI then + self.view_finder_x = row:getPageX(grid_page_end) + self.view_finder_w = row:getPageX(grid_page_start, true) - self.view_finder_x + if blank_page_slots_after_end > 0 then + self.view_finder_x = self.view_finder_x + + BookMapRow:getLeftSpacingForNumberOfPageSlots(blank_page_slots_after_end, self.pages_per_row, self.row_width) + + row.pages_frame_border -- (needed, but not sure why it is needed...) + end + else + self.view_finder_x = row:getPageX(grid_page_start) + self.view_finder_w = row:getPageX(grid_page_end, true) - self.view_finder_x + self.view_finder_x = self.view_finder_x + left_spacing + end + -- we requested with_page_sep, so leave these blank spaces between page slots outside the viewfinder + self.view_finder_x = self.view_finder_x + 1 + self.view_finder_w = self.view_finder_w - 1 + + for idx=1, self.nb_grid_items do + local p = grid_page_start + idx - 1 + if p < 1 or p > self.nb_pages then + self.grid[idx].page_idx = nil -- no action on Tap + self:clearTile(idx) + else + self.grid[idx].page_idx = p -- go there on Tap + local delayed = self.ui.thumbnail:getPageThumbnail(p, self.grid_item_width, self.grid_item_height, self.requests_batch_id, function(tile, batch_id, async_response) + if batch_id ~= self.requests_batch_id then + -- Response from an obsolete request + return + end + if not tile then -- failure notification + return + end + -- If tile was in the cache, we get this immediately called with async_response=false, + -- and we don't need to do any setDirty as a full one will be done below. + self:showTile(idx, p, tile, async_response) + end) + if delayed then + self:clearTile(idx, true) + self.wait_for_refresh_on_show_tile = true + end + end + end + UIManager:setDirty(self, function() + return "ui", self.dimen + end) +end + +function PageBrowserWidget:paintTo(bb, x, y) + -- Paint regular sub widgets the classic way + InputContainer.paintTo(self, bb, x, y) + -- If we would prefer to see the BookMapRow top border always take the full width + -- so it acts as a separator from the thumbnail grid, add this: + -- bb:paintRect(0, self.dimen.h - self.row_height, self.dimen.w, BookMapRow.pages_frame_border, Blitbuffer.COLOR_BLACK) + -- And explicitely paint our viewfinder over the BookMapRow + bb:paintBorder(self.view_finder_x, self.view_finder_y, self.view_finder_w, self.view_finder_h, + self.view_finder_bw, Blitbuffer.COLOR_BLACK, self.view_finder_r) +end + +function PageBrowserWidget:clearTile(grid_idx, in_progress, do_refresh) + local item_frame = self.grid[grid_idx] -- FrameContainer + local item_container = item_frame[1] -- CenterContainer + local dimen = item_frame.dimen + if item_container[1] then -- TextWidget or FrameContainer + if item_container[1].dimen then + dimen = item_container[1].dimen:copy() + end + if item_container[1].free then + item_container[1]:free() + end + end + -- Quickly showing the first tile while the whole page is still being refreshed + -- can cause some papercut-like refresh glitch on this first tile, with even more + -- chances if we put gray things in the initial page (as gray is painted black + -- and then becomes gray, making it 2 steps and longer). + -- This seems to be mitigated with our self.wait_for_refresh_on_show_tile trick. + if in_progress then + item_container[1] = TextWidget:new{ + text = "♲", -- gray symbol (which initially caused refresh glitches) + -- Alternatives (mostly from Nerdfont): + -- text = "\u{26F6}", -- square with four corners + -- text = "\u{ED36}", + -- text = "\u{F196}", -- square with plus inside + -- text = "\u{ED5F}", -- square with plus at top right + -- text = "\u{F141}", + -- text = "\u{EB52}", + -- text = "\u{EB4F}", + -- text = "\u{F021}", + face = Font:getFace("cfont", 20), + } + else + item_container[1] = VerticalSpan:new{ width = 0, } + end + if do_refresh then + UIManager:setDirty(self, function() + return "ui", dimen + end) + end +end + +function PageBrowserWidget:showTile(grid_idx, page, tile, do_refresh) + local item_frame = self.grid[grid_idx] -- FrameContainer + local item_container = item_frame[1] -- CenterContainer + if item_container[1] and item_container[1].free then -- TextWidget + item_container[1]:free() + end + local border = page == self.cur_page and Size.border.thick or Size.border.thin + local thumb_frame = FrameContainer:new{ + is_page_thumbnail = true, -- for tap handler + margin = 0, + padding = 0, + bordersize = border, + background = Blitbuffer.COLOR_WHITE, + ImageWidget:new{ + image = tile.bb, + image_disposable = false, + }, + } + item_container[1] = thumb_frame + -- thumb_frame will overflow its CenterContainer because of the added borders, + -- but CenterContainer handles that well. We will refresh the outer dimensions. + + if do_refresh then + if self.wait_for_refresh_on_show_tile then + self.wait_for_refresh_on_show_tile = nil + -- Be sure the main view initial refresh has ended before refreshing + -- this first thumbnail, to avoid papercut refresh glitches. + UIManager:waitForVSync() + end + UIManager:setDirty(self, function() + return "ui", thumb_frame.dimen + end) + end +end + +function PageBrowserWidget:showHelp() + UIManager:show(InfoMessage:new{ + text = [[ +Page browser shows thumbnails of pages. + +The bottom row displays an extract of the book map around the shown pages: see the book map help for details. + +Swipe along the top or left screen edge to change the number of columns or rows. +Swipe vertically to move one row, horizontally to move one page. +Swipe horizontally in the bottom row to move by the full stripe. +Tap in the bottom row on a page to focus thumbnails on this page. +Tap on a thumbnail to go read this page. +Any multiswipe will close the page browser.]], + }) +end + +function PageBrowserWidget:onClose(close_all_parents) + if self.requests_batch_id then + self.ui.thumbnail:cancelPageThumbnailRequests(self.requests_batch_id) + end + logger.dbg("closing PageBrowserWidget") + -- Close this widget + UIManager:close(self) + if self.launcher then + -- We were launched by a BookMapWidget, don't do any cleanup. + if close_all_parents then + -- The last one of these (which has no launcher attribute) + -- will do the cleanup below. + self.launcher:onClose(true) + end + else + -- Remove all thumbnails generated for a different target size than + -- the last one used (no need to keep old sizes if the user played + -- with nb_cols/nb_rows, as on next opening, we just need the ones + -- with the current size to be available) + self.ui.thumbnail:tidyCache() + -- Force a GC to free the memory used by the widgets and tiles + -- (delay it a bit so this pause is less noticable) + UIManager:scheduleIn(0.5, function() + collectgarbage() + collectgarbage() + end) + -- As we're getting back to Reader, do a full flashing refresh to remove + -- any ghost trace of thumbnails or black page slots + UIManager:setDirty(self.ui.dialog, "full") + end + return true +end + +function PageBrowserWidget:saveSettings(reset) + if reset then + self.nb_rows = nil + self.nb_cols = nil + end + self.ui.doc_settings:saveSetting("page_browser_nb_rows", self.nb_rows) + self.ui.doc_settings:saveSetting("page_browser_nb_cols", self.nb_cols) + -- We also save them as global settings, so they will apply on other books + -- where they were not already set + G_reader_settings:saveSetting("page_browser_nb_rows", self.nb_rows) + G_reader_settings:saveSetting("page_browser_nb_cols", self.nb_cols) +end + +function PageBrowserWidget:updateNbCols(value, relative) + local new_nb_cols + if relative then + new_nb_cols = self.nb_cols + value + else + new_nb_cols = value + end + if new_nb_cols < self.min_nb_cols then + new_nb_cols = self.min_nb_cols + end + if new_nb_cols > self.max_nb_cols then + new_nb_cols = self.max_nb_cols + end + if new_nb_cols == self.nb_cols then + return false + end + self.nb_cols = new_nb_cols + self:saveSettings() + return true +end + +function PageBrowserWidget:updateNbRows(value, relative) + local new_nb_rows + if relative then + new_nb_rows = self.nb_rows + value + else + new_nb_rows = value + end + if new_nb_rows < self.min_nb_rows then + new_nb_rows = self.min_nb_rows + end + if new_nb_rows > self.max_nb_rows then + new_nb_rows = self.max_nb_rows + end + if new_nb_rows == self.nb_rows then + return false + end + self.nb_rows = new_nb_rows + self:saveSettings() + return true +end + +function PageBrowserWidget:updateFocusPage(value, relative) + local new_focus_page + if relative then + new_focus_page = self.focus_page + value + else + new_focus_page = value + end + if new_focus_page < 1 then + new_focus_page = 1 + end + if new_focus_page > self.nb_pages then + new_focus_page = self.nb_pages + end + if new_focus_page == self.focus_page then + return false + end + self.focus_page = new_focus_page + return true +end + +function PageBrowserWidget:onScrollPageUp() + if self:updateFocusPage(-self.nb_grid_items, true) then + self:update() + end + return true +end + +function PageBrowserWidget:onScrollPageDown() + if self:updateFocusPage(self.nb_grid_items, true) then + self:update() + end + return true +end + +function PageBrowserWidget:onScrollRowUp() + if self:updateFocusPage(-self.nb_cols, true) then + self:update() + end + return true +end + +function PageBrowserWidget:onScrollRowDown() + if self:updateFocusPage(self.nb_cols, true) then + self:update() + end + return true +end + +function PageBrowserWidget:onSwipe(arg, ges) + local direction = BD.flipDirectionIfMirroredUILayout(ges.direction) + + if direction == "north" or direction == "south" then + -- Swipe along the screen left edge: increase/decrease nb of thumbnail rows + -- (Should this be mirrored if RTL UI? It would be consistent with how it + -- happens in BookMapWidget - but here, having it on the left is to have it + -- less accessible to right handed people so they can scroll up/down more + -- easily.) + if ges.pos.x < Screen:getWidth() * 1/8 then + local rel = direction == "north" and 1 or -1 + if self:updateNbRows(rel, true) then + self:updateLayout() + end + return true + else + -- As onScrollRowUp/Down() + local rel = direction == "north" and 1 or -1 + if self:updateFocusPage(rel*self.nb_cols, true) then + self:update() + end + return true + end + elseif direction == "west" or direction == "east" then + if ges.pos.y < Screen:getHeight() * 1/8 then + -- Swipe along the screen top edge: increase/decrease nb of thumbnail cols + local rel = direction == "west" and 1 or -1 + if self:updateNbCols(rel, true) then + self:updateLayout() + end + return true + elseif ges.pos.y > Screen:getHeight() - self.row_height then + -- Inside BookMapRow at bottom: scroll by a full pages_per_row + -- (Handling pan and hold/pan/release when started on view finder + -- would be nice, as it might be an intuitive naive action on + -- this area... but well...) + local rel = direction == "west" and 1 or -1 + if self:updateFocusPage(rel*self.pages_per_row, true) then + self:update() + end + return true + else + -- As onScrollPageUp/Down() + local rel = direction == "west" and 1 or -1 + if self:updateFocusPage(rel*self.nb_grid_items, true) then + self:update() + end + return true + end + else + -- diagonal swipe + -- trigger full refresh + UIManager:setDirty(nil, "full") + -- a long diagonal swipe may also be used for taking a screenshot, + -- so let it propagate + return false + end +end + +function PageBrowserWidget:onPinch(arg, ges) + if ges.direction == "horizontal" then + if self:updateNbCols(1, true) then + self:updateLayout() + end + elseif ges.direction == "vertical" then + if self:updateNbRows(1, true) then + self:updateLayout() + end + elseif ges.direction == "diagonal" then + local updated = self:updateNbCols(1, true) + updated = self:updateNbRows(1, true) or updated + if updated then + self:updateLayout() + end + end + return true +end + +function PageBrowserWidget:onSpread(arg, ges) + if ges.direction == "horizontal" then + if self:updateNbCols(-1, true) then + self:updateLayout() + end + elseif ges.direction == "vertical" then + if self:updateNbRows(-1, true) then + self:updateLayout() + end + elseif ges.direction == "diagonal" then + local updated = self:updateNbCols(-1, true) + updated = self:updateNbRows(-1, true) or updated + if updated then + self:updateLayout() + end + end + return true +end + +function PageBrowserWidget:onMultiSwipe(arg, ges) + -- All swipes gestures are used for navigation. + -- Allow for quick closing with any multiswipe. + self:onClose() + return true +end + +function PageBrowserWidget:onTap(arg, ges) + -- If tap in the bottom BookMapRow, put page at tap position + -- as focus page, so it goes into our viewfinder + if ges.pos.y > Screen:getHeight() - self.row_height then + local page = self.row[1]:getPageAtX(ges.pos.x) + if page then + -- Have it in the middle of viewfinder, and not where + -- the self.focus_page_shift would put it + page = page - math.floor(self.nb_grid_items/2) + self.focus_page_shift + if self:updateFocusPage(page, false) then + self:update() + end + end + return true + end + -- Tap on title: do nothing + if ges.pos.y < self.title_bar_h then + return true + end + -- If tap on a thumbnail, close widget and go to that page + for idx=1, self.nb_grid_items do + if ges.pos:intersectWith(self.grid[idx].dimen) then + local page = self.grid[idx].page_idx + if page and self.grid[idx][1][1].is_page_thumbnail then + -- Only allow tap on fully displayed thumbnails. + -- Also, a thumbnail might be smaller than the original grid + -- item dimension. Be sure the tap is on it (otherwise, it's + -- a tap in the inter thumbnail margin, that we'd rather not + -- handle) + local thumb_frame = self.grid[idx][1][1] + if ges.pos:intersectWith(thumb_frame.dimen) then + -- On PDF documents, jumping to a page may block for a few + -- seconds while the page is rendered. So, make the border + -- bigger so the user knows his tap is being processed. + local orig_bordersize = thumb_frame.bordersize + thumb_frame.bordersize = Size.border.thick * 2 + local b_inc = thumb_frame.bordersize - orig_bordersize + UIManager:widgetRepaint(thumb_frame, thumb_frame.dimen.x-b_inc, thumb_frame.dimen.y-b_inc) + Screen:refreshFast(thumb_frame.dimen.x, thumb_frame.dimen.y, thumb_frame.dimen.w, thumb_frame.dimen.h) + -- (refresh "fast" will make gray drawn black and may make the + -- thumbnail a little uglier - but this enhances the effect + -- of "being processed"!) + -- Close the BookMapWidget that launched this PageBrowser + -- and all their ancestors up to Reader + self:onClose(true) + self.ui.link:addCurrentLocationToStack() + self.ui:handleEvent(Event:new("GotoPage", page)) + -- Note: with ReaderPaging, if we tap on the thumbnail for the current + -- page, nothing would be refreshed. Our :onClose(true) will have the + -- last ancestor issue a full refresh that will ensure it is painted. + return true + end + end + break + end + end + -- If tap on a blank area, handle as prev/next page, so people + -- not friend with swipe can still move around + if BD.flipIfMirroredUILayout(ges.pos.x < Screen:getWidth()/2) then + self:onScrollPageUp() + else + self:onScrollPageDown() + end + return true +end + +function PageBrowserWidget:onHold(arg, ges) + -- If hold in the bottom BookMapRow, open a new BookMapWidget + -- and focus on this page. We'll show a rounded square below + -- our current focus_page to help locating where we were (it's + -- quite more complicated to draw a rounded rectangle around + -- multiple pages to figure our view finder, as these pages + -- may be splitted onto multiple BookMapRows...) + if ges.pos.y > Screen:getHeight() - self.row_height then + local page = self.row[1]:getPageAtX(ges.pos.x) + if page then + local extra_symbols_pages = {} + extra_symbols_pages[self.focus_page] = 0x25A2 -- white square with rounder corners + UIManager:show(BookMapWidget:new{ + launcher = self, + ui = self.ui, + focus_page = page, + extra_symbols_pages = extra_symbols_pages, + }) + end + return true + end + return true +end + +return PageBrowserWidget diff --git a/plugins/statistics.koplugin/main.lua b/plugins/statistics.koplugin/main.lua index 52e5555eb..270873eb8 100644 --- a/plugins/statistics.koplugin/main.lua +++ b/plugins/statistics.koplugin/main.lua @@ -234,6 +234,10 @@ function ReaderStatistics:initData() end end +function ReaderStatistics:isEnabled() + return self.settings.is_enabled +end + -- Reset the (volatile) stats on page count changes (e.g., after a font size update) function ReaderStatistics:onUpdateToc() local new_pagecount = self.view.document:getPageCount() @@ -2454,4 +2458,41 @@ function ReaderStatistics:onShowCalendarView() UIManager:show(self:getCalendarView()) end +function ReaderStatistics:getCurrentBookReadPages() + if self:isDocless() or not self.settings.is_enabled then return end + self:insertDB(self.id_curr_book) + local sql_stmt = [[ + SELECT + page, + min(sum(duration), ?) AS durations, + strftime("%s", "now") - max(start_time) AS delay + FROM page_stat + WHERE id_book = ? + GROUP BY page + ORDER BY page; + ]] + local conn = SQ3.open(db_location) + local stmt = conn:prepare(sql_stmt) + local res, nb = stmt:reset():bind(self.settings.max_sec, self.id_curr_book):resultset("i") + stmt:close() + conn:close() + local read_pages = {} + local max_duration = 0 + for i=1, nb do + local page, duration, delay = res[1][i], res[2][i], res[3][i] + page = tonumber(page) + duration = tonumber(duration) + delay = tonumber(delay) + read_pages[page] = {duration, delay} + if duration > max_duration then + max_duration = duration + end + end + for page, info in pairs(read_pages) do + -- Make the value a duration ratio (vs capped or max duration) + read_pages[page][1] = info[1] / max_duration + end + return read_pages +end + return ReaderStatistics