2
0
mirror of https://github.com/koreader/koreader synced 2024-11-11 19:11:14 +00:00
koreader/frontend/apps/reader/readerui.lua
NiLuJe e7acec1526 ReaderUI: Saner FM/RD lifecycle
* Ensure that going from one to the other tears down the former and
    its plugins before instantiating the latter and its plugins.

UIManager: Unify Event sending & broadcasting
  * Make the two behave the same way (walk the widget stack from top to
    bottom), and properly handle the window stack shrinking shrinking
    *and* growing.
    Previously, broadcasting happened bottom-to-top and didn't really
    handle the list shrinking/growing, while sending only handled the list
    shrinking by a single element, and hopefully that element being the one
    the event was just sent to.

These two items combined allowed us to optimize suboptimal
refresh behavior with Menu and other Menu classes when
opening/closing a document.
e.g., the "opening document" Notification is now properly regional,
and the "open last doc" option no longer flashes like a crazy person
anymore.

Plugins: Allow optimizing Menu refresh with custom menus, too.

Requires moving Menu's close_callback *after* onMenuSelect, which, eh,
probably makes sense, and is probably harmless in the grand scheme of
things.
2021-05-05 20:37:33 +02:00

828 lines
28 KiB
Lua

--[[
ReaderUI is an abstraction for a reader interface.
It works using data gathered from a document interface.
]]--
local BD = require("ui/bidi")
local Cache = require("cache")
local ConfirmBox = require("ui/widget/confirmbox")
local Device = require("device")
local DeviceListener = require("device/devicelistener")
local DocSettings = require("docsettings")
local DocumentRegistry = require("document/documentregistry")
local Event = require("ui/event")
local FileManagerBookInfo = require("apps/filemanager/filemanagerbookinfo")
local FileManagerCollection = require("apps/filemanager/filemanagercollection")
local FileManagerHistory = require("apps/filemanager/filemanagerhistory")
local FileManagerFileSearcher = require("apps/filemanager/filemanagerfilesearcher")
local FileManagerShortcuts = require("apps/filemanager/filemanagershortcuts")
local InfoMessage = require("ui/widget/infomessage")
local InputContainer = require("ui/widget/container/inputcontainer")
local InputDialog = require("ui/widget/inputdialog")
local PluginLoader = require("pluginloader")
local ReaderActivityIndicator = require("apps/reader/modules/readeractivityindicator")
local ReaderBack = require("apps/reader/modules/readerback")
local ReaderBookmark = require("apps/reader/modules/readerbookmark")
local ReaderConfig = require("apps/reader/modules/readerconfig")
local ReaderCoptListener = require("apps/reader/modules/readercoptlistener")
local ReaderCropping = require("apps/reader/modules/readercropping")
local ReaderDeviceStatus = require("apps/reader/modules/readerdevicestatus")
local ReaderDictionary = require("apps/reader/modules/readerdictionary")
local ReaderFont = require("apps/reader/modules/readerfont")
local ReaderGoto = require("apps/reader/modules/readergoto")
local ReaderHinting = require("apps/reader/modules/readerhinting")
local ReaderHighlight = require("apps/reader/modules/readerhighlight")
local ReaderKoptListener = require("apps/reader/modules/readerkoptlistener")
local ReaderLink = require("apps/reader/modules/readerlink")
local ReaderMenu = require("apps/reader/modules/readermenu")
local ReaderPageMap = require("apps/reader/modules/readerpagemap")
local ReaderPanning = require("apps/reader/modules/readerpanning")
local ReaderRotation = require("apps/reader/modules/readerrotation")
local ReaderPaging = require("apps/reader/modules/readerpaging")
local ReaderRolling = require("apps/reader/modules/readerrolling")
local ReaderSearch = require("apps/reader/modules/readersearch")
local ReaderStatus = require("apps/reader/modules/readerstatus")
local ReaderStyleTweak = require("apps/reader/modules/readerstyletweak")
local ReaderToc = require("apps/reader/modules/readertoc")
local ReaderTypeset = require("apps/reader/modules/readertypeset")
local ReaderTypography = require("apps/reader/modules/readertypography")
local ReaderView = require("apps/reader/modules/readerview")
local ReaderWikipedia = require("apps/reader/modules/readerwikipedia")
local ReaderZooming = require("apps/reader/modules/readerzooming")
local Screenshoter = require("ui/widget/screenshoter")
local SettingsMigration = require("ui/data/settings_migration")
local UIManager = require("ui/uimanager")
local ffiUtil = require("ffi/util")
local lfs = require("libs/libkoreader-lfs")
local logger = require("logger")
local util = require("util")
local _ = require("gettext")
local Screen = require("device").screen
local T = ffiUtil.template
local ReaderUI = InputContainer:new{
name = "ReaderUI",
active_widgets = {},
-- if we have a parent container, it must be referenced for now
dialog = nil,
-- the document interface
document = nil,
-- password for document unlock
password = nil,
postInitCallback = nil,
postReaderCallback = nil,
}
function ReaderUI:registerModule(name, ui_module, always_active)
if name then self[name] = ui_module end
ui_module.name = "reader" .. name
table.insert(self, ui_module)
if always_active then
-- to get events even when hidden
table.insert(self.active_widgets, ui_module)
end
end
function ReaderUI:registerPostInitCallback(callback)
table.insert(self.postInitCallback, callback)
end
function ReaderUI:registerPostReadyCallback(callback)
table.insert(self.postReaderCallback, callback)
end
function ReaderUI:init()
-- cap screen refresh on pan to 2 refreshes per second
local pan_rate = Screen.low_pan_rate and 2.0 or 30.0
self.postInitCallback = {}
self.postReaderCallback = {}
-- if we are not the top level dialog ourselves, it must be given in the table
if not self.dialog then
self.dialog = self
end
self.doc_settings = DocSettings:open(self.document.file)
-- Handle local settings migration
SettingsMigration:migrateSettings(self.doc_settings)
if Device:hasKeys() then
self.key_events.Home = { {"Home"}, doc = "open file browser" }
end
-- a view container (so it must be child #1!)
-- all paintable widgets need to be a child of reader view
self:registerModule("view", ReaderView:new{
dialog = self.dialog,
dimen = self.dimen,
ui = self,
document = self.document,
})
-- goto link controller
self:registerModule("link", ReaderLink:new{
dialog = self.dialog,
view = self.view,
ui = self,
document = self.document,
})
-- text highlight
self:registerModule("highlight", ReaderHighlight:new{
dialog = self.dialog,
view = self.view,
ui = self,
document = self.document,
})
-- menu widget should be registered after link widget and highlight widget
-- so that taps on link and highlight areas won't popup reader menu
-- reader menu controller
self:registerModule("menu", ReaderMenu:new{
view = self.view,
ui = self
})
-- rotation controller
self:registerModule("rotation", ReaderRotation:new{
dialog = self.dialog,
view = self.view,
ui = self
})
-- Table of content controller
self:registerModule("toc", ReaderToc:new{
dialog = self.dialog,
view = self.view,
ui = self
})
-- bookmark controller
self:registerModule("bookmark", ReaderBookmark:new{
dialog = self.dialog,
view = self.view,
ui = self
})
-- reader goto controller
-- "goto" being a dirty keyword in Lua?
self:registerModule("gotopage", ReaderGoto:new{
dialog = self.dialog,
view = self.view,
ui = self,
document = self.document,
})
-- dictionary
self:registerModule("dictionary", ReaderDictionary:new{
dialog = self.dialog,
view = self.view,
ui = self,
document = self.document,
})
-- wikipedia
self:registerModule("wikipedia", ReaderWikipedia:new{
dialog = self.dialog,
view = self.view,
ui = self,
document = self.document,
})
-- screenshot controller
self:registerModule("screenshot", Screenshoter:new{
prefix = 'Reader',
dialog = self.dialog,
view = self.view,
ui = self
}, true)
-- device status controller
self:registerModule("battery", ReaderDeviceStatus:new{
ui = self,
})
-- configurable controller
if self.document.info.configurable then
-- config panel controller
self:registerModule("config", ReaderConfig:new{
configurable = self.document.configurable,
dialog = self.dialog,
view = self.view,
ui = self,
document = self.document,
})
if self.document.info.has_pages then
-- kopt option controller
self:registerModule("koptlistener", ReaderKoptListener:new{
dialog = self.dialog,
view = self.view,
ui = self,
document = self.document,
})
else
-- cre option controller
self:registerModule("crelistener", ReaderCoptListener:new{
dialog = self.dialog,
view = self.view,
ui = self,
document = self.document,
})
end
-- activity indicator for when some settings take time to take effect (Kindle under KPV)
if not ReaderActivityIndicator:isStub() then
self:registerModule("activityindicator", ReaderActivityIndicator:new{
dialog = self.dialog,
view = self.view,
ui = self,
document = self.document,
})
end
end
-- for page specific controller
if self.document.info.has_pages then
-- cropping controller
self:registerModule("cropping", ReaderCropping:new{
dialog = self.dialog,
view = self.view,
ui = self,
document = self.document,
})
-- paging controller
self:registerModule("paging", ReaderPaging:new{
pan_rate = pan_rate,
dialog = self.dialog,
view = self.view,
ui = self
})
-- zooming controller
self:registerModule("zooming", ReaderZooming:new{
dialog = self.dialog,
document = self.document,
view = self.view,
ui = self
})
-- panning controller
self:registerModule("panning", ReaderPanning:new{
dialog = self.dialog,
view = self.view,
ui = self
})
-- hinting controller
self:registerModule("hinting", ReaderHinting:new{
dialog = self.dialog,
zoom = self.zooming,
view = self.view,
ui = self,
document = self.document,
})
else
-- load crengine default settings (from cr3.ini, some of these
-- will be overriden by our settings by some reader modules below)
if self.document.setupDefaultView then
self.document:setupDefaultView()
end
-- make sure we render document first before calling any callback
self:registerPostInitCallback(function()
local start_ts = ffiUtil.getTimestamp()
if not self.document:loadDocument() then
self:dealWithLoadDocumentFailure()
end
logger.dbg(string.format(" loading took %.3f seconds", ffiUtil.getDuration(start_ts)))
-- used to read additional settings after the document has been
-- loaded (but not rendered yet)
self:handleEvent(Event:new("PreRenderDocument", self.doc_settings))
start_ts = ffiUtil.getTimestamp()
self.document:render()
logger.dbg(string.format(" rendering took %.3f seconds", ffiUtil.getDuration(start_ts)))
-- Uncomment to output the built DOM (for debugging)
-- logger.dbg(self.document:getHTMLFromXPointer(".0", 0x6830))
end)
-- styletweak controller (must be before typeset controller)
self:registerModule("styletweak", ReaderStyleTweak:new{
dialog = self.dialog,
view = self.view,
ui = self
})
-- typeset controller
self:registerModule("typeset", ReaderTypeset:new{
dialog = self.dialog,
view = self.view,
ui = self
})
-- font menu
self:registerModule("font", ReaderFont:new{
dialog = self.dialog,
view = self.view,
ui = self
})
-- typography menu (replaces previous hyphenation menu / ReaderHyphenation)
self:registerModule("typography", ReaderTypography:new{
dialog = self.dialog,
view = self.view,
ui = self
})
-- rolling controller
self:registerModule("rolling", ReaderRolling:new{
pan_rate = pan_rate,
dialog = self.dialog,
view = self.view,
ui = self
})
-- pagemap controller
self:registerModule("pagemap", ReaderPageMap:new{
dialog = self.dialog,
view = self.view,
ui = self
})
end
self.disable_double_tap = G_reader_settings:nilOrTrue("disable_double_tap")
-- back location stack
self:registerModule("back", ReaderBack:new{
ui = self,
view = self.view,
})
-- fulltext search
self:registerModule("search", ReaderSearch:new{
dialog = self.dialog,
view = self.view,
ui = self
})
-- book status
self:registerModule("status", ReaderStatus:new{
ui = self,
document = self.document,
view = self.view,
})
-- file searcher
self:registerModule("filesearcher", FileManagerFileSearcher:new{
dialog = self.dialog,
ui = self,
})
-- folder shortcuts
self:registerModule("folder_shortcuts", FileManagerShortcuts:new{
dialog = self.dialog,
ui = self,
})
-- history view
self:registerModule("history", FileManagerHistory:new{
dialog = self.dialog,
ui = self,
})
-- collections/favorites view
self:registerModule("collections", FileManagerCollection:new{
dialog = self.dialog,
ui = self,
})
-- book info
self:registerModule("bookinfo", FileManagerBookInfo:new{
dialog = self.dialog,
document = self.document,
ui = self,
})
-- event listener to change device settings
self:registerModule("devicelistener", DeviceListener:new {
document = self.document,
view = self.view,
ui = self,
})
-- koreader plugins
for _, plugin_module in ipairs(PluginLoader:loadPlugins()) do
local ok, plugin_or_err = PluginLoader:createPluginInstance(
plugin_module,
{
dialog = self.dialog,
view = self.view,
ui = self,
document = self.document,
})
if ok then
self:registerModule(plugin_module.name, plugin_or_err)
logger.info("RD loaded plugin", plugin_module.name,
"at", plugin_module.path)
end
end
if Device:hasWifiToggle() then
local NetworkListener = require("ui/network/networklistener")
self:registerModule("networklistener", NetworkListener:new {
document = self.document,
view = self.view,
ui = self,
})
end
-- Allow others to change settings based on external factors
-- Must be called after plugins are loaded & before setting are read.
self:handleEvent(Event:new("DocSettingsLoad", self.doc_settings, self.document))
-- we only read settings after all the widgets are initialized
self:handleEvent(Event:new("ReadSettings", self.doc_settings))
for _,v in ipairs(self.postInitCallback) do
v()
end
self.postInitCallback = nil
-- Now that document is loaded, store book metadata in settings
-- (so that filemanager can use it from sideCar file to display
-- Book information).
self.doc_settings:saveSetting("doc_props", self.document:getProps())
-- After initialisation notify that document is loaded and rendered
-- CREngine only reports correct page count after rendering is done
-- Need the same event for PDF document
self:handleEvent(Event:new("ReaderReady", self.doc_settings))
for _,v in ipairs(self.postReaderCallback) do
v()
end
self.postReaderCallback = nil
-- print("Ordered registered gestures:")
-- for _, tzone in ipairs(self._ordered_touch_zones) do
-- print(" "..tzone.def.id)
-- end
end
function ReaderUI:setLastDirForFileBrowser(dir)
if dir and #dir > 1 and dir:sub(-1) == "/" then
dir = dir:sub(1, -2)
end
self.last_dir_for_file_browser = dir
end
function ReaderUI:getLastDirFile(to_file_browser)
if to_file_browser and self.last_dir_for_file_browser then
local dir = self.last_dir_for_file_browser
self.last_dir_for_file_browser = nil
return dir
end
local QuickStart = require("ui/quickstart")
local last_dir
local last_file = G_reader_settings:readSetting("lastfile")
-- ignore quickstart guide as last_file so we can go back to home dir
if last_file and last_file ~= QuickStart.quickstart_filename then
last_dir = last_file:match("(.*)/")
end
return last_dir, last_file
end
function ReaderUI:showFileManager(file)
local FileManager = require("apps/filemanager/filemanager")
local last_dir, last_file
if file then
last_dir, last_file = util.splitFilePathName(file)
last_dir = last_dir:match("(.*)/")
else
last_dir, last_file = self:getLastDirFile(true)
end
if FileManager.instance then
FileManager.instance:reinit(last_dir, last_file)
else
FileManager:showFiles(last_dir, last_file)
end
end
function ReaderUI:onShowingReader()
-- Allows us to optimize out a few useless refreshes in various CloseWidgets handlers...
self.tearing_down = true
self.dithered = nil
self:onClose()
end
-- Same as above, except we don't close it yet. Useful for plugins that need to close custom Menus before calling showReader.
function ReaderUI:onSetupShowReader()
self.tearing_down = true
self.dithered = nil
end
--- @note: Will sanely close existing FileManager/ReaderUI instance for you!
function ReaderUI:showReader(file, provider)
logger.dbg("show reader ui")
if lfs.attributes(file, "mode") ~= "file" then
UIManager:show(InfoMessage:new{
text = T(_("File '%1' does not exist."), BD.filepath(file))
})
return
end
if not DocumentRegistry:hasProvider(file) and provider == nil then
UIManager:show(InfoMessage:new{
text = T(_("File '%1' is not supported."), BD.filepath(file))
})
self:showFileManager(file)
return
end
-- We can now signal the existing ReaderUI/FileManager instances that it's time to go bye-bye...
UIManager:broadcastEvent(Event:new("ShowingReader"))
-- prevent crash due to incompatible bookmarks
--- @todo Split bookmarks from metadata and do per-engine in conversion.
provider = provider or DocumentRegistry:getProvider(file)
if provider.provider then
local doc_settings = DocSettings:open(file)
local bookmarks = doc_settings:readSetting("bookmarks") or {}
if #bookmarks >= 1 and
((provider.provider == "crengine" and type(bookmarks[1].page) == "number") or
(provider.provider == "mupdf" and type(bookmarks[1].page) == "string")) then
UIManager:show(ConfirmBox:new{
text = T(_("The document '%1' with bookmarks or highlights was previously opened with a different engine. To prevent issues, bookmarks need to be deleted before continuing."),
BD.filepath(file)),
ok_text = _("Delete"),
ok_callback = function()
doc_settings:delSetting("bookmarks")
doc_settings:close()
self:showReaderCoroutine(file, provider)
end,
cancel_callback = function() self:showFileManager() end,
})
else
self:showReaderCoroutine(file, provider)
end
end
end
function ReaderUI:showReaderCoroutine(file, provider)
UIManager:show(InfoMessage:new{
text = T(_("Opening file '%1'."), BD.filepath(file)),
timeout = 0.0,
})
-- doShowReader might block for a long time, so force repaint here
UIManager:forceRePaint()
UIManager:nextTick(function()
logger.dbg("creating coroutine for showing reader")
local co = coroutine.create(function()
self:doShowReader(file, provider)
end)
local ok, err = coroutine.resume(co)
if err ~= nil or ok == false then
io.stderr:write('[!] doShowReader coroutine crashed:\n')
io.stderr:write(debug.traceback(co, err, 1))
UIManager:show(InfoMessage:new{
text = _("No reader engine for this file or invalid file.")
})
self:showFileManager()
end
end)
end
local _running_instance = nil
function ReaderUI:doShowReader(file, provider)
logger.info("opening file", file)
-- Keep only one instance running
if _running_instance then
_running_instance:onClose()
end
local document = DocumentRegistry:openDocument(file, provider)
if not document then
UIManager:show(InfoMessage:new{
text = _("No reader engine for this file or invalid file.")
})
self:showFileManager()
return
end
if document.is_locked then
logger.info("document is locked")
self._coroutine = coroutine.running() or self._coroutine
self:unlockDocumentWithPassword(document)
if coroutine.running() then
local unlock_success = coroutine.yield()
if not unlock_success then
self:showFileManager()
return
end
end
end
require("readhistory"):addItem(file) -- (will update "lastfile")
local reader = ReaderUI:new{
dimen = Screen:getSize(),
covers_fullscreen = true, -- hint for UIManager:_repaint()
document = document,
}
local title = reader.document:getProps().title
if title ~= "" then
Screen:setWindowTitle(title)
else
local _, filename = util.splitFilePathName(file)
Screen:setWindowTitle(filename)
end
Device:notifyBookState(title, document)
-- This is mostly for the few callers that bypass the coroutine shenanigans and call doShowReader directly,
-- instead of showReader...
-- Otherwise, showReader will have taken care of that *before* instantiating a new RD,
-- in order to ensure a sane ordering of plugins teardown -> instantiation.
local FileManager = require("apps/filemanager/filemanager")
if FileManager.instance then
FileManager.instance:onClose()
end
UIManager:show(reader)
_running_instance = reader
end
function ReaderUI:_getRunningInstance()
return _running_instance
end
function ReaderUI:unlockDocumentWithPassword(document, try_again)
logger.dbg("show input password dialog")
self.password_dialog = InputDialog:new{
title = try_again and _("Password is incorrect, try again?")
or _("Input document password"),
buttons = {
{
{
text = _("Cancel"),
enabled = true,
callback = function()
self:closeDialog()
coroutine.resume(self._coroutine)
end,
},
{
text = _("OK"),
enabled = true,
callback = function()
local success = self:onVerifyPassword(document)
self:closeDialog()
if success then
coroutine.resume(self._coroutine, success)
else
self:unlockDocumentWithPassword(document, true)
end
end,
},
},
},
text_type = "password",
}
UIManager:show(self.password_dialog)
self.password_dialog:onShowKeyboard()
end
function ReaderUI:onVerifyPassword(document)
local password = self.password_dialog:getInputText()
return document:unlock(password)
end
function ReaderUI:closeDialog()
self.password_dialog:onClose()
UIManager:close(self.password_dialog)
end
function ReaderUI:onScreenResize(dimen)
self.dimen = dimen
self:updateTouchZonesOnScreenResize(dimen)
end
function ReaderUI:saveSettings()
self:handleEvent(Event:new("SaveSettings"))
self.doc_settings:flush()
G_reader_settings:flush()
end
function ReaderUI:onFlushSettings()
self:saveSettings()
end
function ReaderUI:closeDocument()
self.document:close()
self.document = nil
end
function ReaderUI:notifyCloseDocument()
self:handleEvent(Event:new("CloseDocument"))
if self.document:isEdited() then
local setting = G_reader_settings:readSetting("save_document")
if setting == "always" then
self:closeDocument()
elseif setting == "disable" then
self.document:discardChange()
self:closeDocument()
else
UIManager:show(ConfirmBox:new{
text = _("Do you want to save this document?"),
ok_text = _("Save"),
cancel_text = _("Don't save"),
ok_callback = function()
self:closeDocument()
end,
cancel_callback = function()
self.document:discardChange()
self:closeDocument()
end,
})
end
else
self:closeDocument()
end
end
function ReaderUI:onClose(full_refresh)
logger.dbg("closing reader")
PluginLoader:finalize()
Device:notifyBookState(nil, nil)
if full_refresh == nil then
full_refresh = true
end
-- if self.dialog is us, we'll have our onFlushSettings() called
-- by UIManager:close() below, so avoid double save
if self.dialog ~= self then
self:saveSettings()
end
if self.document ~= nil then
logger.dbg("closing document")
self:notifyCloseDocument()
end
UIManager:close(self.dialog, full_refresh and "full")
-- serialize last used items for later launch
Cache:serialize()
if _running_instance == self then
_running_instance = nil
end
end
function ReaderUI:dealWithLoadDocumentFailure()
-- Sadly, we had to delay loadDocument() to about now, so we only
-- know now this document is not valid or recognized.
-- We can't do much more than crash properly here (still better than
-- going on and segfaulting when calling other methods on unitiliazed
-- _document)
-- We must still remove it from lastfile and history (as it has
-- already been added there) so that koreader don't crash again
-- at next launch...
require("readhistory"):removeItemByPath(self.document.file) -- (will update "lastfile")
-- As we are in a coroutine, we can pause and show an InfoMessage before exiting
local _coroutine = coroutine.running()
if coroutine then
logger.warn("crengine failed recognizing or parsing this file: unsupported or invalid document")
UIManager:show(InfoMessage:new{
text = _("Failed recognizing or parsing this file: unsupported or invalid document.\nKOReader will exit now."),
dismiss_callback = function()
coroutine.resume(_coroutine, false)
end,
})
coroutine.yield() -- pause till InfoMessage is dismissed
end
-- We have to error and exit the coroutine anyway to avoid any segfault
error("crengine failed recognizing or parsing this file: unsupported or invalid document")
end
function ReaderUI:onHome()
self:onClose()
self:showFileManager()
return true
end
function ReaderUI:reloadDocument(after_close_callback)
local file = self.document.file
local provider = getmetatable(self.document).__index
-- Mimic onShowingReader's refresh optimizations
self.tearing_down = true
self.dithered = nil
self:handleEvent(Event:new("CloseReaderMenu"))
self:handleEvent(Event:new("CloseConfigMenu"))
self.highlight:onClose() -- close highlight dialog if any
self:onClose(false)
if after_close_callback then
-- allow caller to do stuff between close an re-open
after_close_callback(file, provider)
end
self:showReader(file, provider)
end
function ReaderUI:switchDocument(new_file)
if not new_file then return end
-- Mimic onShowingReader's refresh optimizations
self.tearing_down = true
self.dithered = nil
self:handleEvent(Event:new("CloseReaderMenu"))
self:handleEvent(Event:new("CloseConfigMenu"))
self.highlight:onClose() -- close highlight dialog if any
self:onClose(false)
self:showReader(new_file)
end
function ReaderUI:onOpenLastDoc()
self:switchDocument(self.menu:getPreviousFile())
end
function ReaderUI:getCurrentPage()
if self.document.info.has_pages then
return self.paging.current_page
else
return self.document:getCurrentPage()
end
end
return ReaderUI