2
0
mirror of https://github.com/koreader/koreader synced 2024-11-11 19:11:14 +00:00
koreader/frontend/ui/widget/filechooser.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

591 lines
21 KiB
Lua

local BD = require("ui/bidi")
local Device = require("device")
local DocSettings = require("docsettings")
local DocumentRegistry = require("document/documentregistry")
local OpenWithDialog = require("ui/widget/openwithdialog")
local ConfirmBox = require("ui/widget/confirmbox")
local Font = require("ui/font")
local Menu = require("ui/widget/menu")
local UIManager = require("ui/uimanager")
local ffi = require("ffi")
local lfs = require("libs/libkoreader-lfs")
local ffiUtil = require("ffi/util")
local T = ffiUtil.template
local _ = require("gettext")
local N_ = _.ngettext
local Screen = Device.screen
local util = require("util")
local getFileNameSuffix = util.getFileNameSuffix
local getFriendlySize = util.getFriendlySize
local FileChooser = Menu:extend{
cface = Font:getFace("smallinfofont"),
no_title = true,
path = lfs.currentdir(),
show_path = true,
parent = nil,
show_hidden = nil,
-- NOTE: Input is *always* a relative entry name
exclude_dirs = {
-- KOReader / Kindle
"%.sdr$",
-- Kobo
"^%.adobe%-digital%-editions$",
"^%.kobo$",
"^%.kobo%-images$",
-- macOS
"^%.fseventsd$",
"^%.Trashes$",
"^%.Spotlight%-V100$",
-- *nix
"^%.Trash$",
"^%.Trash%-%d+$",
-- Windows
"^RECYCLED$",
"^RECYCLER$",
"^%$Recycle%.Bin$",
"^System Volume Information$",
-- Plato
"^%.thumbnail%-previews$",
"^%.reading%-states$",
},
exclude_files = {
-- macOS
"^%.DS_Store$",
-- *nix
"^%.directory$",
-- Windows
"^Thumbs%.db$",
-- Calibre
"^driveinfo%.calibre$",
"^metadata%.calibre$",
-- Plato
"^%.fat32%-epoch$",
"^%.metadata%.json$",
},
collate = "strcoll", -- or collate = "access",
reverse_collate = false,
path_items = {}, -- store last browsed location (item index) for each path
goto_letter = true,
}
-- Cache of content we knew of for directories that are not readable
-- (i.e. /storage/emulated/ on Android that we can meet when coming
-- from readable /storage/emulated/0/ - so we know it contains "0/")
local unreadable_dir_content = {}
function FileChooser:init()
self.width = Screen:getWidth()
-- Standard dir exclusion list
self.show_dir = function(dirname)
for _, pattern in ipairs(self.exclude_dirs) do
if dirname:match(pattern) then return false end
end
return true
end
-- Standard file exclusion list
self.show_file = function(filename)
for _, pattern in ipairs(self.exclude_files) do
if filename:match(pattern) then return false end
end
return true
end
self.list = function(path, dirs, files, count_only)
-- lfs.dir directory without permission will give error
local ok, iter, dir_obj = pcall(lfs.dir, path)
if ok then
unreadable_dir_content[path] = nil
for f in iter, dir_obj do
if count_only then
if ((not self.show_hidden and not util.stringStartsWith(f, "."))
or (self.show_hidden and f ~= "." and f ~= ".." and not util.stringStartsWith(f, "._")))
and self.show_dir(f)
and self.show_file(f)
then
table.insert(dirs, true)
end
elseif self.show_hidden or not util.stringStartsWith(f, ".") then
local filename = path.."/"..f
local attributes = lfs.attributes(filename)
if attributes ~= nil then
if attributes.mode == "directory" and f ~= "." and f ~= ".." then
if self.show_dir(f) then
table.insert(dirs, {name = f,
suffix = getFileNameSuffix(f),
fullpath = filename,
attr = attributes})
end
-- Always ignore macOS resource forks.
elseif attributes.mode == "file" and not util.stringStartsWith(f, "._") then
if self.show_file(f) then
if self.file_filter == nil or self.file_filter(filename) or self.show_unsupported then
local percent_finished = 0
if self.collate == "percent_unopened_first" or self.collate == "percent_unopened_last" then
if DocSettings:hasSidecarFile(filename) then
local docinfo = DocSettings:open(filename)
percent_finished = docinfo.data.percent_finished
if percent_finished == nil then
percent_finished = 0
end
end
end
table.insert(files, {name = f,
suffix = getFileNameSuffix(f),
fullpath = filename,
attr = attributes,
percent_finished = percent_finished })
end
end
end
end
end
end
else -- error, probably "permission denied"
if unreadable_dir_content[path] then
-- Add this dummy item that will be replaced with a message
-- by genItemTableFromPath()
table.insert(dirs, {
name = "./.",
fullpath = path,
attr = lfs.attributes(path),
})
-- If we knew about some content (if we had come up from them
-- to this directory), have them shown
for k, v in pairs(unreadable_dir_content[path]) do
if v.attr and v.attr.mode == "directory" then
table.insert(dirs, v)
else
table.insert(files, v)
end
end
end
end
end
self.item_table = self:genItemTableFromPath(self.path)
Menu.init(self) -- call parent's init()
end
function FileChooser:genItemTableFromPath(path)
local dirs = {}
local files = {}
local up_folder_arrow = BD.mirroredUILayout() and BD.ltr("../ ⬆") or "⬆ ../"
self.list(path, dirs, files)
local sorting
if self.collate == "strcoll" then
sorting = function(a, b)
return ffiUtil.strcoll(a.name, b.name)
end
elseif self.collate == "access" then
sorting = function(a, b)
return a.attr.access > b.attr.access
end
elseif self.collate == "modification" then
sorting = function(a, b)
return a.attr.modification > b.attr.modification
end
elseif self.collate == "change" then
sorting = function(a, b)
if DocSettings:hasSidecarFile(a.fullpath) and not DocSettings:hasSidecarFile(b.fullpath) then
return false
end
if not DocSettings:hasSidecarFile(a.fullpath) and DocSettings:hasSidecarFile(b.fullpath) then
return true
end
return a.attr.change > b.attr.change
end
elseif self.collate == "size" then
sorting = function(a, b)
return a.attr.size < b.attr.size
end
elseif self.collate == "type" then
sorting = function(a, b)
if a.suffix == nil and b.suffix == nil then
return ffiUtil.strcoll(a.name, b.name)
else
return ffiUtil.strcoll(a.suffix, b.suffix)
end
end
elseif self.collate == "percent_unopened_first" or self.collate == "percent_unopened_last" then
sorting = function(a, b)
if DocSettings:hasSidecarFile(a.fullpath) and not DocSettings:hasSidecarFile(b.fullpath) then
if self.collate == "percent_unopened_first" then
return false
else
return true
end
end
if not DocSettings:hasSidecarFile(a.fullpath) and DocSettings:hasSidecarFile(b.fullpath) then
if self.collate == "percent_unopened_first" then
return true
else
return false
end
end
if not DocSettings:hasSidecarFile(a.fullpath) and not DocSettings:hasSidecarFile(b.fullpath) then
return a.name < b.name
end
if a.attr.mode == "directory" then return a.name < b.name end
if b.attr.mode == "directory" then return a.name < b.name end
return a.percent_finished < b.percent_finished
end
elseif self.collate == "numeric" then
-- adapted from: http://notebook.kulchenko.com/algorithms/alphanumeric-natural-sorting-for-humans-in-lua
local function addLeadingZeroes(d)
local dec, n = string.match(d, "(%.?)0*(.+)")
return #dec > 0 and ("%.12f"):format(d) or ("%s%03d%s"):format(dec, #n, n)
end
sorting = function(a, b)
return tostring(a.name):gsub("%.?%d+", addLeadingZeroes)..("%3d"):format(#b.name)
< tostring(b.name):gsub("%.?%d+",addLeadingZeroes)..("%3d"):format(#a.name)
end
else
sorting = function(a, b)
return a.name < b.name
end
end
if self.reverse_collate then
local sorting_unreversed = sorting
sorting = function(a, b) return sorting_unreversed(b, a) end
end
if self.collate ~= "strcoll_mixed" then
table.sort(dirs, sorting)
table.sort(files, sorting)
end
if path ~= "/" then table.insert(dirs, 1, {name = ".."}) end
if self.show_current_dir_for_hold then table.insert(dirs, 1, {name = "."}) end
local item_table = {}
for i, dir in ipairs(dirs) do
-- count sume of directories and files inside dir
local sub_dirs = {}
local dir_files = {}
local subdir_path = self.path.."/"..dir.name
self.list(subdir_path, sub_dirs, dir_files, true)
local num_items = #sub_dirs + #dir_files
local istr = ffiUtil.template(N_("1 item", "%1 items", num_items), num_items)
local text
local bidi_wrap_func
if dir.name == ".." then
text = up_folder_arrow
elseif dir.name == "." then -- possible with show_current_dir_for_hold
text = _("Long-press to select current folder")
elseif dir.name == "./." then -- added as content of an unreadable directory
text = _("Current folder not readable. Some content may not be shown.")
else
text = dir.name.."/"
bidi_wrap_func = BD.directory
end
table.insert(item_table, {
text = text,
bidi_wrap_func = bidi_wrap_func,
mandatory = istr,
path = subdir_path,
is_go_up = dir.name == ".."
})
end
-- set to false to show all files in regular font
-- set to "opened" to show opened files in bold
-- otherwise, show new files in bold
local show_file_in_bold = G_reader_settings:readSetting("show_file_in_bold")
for i = 1, #files do
local file = files[i]
local full_path = self.path.."/"..file.name
local file_size = lfs.attributes(full_path, "size") or 0
local sstr = getFriendlySize(file_size)
local file_item = {
text = file.name,
bidi_wrap_func = BD.filename,
mandatory = sstr,
path = full_path
}
if show_file_in_bold ~= false then
file_item.bold = DocSettings:hasSidecarFile(full_path)
if show_file_in_bold ~= "opened" then
file_item.bold = not file_item.bold
end
end
table.insert(item_table, file_item)
end
if self.collate == "strcoll_mixed" then
sorting = function(a, b)
if b.text == up_folder_arrow then return false end
return ffiUtil.strcoll(a.text, b.text)
end
if self.reverse_collate then
local sorting_unreversed = sorting
sorting = function(a, b) return sorting_unreversed(b, a) end
end
table.sort(item_table, sorting)
end
-- lfs.dir iterated node string may be encoded with some weird codepage on
-- Windows we need to encode them to utf-8
if ffi.os == "Windows" then
for k, v in pairs(item_table) do
if v.text then
v.text = ffiUtil.multiByteToUTF8(v.text) or ""
end
end
end
return item_table
end
function FileChooser:updateItems(select_number)
Menu.updateItems(self, select_number) -- call parent's updateItems()
self.path_items[self.path] = (self.page - 1) * self.perpage + (select_number or 1)
end
function FileChooser:refreshPath()
local itemmatch = nil
local _, folder_name = util.splitFilePathName(self.path)
Screen:setWindowTitle(folder_name)
if self.focused_path then
itemmatch = {path = self.focused_path}
-- We use focused_path only once, but remember it
-- for CoverBrower to re-apply it on startup if needed
self.prev_focused_path = self.focused_path
self.focused_path = nil
end
self:switchItemTable(nil, self:genItemTableFromPath(self.path), self.path_items[self.path], itemmatch)
end
function FileChooser:changeToPath(path, focused_path)
path = ffiUtil.realpath(path)
self.path = path
if focused_path then
self.focused_path = focused_path
-- We know focused_path is a child of path. In case path is
-- not a readable directory, we can have focused_path shown,
-- to allow the user to go back in it
if not unreadable_dir_content[path] then
unreadable_dir_content[path] = {}
end
if not unreadable_dir_content[path][focused_path] then
unreadable_dir_content[path][focused_path] = {
name = focused_path:sub(#path+2),
fullpath = focused_path,
attr = lfs.attributes(focused_path),
}
end
end
self:refreshPath()
self:onPathChanged(path)
end
function FileChooser:onFolderUp()
self:changeToPath(string.format("%s/..", self.path), self.path)
end
function FileChooser:changePageToPath(path)
if not path then return end
for num, item in ipairs(self.item_table) do
if item.path == path then
local page = math.floor((num-1) / self.perpage) + 1
if page ~= self.page then
self:onGotoPage(page)
end
break
end
end
end
function FileChooser:toggleHiddenFiles()
self.show_hidden = not self.show_hidden
self:refreshPath()
end
function FileChooser:toggleUnsupportedFiles()
self.show_unsupported = not self.show_unsupported
self:refreshPath()
end
function FileChooser:setCollate(collate)
self.collate = collate
self:refreshPath()
end
function FileChooser:toggleReverseCollate()
self.reverse_collate = not self.reverse_collate
self:refreshPath()
end
function FileChooser:onMenuSelect(item)
-- parent directory of dir without permission get nil mode
-- we need to change to parent path in this case
if lfs.attributes(item.path, "mode") == "file" then
self:onFileSelect(item.path)
else
self:changeToPath(item.path, item.is_go_up and self.path)
end
return true
end
function FileChooser:onMenuHold(item)
self:onFileHold(item.path)
return true
end
function FileChooser:onFileSelect(file)
UIManager:close(self)
return true
end
function FileChooser:onFileHold(file)
return true
end
function FileChooser:onPathChanged(path)
return true
end
function FileChooser:getNextFile(curr_file)
local next_file
for index, data in pairs(self.item_table) do
if data.path == curr_file then
if index+1 <= #self.item_table then
next_file = self.item_table[index+1].path
if lfs.attributes(next_file, "mode") == "file" and DocumentRegistry:hasProvider(next_file) then
break
else
next_file = nil
end
end
end
end
return next_file
end
function FileChooser:showSetProviderButtons(file, filemanager_instance, reader_ui, one_time_providers)
local __, filename_pure = util.splitFilePathName(file)
local filename_suffix = util.getFileNameSuffix(file)
local buttons = {}
local radio_buttons = {}
local filetype_provider = G_reader_settings:readSetting("provider") or {}
local providers = DocumentRegistry:getProviders(file)
if providers ~= nil then
for ___, provider in ipairs(providers) do
-- we have no need for extension, mimetype, weights, etc. here
provider = provider.provider
table.insert(radio_buttons, {
{
text = provider.provider_name,
checked = DocumentRegistry:getProvider(file) == provider,
provider = provider,
},
})
end
else
local provider = DocumentRegistry:getProvider(file)
table.insert(radio_buttons, {
{
-- @translators %1 is the provider name, such as Cool Reader Engine or MuPDF.
text = T(_("%1 ~Unsupported"), provider.provider_name),
checked = true,
provider = provider,
},
})
end
if one_time_providers and #one_time_providers > 0 then
for ___, provider in ipairs(one_time_providers) do
provider.one_time_provider = true
table.insert(radio_buttons, {
{
text = provider.provider_name,
provider = provider,
},
})
end
end
table.insert(buttons, {
{
text = _("Cancel"),
callback = function()
UIManager:close(self.set_provider_dialog)
end,
},
{
text = _("Open"),
is_enter_default = true,
callback = function()
local provider = self.set_provider_dialog.radio_button_table.checked_button.provider
if provider.one_time_provider then
UIManager:close(self.set_provider_dialog)
provider.callback()
return
end
-- always for this file
if self.set_provider_dialog._check_file_button.checked then
UIManager:show(ConfirmBox:new{
text = T(_("Always open '%2' with %1?"),
provider.provider_name, BD.filename(filename_pure)),
ok_text = _("Always"),
ok_callback = function()
DocumentRegistry:setProvider(file, provider, false)
reader_ui:showReader(file, provider)
UIManager:close(self.set_provider_dialog)
end,
})
-- always for all files of this file type
elseif self.set_provider_dialog._check_global_button.checked then
UIManager:show(ConfirmBox:new{
text = T(_("Always open %2 files with %1?"),
provider.provider_name, filename_suffix),
ok_text = _("Always"),
ok_callback = function()
DocumentRegistry:setProvider(file, provider, true)
reader_ui:showReader(file, provider)
UIManager:close(self.set_provider_dialog)
end,
})
else
-- just once
reader_ui:showReader(file, provider)
UIManager:close(self.set_provider_dialog)
end
end,
},
})
if filetype_provider[filename_suffix] ~= nil then
table.insert(buttons, {
{
text = _("Reset default"),
callback = function()
filetype_provider[filename_suffix] = nil
G_reader_settings:saveSetting("provider", filetype_provider)
UIManager:close(self.set_provider_dialog)
end,
},
})
end
self.set_provider_dialog = OpenWithDialog:new{
title = T(_("Open %1 with:"), BD.filename(filename_pure)),
radio_buttons = radio_buttons,
buttons = buttons,
}
UIManager:show(self.set_provider_dialog)
end
return FileChooser