mirror of https://github.com/koreader/koreader
CoverBrowser plugin: alt views for File Browser and History (#2940)
* CoverBrowser plugin: alt views for File Browser and History * Added Prune cache and Compact cache menu actions * Support for book descriptions, and settings stored in dbpull/3096/head
parent
7277059176
commit
3b5cd4c23b
@ -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<int64_t> 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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
Loading…
Reference in New Issue