diff --git a/frontend/ui/elements/filemanager_menu_order.lua b/frontend/ui/elements/filemanager_menu_order.lua index 3fa47a20c..7718c9c66 100644 --- a/frontend/ui/elements/filemanager_menu_order.lua +++ b/frontend/ui/elements/filemanager_menu_order.lua @@ -6,6 +6,7 @@ local order = { "main", }, setting = { + "filemanager_display_mode", "show_hidden_files", "----------------------------", "sort_by", diff --git a/plugins/coverbrowser.koplugin/bookinfomanager.lua b/plugins/coverbrowser.koplugin/bookinfomanager.lua new file mode 100644 index 000000000..15c732660 --- /dev/null +++ b/plugins/coverbrowser.koplugin/bookinfomanager.lua @@ -0,0 +1,587 @@ +local Blitbuffer = require("ffi/blitbuffer") +local DataStorage = require("datastorage") +local DocumentRegistry = require("document/documentregistry") +local SQ3 = require("lua-ljsqlite3/init") +local UIManager = require("ui/uimanager") +local lfs = require("libs/libkoreader-lfs") +local logger = require("logger") +local util = require("ffi/util") +local splitFilePathName = require("util").splitFilePathName +local _ = require("gettext") +local T = require("ffi/util").template + +-- Util functions needed by this plugin, but that may be added to existing base/ffi/ files +local xutil = require("xutil") + +-- Database definition +local BOOKINFO_DB_VERSION = "2-20170701" +local BOOKINFO_DB_SCHEMA = [[ + -- For caching book cover and metadata + CREATE TABLE IF NOT EXISTS bookinfo ( + -- Internal book cache id + -- (not to be used to identify a book, it may change for a same book) + bcid INTEGER PRIMARY KEY AUTOINCREMENT, + + -- File location and filename + directory TEXT NOT NULL, -- split by dir/name so we can get all files in a directory + filename TEXT NOT NULL, -- and can implement pruning of no more existing files + + -- Extraction status and result + in_progress INTEGER, -- 0 (done), >0 : nb of tries (to avoid re-doing extractions that crashed us) + unsupported TEXT, -- NULL if supported / reason for being unsupported + cover_fetched TEXT, -- NULL / 'Y' = action of fetching cover was made (whether we got one or not) + has_meta TEXT, -- NULL / 'Y' = has metadata (title, authors...) + has_cover TEXT, -- NULL / 'Y' = has cover image (cover_*) + cover_sizetag TEXT, -- 'M' (Medium, MosaicMenuItem) / 's' (small, ListMenuItem) + + -- Other properties that can be set and returned as is (not used here) + -- If user doesn't want to see these (wrong metadata, offending cover...) + ignore_meta TEXT, -- NULL / 'Y' = ignore these metadata + ignore_cover TEXT, -- NULL / 'Y' = ignore this cover + + -- Book info + pages INTEGER, + + -- Metadata (only these are returned by the engines) + title TEXT, + authors TEXT, + series TEXT, + language TEXT, + keywords TEXT, + description TEXT, + + -- Cover image + cover_w INTEGER, -- blitbuffer width + cover_h INTEGER, -- blitbuffer height + cover_btype INTEGER, -- blitbuffer type (internal) + cover_bpitch INTEGER, -- blitbuffer pitch (internal) + cover_datalen INTEGER, -- blitbuffer uncompressed data length + cover_dataz BLOB -- blitbuffer data compressed with zlib + ); + CREATE UNIQUE INDEX IF NOT EXISTS dir_filename ON bookinfo(directory, filename); + + -- For keeping track of DB schema version + CREATE TABLE IF NOT EXISTS config ( + key TEXT PRIMARY KEY, + value TEXT + ); + -- this will not override previous version value, so we'll get the old one if old schema + INSERT OR IGNORE INTO config VALUES ('version', ']] .. BOOKINFO_DB_VERSION .. [['); + +]] + +local BOOKINFO_COLS_SET = { + "directory", + "filename", + "in_progress", + "unsupported", + "cover_fetched", + "has_meta", + "has_cover", + "cover_sizetag", + "ignore_meta", + "ignore_cover", + "pages", + "title", + "authors", + "series", + "language", + "keywords", + "description", + "cover_w", + "cover_h", + "cover_btype", + "cover_bpitch", + "cover_datalen", + "cover_dataz", + } + +local bookinfo_values_sql = {} -- for "VALUES (?, ?, ?,...)" insert sql part +for i=1, #BOOKINFO_COLS_SET do + table.insert(bookinfo_values_sql, "?") +end + +-- Build our most often used SQL queries according to columns +local BOOKINFO_INSERT_SQL = "INSERT OR REPLACE INTO bookinfo " .. + "(" .. table.concat(BOOKINFO_COLS_SET, ",") .. ") " .. + "VALUES (" .. table.concat(bookinfo_values_sql, ",") .. ")" +local BOOKINFO_SELECT_SQL = "SELECT " .. table.concat(BOOKINFO_COLS_SET, ",") .. " FROM bookinfo " .. + "WHERE directory=? and filename=? and in_progress=0" +local BOOKINFO_IN_PROGRESS_SQL = "SELECT in_progress, filename, unsupported FROM bookinfo WHERE directory=? and filename=?" + + +local BookInfoManager = {} + +function BookInfoManager:init() + self.db_location = DataStorage:getSettingsDir() .. "/bookinfo_cache.sqlite3" + self.db_created = false + self.db_conn = nil + self.max_extract_tries = 3 -- don't try more than that to extract info from a same book + self.subprocesses_collector = nil + self.subprocesses_collect_interval = 10 -- do that every 10 seconds + self.subprocesses_pids = {} + self.subprocesses_last_added_ts = nil + self.subprocesses_killall_timeout_seconds = 300 -- cleanup timeout for stuck subprocesses + -- 300 seconds should be enough to open and get info from 9-10 books +end + +-- DB management +function BookInfoManager:getDbSize() + local file_size = lfs.attributes(self.db_location, "size") or 0 + local sstr + if file_size > 1024*1024 then + sstr = string.format("%4.1f MB", file_size/1024/1024) + elseif file_size > 1024 then + sstr = string.format("%4.1f KB", file_size/1024) + else + sstr = string.format("%d B", file_size) + end + return sstr +end + +function BookInfoManager:createDB() + local db_conn = SQ3.open(self.db_location) + -- Less error cases to check if we do it that way + -- Create it (noop if already there) + db_conn:exec(BOOKINFO_DB_SCHEMA) + -- Check version (not updated by previous exec if already there) + local res = db_conn:exec("SELECT value FROM config where key='version';") + if res[1][1] ~= BOOKINFO_DB_VERSION then + logger.warn("BookInfo cache DB schema updated from version ", res[1][1], "to version", BOOKINFO_DB_VERSION) + logger.warn("Deleting existing", self.db_location, "to recreate it") + db_conn:close() + os.remove(self.db_location) + -- Re-create it + db_conn = SQ3.open(self.db_location) + db_conn:exec(BOOKINFO_DB_SCHEMA) + end + db_conn:close() + self.db_created = true +end + +function BookInfoManager:openDbConnection() + if self.db_conn then + return + end + if not self.db_created then + self:createDB() + end + self.db_conn = SQ3.open(self.db_location) + xutil.sqlite_set_timeout(self.db_conn, 5000) -- 5 seconds + + -- Prepare our most often used SQL statements + self.set_stmt = self.db_conn:prepare(BOOKINFO_INSERT_SQL) + self.get_stmt = self.db_conn:prepare(BOOKINFO_SELECT_SQL) + self.in_progress_stmt = self.db_conn:prepare(BOOKINFO_IN_PROGRESS_SQL) +end + +function BookInfoManager:closeDbConnection() + if self.db_conn then + self.db_conn:close() + self.db_conn = nil + end +end + +function BookInfoManager:deleteDb() + self:closeDbConnection() + os.remove(self.db_location) + self.db_created = false +end + +function BookInfoManager:compactDb() + -- Reduce db size (note: "when VACUUMing a database, as much as twice the + -- size of the original database file is required in free disk space") + -- By default, sqlite will use a temporary file in /tmp/ . On Kobo, /tmp/ + -- is 16 Mb, and this will crash if DB is > 16Mb. For now, it's safer to + -- use memory for temp files (which will also cause a crash when DB size + -- is bigger than available memory...) + local prev_size = self:getDbSize() + self:openDbConnection() + self.db_conn:exec("PRAGMA temp_store = 2") -- use memory for temp files + -- self.db_conn:exec("VACUUM") + -- Catch possible "memory or disk is full" error + local ok, errmsg = pcall(self.db_conn.exec, self.db_conn, "VACUUM") -- this may take some time + self:closeDbConnection() + if not ok then + return T(_("Failed compacting database: %1"), errmsg) + end + local cur_size = self:getDbSize() + return T(_("Cache database size reduced from %1 to %2."), prev_size, cur_size) +end + +-- Settings management, stored in 'config' table +function BookInfoManager:loadSettings() + if lfs.attributes(self.db_location, "mode") ~= "file" then + -- no db, empty config + self.settings = {} + return + end + self.settings = {} + self:openDbConnection() + local res = self.db_conn:exec("SELECT key, value FROM config") + local keys = res[1] + local values = res[2] + for i, key in ipairs(keys) do + self.settings[key] = values[i] + end +end + +function BookInfoManager:getSetting(key) + if not self.settings then + self:loadSettings() + end + return self.settings[key] +end + +function BookInfoManager:saveSetting(key, value) + if not value or value == false or value == "" then + if lfs.attributes(self.db_location, "mode") ~= "file" then + -- If no db created, no need to save (and create db) an empty value + return + end + end + self:openDbConnection() + local query = "INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)" + local stmt = self.db_conn:prepare(query) + if value == false then -- convert false to NULL + value = nil + elseif value == true then -- convert true to "Y" + value = "Y" + end + stmt:bind(key, value) + stmt:step() -- commited + stmt:clearbind():reset() -- cleanup + -- Reload settings, so we may get (or not if it failed) what we just saved + self:loadSettings() +end + +-- Bookinfo management +function BookInfoManager:getBookInfo(filepath, get_cover) + local directory, filename = splitFilePathName(filepath) + self:openDbConnection() + local row = self.get_stmt:bind(directory, filename):step() + self.get_stmt:clearbind():reset() -- get ready for next query + + if not row then -- filepath not in db + return nil + end + + local bookinfo = {} + for num, col in ipairs(BOOKINFO_COLS_SET) do + if col == "pages" then + -- See http://scilua.org/ljsqlite3.html "SQLite Type Mappings" + bookinfo[col] = tonumber(row[num]) -- convert cdata to lua number + else + bookinfo[col] = row[num] -- as is + end + -- specific processing for cover columns + if col == "cover_w" then + if not get_cover then + -- don't bother making a blitbuffer + break + end + bookinfo["cover_bb"] = nil + if bookinfo["has_cover"] then + bookinfo["cover_w"] = tonumber(row[num]) + bookinfo["cover_h"] = tonumber(row[num+1]) + local cover_data = xutil.zlib_uncompress(row[num+5], row[num+4]) + row[num+5] = nil -- release memory used by cover_dataz + -- Blitbuffer.fromstring() expects : w, h, bb_type, bb_data, pitch + bookinfo["cover_bb"] = Blitbuffer.fromstring(row[num], row[num+1], row[num+2], cover_data, row[num+3]) + -- release memory used by uncompressed data: + cover_data = nil -- luacheck: no unused + end + break + end + end + return bookinfo +end + +function BookInfoManager:extractBookInfo(filepath, cover_specs) + -- This will be run in a subprocess + -- Disable cre cache (that will not affect parent process), so we don't + -- fill it with books we're not actually reading + if not self.cre_cache_disabled then + require "libs/libkoreader-cre" + cre.initCache("", 1024*1024*32) -- empty path = no cache + self.cre_cache_disabled = true + end + + local directory, filename = splitFilePathName(filepath) + + -- Initialize the new row that we will INSERT + local dbrow = { } + -- Actually no need to initialize with nil values: + -- for dummy, col in ipairs(BOOKINFO_COLS_SET) do + -- dbrow[col] = nil + -- end + dbrow.directory = directory + dbrow.filename = filename + + -- To be able to catch a BAD book we have already tried to process but + -- that made us crash, and that we would try to re-process again, we first + -- insert a nearly empty row with in_progress = 1 (incremented if previously set) + -- (This will also flag a book being processed when the user changed paged and + -- kill the previous page background process, but well...) + local tried_enough = false + local prev_tries = 0 + -- Get nb of previous tries if record already there + self:openDbConnection() + self.in_progress_stmt:bind(directory, filename) + local cur_in_progress = self.in_progress_stmt:step() + self.in_progress_stmt:clearbind():reset() -- get ready for next query + if cur_in_progress then + prev_tries = tonumber(cur_in_progress[1]) + end + -- Increment it and check if we have already tried enough + if prev_tries < self.max_extract_tries then + if prev_tries > 0 then + logger.dbg("Seen", prev_tries, "previous attempts at info extraction", filepath , ", trying again") + end + dbrow.in_progress = prev_tries + 1 -- extraction not yet successful + else + logger.info("Seen", prev_tries, "previous attempts at info extraction", filepath, ", too many, ignoring it.") + tried_enough = true + dbrow.in_progress = 0 -- row will exist, we'll never be called again + dbrow.unsupported = _("too many interruptions or crashes") -- but caller will now it failed + dbrow.cover_fetched = 'Y' -- so we don't try again if we're called later with cover_specs + end + -- Insert the temporary "in progress" record (or the definitive "unsupported" record) + for num, col in ipairs(BOOKINFO_COLS_SET) do + self.set_stmt:bind1(num, dbrow[col]) + end + self.set_stmt:step() -- commited + self.set_stmt:clearbind():reset() -- get ready for next query + if tried_enough then + return -- Last insert done for this book, we're giving up + end + + -- Proceed with extracting info + local document = DocumentRegistry:openDocument(filepath) + if document then + if document.loadDocument then -- needed for crengine + document:loadDocument() + -- Not needed for getting props: + -- document:render() + -- It would be needed to get nb of pages, but the nb obtained + -- by simply calling here document:getPageCount() is wrong, + -- often 2 to 3 times the nb of pages we see when opening + -- the document (may be some other cre settings should be applied + -- before calling render() ?) + else + -- for all others than crengine, we seem to get an accurate nb of pages + local pages = document:getPageCount() + dbrow.pages = pages + end + -- via pcall because picdocument:getProps() always fails (we could + -- check document.is_pic, but this way, we'll catch any other error) + local ok, props = pcall(document.getProps, document) + if ok then + dbrow.has_meta = 'Y' + if props.title and props.title ~= "" then dbrow.title = props.title end + if props.authors and props.authors ~= "" then dbrow.authors = props.authors end + if props.series and props.series ~= "" then dbrow.series = props.series end + if props.language and props.language ~= "" then dbrow.language = props.language end + if props.keywords and props.keywords ~= "" then dbrow.keywords = props.keywords end + if props.description and props.description ~= "" then dbrow.description = props.description end + end + if cover_specs then + local spec_sizetag = cover_specs.sizetag + local spec_max_cover_w = cover_specs.max_cover_w + local spec_max_cover_h = cover_specs.max_cover_h + + dbrow.cover_fetched = 'Y' -- we had a try at getting a cover + -- XXX make picdocument return a blitbuffer of the image + local cover_bb = document:getCoverPageImage() + if cover_bb then + dbrow.has_cover = 'Y' + dbrow.cover_sizetag = spec_sizetag + -- we should scale down the cover to our max size + local cbb_w, cbb_h = cover_bb:getWidth(), cover_bb:getHeight() + local scale_factor = 1 + if cbb_w > spec_max_cover_w or cbb_h > spec_max_cover_h then + -- scale down if bigger than what we will display + scale_factor = math.min(spec_max_cover_w / cbb_w, spec_max_cover_h / cbb_h) + cbb_w = math.min(math.floor(cbb_w * scale_factor)+1, spec_max_cover_w) + cbb_h = math.min(math.floor(cbb_h * scale_factor)+1, spec_max_cover_h) + local new_bb = cover_bb:scale(cbb_w, cbb_h) + cover_bb:free() + cover_bb = new_bb + end + dbrow.cover_w = cbb_w + dbrow.cover_h = cbb_h + dbrow.cover_btype = cover_bb:getType() + dbrow.cover_bpitch = cover_bb.pitch + local cover_data = Blitbuffer.tostring(cover_bb) + cover_bb:free() -- free bb before compressing to save memory + dbrow.cover_datalen = cover_data:len() + local cover_dataz = xutil.zlib_compress(cover_data) + -- release memory used by uncompressed data: + cover_data = nil -- luacheck: no unused + dbrow.cover_dataz = SQ3.blob(cover_dataz) -- cast to blob for sqlite + logger.dbg("cover for", filename, "scaled by", scale_factor, "=>", cbb_w, "x", cbb_h, "(compressed from ", dbrow.cover_datalen, " to ", cover_dataz:len()) + end + end + DocumentRegistry:closeDocument(filepath) + else + dbrow.unsupported = _("not readable by engine") + dbrow.cover_fetched = 'Y' -- so we don't try again if we're called later if cover_specs + end + dbrow.in_progress = 0 -- extraction completed (successful or definitive failure) + for num, col in ipairs(BOOKINFO_COLS_SET) do + self.set_stmt:bind1(num, dbrow[col]) + end + self.set_stmt:step() + self.set_stmt:clearbind():reset() -- get ready for next query +end + +function BookInfoManager:setBookInfoProperties(filepath, props) + -- If we need to set column=NULL, use props[column] = false (as + -- props[column] = nil would make column disappear from props) + local directory, filename = splitFilePathName(filepath) + self:openDbConnection() + -- Let's do multiple one-column UPDATE (easier than building + -- a multiple columns UPDATE) + local base_query = "UPDATE bookinfo SET %s=? WHERE directory=? AND filename=?" + for k, v in pairs(props) do + local this_prop_query = string.format(base_query, k) -- add column name to query + local stmt = self.db_conn:prepare(this_prop_query) + if v == false then -- convert false to nil (NULL) + v = nil + end + stmt:bind(v, directory, filename) + stmt:step() -- commited + stmt:clearbind():reset() -- cleanup + end +end + +function BookInfoManager:deleteBookInfo(filepath) + local directory, filename = splitFilePathName(filepath) + self:openDbConnection() + local query = "DELETE FROM bookinfo WHERE directory=? AND filename=?" + local stmt = self.db_conn:prepare(query) + stmt:bind(directory, filename) + stmt:step() -- commited + stmt:clearbind():reset() -- cleanup +end + +function BookInfoManager:removeNonExistantEntries() + self:openDbConnection() + local res = self.db_conn:exec("SELECT bcid, directory || filename FROM bookinfo") + local bcids = res[1] + local filepaths = res[2] + local bcids_to_remove = {} + for i, filepath in ipairs(filepaths) do + if lfs.attributes(filepath, "mode") ~= "file" then + table.insert(bcids_to_remove, tonumber(bcids[i])) + end + end + local query = "DELETE FROM bookinfo WHERE bcid=?" + local stmt = self.db_conn:prepare(query) + for i=1, #bcids_to_remove do + stmt:bind(bcids_to_remove[i]) + stmt:step() -- commited + stmt:clearbind():reset() -- cleanup + end + return T(_("Removed %1 / %2 entries from cache."), #bcids_to_remove, #bcids) +end + +-- Background extraction management +function BookInfoManager:collectSubprocesses() + -- We need to regularly watch if a sub-process has terminated by + -- calling waitpid() so this process does not become a zombie hanging + -- around till we exit. + if #self.subprocesses_pids > 0 then + local i = 1 + while i <= #self.subprocesses_pids do -- clean in-place + local pid = self.subprocesses_pids[i] + if xutil.isSubProcessDone(pid) then + table.remove(self.subprocesses_pids, i) + else + i = i + 1 + end + end + if #self.subprocesses_pids > 0 then + -- still some pids around, we'll need to collect again + self.subprocesses_collector = UIManager:scheduleIn( + self.subprocesses_collect_interval, function() + self:collectSubprocesses() + end + ) + -- If we're still waiting for some subprocess, and none have + -- been submitted for some time, it's that one is stuck (and that + -- the user has not left FileManager or changed page - that would + -- have caused a terminateBackgroundJobs() - if we're here, it's + -- that user has left reader in FileBrower and went away) + if util.gettime() > self.subprocesses_last_added_ts + self.subprocesses_killall_timeout_seconds then + logger.warn("Some subprocess were running for too long, killing them") + self:terminateBackgroundJobs() + -- we'll collect them next time we're run + end + else + self.subprocesses_collector = nil + end + end +end + +function BookInfoManager:terminateBackgroundJobs() + logger.dbg("terminating", #self.subprocesses_pids, "subprocesses") + for i=1, #self.subprocesses_pids do + xutil.terminateSubProcess(self.subprocesses_pids[i]) + end +end + +function BookInfoManager:isExtractingInBackground() + return #self.subprocesses_pids > 0 +end + +function BookInfoManager:extractInBackground(files) + if #files == 0 then + return + end + + -- Terminate any previous extraction background task that would be still running + self:terminateBackgroundJobs() + + -- Close current handle on sqlite, so it's not shared by both processes + -- (both processes will re-open one when needed) + BookInfoManager:closeDbConnection() + + -- Define task that will be run in subprocess + local task = function() + logger.dbg(" BG extraction started") + for idx = 1, #files do + local filepath = files[idx].filepath + local cover_specs = files[idx].cover_specs + logger.dbg(" BG extracting:", filepath) + self:extractBookInfo(filepath, cover_specs) + util.usleep(100000) -- give main process 100ms of free cpu to do its processing + end + logger.dbg(" BG extraction done") + end + + -- Run task in sub-process, and remember its pid + local task_pid = xutil.runInSubProcess(task) + if not task_pid then + logger.warn("Failed lauching background extraction sub-process (fork failed)") + return false -- let caller know it failed + end + table.insert(self.subprocesses_pids, task_pid) + self.subprocesses_last_added_ts = util.gettime() + + -- We need to collect terminated jobs pids (so they do not stay "zombies" + -- and fill linux processes table) + -- We set a single scheduled action for that + if not self.subprocesses_collector then -- there's not one already scheduled + self.subprocesses_collector = UIManager:scheduleIn( + self.subprocesses_collect_interval, function() + self:collectSubprocesses() + end + ) + end + return true +end + +BookInfoManager:init() + +return BookInfoManager diff --git a/plugins/coverbrowser.koplugin/covermenu.lua b/plugins/coverbrowser.koplugin/covermenu.lua new file mode 100644 index 000000000..4d35ef98e --- /dev/null +++ b/plugins/coverbrowser.koplugin/covermenu.lua @@ -0,0 +1,465 @@ +local Device = require("device") +local DocumentRegistry = require("document/documentregistry") +local FileManagerBookInfo = require("apps/filemanager/filemanagerbookinfo") +local ImageViewer = require("ui/widget/imageviewer") +local Menu = require("ui/widget/menu") +local TextViewer = require("ui/widget/textviewer") +local UIManager = require("ui/uimanager") +local logger = require("logger") +local util = require("ffi/util") +local _ = require("gettext") + +local BookInfoManager = require("bookinfomanager") + +-- This is a kind of "base class" for both MosaicMenu and ListMenu. +-- It implements the common code shared by these, mostly the non-UI +-- work : the updating of items and the management of backgrouns jobs. +-- +-- Here are defined the common overriden methods of Menu: +-- :updateItems(select_number) +-- :onCloseWidget() +-- :onSwipe(arg, ges_ev) +-- +-- MosaicMenu or ListMenu should implement specific UI methods: +-- :_recalculateDimen() +-- :_updateItemsBuildUI() +-- This last method is called in the middle of :updateItems() , and +-- should fill self.item_group with some specific UI layout. It may add +-- not found item to self.items_to_update for us to update() them +-- regularly. + +-- Simple holder of methods that will replace those +-- in the real Menu class or instance +local CoverMenu = {} + +function CoverMenu:updateItems(select_number) + -- As done in Menu:updateItems() + local old_dimen = self.dimen and self.dimen:copy() + -- self.layout must be updated for focusmanager + self.layout = {} + self.item_group:free() -- avoid memory leaks by calling free() on all our sub-widgets + self.item_group:clear() + -- strange, best here if resetLayout() are done after _recalculateDimen(), + -- unlike what is done in menu.lua + self:_recalculateDimen() + self.page_info:resetLayout() + self.return_button:resetLayout() + -- default to select the first item + if not select_number then + select_number = 1 + end + + -- Reset the list of items not found in db that will need to + -- be updated by a scheduled action + self.items_to_update = {} + -- Cancel any previous (now obsolete) scheduled update + if self.items_update_action then + UIManager:unschedule(self.items_update_action) + self.items_update_action = nil + end + + -- Force garbage collecting before drawing a new page. + -- It's not really needed from a memory usage point of view, we did + -- all the free() where necessary, and koreader memory usage seems + -- stable when file browsing only (15-25 MB). + -- But I witnessed some freezes after browsing a lot when koreader's main + -- process was using 100% cpu (and some slow downs while drawing soon before + -- the freeze, like the full refresh happening before the final drawing of + -- new text covers), while still having a small memory usage (20/30 Mb) + -- that I suspect may be some garbage collecting happening at one point + -- and getting stuck... + -- With this, garbage collecting may be more deterministic, and it has + -- no negative impact on user experience. + collectgarbage() + collectgarbage() + + -- Specific UI building implementation (defined in some other module) + self:_updateItemsBuildUI() + + -- As done in Menu:updateItems() + if self.item_group[1] then + if not Device:isTouchDevice() then + -- only draw underline for nontouch device + -- reset focus manager accordingly + self.selected = { x = 1, y = select_number } + -- set focus to requested menu item + self.item_group[select_number]:onFocus() + -- This will not work with our MosaicMenu, as a MosaicMenuItem is + -- not a direct child of item_group (which contains VerticalSpans + -- and HorizontalGroup...) + end + -- update page information + self.page_info_text:setText(util.template(_("page %1 of %2"), self.page, self.page_num)) + self.page_info_left_chev:showHide(self.page_num > 1) + self.page_info_right_chev:showHide(self.page_num > 1) + self.page_info_first_chev:showHide(self.page_num > 2) + self.page_info_last_chev:showHide(self.page_num > 2) + self.page_return_arrow:showHide(self.onReturn ~= nil) + + self.page_info_left_chev:enableDisable(self.page > 1) + self.page_info_right_chev:enableDisable(self.page < self.page_num) + self.page_info_first_chev:enableDisable(self.page > 1) + self.page_info_last_chev:enableDisable(self.page < self.page_num) + self.page_return_arrow:enableDisable(#self.paths > 0) + else + self.page_info_text:setText(_("No choices available")) + end + UIManager:setDirty("all", function() + local refresh_dimen = + old_dimen and old_dimen:combine(self.dimen) + or self.dimen + return "ui", refresh_dimen + end) + + -- As additionally done in FileChooser:updateItems() + if self.path_items then + self.path_items[self.path] = (self.page - 1) * self.perpage + (select_number or 1) + end + + -- Deal with items not found in db + if #self.items_to_update > 0 then + -- Prepare for background info extraction job + local files_to_index = {} -- table of {filepath, cover_specs} + for i=1, #self.items_to_update do + table.insert(files_to_index, { + filepath = self.items_to_update[i].filepath, + cover_specs = self.items_to_update[i].cover_specs + }) + end + -- Launch it at nextTick, so UIManager can render us smoothly + UIManager:nextTick(function() + local launched = BookInfoManager:extractInBackground(files_to_index) + if not launched then -- fork failed (never experienced that, but let's deal with it) + -- Cancel scheduled update, as it won't get any result + if self.items_update_action then + UIManager:unschedule(self.items_update_action) + self.items_update_action = nil + end + local InfoMessage = require("ui/widget/infomessage") + UIManager:show(InfoMessage:new{ + text = _("Start-up of background extraction job failed.\nPlease restart KOReader or your device.") + }) + end + end) + + -- Scheduled update action + self.items_update_action = function() + logger.dbg("Scheduled items update:", #self.items_to_update, "waiting") + local is_still_extracting = BookInfoManager:isExtractingInBackground() + local i = 1 + while i <= #self.items_to_update do -- process and clean in-place + local item = self.items_to_update[i] + item:update() + if item.bookinfo_found then + logger.dbg(" found", item.text) + local refreshfunc = function() + if item.refresh_dimen then + -- MosaicMenuItem may exceed its own dimen in its paintTo + -- with its "description" hint + return "ui", item.refresh_dimen + else + return "ui", item[1].dimen + end + end + UIManager:setDirty(self.show_parent, refreshfunc) + table.remove(self.items_to_update, i) + else + logger.dbg(" not yet found", item.text) + i = i + 1 + end + end + if #self.items_to_update > 0 then -- re-schedule myself + if is_still_extracting then -- we have still chances to get new stuff + logger.dbg("re-scheduling items update:", #self.items_to_update, "still waiting") + UIManager:scheduleIn(1, self.items_update_action) + else + logger.dbg("Not all items found, but background extraction has stopped, not re-scheduling") + end + else + logger.dbg("items update completed") + end + end + UIManager:scheduleIn(1, self.items_update_action) + end + + -- (We may not need to do the following if we extend onFileHold + -- code in filemanager.lua to check for existence and call a + -- method: self:getAdditionalButtons() to add our buttons + -- to its own set.) + + -- We want to add some buttons to the onFileHold popup. This function + -- is dynamically created by FileManager:init(), and we don't want + -- to override this... So, here, when we see the onFileHold function, + -- we replace it by ours. + -- (FileManager may replace file_chooser.onFileHold after we've been called once, so we need + -- to replace it again if it is not ours) + if not self.onFileHold_ours -- never replaced + or self.onFileHold ~= self.onFileHold_ours then -- it is no more ours + -- Store original function, so we can call it + self.onFileHold_orig = self.onFileHold + + -- Replace it with ours + -- This causes luacheck warning: "shadowing upvalue argument 'self' on line 34". + -- Ignoring it (as done in filemanager.lua for the same onFileHold) + self.onFileHold = function(self, file) -- luacheck: ignore + -- Call original function: it will create a ButtonDialogTitle + -- and store it as self.file_dialog, and UIManager:show() it. + self.onFileHold_orig(self, file) + + local bookinfo = BookInfoManager:getBookInfo(file) + if not bookinfo then + -- If no bookinfo (yet) about this file, let the original dialog be + return true + end + + -- Remember some of this original ButtonDialogTitle properties + local orig_title = self.file_dialog.title + local orig_title_align = self.file_dialog.title_align + local orig_buttons = self.file_dialog.buttons + -- Close original ButtonDialogTitle (it has not yet been painted + -- on screen, so we won't see it) + UIManager:close(self.file_dialog) + + -- Replace Book information callback to use directly our bookinfo + orig_buttons[4][2].callback = function() + FileManagerBookInfo:show(file, bookinfo) + UIManager:close(self.file_dialog) + end + + -- Add some new buttons to original buttons set + table.insert(orig_buttons, { + { -- Allow user to view real size cover in ImageViewer + text = _("View full size cover"), + enabled = bookinfo.has_cover and true or false, + callback = function() + local document = DocumentRegistry:openDocument(file) + if document then + local cover_bb = document:getCoverPageImage() + local imgviewer = ImageViewer:new{ + image = cover_bb, + with_title_bar = false, + fullscreen = true, + } + UIManager:show(imgviewer) + UIManager:close(self.file_dialog) + DocumentRegistry:closeDocument(file) + end + end, + }, + { -- Allow user to directly view description in TextViewer + text = bookinfo.description and _("View book description") or _("No book description"), + enabled = bookinfo.description and true or false, + callback = function() + local description = require("util").htmlToPlainTextIfHtml(bookinfo.description) + local textviewer = TextViewer:new{ + title = bookinfo.title, + text = description, + } + UIManager:show(textviewer) + UIManager:close(self.file_dialog) + end, + }, + }) + table.insert(orig_buttons, { + { -- Allow user to ignore some offending cover image + text = bookinfo.ignore_cover and _("Unignore cover") or _("Ignore cover"), + enabled = bookinfo.has_cover and true or false, + callback = function() + BookInfoManager:setBookInfoProperties(file, { + ["ignore_cover"] = not bookinfo.ignore_cover and 'Y' or false, + }) + UIManager:close(self.file_dialog) + self:updateItems() + end, + }, + { -- Allow user to ignore some bad metadata (filename will be used instead) + text = bookinfo.ignore_meta and _("Unignore metadata") or _("Ignore metadata"), + enabled = bookinfo.has_meta and true or false, + callback = function() + BookInfoManager:setBookInfoProperties(file, { + ["ignore_meta"] = not bookinfo.ignore_meta and 'Y' or false, + }) + UIManager:close(self.file_dialog) + self:updateItems() + end, + }, + }) + table.insert(orig_buttons, { + { -- Allow a new extraction (multiple interruptions, book replaced)... + text = _("Refresh cached book information"), + enabled = bookinfo and true or false, + callback = function() + BookInfoManager:deleteBookInfo(file) + UIManager:close(self.file_dialog) + self:updateItems() + end, + }, + }) + + -- Create the new ButtonDialogTitle, and let UIManager show it + local ButtonDialogTitle = require("ui/widget/buttondialogtitle") + self.file_dialog = ButtonDialogTitle:new{ + title = orig_title, + title_align = orig_title_align, + buttons = orig_buttons, + } + UIManager:show(self.file_dialog) + return true + end + + -- Remember our function + self.onFileHold_ours = self.onFileHold + end +end + +-- Similar to onFileHold setup just above, but for History, +-- which is plugged in main.lua _FileManagerHistory_updateItemTable() +function CoverMenu:onHistoryMenuHold(item) + -- Call original function: it will create a ButtonDialog + -- and store it as self.histfile_dialog, and UIManager:show() it. + self.onMenuHold_orig(self, item) + local file = item.file + + local bookinfo = BookInfoManager:getBookInfo(file) + if not bookinfo then + -- If no bookinfo (yet) about this file, let the original dialog be + return true + end + + -- Remember some of this original ButtonDialog properties + local orig_buttons = self.histfile_dialog.buttons + -- Close original ButtonDialog (it has not yet been painted + -- on screen, so we won't see it) + UIManager:close(self.histfile_dialog) + + -- Replace Book information callback to use directly our bookinfo + orig_buttons[2][1].callback = function() + FileManagerBookInfo:show(file, bookinfo) + UIManager:close(self.histfile_dialog) + end + -- Re-organise buttons to make them more coherent with those we're going to add + -- Move up "Clear history of deleted items" and down "Book information", so + -- it's now similar to File browser's onFileHold + -- (The original organisation is fine in classic mode) + orig_buttons[2], orig_buttons[4] = orig_buttons[4], orig_buttons[2] + + -- Add some new buttons to original buttons set + table.insert(orig_buttons, { + { -- Allow user to view real size cover in ImageViewer + text = _("View full size cover"), + enabled = bookinfo.has_cover and true or false, + callback = function() + local document = DocumentRegistry:openDocument(file) + if document then + local cover_bb = document:getCoverPageImage() + local imgviewer = ImageViewer:new{ + image = cover_bb, + with_title_bar = false, + fullscreen = true, + } + UIManager:show(imgviewer) + UIManager:close(self.histfile_dialog) + DocumentRegistry:closeDocument(file) + end + end, + }, + { -- Allow user to directly view description in TextViewer + text = bookinfo.description and _("View book description") or _("No book description"), + enabled = bookinfo.description and true or false, + callback = function() + local description = require("util").htmlToPlainTextIfHtml(bookinfo.description) + local textviewer = TextViewer:new{ + title = bookinfo.title, + text = description, + } + UIManager:show(textviewer) + UIManager:close(self.histfile_dialog) + end, + }, + }) + table.insert(orig_buttons, { + { -- Allow user to ignore some offending cover image + text = bookinfo.ignore_cover and _("Unignore cover") or _("Ignore cover"), + enabled = bookinfo.has_cover and true or false, + callback = function() + BookInfoManager:setBookInfoProperties(file, { + ["ignore_cover"] = not bookinfo.ignore_cover and 'Y' or false, + }) + UIManager:close(self.histfile_dialog) + self:updateItems() + end, + }, + { -- Allow user to ignore some bad metadata (filename will be used instead) + text = bookinfo.ignore_meta and _("Unignore metadata") or _("Ignore metadata"), + enabled = bookinfo.has_meta and true or false, + callback = function() + BookInfoManager:setBookInfoProperties(file, { + ["ignore_meta"] = not bookinfo.ignore_meta and 'Y' or false, + }) + UIManager:close(self.histfile_dialog) + self:updateItems() + end, + }, + }) + table.insert(orig_buttons, { + { -- Allow a new extraction (multiple interruptions, book replaced)... + text = _("Refresh cached book information"), + enabled = bookinfo and true or false, + callback = function() + BookInfoManager:deleteBookInfo(file) + UIManager:close(self.histfile_dialog) + self:updateItems() + end, + }, + }) + + -- Create the new ButtonDialog, and let UIManager show it + local ButtonDialog = require("ui/widget/buttondialog") + self.histfile_dialog = ButtonDialog:new{ + buttons = orig_buttons, + } + UIManager:show(self.histfile_dialog) + return true +end + +function CoverMenu:onCloseWidget() + -- Due to close callback in FileManagerHistory:onShowHist, we may be called + -- multiple times (witnessed that with print(debug.traceback()) + + -- Stop background job if any (so that full cpu is available to reader) + logger.dbg("CoverMenu:onCloseWidget: terminating jobs if needed") + BookInfoManager:terminateBackgroundJobs() + BookInfoManager:closeDbConnection() -- sqlite connection no more needed + + -- Cancel any still scheduled update + if self.items_update_action then + logger.dbg("CoverMenu:onCloseWidget: unscheduling items_update_action") + UIManager:unschedule(self.items_update_action) + self.items_update_action = nil + end + + -- Propagate a call to free() to all our sub-widgets, to release memory used by their _bb + self.item_group:free() + + -- Force garbage collecting when leaving too + collectgarbage() + collectgarbage() + + -- Call original Menu:onCloseWidget (no subclass seems to override it) + Menu.onCloseWidget(self) +end + +-- Overriden just to allow full refresh (useful with images) +function CoverMenu:onSwipe(arg, ges_ev) + if ges_ev.direction == "west" then + self:onNextPage() + elseif ges_ev.direction == "east" then + self:onPrevPage() + elseif ges_ev.direction ~= "north" and ges_ev.direction ~= "south" then + -- but not if north/south, and we're triggering menu + -- trigger full refresh + UIManager:setDirty(nil, "full") + end +end + +return CoverMenu diff --git a/plugins/coverbrowser.koplugin/listmenu.lua b/plugins/coverbrowser.koplugin/listmenu.lua new file mode 100644 index 000000000..a75b4491b --- /dev/null +++ b/plugins/coverbrowser.koplugin/listmenu.lua @@ -0,0 +1,743 @@ +local Blitbuffer = require("ffi/blitbuffer") +local CenterContainer = require("ui/widget/container/centercontainer") +local Device = require("device") +local DocSettings = require("docsettings") +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 ImageWidget = require("ui/widget/imagewidget") +local InfoMessage = require("ui/widget/infomessage") +local InputContainer = require("ui/widget/container/inputcontainer") +local LeftContainer = require("ui/widget/container/leftcontainer") +local LineWidget = require("ui/widget/linewidget") +local OverlapGroup = require("ui/widget/overlapgroup") +local RightContainer = require("ui/widget/container/rightcontainer") +local TextBoxWidget = require("ui/widget/textboxwidget") +local TextWidget = require("ui/widget/textwidget") +local UIManager = require("ui/uimanager") +local UnderlineContainer = require("ui/widget/container/underlinecontainer") +local VerticalGroup = require("ui/widget/verticalgroup") +local VerticalSpan = require("ui/widget/verticalspan") +local WidgetContainer = require("ui/widget/container/widgetcontainer") +local lfs = require("libs/libkoreader-lfs") +local util = require("util") +local _ = require("gettext") +local Screen = Device.screen +local T = require("ffi/util").template + +local BookInfoManager = require("bookinfomanager") + +-- Here is the specific UI implementation for "list" display modes +-- (see covermenu.lua for the generic code) + +-- We will show a rotated dogear at bottom right corner of cover widget for +-- opened files (the dogear will make it look like a "used book") +local corner_mark = ImageWidget:new{ + file = "resources/icons/dogear.png", + rotation_angle = 270 +} + +-- ItemShortCutIcon (for keyboard navigation) is private to menu.lua and can't be accessed, +-- so we need to redefine it +local ItemShortCutIcon = WidgetContainer:new{ + dimen = Geom:new{ w = 22, h = 22 }, + key = nil, + bordersize = 2, + radius = 0, + style = "square", +} + +function ItemShortCutIcon:init() + if not self.key then + return + end + local radius = 0 + local background = Blitbuffer.COLOR_WHITE + if self.style == "rounded_corner" then + radius = math.floor(self.width/2) + elseif self.style == "grey_square" then + background = Blitbuffer.gray(0.2) + end + local sc_face + if self.key:len() > 1 then + sc_face = Font:getFace("ffont", 14) + else + sc_face = Font:getFace("scfont", 22) + end + self[1] = FrameContainer:new{ + padding = 0, + bordersize = self.bordersize, + radius = radius, + background = background, + dimen = self.dimen, + CenterContainer:new{ + dimen = self.dimen, + TextWidget:new{ + text = self.key, + face = sc_face, + }, + }, + } +end + + +-- Based on menu.lua's MenuItem +local ListMenuItem = InputContainer:new{ + entry = {}, + text = nil, + show_parent = nil, + detail = nil, + dimen = nil, + shortcut = nil, + shortcut_style = "square", + _underline_container = nil, + do_cover_image = false, + do_filename_only = false, + do_hint_opened = false, + been_opened = false, + init_done = false, + bookinfo_found = false, + cover_specs = nil, + has_description = false, +} + +function ListMenuItem:init() + -- filepath may be provided as 'file' (history) or 'path' (filechooser) + -- store it as attribute so we can use it elsewhere + self.filepath = self.entry.file or self.entry.path + + -- As done in MenuItem + -- Squared letter for keyboard navigation + if self.shortcut then + local shortcut_icon_dimen = Geom:new() + shortcut_icon_dimen.w = math.floor(self.dimen.h*2/5) + shortcut_icon_dimen.h = shortcut_icon_dimen.w + -- To keep a simpler widget structure, this shortcut icon will not + -- be part of it, but will be painted over the widget in our paintTo + self.shortcut_icon = ItemShortCutIcon:new{ + dimen = shortcut_icon_dimen, + key = self.shortcut, + style = self.shortcut_style, + } + end + self.detail = self.text + + -- we need this table per-instance, so we declare it here + if Device:isTouchDevice() then + self.ges_events = { + TapSelect = { + GestureRange:new{ + ges = "tap", + range = self.dimen, + }, + doc = "Select Menu Item", + }, + HoldSelect = { + GestureRange:new{ + ges = "hold", + range = self.dimen, + }, + doc = "Hold Menu Item", + }, + } + end + if Device:hasKeys() then + self.active_key_events = { + Select = { {"Press"}, doc = "chose selected item" }, + } + end + + -- We now build the minimal widget container that won't change after udpate() + + -- As done in MenuItem + -- for compatibility with keyboard navigation + -- (which does not seem to work well when multiple pages, + -- even with classic menu) + self.underline_h = 1 -- smaller than default (3) to not shift our vertical aligment + self._underline_container = UnderlineContainer:new{ + vertical_align = "center", + dimen = Geom:new{ + w = self.width, + h = self.height + }, + linesize = self.underline_h, + padding = 0, + -- widget : will be filled in self:update() + } + self[1] = self._underline_container + + -- Remaining part of initialization is done in update(), because we may + -- have to do it more than once if item not found in db + self:update() + self.init_done = true +end + +function ListMenuItem:update() + -- We will be a disctinctive widget whether we are a directory, + -- a known file with image / without image, or a not yet known file + local widget + + -- we'll add a VerticalSpan of same size as underline container for balance + local dimen = Geom:new{ + w = self.width, + h = self.height - 2 * self.underline_h + } + + if lfs.attributes(self.filepath, "mode") == "directory" then + self.is_directory = true + -- nb items on the right, directory name on the left + local wright = TextWidget:new{ + text = self.mandatory, + face = Font:getFace("infont", 15), + } + local wleft_width = dimen.w - wright:getSize().w + local wleft = TextBoxWidget:new{ + text = self.text, + face = Font:getFace("cfont", 20), + width = wleft_width, + alignment = "left", + bold = true, + } + widget = OverlapGroup:new{ + dimen = dimen, + LeftContainer:new{ + dimen = dimen, + HorizontalGroup:new{ + HorizontalSpan:new{ width = Screen:scaleBySize(10) }, + wleft, + } + }, + RightContainer:new{ + dimen = dimen, + HorizontalGroup:new{ + wright, + HorizontalSpan:new{ width = Screen:scaleBySize(10) }, + }, + }, + } + else + -- File + local border_size = 1 + local max_img_w = dimen.h - 2*border_size -- width = height, squared + local max_img_h = dimen.h - 2*border_size + + local bookinfo = BookInfoManager:getBookInfo(self.filepath, self.do_cover_image) + if bookinfo and self.do_cover_image and not bookinfo.ignore_cover then + if not bookinfo.cover_fetched then + -- cover was not fetched previously, do as if not found + -- to force a new extraction + bookinfo = nil + end + -- If there's already a cover and it's a "M" size (MosaicMenuItem), + -- we'll use it and scale it down (it may slow a bit rendering, + -- but "M" size may be useful in another view (FileBrowser/History), + -- so we don't replace it). + end + + if bookinfo then -- This book is known + self.bookinfo_found = true + local cover_bb_used = false + + -- Build the left widget : image if wanted + local wleft = nil + local wleft_width = 0 -- if not do_cover_image + local wleft_height + if self.do_cover_image then + wleft_height = dimen.h + wleft_width = wleft_height -- make it squared + if bookinfo.has_cover and not bookinfo.ignore_cover then + cover_bb_used = true + -- Let ImageWidget do the scaling and give us the final size + local scale_factor = math.min(max_img_w / bookinfo.cover_w, max_img_h / bookinfo.cover_h) + local wimage = ImageWidget:new{ + image = bookinfo.cover_bb, + scale_factor = scale_factor, + } + wimage:_render() + local image_size = wimage:getSize() -- get final widget size + wleft = CenterContainer:new{ + dimen = Geom:new{ w = wleft_width, h = wleft_height }, + FrameContainer:new{ + width = image_size.w + 2*border_size, + height = image_size.h + 2*border_size, + margin = 0, + padding = 0, + bordersize = border_size, + wimage, + } + } + else + -- empty element the size of an image + wleft = CenterContainer:new{ + dimen = Geom:new{ w = wleft_width, h = wleft_height }, + HorizontalSpan:new{ width = wleft_width }, + } + end + end + -- In case we got a blitbuffer and didnt use it (ignore_cover), free it + if bookinfo.cover_bb and not cover_bb_used then + bookinfo.cover_bb:free() + end + -- So we can draw an indicator if this book has a description + if bookinfo.description then + self.has_description = true + end + + -- Gather some info, mostly for right widget: + -- file size (self.mandatory) (not available with History) + -- file type + -- pages read / nb of pages (not available for crengine doc not opened) + local directory, filename = util.splitFilePathName(self.filepath) -- luacheck: no unused + local filename_without_suffix, filetype = util.splitFileNameSuffix(filename) + local fileinfo_str = filetype + if self.mandatory then + fileinfo_str = self.mandatory .. " " .. fileinfo_str + end + -- Current page / pages are available or more accurate in .sdr/metadata.lua + local pages_str = "" + local percent_finished + local pages = bookinfo.pages -- default to those in bookinfo db + if DocSettings:hasSidecarFile(self.filepath) then + self.been_opened = true + local docinfo = DocSettings:open(self.filepath) + -- We can get nb of page in the new 'doc_pages' setting, or from the old 'stats.page' + if docinfo.data.doc_pages then + pages = docinfo.data.doc_pages + elseif docinfo.data.stats and docinfo.data.stats.pages then + if docinfo.data.stats.pages ~= 0 then -- crengine with statistics disabled stores 0 + pages = docinfo.data.stats.pages + end + end + percent_finished = docinfo.data.percent_finished + end + if percent_finished then + if pages then + pages_str = T(_("%1 % of %2 pages"), math.floor(100*percent_finished), pages) + else + pages_str = string.format("%d %%", math.floor(100*percent_finished)) + end + else + if pages then + pages_str = T(_("%1 pages"), pages) + end + end + + -- Build the right widget + + local wfileinfo = TextWidget:new{ + text = fileinfo_str, + face = Font:getFace("cfont", 14), + } + local wpageinfo = TextWidget:new{ + text = pages_str, + face = Font:getFace("cfont", 14), + } + + local wright_width = math.max(wfileinfo:getSize().w, wpageinfo:getSize().w) + local wright_right_padding = Screen:scaleBySize(10) + + -- We just built two string to be put one on top of the other, and we want + -- the combo centered. Given the nature of our strings (numbers, + -- uppercase MB/KB on top text, number and lowercase "page" on bottom text), + -- we get the annoying feeling it's not centered but shifted towards top. + -- Let's add a small VerticalSpan at top to give a better visual + -- feeling of centering. + local wright = CenterContainer:new{ + dimen = Geom:new{ w = wright_width, h = dimen.h }, + VerticalGroup:new{ + align = "right", + VerticalSpan:new{ width = Screen:scaleBySize(2) }, + wfileinfo, + wpageinfo, + } + } + + + -- Build the middle main widget, in the space available + local wmain_left_padding = Screen:scaleBySize(10) + if self.do_cover_image then + -- we need less padding, as cover image, most often in + -- portrait mode, will provide some padding + wmain_left_padding = Screen:scaleBySize(5) + end + local wmain_right_padding = Screen:scaleBySize(10) -- used only for next calculation + local wmain_width = dimen.w - wleft_width - wmain_left_padding - wmain_right_padding - wright_width - wright_right_padding + + local fontname_title = "cfont" + local fontname_authors = "cfont" + local fontsize_title = 20 + local fontsize_authors = 18 + local wtitle, wauthors + local title, authors + -- whether to use or not title and authors + if self.do_filename_only or bookinfo.ignore_meta then + title = filename_without_suffix -- made out above + authors = nil + else + title = bookinfo.title and bookinfo.title or filename_without_suffix + authors = bookinfo.authors + end + -- add Series metadata if requested + if bookinfo.series then + if BookInfoManager:getSetting("append_series_to_title") then + if title then + title = title .. " - " .. bookinfo.series + else + title = bookinfo.series + end + end + if BookInfoManager:getSetting("append_series_to_authors") then + if authors then + authors = authors .. " - " .. bookinfo.series + else + authors = bookinfo.series + end + end + end + if bookinfo.unsupported then + -- Let's show this fact in place of the anyway empty authors slot + authors = T(_("(no book information: %1)"), bookinfo.unsupported) + end + -- Build title and authors texts with decreasing font size + -- till it fits in the space available + while true do + -- Free previously made widgets to avoid memory leaks + if wtitle then + wtitle:free() + end + if wauthors then + wauthors:free() + wauthors = nil + end + -- BookInfoManager:extractBookInfo() made sure + -- to save as nil (NULL) metadata that were an empty string + wtitle = TextBoxWidget:new{ + text = title, + face = Font:getFace(fontname_title, fontsize_title), + width = wmain_width, + alignment = "left", + bold = true, + } + local height = wtitle:getSize().h + if authors then + wauthors = TextBoxWidget:new{ + text = authors, + face = Font:getFace(fontname_authors, fontsize_authors), + width = wmain_width, + alignment = "left", + } + height = height + wauthors:getSize().h + end + if height < dimen.h then -- we fit ! + break + end + -- If we don't fit, decrease both font sizes + fontsize_title = fontsize_title - 1 + fontsize_authors = fontsize_authors - 1 + -- Don't go too low, and get out of this loop + if fontsize_title < 3 or fontsize_authors < 3 then + break + end + end + + local wmain = LeftContainer:new{ + dimen = dimen, + VerticalGroup:new{ + wtitle, + wauthors, + } + } + + -- Build the final widget + widget = OverlapGroup:new{ + dimen = dimen, + } + if self.do_cover_image then + -- add left widget + if wleft then + -- no need for left padding, as cover image, most often in + -- portrait mode, will have some padding - the rare landscape + -- mode cover image will be stuck to screen side thus + table.insert(widget, wleft) + end + -- pad main widget on the left with size of left widget + wmain = HorizontalGroup:new{ + HorizontalSpan:new{ width = wleft_width }, + HorizontalSpan:new{ width = wmain_left_padding }, + wmain + } + else + -- pad main widget on the left + wmain = HorizontalGroup:new{ + HorizontalSpan:new{ width = wmain_left_padding }, + wmain + } + end + -- add padded main widget + table.insert(widget, LeftContainer:new{ + dimen = dimen, + wmain + }) + -- add right widget + table.insert(widget, RightContainer:new{ + dimen = dimen, + HorizontalGroup:new{ + wright, + HorizontalSpan:new{ width = wright_right_padding }, + }, + }) + + else -- bookinfo not found + if self.init_done then + -- Non-initial update(), but our widget is still not found: + -- it does not need to change, so avoid remaking the same widget + return + end + -- If we're in no image mode, don't save images in DB : people + -- who don't care about images will have a smaller DB, but + -- a new extraction will have to be made when one switch to image mode + if self.do_cover_image then + -- Not in db, we're going to fetch some cover + self.cover_specs = { + sizetag = "s", + max_cover_w = max_img_w, + max_cover_h = max_img_h, + } + end + -- + if self.do_hint_opened and DocSettings:hasSidecarFile(self.filepath) then + self.been_opened = true + end + -- A real simple widget, nothing fancy + widget = LeftContainer:new{ + dimen = dimen, + HorizontalGroup:new{ + HorizontalSpan:new{ width = Screen:scaleBySize(10) }, + TextBoxWidget:new{ + text = self.text .. "…", -- display hint it's being loaded + face = Font:getFace("cfont", 18), + width = dimen.w - 2 * Screen:scaleBySize(10), + alignment = "left", + } + }, + } + end + end + + -- Fill container with our widget + if self._underline_container[1] then + -- There is a previous one, that we need to free() + local previous_widget = self._underline_container[1] + previous_widget:free() + end + -- Add some pad at top to balance with hidden underline line at bottom + self._underline_container[1] = VerticalGroup:new{ + VerticalSpan:new{ width = self.underline_h }, + widget + } +end + +function ListMenuItem:paintTo(bb, x, y) + -- We may get non-integer x or y (see mosaicmenu.lua) + -- Make them integer: + x = math.floor(x) + y = math.floor(y) + + -- Original painting + InputContainer.paintTo(self, bb, x, y) + + -- to which we paint over the shortcut icon + if self.shortcut_icon then + -- align it on bottom left corner of sub-widget + local target = self[1][1][2] + local ix = 0 + local iy = target.dimen.h - self.shortcut_icon.dimen.h + self.shortcut_icon:paintTo(bb, x+ix, y+iy) + end + + -- to which we paint over a dogear if needed + if self.do_hint_opened and self.been_opened then + -- align it on bottom right corner of widget + local ix = self.width - corner_mark:getSize().w + local iy = self.height - corner_mark:getSize().h + corner_mark:paintTo(bb, x+ix, y+iy) + end + + -- to which we paint a small indicator if this book has a description + if self.has_description and not BookInfoManager:getSetting("no_hint_description") then + local target = self[1][1][2] + local d_w = Screen:scaleBySize(3) + local d_h = math.ceil(target.dimen.h / 4) + if self.do_cover_image and target[1][1][1] then + -- it has an image, align it on image's framecontainer's right border + target = target[1][1] + bb:paintBorder(target.dimen.x + target.dimen.w - 1, target.dimen.y, d_w, d_h, 1) + else + -- no image, align it to the left border + bb:paintBorder(x, y, d_w, d_h, 1) + end + end +end + +-- As done in MenuItem +function ListMenuItem:onFocus() + self._underline_container.color = Blitbuffer.COLOR_BLACK + self.key_events = self.active_key_events + return true +end + +function ListMenuItem:onUnfocus() + self._underline_container.color = Blitbuffer.COLOR_WHITE + self.key_events = {} + return true +end + +function ListMenuItem:onShowItemDetail() + UIManager:show(InfoMessage:new{ text = self.detail, }) + return true +end + +-- The transient color inversions done in MenuItem:onTapSelect +-- and MenuItem:onHoldSelect are ugly when done on an image, +-- so let's not do it +-- Also, no need for 2nd arg 'pos' (only used in readertoc.lua) +function ListMenuItem:onTapSelect(arg) + self.menu:onMenuSelect(self.entry) + return true +end + +function ListMenuItem:onHoldSelect(arg, ges) + self.menu:onMenuHold(self.entry) + return true +end + + +-- Simple holder of methods that will replace those +-- in the real Menu class or instance +local ListMenu = {} + +function ListMenu:_recalculateDimen() + self.dimen.w = self.width + self.dimen.h = self.height or Screen:getHeight() + + -- Find out available height from other UI elements made in Menu + self.others_height = 0 + if self.title_bar then -- Menu:init() has been done + if not self.is_borderless then + self.others_height = self.others_height + 2 + end + if not self.no_title then + self.others_height = self.others_height + self.header_padding + self.others_height = self.others_height + self.title_bar.dimen.h + end + if self.page_info then + self.others_height = self.others_height + self.page_info:getSize().h + end + else + -- Menu:init() not yet done: other elements used to calculate self.others_heights + -- are not yet defined, so next calculations will be wrong, and we may get + -- a self.perpage higher than it should be: Menu:init() will set a wrong self.page. + -- We'll have to update it, if we want FileManager to get back to the original page. + self.page_recalc_needed_next_time = true + -- Also remember original position, which will be changed by Menu/FileChooser + -- to a probably wrong value + self.itemnum_orig = self.path_items[self.path] + end + local available_height = self.dimen.h - self.others_height + + -- 64 hardcoded for now, gives 10 items both in filemanager + -- and history on kobo glo hd + local item_height_min = Screen:scaleBySize(64) + self.perpage = math.floor(available_height / item_height_min) + self.page_num = math.ceil(#self.item_table / self.perpage) + + local height_remaining = available_height - self.perpage * item_height_min + height_remaining = height_remaining - (self.perpage+1) -- N+1 LineWidget separators + self.item_height = item_height_min + math.floor(height_remaining / self.perpage) + self.item_width = self.dimen.w + self.item_dimen = Geom:new{ + w = self.item_width, + h = self.item_height + } + + if self.page_recalc_needed then + -- self.page has probably been set to a wrong value, + -- we recalculate it here as done in Menu:init() + if #self.item_table > 0 then + self.page = math.ceil((self.itemnum_orig or 1) / self.perpage) + end + self.page_recalc_needed = nil + self.itemnum_orig = nil + end + if self.page_recalc_needed_next_time then + self.page_recalc_needed = true + self.page_recalc_needed_next_time = nil + end +end + +function ListMenu:_updateItemsBuildUI() + -- Build our list + -- We separate items with a 1px LineWidget (no need for + -- scaleBySize, thin is fine) + table.insert(self.item_group, LineWidget:new{ + dimen = Geom:new{ w = self.width, h = 1 }, + background = Blitbuffer.COLOR_GREY, + style = "solid", + }) + local idx_offset = (self.page - 1) * self.perpage + for idx = 1, self.perpage do + local entry = self.item_table[idx_offset + idx] + if entry == nil then break end + + -- Keyboard shortcuts, as done in Menu + local item_shortcut = nil + local shortcut_style = "square" + if self.is_enable_shortcut then + -- give different shortcut_style to keys in different + -- lines of keyboard + if idx >= 11 and idx <= 20 then + shortcut_style = "grey_square" + end + item_shortcut = self.item_shortcuts[idx] + if item_shortcut == "Enter" then + item_shortcut = "Ent" + end + end + + local item_tmp = ListMenuItem:new{ + height = self.item_height, + width = self.item_width, + entry = entry, + text = util.getMenuText(entry), + show_parent = self.show_parent, + mandatory = entry.mandatory, + dimen = self.item_dimen:new(), + shortcut = item_shortcut, + shortcut_style = shortcut_style, + menu = self, + do_cover_image = self._do_cover_images, + do_hint_opened = self._do_hint_opened, + do_filename_only = self._do_filename_only, + } + table.insert(self.item_group, item_tmp) + table.insert(self.item_group, LineWidget:new{ + dimen = Geom:new{ w = self.width, h = 1 }, + background = Blitbuffer.COLOR_GREY, + style = "solid", + }) + + -- this is for focus manager + table.insert(self.layout, {item_tmp}) + + if not item_tmp.bookinfo_found and not item_tmp.is_directory then + -- Register this item for update + table.insert(self.items_to_update, item_tmp) + end + + end +end + +return ListMenu diff --git a/plugins/coverbrowser.koplugin/main.lua b/plugins/coverbrowser.koplugin/main.lua new file mode 100644 index 000000000..11e6b7796 --- /dev/null +++ b/plugins/coverbrowser.koplugin/main.lua @@ -0,0 +1,443 @@ +local InputContainer = require("ui/widget/container/inputcontainer") +local UIManager = require("ui/uimanager") +local logger = require("logger") +local _ = require("gettext") +local BookInfoManager = require("bookinfomanager") + +--[[ + This plugin provides additional display modes to file browsers (File Manager + and History). + It does that by dynamically replacing some methods code to their classes + or instances. +--]] + +-- We need to save the original methods early here as locals. +-- For some reason, saving them as attributes in init() does not allow +-- us to get back to classic mode +local FileChooser = require("ui/widget/filechooser") +local _FileChooser__recalculateDimen_orig = FileChooser._recalculateDimen +local _FileChooser_updateItems_orig = FileChooser.updateItems +local _FileChooser_onCloseWidget_orig = FileChooser.onCloseWidget +local _FileChooser_onSwipe_orig = FileChooser.onSwipe + +local FileManagerHistory = require("apps/filemanager/filemanagerhistory") +local _FileManagerHistory_updateItemTable_orig = FileManagerHistory.updateItemTable + +-- Available display modes +local DISPLAY_MODES = { + -- nil or "" -- classic : filename only + mosaic_image = true, -- 3x3 grid covers with images + mosaic_text = true, -- 3x3 grid covers text only + list_image_meta = true, -- image with metadata (title/authors) + list_only_meta = true, -- metadata with no image + list_image_filename = true, -- image with filename (no metadata) +} + +local CoverBrowser = InputContainer:new{} + +function CoverBrowser:init() + -- As we don't know how to run and kill subprocesses on Android (for + -- background info extraction), disable this plugin for now. + -- XXX What about the emulator on Windows ? + if require("ffi/util").isAndroid() then + return + end + + self.filemanager_display_mode = BookInfoManager:getSetting("filemanager_display_mode") + self:setupFileManagerDisplayMode() + + self.history_display_mode = BookInfoManager:getSetting("history_display_mode") + self:setupHistoryDisplayMode() + + self.ui.menu:registerToMainMenu(self) + + -- If KOReader has started directly to FileManager, the FileManager + -- instance is being init()'ed and there is no FileManager.instance yet, + -- but there'll be one at next tick. + UIManager:nextTick(function() + self:refreshFileManagerInstance() + end) +end + +function CoverBrowser:addToMainMenu(menu_items) + if not self.ui.view then -- only for FileManager menu + menu_items.filemanager_display_mode = { + text = _("Display mode"), + sub_item_table = { + -- selecting these does not close menu, which may be nice + -- so one can see how they look below the menu + { + text = _("Classic (filename only)"), + checked_func = function() return not self.filemanager_display_mode end, + callback = function() + self:setupFileManagerDisplayMode("") + end, + }, + { + text = _("Mosaic with cover images"), + checked_func = function() return self.filemanager_display_mode == "mosaic_image" end, + callback = function() + self:setupFileManagerDisplayMode("mosaic_image") + end, + }, + { + text = _("Mosaic with text covers"), + checked_func = function() return self.filemanager_display_mode == "mosaic_text" end, + callback = function() + self:setupFileManagerDisplayMode("mosaic_text") + end, + }, + { + text = _("List with image and metadata"), + checked_func = function() return self.filemanager_display_mode == "list_image_meta" end, + callback = function() + self:setupFileManagerDisplayMode("list_image_meta") + end, + }, + { + text = _("List with metadata, no image"), + checked_func = function() return self.filemanager_display_mode == "list_only_meta" end, + callback = function() + self:setupFileManagerDisplayMode("list_only_meta") + end, + }, + { + text = _("List with image and filename"), + checked_func = function() return self.filemanager_display_mode == "list_image_filename" end, + callback = function() + self:setupFileManagerDisplayMode("list_image_filename") + end, + separator = true, + }, + -- Plug the same choices for History here as a submenu + -- (Any other suitable place for that ?) + { + separator = true, + text = _("History display mode"), + sub_item_table = { + { + text = _("Classic (filename only)"), + checked_func = function() return not self.history_display_mode end, + callback = function() + self:setupHistoryDisplayMode("") + end, + }, + { + text = _("Mosaic with cover images"), + checked_func = function() return self.history_display_mode == "mosaic_image" end, + callback = function() + self:setupHistoryDisplayMode("mosaic_image") + end, + }, + { + text = _("Mosaic with text covers"), + checked_func = function() return self.history_display_mode == "mosaic_text" end, + callback = function() + self:setupHistoryDisplayMode("mosaic_text") + end, + }, + { + text = _("List with image and metadata"), + checked_func = function() return self.history_display_mode == "list_image_meta" end, + callback = function() + self:setupHistoryDisplayMode("list_image_meta") + end, + }, + { + text = _("List with metadata, no image"), + checked_func = function() return self.history_display_mode == "list_only_meta" end, + callback = function() + self:setupHistoryDisplayMode("list_only_meta") + end, + }, + { + text = _("List with image and filename"), + checked_func = function() return self.history_display_mode == "list_image_filename" end, + callback = function() + self:setupHistoryDisplayMode("list_image_filename") + end, + separator = true, + }, + }, + }, + -- Misc settings + { + text = _("Other settings"), + sub_item_table = { + { + text = _("Show hint for books with description"), + checked_func = function() return not BookInfoManager:getSetting("no_hint_description") end, + callback = function() + if BookInfoManager:getSetting("no_hint_description") then + BookInfoManager:saveSetting("no_hint_description", false) + else + BookInfoManager:saveSetting("no_hint_description", true) + end + self:refreshFileManagerInstance() + end, + }, + { + text = _("Show hint for opened books in history"), + checked_func = function() return BookInfoManager:getSetting("history_hint_opened") end, + callback = function() + if BookInfoManager:getSetting("history_hint_opened") then + BookInfoManager:saveSetting("history_hint_opened", false) + else + BookInfoManager:saveSetting("history_hint_opened", true) + end + self:refreshFileManagerInstance() + end, + }, + { + text = _("Append series metadata to authors"), + checked_func = function() return BookInfoManager:getSetting("append_series_to_authors") end, + callback = function() + if BookInfoManager:getSetting("append_series_to_authors") then + BookInfoManager:saveSetting("append_series_to_authors", false) + else + BookInfoManager:saveSetting("append_series_to_authors", true) + end + self:refreshFileManagerInstance() + end, + }, + { + text = _("Append series metadata to title"), + checked_func = function() return BookInfoManager:getSetting("append_series_to_title") end, + callback = function() + if BookInfoManager:getSetting("append_series_to_title") then + BookInfoManager:saveSetting("append_series_to_title", false) + else + BookInfoManager:saveSetting("append_series_to_title", true) + end + self:refreshFileManagerInstance() + end, + }, + }, + }, + { + text = _("Book info cache management"), + sub_item_table = { + { + text_func = function() -- add current db size to menu text + local sstr = BookInfoManager:getDbSize() + return _("Current cache size: ") .. sstr + end, + -- no callback, only for information + }, + { + text = _("Prune cache of removed books"), + callback = function() + local ConfirmBox = require("ui/widget/confirmbox") + UIManager:close(self.file_dialog) + UIManager:show(ConfirmBox:new{ + -- Checking file existences is quite fast, but deleting entries is slow. + text = _("Are you sure that you want to prune cache of removed books?\n(This may take a while.)"), + ok_text = _("Prune cache"), + ok_callback = function() + local InfoMessage = require("ui/widget/infomessage") + local msg = InfoMessage:new{ text = _("Pruning cache of removed books…") } + UIManager:show(msg) + UIManager:nextTick(function() + local summary = BookInfoManager:removeNonExistantEntries() + UIManager:close(msg) + UIManager:show( InfoMessage:new{ text = summary } ) + end) + end + }) + end, + }, + { + text = _("Compact cache database"), + callback = function() + local ConfirmBox = require("ui/widget/confirmbox") + UIManager:close(self.file_dialog) + UIManager:show(ConfirmBox:new{ + text = _("Are you sure that you want to compact cache database?\n(This may take a while.)"), + ok_text = _("Compact database"), + ok_callback = function() + local InfoMessage = require("ui/widget/infomessage") + local msg = InfoMessage:new{ text = _("Compacting cache database…") } + UIManager:show(msg) + UIManager:nextTick(function() + local summary = BookInfoManager:compactDb() + UIManager:close(msg) + UIManager:show( InfoMessage:new{ text = summary } ) + end) + end + }) + end, + }, + { + text = _("Delete cache database"), + callback = function() + local ConfirmBox = require("ui/widget/confirmbox") + UIManager:close(self.file_dialog) + UIManager:show(ConfirmBox:new{ + text = _("Are you sure that you want to delete cover and metadata cache?\n(This will also reset your display mode settings.)"), + ok_text = _("Purge"), + ok_callback = function() + BookInfoManager:deleteDb() + end + }) + end, + }, + }, + }, + }, + } + end +end + +function CoverBrowser:refreshFileManagerInstance(cleanup) + local FileManager = require("apps/filemanager/filemanager") + local fm = FileManager.instance + if fm then + local fc = fm.file_chooser + if cleanup then -- clean instance properties we may have set + if fc.onFileHold_orig then + -- remove our onFileHold that extended file_dialog with new buttons + fc.onFileHold = fc.onFileHold_orig + fc.onFileHold_orig = nil + fc.onFileHold_ours = nil + end + end + fc:updateItems() + end +end + +function CoverBrowser:setupFileManagerDisplayMode(display_mode) + if not display_mode then -- if none provided, use current one + display_mode = self.filemanager_display_mode + end + if not DISPLAY_MODES[display_mode] then + display_mode = nil + end + self.filemanager_display_mode = display_mode + BookInfoManager:saveSetting("filemanager_display_mode", self.filemanager_display_mode) + logger.dbg("CoverBrowser: setting FileManager display mode to:", display_mode or "classic") + + if not display_mode then -- classic mode + -- Put back original methods + FileChooser.updateItems = _FileChooser_updateItems_orig + FileChooser.onCloseWidget = _FileChooser_onCloseWidget_orig + FileChooser.onSwipe = _FileChooser_onSwipe_orig + FileChooser._recalculateDimen = _FileChooser__recalculateDimen_orig + -- Also clean-up what we added, even if it does not bother original code + FileChooser._updateItemsBuildUI = nil + FileChooser._do_cover_images = nil + FileChooser._do_filename_only = nil + FileChooser._do_hint_opened = nil + self:refreshFileManagerInstance(true) + return + end + + -- In both mosaic and list modes, replace original methods with those from + -- our generic CoverMenu + local CoverMenu = require("covermenu") + FileChooser.updateItems = CoverMenu.updateItems + FileChooser.onCloseWidget = CoverMenu.onCloseWidget + FileChooser.onSwipe = CoverMenu.onSwipe + + if display_mode == "mosaic_image" or display_mode == "mosaic_text" then -- mosaic mode + -- Replace some other original methods with those from our MosaicMenu + local MosaicMenu = require("mosaicmenu") + FileChooser._recalculateDimen = MosaicMenu._recalculateDimen + FileChooser._updateItemsBuildUI = MosaicMenu._updateItemsBuildUI + -- Set MosaicMenu behaviour: + FileChooser._do_cover_images = display_mode ~= "mosaic_text" + FileChooser._do_hint_opened = true -- dogear at bottom + -- One could override default 3x3 grid here (put that as settings ?) + -- FileChooser.nb_cols_portrait = 4 + -- FileChooser.nb_rows_portrait = 4 + -- FileChooser.nb_cols_landscape = 6 + -- FileChooser.nb_rows_landscape = 3 + + elseif display_mode == "list_image_meta" or display_mode == "list_only_meta" or + display_mode == "list_image_filename" then -- list modes + -- Replace some other original methods with those from our ListMenu + local ListMenu = require("listmenu") + FileChooser._recalculateDimen = ListMenu._recalculateDimen + FileChooser._updateItemsBuildUI = ListMenu._updateItemsBuildUI + -- Set ListMenu behaviour: + FileChooser._do_cover_images = display_mode ~= "list_only_meta" + FileChooser._do_filename_only = display_mode == "list_image_filename" + FileChooser._do_hint_opened = true -- dogear at bottom + end + + self:refreshFileManagerInstance() +end + +local function _FileManagerHistory_updateItemTable(self) + -- 'self' here is the single FileManagerHistory instance + -- FileManagerHistory has just created a new instance of Menu as 'hist_menu' + -- at each display of History. Soon after instantiation, this method + -- is called. The first time it is called, we replace some methods. + local display_mode = self.display_mode + local hist_menu = self.hist_menu + + if not hist_menu._coverbrowser_overridden then + hist_menu._coverbrowser_overridden = true + + -- In both mosaic and list modes, replace original methods with those from + -- our generic CoverMenu + local CoverMenu = require("covermenu") + hist_menu.updateItems = CoverMenu.updateItems + hist_menu.onCloseWidget = CoverMenu.onCloseWidget + hist_menu.onSwipe = CoverMenu.onSwipe + -- Also replace original onMenuHold (it will use original method, so remember it) + hist_menu.onMenuHold_orig = hist_menu.onMenuHold + hist_menu.onMenuHold = CoverMenu.onHistoryMenuHold + + if display_mode == "mosaic_image" or display_mode == "mosaic_text" then -- mosaic mode + -- Replace some other original methods with those from our MosaicMenu + local MosaicMenu = require("mosaicmenu") + hist_menu._recalculateDimen = MosaicMenu._recalculateDimen + hist_menu._updateItemsBuildUI = MosaicMenu._updateItemsBuildUI + -- Set MosaicMenu behaviour: + hist_menu._do_cover_images = display_mode ~= "mosaic_text" + -- no need for do_hint_opened with History + + elseif display_mode == "list_image_meta" or display_mode == "list_only_meta" or + display_mode == "list_image_filename" then -- list modes + -- Replace some other original methods with those from our ListMenu + local ListMenu = require("listmenu") + hist_menu._recalculateDimen = ListMenu._recalculateDimen + hist_menu._updateItemsBuildUI = ListMenu._updateItemsBuildUI + -- Set ListMenu behaviour: + hist_menu._do_cover_images = display_mode ~= "list_only_meta" + hist_menu._do_filename_only = display_mode == "list_image_filename" + -- no need for do_hint_opened with History + + end + hist_menu._do_hint_opened = BookInfoManager:getSetting("history_hint_opened") + end + + -- We do now the single thing done in FileManagerHistory:updateItemTable(): + hist_menu:switchItemTable(self.hist_menu_title, require("readhistory").hist) +end + +function CoverBrowser:setupHistoryDisplayMode(display_mode) + if not display_mode then -- if none provided, use current one + display_mode = self.history_display_mode + end + if not DISPLAY_MODES[display_mode] then + display_mode = nil + end + self.history_display_mode = display_mode + BookInfoManager:saveSetting("history_display_mode", self.history_display_mode) + logger.dbg("CoverBrowser: setting History display mode to:", display_mode or "classic") + + -- We only need to replace one FileManagerHistory method + if not display_mode then -- classic mode + -- Put back original methods + FileManagerHistory.updateItemTable = _FileManagerHistory_updateItemTable_orig + FileManagerHistory.display_mode = nil + else + -- Replace original method with the one defined above + FileManagerHistory.updateItemTable = _FileManagerHistory_updateItemTable + -- And let it know which display_mode we should use + FileManagerHistory.display_mode = display_mode + end +end + +return CoverBrowser diff --git a/plugins/coverbrowser.koplugin/mosaicmenu.lua b/plugins/coverbrowser.koplugin/mosaicmenu.lua new file mode 100644 index 000000000..ae953ec09 --- /dev/null +++ b/plugins/coverbrowser.koplugin/mosaicmenu.lua @@ -0,0 +1,741 @@ +local Blitbuffer = require("ffi/blitbuffer") +local BottomContainer = require("ui/widget/container/bottomcontainer") +local CenterContainer = require("ui/widget/container/centercontainer") +local Device = require("device") +local DocSettings = require("docsettings") +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 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 TextBoxWidget = require("ui/widget/textboxwidget") +local TextWidget = require("ui/widget/textwidget") +local UIManager = require("ui/uimanager") +local UnderlineContainer = require("ui/widget/container/underlinecontainer") +local VerticalGroup = require("ui/widget/verticalgroup") +local VerticalSpan = require("ui/widget/verticalspan") +local WidgetContainer = require("ui/widget/container/widgetcontainer") +local lfs = require("libs/libkoreader-lfs") +local _ = require("gettext") +local Screen = Device.screen +local getMenuText = require("util").getMenuText + +local BookInfoManager = require("bookinfomanager") + +-- Here is the specific UI implementation for "mosaic" display modes +-- (see covermenu.lua for the generic code) + +-- We will show a rotated dogear at bottom right corner of cover widget for +-- opened files (the dogear will make it look like a "used book") +local corner_mark = ImageWidget:new{ + file = "resources/icons/dogear.png", + rotation_angle = 270 +} + +-- ItemShortCutIcon (for keyboard navigation) is private to menu.lua and can't be accessed, +-- so we need to redefine it +local ItemShortCutIcon = WidgetContainer:new{ + dimen = Geom:new{ w = 22, h = 22 }, + key = nil, + bordersize = 2, + radius = 0, + style = "square", +} + +function ItemShortCutIcon:init() + if not self.key then + return + end + local radius = 0 + local background = Blitbuffer.COLOR_WHITE + if self.style == "rounded_corner" then + radius = math.floor(self.width/2) + elseif self.style == "grey_square" then + background = Blitbuffer.gray(0.2) + end + local sc_face + if self.key:len() > 1 then + sc_face = Font:getFace("ffont", 14) + else + sc_face = Font:getFace("scfont", 22) + end + self[1] = FrameContainer:new{ + padding = 0, + bordersize = self.bordersize, + radius = radius, + background = background, + dimen = self.dimen, + CenterContainer:new{ + dimen = self.dimen, + TextWidget:new{ + text = self.key, + face = sc_face, + }, + }, + } +end + + +-- We may find a better algorithm, or just a set of +-- nice looking combinations of 3 sizes to iterate thru +-- The rendering of the TextBoxWidget we're doing below +-- with decreasing font sizes till it fits is quite expensive. + +local FakeCover = FrameContainer:new{ + width = nil, + height = nil, + margin = 0, + padding = 0, + bordersize = 1, + filename = nil, + title = nil, + authors = nil, + -- these font sizes will be scaleBySize'd by Font:getFace() + authors_font_max = 20, + authors_font_min = 6, + title_font_max = 24, + title_font_min = 10, + filename_font_max = 10, + filename_font_min = 8, + top_pad = Screen:scaleBySize(5), + bottom_pad = Screen:scaleBySize(5), + sizedec_step = Screen:scaleBySize(2), -- speeds up a bit if we don't do all font sizes + initial_sizedec = 0, +} + +function FakeCover:init() + -- BookInfoManager:extractBookInfo() made sure + -- to save as nil (NULL) metadata that were an empty string + local authors = self.authors + local title = self.title + local filename = self.filename + -- (some engines may have already given filename (without extension) as title) + if not title then -- use filename as title (big and centered) + title = filename + filename = nil + end + -- If no authors, and title is filename without extension, it was + -- probably made by an engine, and we can consider it a filename, and + -- act according to common usage in naming files. + if not authors and title and self.filename:sub(1,title:len()) == title then + -- Replace a hyphen surrounded by spaces (which most probably was + -- used to separate Authors/Title/Serie/Year/Categorie in the + -- filename with a \n + title = title:gsub(" %- ", "\n") + -- Same with | + title = title:gsub("|", "\n") + -- Also replace underscores with spaces + title = title:gsub("_", " ") + end + + -- We build the VerticalGroup widget with decreasing font sizes till + -- the widget fits into available height + local width = self.width - 2*(self.bordersize + self.margin + self.padding) + local height = self.height - 2*(self.bordersize + self.margin + self.padding) + local text_width = 7/8 * width -- make width of text smaller to have some padding + local inter_pad + local sizedec = self.initial_sizedec + local authors_wg, title_wg, filename_wg + local loop2 = false -- we may do a second pass with modifier title and authors strings + while true do + -- Free previously made widgets to avoid memory leaks + if authors_wg then + authors_wg:free() + authors_wg = nil + end + if title_wg then + title_wg:free() + title_wg = nil + end + if filename_wg then + filename_wg:free() + filename_wg = nil + end + -- Build new widgets + local texts_height = 0 + if authors then + authors_wg = TextBoxWidget:new{ + text = authors, + face = Font:getFace("cfont", math.max(self.authors_font_max - sizedec, self.authors_font_min)), + width = text_width, + alignment = "center", + } + texts_height = texts_height + authors_wg:getSize().h + end + if title then + title_wg = TextBoxWidget:new{ + text = title, + face = Font:getFace("cfont", math.max(self.title_font_max - sizedec, self.title_font_min)), + width = text_width, + alignment = "center", + } + texts_height = texts_height + title_wg:getSize().h + end + if filename then + filename_wg = TextBoxWidget:new{ + text = filename, + face = Font:getFace("cfont", math.max(self.filename_font_max - sizedec, self.filename_font_min)), + width = text_width, + alignment = "center", + } + texts_height = texts_height + filename_wg:getSize().h + end + local free_height = height - texts_height + if authors then + free_height = free_height - self.top_pad + end + if filename then + free_height = free_height - self.bottom_pad + end + inter_pad = math.floor(free_height / 2) + + -- XXX We can benefit from adding to ui/widget/textboxwidget.lua at line 141 + -- ("either a very long english word"): + -- if adjusted_idx == offset then self.has_split_inside_word = true end + -- The following as no effect till then + local textboxes_ok = true + if (authors_wg and authors_wg.has_split_inside_word) or (title_wg and title_wg.has_split_inside_word) then + -- We may get a nicer cover at next lower font size + textboxes_ok = false + end + + if textboxes_ok and free_height > 0.2 * height then -- enough free space to not look constrained + break + end + -- (We may store the first widgets matching free space requirements but + -- not textboxes_ok, so that if we never ever get textboxes_ok candidate, + -- we can use them instead of the super-small strings-modified we'll have + -- at the end that are worse than the firsts) + + sizedec = sizedec + self.sizedec_step + if sizedec > 20 then -- break out of loop when too small + -- but try a 2nd loop with some cleanup to strings (for filenames + -- with no space but hyphen or underscore instead) + if not loop2 then + loop2 = true + sizedec = self.initial_sizedec -- restart from initial big size + -- Replace underscores and hyphens with spaces, to allow text wrap there. + if title then + title = title:gsub("-", " "):gsub("_", " ") + end + if authors then + authors = authors:gsub("-", " "):gsub("_", " ") + end + else -- 2nd loop done, no luck, give up + break + end + end + end + + local vgroup = VerticalGroup:new{} + if authors then + table.insert(vgroup, VerticalSpan:new{ width = self.top_pad }) + table.insert(vgroup, authors_wg) + end + table.insert(vgroup, VerticalSpan:new{ width = inter_pad }) + if title then + table.insert(vgroup, title_wg) + end + table.insert(vgroup, VerticalSpan:new{ width = inter_pad }) + if filename then + table.insert(vgroup, filename_wg) + table.insert(vgroup, VerticalSpan:new{ width = self.bottom_pad }) + end + + -- As we are a FrameContainer, a border will be painted around self[1] + self[1] = CenterContainer:new{ + dimen = Geom:new{ + w = width, + h = height, + }, + vgroup, + } +end + + +-- Based on menu.lua's MenuItem +local MosaicMenuItem = InputContainer:new{ + entry = {}, + text = nil, + show_parent = nil, + detail = nil, + dimen = nil, + shortcut = nil, + shortcut_style = "square", + _underline_container = nil, + do_cover_image = false, + do_hint_opened = false, + been_opened = false, + init_done = false, + bookinfo_found = false, + cover_specs = nil, + has_description = false, +} + +function MosaicMenuItem:init() + -- filepath may be provided as 'file' (history) or 'path' (filechooser) + -- store it as attribute so we can use it elsewhere + self.filepath = self.entry.file or self.entry.path + + -- As done in MenuItem + -- Squared letter for keyboard navigation + if self.shortcut then + local shortcut_icon_dimen = Geom:new() + shortcut_icon_dimen.w = math.floor(self.dimen.h*1/5) + shortcut_icon_dimen.h = shortcut_icon_dimen.w + -- To keep a simpler widget structure, this shortcut icon will not + -- be part of it, but will be painted over the widget in our paintTo + self.shortcut_icon = ItemShortCutIcon:new{ + dimen = shortcut_icon_dimen, + key = self.shortcut, + style = self.shortcut_style, + } + end + self.detail = self.text + + -- we need this table per-instance, so we declare it here + if Device:isTouchDevice() then + self.ges_events = { + TapSelect = { + GestureRange:new{ + ges = "tap", + range = self.dimen, + }, + doc = "Select Menu Item", + }, + HoldSelect = { + GestureRange:new{ + ges = "hold", + range = self.dimen, + }, + doc = "Hold Menu Item", + }, + } + end + if Device:hasKeys() then + self.active_key_events = { + Select = { {"Press"}, doc = "chose selected item" }, + } + end + + -- We now build the minimal widget container that won't change after udpate() + + -- As done in MenuItem + -- for compatibility with keyboard navigation + -- (which does not seem to work well when multiple pages, + -- even with classic menu) + self.underline_h = 1 -- smaller than default (3), don't waste space + self._underline_container = UnderlineContainer:new{ + vertical_align = "center", + dimen = Geom:new{ + w = self.width, + h = self.height + }, + linesize = self.underline_h, + -- widget : will be filled in self:update() + } + self[1] = self._underline_container + + -- Remaining part of initialization is done in update(), because we may + -- have to do it more than once if item not found in db + self:update() + self.init_done = true +end + +function MosaicMenuItem:update() + -- We will be a disctinctive widget whether we are a directory, + -- a known file with image / without image, or a not yet known file + local widget + + local dimen = Geom:new{ + w = self.width, + h = self.height - self.underline_h + } + + if lfs.attributes(self.filepath, "mode") == "directory" then + self.is_directory = true + -- Directory : rounded corners + local margin = Screen:scaleBySize(5) -- make directories less wide + local padding = Screen:scaleBySize(5) + local border_size = Screen:scaleBySize(2) -- make directories bolder + local dimen_in = Geom:new{ + w = dimen.w - (margin + padding + border_size)*2, + h = dimen.h - (margin + padding + border_size)*2 + } + local text = self.text + if text:match('/$') then -- remove /, more readable + text = text:sub(1, -2) + end + local directory = TextBoxWidget:new{ + text = text, + face = Font:getFace("cfont", 20), + width = dimen_in.w, + alignment = "center", + bold = true, + } + local nbitems = TextBoxWidget:new{ + text = self.mandatory, + face = Font:getFace("infont", 15), + width = dimen_in.w, + alignment = "center", + } + widget = FrameContainer:new{ + width = dimen.w, + height = dimen.h, + margin = margin, + padding = padding, + bordersize = border_size, + radius = Screen:scaleBySize(10), + OverlapGroup:new{ + dimen = dimen_in, + CenterContainer:new{ dimen=dimen_in, directory}, + BottomContainer:new{ dimen=dimen_in, nbitems}, + }, + } + else + -- File : various appearances + -- We'll draw a border around cover images, it may not be + -- needed with some covers, but it's nicer when cover is + -- a pure white background (like rendered text page) + local border_size = 1 + local max_img_w = dimen.w - 2*border_size + local max_img_h = dimen.h - 2*border_size + + if self.do_hint_opened and DocSettings:hasSidecarFile(self.filepath) then + self.been_opened = true + end + + local bookinfo = BookInfoManager:getBookInfo(self.filepath, self.do_cover_image) + if bookinfo and self.do_cover_image and not bookinfo.ignore_cover then + if bookinfo.cover_fetched then + if bookinfo.has_cover and bookinfo.cover_sizetag ~= "M" then + -- there is a cover, but it's a small one (made by ListMenuItem), + -- and it would be ugly if scaled up to MosaicMenuItem size: + -- do as if not found to force a new extraction with our size + if bookinfo.cover_bb then + bookinfo.cover_bb:free() + end + bookinfo = nil + -- Note: with the current size differences between FileManager + -- and the History windows, we'll get lower max_img_* in History. + -- So, when one get Items first generated by the other, it will + -- have to do some scaling. Hopefully, people most probably + -- browse a lot more files than have them in history, so + -- it's most probably History that will have to do some scaling. + end + -- if not has_cover, book has no cover, no need to try again + else + -- cover was not fetched previously, do as if not found + -- to force a new extraction + bookinfo = nil + end + end + + if bookinfo then -- This book is known + local cover_bb_used = false + self.bookinfo_found = true + -- For wikipedia saved as epub, we made a cover from the 1st pic of the page, + -- which may not say much about the book. So, here, pretend we don't have + -- a cover + if bookinfo.authors and bookinfo.authors:match("^Wikipedia ") then + bookinfo.has_cover = nil + end + if self.do_cover_image and bookinfo.has_cover and not bookinfo.ignore_cover then + cover_bb_used = true + -- Let ImageWidget do the scaling and give us a bb that fit + local scale_factor = math.min(max_img_w / bookinfo.cover_w, max_img_h / bookinfo.cover_h) + local image= ImageWidget:new{ + image = bookinfo.cover_bb, + scale_factor = scale_factor, + } + image:_render() + local image_size = image:getSize() + widget = CenterContainer:new{ + dimen = dimen, + FrameContainer:new{ + width = image_size.w + 2*border_size, + height = image_size.h + 2*border_size, + margin = 0, + padding = 0, + bordersize = border_size, + image, + } + } + else + -- add Series metadata if requested + if bookinfo.series then + if BookInfoManager:getSetting("append_series_to_title") then + if bookinfo.title then + bookinfo.title = bookinfo.title .. " - " .. bookinfo.series + else + bookinfo.title = bookinfo.series + end + end + if BookInfoManager:getSetting("append_series_to_authors") then + if bookinfo.authors then + bookinfo.authors = bookinfo.authors .. " - " .. bookinfo.series + else + bookinfo.authors = bookinfo.series + end + end + end + widget = CenterContainer:new{ + dimen = dimen, + FakeCover:new{ + -- reduced width to make it look less squared, more like a book + width = math.floor(dimen.w * 7/8), + height = dimen.h, + bordersize = border_size, + filename = self.text, + title = not bookinfo.ignore_meta and bookinfo.title, + authors = not bookinfo.ignore_meta and bookinfo.authors, + } + } + end + -- In case we got a blitbuffer and didnt use it (ignore_cover, wikipedia), free it + if bookinfo.cover_bb and not cover_bb_used then + bookinfo.cover_bb:free() + end + -- So we can draw an indicator if this book has a description + if bookinfo.description then + self.has_description = true + end + + else -- bookinfo not found + if self.init_done then + -- Non-initial update(), but our widget is still not found: + -- it does not need to change, so avoid making the same FakeCover + return + end + -- If we're in no image mode, don't save images in DB : people + -- who don't care about images will have a smaller DB, but + -- a new extraction will have to be made when one switch to image mode + if self.do_cover_image then + -- Not in db, we're going to fetch some cover + self.cover_specs = { + sizetag = "M", + max_cover_w = max_img_w, + max_cover_h = max_img_h, + } + end + -- Same as real FakeCover, but let it be squared (like a file) + widget = CenterContainer:new{ + dimen = dimen, + FakeCover:new{ + width = dimen.w, + height = dimen.h, + bordersize = border_size, + filename = self.text .. "\n…", -- display hint it's being loaded + initial_sizedec = 4, -- start with a smaller font when filenames only + } + } + end + end + + -- Fill container with our widget + if self._underline_container[1] then + -- There is a previous one, that we need to free() + local previous_widget = self._underline_container[1] + previous_widget:free() + end + self._underline_container[1] = widget +end + +function MosaicMenuItem:paintTo(bb, x, y) + -- We may get non-integer x or y that would cause some mess with image + -- inside FrameContainer were image would be drawn on top of the top border... + -- XXX We can stop having non-integer x/y by patching textwidget.lua + -- TextWidget:updateSize(): + -- self._length = math.ceil(tsize.x) + -- self._height = math.ceil(self.face.size * 1.5) + -- In the meantime, make them integer: + x = math.floor(x) + y = math.floor(y) + + -- Original painting + InputContainer.paintTo(self, bb, x, y) + + -- to which we paint over the shortcut icon + if self.shortcut_icon then + -- align it on bottom left corner of widget + local target = self + local ix = 0 + local iy = target.dimen.h - self.shortcut_icon.dimen.h + self.shortcut_icon:paintTo(bb, x+ix, y+iy) + end + + -- to which we paint over a dogear if needed + if self.do_hint_opened and self.been_opened then + -- align it on bottom right corner of sub-widget + local target = self[1][1][1] + local ix = self.width - math.ceil((self.width - target.dimen.w)/2) - corner_mark:getSize().w + local iy = self.height - math.ceil((self.height - target.dimen.h)/2) - corner_mark:getSize().h + -- math.ceil() makes it looks better than math.floor() + corner_mark:paintTo(bb, x+ix, y+iy) + end + + -- to which we paint a small indicator if this book has a description + if self.has_description and not BookInfoManager:getSetting("no_hint_description") then + -- On book's right (for similarity to ListMenuItem) + local target = self[1][1][1] + local d_w = Screen:scaleBySize(3) + local d_h = math.ceil(target.dimen.h / 8) + -- Paint it directly relative to target.dimen.x/y which has been computed at this point + local ix = target.dimen.w - 1 + local iy = 0 + bb:paintBorder(target.dimen.x+ix, target.dimen.y+iy, d_w, d_h, 1) + local x_overflow = target.dimen.x+ix+d_w - x - self.dimen.w + if x_overflow > 0 then + -- Set alternate dimen to be marked as dirty to include this description in refresh + self.refresh_dimen = self[1].dimen:copy() + self.refresh_dimen.w = self.refresh_dimen.w + x_overflow + end + + end +end + +-- As done in MenuItem +function MosaicMenuItem:onFocus() + self._underline_container.color = Blitbuffer.COLOR_BLACK + self.key_events = self.active_key_events + return true +end + +function MosaicMenuItem:onUnfocus() + self._underline_container.color = Blitbuffer.COLOR_WHITE + self.key_events = {} + return true +end + +function MosaicMenuItem:onShowItemDetail() + UIManager:show(InfoMessage:new{ text = self.detail, }) + return true +end + +-- The transient color inversions done in MenuItem:onTapSelect +-- and MenuItem:onHoldSelect are ugly when done on an image, +-- so let's not do it +-- Also, no need for 2nd arg 'pos' (only used in readertoc.lua) +function MosaicMenuItem:onTapSelect(arg) + self.menu:onMenuSelect(self.entry) + return true +end + +function MosaicMenuItem:onHoldSelect(arg, ges) + self.menu:onMenuHold(self.entry) + return true +end + + +-- Simple holder of methods that will replace those +-- in the real Menu class or instance +local MosaicMenu = {} + +function MosaicMenu:_recalculateDimen() + self.dimen.w = self.width + self.dimen.h = self.height or Screen:getHeight() + + local portrait_mode = true + if Screen:getWidth() > Screen:getHeight() then + portrait_mode = false + end + -- 3 x 3 grid by default if not initially provided (4 x 2 in landscape mode) + if portrait_mode then + self.nb_cols = self.nb_cols_portrait or 3 + self.nb_rows = self.nb_rows_portrait or 3 + else + self.nb_cols = self.nb_cols_landscape or 4 + self.nb_rows = self.nb_rows_landscape or 2 + end + self.perpage = self.nb_rows * self.nb_cols + self.page_num = math.ceil(#self.item_table / self.perpage) + + -- Find out available height from other UI elements made in Menu + self.others_height = 0 + if self.title_bar then -- init() has been done + if not self.is_borderless then + self.others_height = self.others_height + 2 + end + if not self.no_title then + self.others_height = self.others_height + self.header_padding + self.others_height = self.others_height + self.title_bar.dimen.h + end + if self.page_info then + self.others_height = self.others_height + self.page_info:getSize().h + end + end + + -- Set our items target size + self.item_margin = Screen:scaleBySize(10) + self.item_height = math.floor((self.dimen.h - self.others_height - (1+self.nb_rows)*self.item_margin) / self.nb_rows) + self.item_width = math.floor((self.dimen.w - (1+self.nb_cols)*self.item_margin) / self.nb_cols) + self.item_dimen = Geom:new{ + w = self.item_width, + h = self.item_height + } +end + +function MosaicMenu:_updateItemsBuildUI() + -- Build our grid + local idx_offset = (self.page - 1) * self.perpage + local cur_row = nil + for idx = 1, self.perpage do + local entry = self.item_table[idx_offset + idx] + if entry == nil then break end + + if idx % self.nb_cols == 1 then -- new row + table.insert(self.item_group, VerticalSpan:new{ width = self.item_margin }) + cur_row = HorizontalGroup:new{} + table.insert(self.item_group, cur_row) + table.insert(cur_row, HorizontalSpan:new({ width = self.item_margin })) + end + + -- Keyboard shortcuts, as done in Menu + local item_shortcut = nil + local shortcut_style = "square" + if self.is_enable_shortcut then + -- give different shortcut_style to keys in different + -- lines of keyboard + if idx >= 11 and idx <= 20 then + shortcut_style = "grey_square" + end + item_shortcut = self.item_shortcuts[idx] + if item_shortcut == "Enter" then + item_shortcut = "Ent" + end + end + + local item_tmp = MosaicMenuItem:new{ + height = self.item_height, + width = self.item_width, + entry = entry, + text = getMenuText(entry), + show_parent = self.show_parent, + mandatory = entry.mandatory, + dimen = self.item_dimen:new(), + shortcut = item_shortcut, + shortcut_style = shortcut_style, + menu = self, + do_cover_image = self._do_cover_images, + do_hint_opened = self._do_hint_opened, + } + table.insert(cur_row, item_tmp) + table.insert(cur_row, HorizontalSpan:new({ width = self.item_margin })) + + -- this is for focus manager + table.insert(self.layout, {item_tmp}) + + if not item_tmp.bookinfo_found and not item_tmp.is_directory then + -- Register this item for update + table.insert(self.items_to_update, item_tmp) + end + end + table.insert(self.item_group, VerticalSpan:new{ width = self.item_margin }) -- bottom padding +end + +return MosaicMenu diff --git a/plugins/coverbrowser.koplugin/xutil.lua b/plugins/coverbrowser.koplugin/xutil.lua new file mode 100644 index 000000000..bb9c56fd9 --- /dev/null +++ b/plugins/coverbrowser.koplugin/xutil.lua @@ -0,0 +1,102 @@ +local ffi = require("ffi") +local util = require("ffi/util") +local C = ffi.C + +-- Utilities functions needed by this plugin, but that may be added to +-- existing base/ffi/ files +local xutil = {} + + +-- Sub-process management (may be put into base/ffi/util.lua) +function xutil.runInSubProcess(func) + if util.isAndroid() then + -- not sure how to do that on android + return nil + else + local pid = C.fork() + if pid == 0 then -- child process + -- Just run the provided lua code object in this new process, + -- and exit immediatly (so we do not release drivers and + -- resources still used by parent process) + func() + os.exit(0) + end + -- parent/main process, return pid of child + if pid == -1 then -- On failure, -1 is returned in the parent + return false + end + return pid + end +end + +function xutil.isSubProcessDone(pid) + local status = ffi.new('int[1]') + local ret = C.waitpid(pid, status, 1) -- 1 = WNOHANG : don't wait, just tell + -- status = tonumber(status[0]) + -- local logger = require("logger") + -- logger.dbg("waitpid for", pid, ":", ret, "/", status) + -- still running: ret = 0 , status = 0 + -- exited: ret = pid , status = 0 or 9 if killed + -- no more running: ret = -1 , status = 0 + if ret == pid or ret == -1 then + return true + end +end + +function xutil.terminateSubProcess(pid) + local done = xutil.isSubProcessDone(pid) + if not done then + -- local logger = require("logger") + -- logger.dbg("killing subprocess", pid) + -- we kill with signal 9/SIGKILL, which may be violent, but ensures + -- that it is terminated (a process may catch or ignore SIGTERM) + C.kill(pid, 9) + -- process will still have to be collected with calls to util.isSubProcessDone(), + -- which may still return false for some small amount of time after our kill() + end +end + + +-- Data compression/decompression of strings thru zlib (may be put in a new base/ffi/zlib.lua) +-- from http://luajit.org/ext_ffi_tutorial.html +ffi.cdef[[ +unsigned long compressBound(unsigned long sourceLen); +int compress2(uint8_t *dest, unsigned long *destLen, + const uint8_t *source, unsigned long sourceLen, int level); +int uncompress(uint8_t *dest, unsigned long *destLen, + const uint8_t *source, unsigned long sourceLen); +]] +local zlib = ffi.load(ffi.os == "Windows" and "zlib1" or "z") + +function xutil.zlib_compress(data) + local n = zlib.compressBound(#data) + local buf = ffi.new("uint8_t[?]", n) + local buflen = ffi.new("unsigned long[1]", n) + local res = zlib.compress2(buf, buflen, data, #data, 9) + assert(res == 0) + return ffi.string(buf, buflen[0]) +end + +function xutil.zlib_uncompress(zdata, datalen) + local buf = ffi.new("uint8_t[?]", datalen) + local buflen = ffi.new("unsigned long[1]", datalen) + local res = zlib.uncompress(buf, buflen, zdata, #zdata) + assert(res == 0) + return ffi.string(buf, buflen[0]) +end + + +-- Not provided by base/thirdparty/lua-ljsqlite3/init.lua +-- Add a timeout to a lua-ljsqlite3 connection +-- We need that if we have multiple processes accessing the same +-- SQLite db for reading or writting (read lock and write lock can't be +-- obtained at the same time, so waiting & retry is needed) +-- SQLite will retry getting a lock every 1ms to 100ms for +-- the timeout_ms given here +local sql = ffi.load("sqlite3") +function xutil.sqlite_set_timeout(conn, timeout_ms) + sql.sqlite3_busy_timeout(conn._ptr, timeout_ms) +end +-- For reference, SQ3 doc at: http://scilua.org/ljsqlite3.html + +return xutil