Files
bookorbit/koreader-plugin/bookorbit.koplugin/bookorbit_settings_page.lua
T
neon 267f53fccd feat(koreader): colour the widget board on colour screens
Add a Colour setting to Settings > Home: Accents, the default, colours each
widget's marks and draws Continue reading in its cover's deep shade; Tinted
also washes every tile in its widget's colour; Off keeps the greys. Black-and-
white screens draw as before, colour e-readers get stronger shades, and night
mode keeps each colour's hue.
2026-10-27 15:27:39 +00:00

957 lines
45 KiB
Lua

--[[--
BookOrbit settings: one level deep, every value visible where it is changed.
On phones and 6-7" readers the root lists the groups with their current
values and each group is one page. On tablets and in landscape the groups sit
beside the open one, so nothing is drilled into. The shortcut picker is its
own page from Opening BookOrbit.
]]
local Device = require("device")
local Geom = require("ui/geometry")
local IconWidget = require("ui/widget/iconwidget")
local UIManager = require("ui/uimanager")
local VerticalSpan = require("ui/widget/verticalspan")
local _ = require("gettext")
local T = require("ffi/util").template
local BoardMetrics = require("bookorbit_board_metrics")
local BoardStore = require("bookorbit_board_store")
local BoardUI = require("bookorbit_board_ui")
local DownloadRoot = require("bookorbit_download_root")
local MenuPage = require("bookorbit_menu_page")
local MenuUI = require("bookorbit_menu_ui")
local Shortcut = require("bookorbit_shortcut")
local INK = BoardMetrics.INK
local px = BoardMetrics.px
local text, vstack, hstack, flex, space, icon = BoardUI.text, BoardUI.vstack, BoardUI.hstack, BoardUI.flex, BoardUI.space, BoardUI.icon
local Settings = {}
local STRATEGY = { SILENT = 2, PROMPT = 1, DISABLE = 3 }
local STRATEGY_ORDER = { STRATEGY.SILENT, STRATEGY.PROMPT, STRATEGY.DISABLE }
local PRESETS = { "reader", "stats", "library" }
local TILES = { "outline", "soft", "ink" }
local COLOURS = BoardStore.COLOURS
local function scaleLabel(value)
return string.format("%.2f", value):gsub("0$", "") .. "x"
end
local function indexOf(list, value, fallback)
for index, v in ipairs(list) do
if v == value then return index end
end
return fallback
end
-- ------------------------------------------------------------- summaries
function Settings.openMode(settings)
local mode = settings.catalog_auto_open or "off"
if mode == "home" then return 3 end
if mode == "filemanager" or mode == "always" then return 2 end
return 1
end
local function shortcutLabel(page)
local g = Shortcut.find(Shortcut.current(page.gestures_data))
return g and g.label() or _("no shortcut")
end
local function openingSummary(page)
local mode = Settings.openMode(page.plugin.settings)
local first = mode == 3 and _("BookOrbit is home") or mode == 2 and _("At startup") or _("File browser first")
return first .. " · " .. shortcutLabel(page)
end
local function syncSummary(settings)
local auto = settings.auto_sync and _("Automatic") or _("By hand")
local forward = settings.sync_forward
local jump = forward == STRATEGY.SILENT and _("jump to newer") or forward == STRATEGY.DISABLE and _("stay put") or _("ask before jumping")
return auto .. ", " .. jump
end
local PRESET_LABELS = { reader = function() return _("Reader") end, stats = function() return _("Stats") end, library = function() return _("Library") end }
local function homeSummary(settings)
local state = BoardStore.read(settings)
return T(_("%1 layout, %2 size"),
(PRESET_LABELS[state.preset] or PRESET_LABELS.reader)(),
scaleLabel(BoardMetrics.normalizeUserScale(settings.ui_scale)))
end
local function downloadDir()
return DownloadRoot.get() or "/"
end
local function folderName(path)
local name = tostring(path or ""):gsub("/+$", ""):match("([^/]+)$")
return name or path
end
local CATEGORIES = {
{ id = "opening", icon = "door-open", label = function() return _("Opening BookOrbit") end, sub = function(page) return openingSummary(page) end },
{ id = "sync", icon = "refresh-cw", label = function() return _("Sync") end, sub = function(page) return syncSummary(page.plugin.settings) end },
{ id = "home", icon = "layout-grid", label = function() return _("Home") end, sub = function(page) return homeSummary(page.plugin.settings) end },
{ id = "downloads", icon = "download", label = function() return _("Downloads") end, sub = function() return folderName(downloadDir()) end },
}
local function titleOf(view)
for _i, cat in ipairs(CATEGORIES) do
if cat.id == view then return cat.label() end
end
if view == "account" then return _("Account") end
if view == "about" then return _("About & updates") end
if view == "shortcut" then return _("Shortcut") end
return _("Settings")
end
-- ----------------------------------------------------------------- lists
local function categoryRows(page, w, compact)
local rows = {}
local selected = page.detail
for _i, cat in ipairs(CATEGORIES) do
table.insert(rows, MenuUI.row(w, {
icon = cat.icon, label = cat.label(), bold = compact and selected == cat.id,
sub = not compact and cat.sub(page) or nil, sub_data = true,
chevron = not compact, action = { kind = "view", view = cat.id },
}))
end
return rows
end
local function settingsList(page, w, compact)
local settings = page.plugin.settings
local iw = MenuUI.rowsIn(w)
local waiting = page.plugin.getLifecycleOutboxStatus and (page.plugin:getLifecycleOutboxStatus().count or 0) or 0
local coordinator = page.plugin.getSyncCoordinatorStatus and page.plugin:getSyncCoordinatorStatus() or {}
waiting = waiting + (coordinator.pending_count or 0)
local last_sync = type(settings.last_sync) == "table" and settings.last_sync or {}
local last_error = type(settings.last_error) == "table" and settings.last_error or {}
local status_summary
if not page.plugin:isLoggedIn() then
status_summary = _("Not signed in")
elseif waiting > 0 then
status_summary = T(_("%1 changes waiting"), waiting)
elseif last_error.event ~= "connection_test" and (tonumber(last_error.at) or 0) > (last_sync.event ~= "connection_test" and tonumber(last_sync.at) or 0) then
status_summary = _("The last sync did not finish")
elseif not last_sync.at or last_sync.event == "connection_test" then
status_summary = _("Not synced yet")
else
status_summary = _("All synced")
end
local update = page.plugin.hasKnownUpdate and page.plugin:hasKnownUpdate() and settings.update_latest_version
return {
MenuUI.group(w, categoryRows(page, iw, compact)),
space(nil, px(16)),
MenuUI.section(w, _("Account"), MenuUI.group(w, {
MenuUI.row(iw, { icon = "circle-user-round", label = settings.username or _("Not signed in"), data = true, bold = compact and page.detail == "account",
sub = not compact and MenuUI.serverHost(settings.server_url) or nil, sub_data = true, chevron = not compact, action = { kind = "view", view = "account" } }),
})),
space(nil, px(16)),
MenuUI.section(w, _("Help"), MenuUI.group(w, {
MenuUI.row(iw, { icon = "activity", label = _("Sync status"), sub = not compact and status_summary or nil,
chevron = not compact, action = { kind = "status" } }),
MenuUI.row(iw, { icon = "circle-help", label = _("About & updates"), bold = compact and page.detail == "about",
sub = not compact and (update and T(_("Version %1 is ready"), update) or T(_("Version %1"), page.plugin.PLUGIN_VERSION)) or nil,
trail = not compact and update and MenuUI.chip(_("New"), { ink = true }) or nil,
chevron = not compact, action = { kind = "view", view = "about" } }),
})),
}
end
-- ---------------------------------------------------------------- bodies
local function segRow(w, label, sub, options, selected, action_for, tight)
local pad = px(tight and 8 or 12)
return vstack({ w = w, pad = { pad, 0, pad, 0 } }, {
text(label, { s = 16, w = w }),
sub and text(sub, { s = 13, col = INK.g4, w = w }) or false,
space(nil, px(8)),
MenuUI.segmented(w, options, selected, action_for),
})
end
local function menuBarStrip(page, w)
local names = { "appbar.filebrowser", "appbar.settings", "appbar.tools", "appbar.search", "bookorbit", "appbar.menu" }
-- Six cells across, smaller in a narrow column.
local cell_s = math.min(px(44), math.floor(w / #names))
local s = math.min(px(30), cell_s - px(8))
local cells = {}
for _i, name in ipairs(names) do
local glyph
if name == "bookorbit" then
glyph = MenuUI.tabIcon(s, page.plugin:menuBarIconPath())
else
glyph = IconWidget:new{ icon = name, width = s, height = s }
end
local cell = hstack({ w = cell_s, h = cell_s }, { flex(0), glyph, flex(0) })
if name == "bookorbit" then
cell = BoardUI.overlay(cell_s, cell_s, {
{ widget = cell },
{ widget = BoardUI.box({ w = cell_s, h = cell_s, radius = px(10), border = math.max(1, px(1.5)), border_col = INK.black }) },
})
end
table.insert(cells, cell)
end
local strip = hstack({ w = w, h = px(52) }, { flex(0), cells[1], flex(0), cells[2], flex(0), cells[3], flex(0), cells[4], flex(0), cells[5], flex(0), cells[6], flex(0) })
return vstack({ w = w }, { strip, BoardUI.rect(w, math.max(1, px(1.5)), INK.black) })
end
local function openingBody(page, w, embedded)
local settings = page.plugin.settings
local iw = MenuUI.rowsIn(w)
local mode = Settings.openMode(settings)
local choices = {
{ _("Show the file browser"), _("Open BookOrbit from the menu bar or your shortcut"), "off" },
{ _("Open BookOrbit"), _("Once, when KOReader starts"), "filemanager" },
{ _("Make BookOrbit home"), _("At startup, and whenever you close a book"), "home" },
}
local radios = {}
for index, c in ipairs(choices) do
table.insert(radios, MenuUI.row(iw, { label = c[1], sub = c[2], radio = index == mode, h = 64, action = { kind = "open-mode", mode = c[3] } }))
end
local shortcut_card
if not Device:isTouchDevice() then
shortcut_card = MenuUI.card(w, { space(nil, px(12)), text(_("This device has no touchscreen. KOReader's keyboard shortcuts can open BookOrbit: its action is \"BookOrbit: open\"."), { s = 14, w = iw, lines = 3 }), space(nil, px(12)) })
else
local g = Shortcut.find(Shortcut.current(page.gestures_data))
local pill = MenuUI.pill(g and _("Change") or _("Pick one"), { kind = "outline", h = 36, s = 14, pad_x = 14, action = { kind = "view", view = "shortcut" } })
local illus = MenuUI.gesture(g and g.kind or "none", px(44))
local mid_w = iw - illus:getSize().w - pill:getSize().w - 2 * px(14)
local sub = g and (g.reader_only and _("Opens BookOrbit from any book") or _("Opens BookOrbit from the file browser and from any book")) or _("Pick a gesture to open BookOrbit")
-- A narrow column gives the note a third line and the row the height.
local sub_lines = math.max(2, math.min(3, require("bookorbit_text_pages").linesNeeded(sub, { s = 13 }, mid_w)))
local mid = vstack({ w = mid_w }, {
text(g and g.label() or _("No shortcut"), { s = 16, b = true, w = mid_w }),
text(sub, { s = 13, col = INK.g4, w = mid_w, lines = sub_lines }),
})
shortcut_card = BoardUI.hit(MenuUI.card(w, {
hstack({ w = iw, h = math.max(px(84), mid:getSize().h + px(16)), gap = px(14) }, { illus, mid, pill }),
}), { kind = "view", view = "shortcut" })
end
local enabled = page.plugin:menuBarIconEnabled()
local bar_card = MenuUI.card(w, {
space(nil, px(4)),
menuBarStrip(page, iw),
MenuUI.row(iw, { icon = MenuUI.tabIcon(px(24), page.plugin:menuBarIconPath()), label = _("BookOrbit in the menu bar"),
sub = enabled and _("One tap from KOReader's top menu") or _("Only under Tools in KOReader's menu"), toggle = enabled, action = { kind = "menu-bar" } }),
})
return {
embedded and text(_("Opening BookOrbit"), { s = 18, b = true, w = w }) or false,
embedded and space(nil, px(12)) or false,
MenuUI.section(w, _("When KOReader starts"), MenuUI.group(w, radios, { inset = 0 })),
space(nil, px(16)),
MenuUI.section(w, _("Shortcut"), shortcut_card),
space(nil, px(16)),
MenuUI.section(w, _("KOReader menu bar"), bar_card),
}
end
local function syncBody(page, w, avail, embedded)
local settings = page.plugin.settings
local iw = MenuUI.rowsIn(w)
local auto = MenuUI.group(w, {
MenuUI.row(iw, { label = _("Auto-sync"), sub = _("Pull when a book opens, push when it closes and on sleep"), sublines = 2, toggle = settings.auto_sync == true, action = { kind = "toggle-auto" } }),
MenuUI.row(iw, { label = _("Push while reading"), sub = (settings.pages_before_update or 0) > 0 and _("Every few page turns") or _("Off"), h = 64,
trail = MenuUI.stepper(settings.pages_before_update or 0, _("pages"), { kind = "push", delta = -1 }, { kind = "push", delta = 1 }) }),
MenuUI.row(iw, { label = _("Skip when offline"), sub = _("Never wake Wi-Fi just to sync"), toggle = settings.skip_sync_when_offline == true, action = { kind = "flag", key = "skip_sync_when_offline" } }),
MenuUI.row(iw, { label = _("Two-way highlights & bookmarks"), sub = _("Bring marks made elsewhere to this device"), toggle = settings.annotation_sync == true, action = { kind = "flag", key = "annotation_sync" } }),
}, { inset = 0 })
local options = { _("Jump"), _("Ask"), _("Stay") }
local fwd = indexOf(STRATEGY_ORDER, settings.sync_forward, 2)
local back = indexOf(STRATEGY_ORDER, settings.sync_backward, 3)
local function act(key) return function(index) return { kind = "strategy", key = key, value = STRATEGY_ORDER[index] } end end
local differ
if embedded and avail < px(640) and iw >= px(360) then
local half = math.floor((iw - px(24)) / 2)
differ = MenuUI.card(w, { hstack({ w = iw, gap = px(24), valign = "top" }, {
segRow(half, _("BookOrbit is ahead"), _("Read further elsewhere"), options, fwd, act("sync_forward")),
segRow(iw - half - px(24), _("BookOrbit is behind"), _("This device read further"), options, back, act("sync_backward")),
}) })
else
local a = segRow(iw, _("BookOrbit is further ahead"), _("Read further on another device"), options, fwd, act("sync_forward"))
local b = segRow(iw, _("BookOrbit is further behind"), _("This device read further"), options, back, act("sync_backward"))
a._no_icon, b._no_icon = true, true
differ = MenuUI.group(w, { a, b }, { inset = 0 })
end
return {
embedded and text(_("Sync"), { s = 18, b = true, w = w }) or false,
embedded and space(nil, px(12)) or false,
MenuUI.section(w, _("Automatic"), auto),
space(nil, px(16)),
MenuUI.section(w, _("When positions differ"), differ),
}
end
local function homeBody(page, w, embedded)
local settings = page.plugin.settings
local state = BoardStore.read(settings)
local iw = MenuUI.rowsIn(w)
local top = MenuUI.group(w, {
MenuUI.row(iw, { icon = "layout-grid", label = _("Customize Home"), sub = _("Move, resize, add and remove widgets"), chevron = true, action = { kind = "customize" } }),
MenuUI.row(iw, { icon = "book-open", label = _("Tap Continue reading"), sub = settings.catalog_dashboard_tap_resumes ~= false and _("Opens the book when it is on this device") or _("Shows the book's details"),
toggle = settings.catalog_dashboard_tap_resumes ~= false, action = { kind = "flag", key = "catalog_dashboard_tap_resumes", default_on = true } }),
})
local preset_labels = { _("Reader"), _("Stats"), _("Library") }
local tile_labels = { _("Outline"), _("Soft"), _("Ink") }
-- Colour only on a screen that shows it.
local colour_labels = Device.screen:isColorEnabled() and { _("Accents"), _("Tinted"), _("Off") } or nil
local function colourRow(rw, tight)
local row = segRow(rw, _("Colour"), nil, colour_labels, indexOf(COLOURS, state.colour, 1),
function(index) return { kind = "colour", value = COLOURS[index] } end, tight)
row._no_icon = true
return row
end
-- Colour sits beside Tile style when half a row holds both controls'
-- labels, so the page keeps the height it has on a black-and-white screen.
local function pairsWithTiles(half)
local seg = math.floor((half - 2 * math.max(1, px(1.5))) / 3) - px(8)
for _i, list in ipairs({ tile_labels, colour_labels }) do
for _j, label in ipairs(list) do
local t = text(label, { s = 14, b = true })
local wide = t:getSize().w > seg
t:free()
if wide then return false end
end
end
return true
end
local scale_labels = {}
for index, scale in ipairs(BoardMetrics.UI_SCALES) do
scale_labels[index] = scaleLabel(scale)
end
local a = segRow(iw, _("Starting layout"), _("Replaces your pages with a starter set"), preset_labels, indexOf(PRESETS, state.preset, 1),
function(index) return { kind = "preset", value = PRESETS[index] } end)
local look_gap = px(16)
local look_half = math.floor((iw - look_gap) / 2)
local pair_colour = colour_labels ~= nil and not page.compact and pairsWithTiles(look_half)
local b = segRow(pair_colour and look_half or iw, _("Tile style"), nil, tile_labels, indexOf(TILES, state.tiles, 1),
function(index) return { kind = "tiles", value = TILES[index] } end)
local c = segRow(iw, _("Interface size"), _("Text and controls across BookOrbit"), scale_labels,
indexOf(BoardMetrics.UI_SCALES, BoardMetrics.normalizeUserScale(settings.ui_scale), 1),
function(index) return { kind = "ui-scale", value = BoardMetrics.UI_SCALES[index] } end)
a._no_icon, b._no_icon, c._no_icon = true, true, true
local look
if pair_colour then
local pair = hstack({ w = iw, gap = look_gap, valign = "top" }, { b, colourRow(iw - look_half - look_gap) })
pair._no_icon = true
look = MenuUI.group(w, { a, pair, c }, { inset = 0 })
else
look = MenuUI.group(w, { a, b, c }, { inset = 0 })
end
-- Too narrow to share Tile style's row: Colour is a card of its own after
-- the first, which a two-column page can set beside Look.
local colour_card = colour_labels and not pair_colour and MenuUI.group(w, { colourRow(iw, page.compact ~= nil) }, { inset = 0 }) or nil
-- On a short, wide screen whose columns cannot hold the full card
-- (page.compact, set by build): without the notes, and at level 1 the
-- layout and tile style sharing a row when each half has room.
if page.compact then
look:free()
local gap = px(16)
local half = math.floor((iw - gap) / 2)
local paired = page.compact == 1 and half >= px(180)
a = segRow(paired and half or iw, _("Starting layout"), nil, preset_labels, indexOf(PRESETS, state.preset, 1),
function(index) return { kind = "preset", value = PRESETS[index] } end, true)
b = segRow(paired and (iw - half - gap) or iw, _("Tile style"), nil, tile_labels, indexOf(TILES, state.tiles, 1),
function(index) return { kind = "tiles", value = TILES[index] } end, true)
c = segRow(iw, _("Interface size"), nil, scale_labels,
indexOf(BoardMetrics.UI_SCALES, BoardMetrics.normalizeUserScale(settings.ui_scale), 1),
function(index) return { kind = "ui-scale", value = BoardMetrics.UI_SCALES[index] } end, true)
a._no_icon, b._no_icon, c._no_icon = true, true, true
if paired then
local pair = hstack({ w = iw, gap = gap, valign = "top" }, { a, b })
pair._no_icon = true
look = MenuUI.group(w, { pair, c }, { inset = 0 })
else
look = MenuUI.group(w, { a, b, c }, { inset = 0 })
end
end
local reset = MenuUI.group(w, {
MenuUI.row(iw, { icon = "rotate-ccw", label = _("Reset Home"), sub = _("Back to the starting layout and outline tiles"), action = { kind = "reset-home" } }),
})
return {
embedded and text(_("Home"), { s = 18, b = true, w = w }) or false,
embedded and space(nil, px(12)) or false,
top,
colour_card and space(nil, px(16)) or false,
colour_card or false,
space(nil, px(16)),
MenuUI.section(w, _("Look"), look),
space(nil, px(16)),
reset,
}
end
local function downloadsBody(page, w, embedded)
local iw = MenuUI.rowsIn(w)
local dir = downloadDir()
return {
embedded and text(_("Downloads"), { s = 18, b = true, w = w }) or false,
embedded and space(nil, px(12)) or false,
MenuUI.group(w, {
MenuUI.row(iw, { icon = "download", label = _("Download folder"), sub = dir, sub_data = true, sublines = 2, chevron = true, action = { kind = "download-dir" } }),
}),
space(nil, px(12)),
MenuUI.card(w, { space(nil, px(12)), text(_("Books you download from BookOrbit are saved here. KOReader uses the same folder for its own downloads."), { s = 13, col = INK.g4, w = iw, lines = 3, bg = INK.ge }), space(nil, px(12)) }, { fill = true }),
}
end
-- Test connection says how its last run went in place.
local function testRow(page)
local t = page.test
local o = { icon = "activity", label = _("Test connection"), chevron = true, action = { kind = "test" } }
if t and t.running then
o.sub, o.chevron, o.action = _("Testing…"), false, { kind = "noop" }
elseif t and t.ok then
o.sub = _("Connection works")
elseif t and t.err then
o.sub, o.sub_data = T(_("Connection failed: %1"), t.err), true
end
return o
end
local function accountBody(page, w, embedded)
local settings = page.plugin.settings
local iw = MenuUI.rowsIn(w)
return {
embedded and text(_("Account"), { s = 18, b = true, w = w }) or false,
embedded and space(nil, px(12)) or false,
MenuUI.group(w, {
MenuUI.row(iw, { icon = "globe", label = _("Server"), sub = MenuUI.serverHost(settings.server_url) or _("Not set"), sub_data = true, chevron = true, action = { kind = "server" } }),
MenuUI.row(iw, { icon = "circle-user-round", label = _("Signed in as"), sub = settings.username or _("Nobody"), sub_data = true }),
MenuUI.row(iw, testRow(page)),
}),
space(nil, px(16)),
MenuUI.group(w, {
MenuUI.row(iw, { icon = "log-out", label = _("Sign out"), sub = _("This device stops syncing until you sign in again"), action = { kind = "sign-out" } }),
}),
}
end
-- Recheck all books runs in place: progress, then the outcome, on its row.
local function recheckRow(page)
local s = page.sync
local o = { icon = "list-checks", label = _("Recheck all books"), sub = _("Matches every book on this device again"), chevron = true, action = { kind = "recheck" } }
if s and s.running then
o.sub, o.sub_data, o.chevron, o.action = s.text, true, false, { kind = "noop" }
elseif s and s.done then
o.sub = _("Done: every book was checked again")
elseif s and s.err then
o.sub, o.sub_data = T(_("Could not recheck: %1"), s.err), true
end
return o
end
local function aboutBody(page, w, embedded)
local plugin = page.plugin
local iw = MenuUI.rowsIn(w)
local update = plugin.hasKnownUpdate and plugin:hasKnownUpdate() and plugin.settings.update_latest_version
local pill = update and MenuUI.pill(T(_("Update to %1"), update), { h = 36, s = 14, pad_x = 14, action = { kind = "update" } })
or MenuUI.pill(_("Check for update"), { kind = "outline", h = 36, s = 14, pad_x = 14, action = { kind = "check-update" } })
local mid_w = iw - pill:getSize().w - px(12)
return {
embedded and text(_("About & updates"), { s = 18, b = true, w = w }) or false,
embedded and space(nil, px(12)) or false,
MenuUI.card(w, {
hstack({ w = iw, h = px(76), gap = px(12) }, {
vstack({ w = mid_w }, {
text(_("BookOrbit for KOReader"), { s = 16, b = true, w = mid_w }),
text(update and T(_("Version %1 is ready, you have %2"), update, plugin.PLUGIN_VERSION) or T(_("Version %1"), plugin.PLUGIN_VERSION), { s = 13, col = INK.g4, w = mid_w }),
}),
pill,
}),
}),
space(nil, px(16)),
MenuUI.group(w, {
MenuUI.row(iw, recheckRow(page)),
MenuUI.row(iw, { icon = "file-text", label = _("Technical details"), sub = _("Everything the sync knows, for bug reports"), chevron = true, action = { kind = "diagnostics" } }),
}),
}
end
local BODIES = { opening = openingBody, home = homeBody, downloads = downloadsBody, account = accountBody, about = aboutBody }
local function bodyFor(page, view, w, avail, embedded)
if view == "sync" then return syncBody(page, w, avail, embedded) end
return (BODIES[view] or openingBody)(page, w, embedded)
end
-- --------------------------------------------------------------- shortcut
local function shortcutCards(page, w, illus_max, with_note)
local options = Shortcut.available(Device:hasMultitouch())
local current = Shortcut.current(page.gestures_data)
local W = BoardMetrics.layoutW()
local total = #options + 1
local cols = W >= 900 and math.min(total, 5) or W >= 700 and 3 or 2
local gap = px(10)
-- A card keeps room for its two-line name: a larger interface size takes
-- a column away rather than squeeze them.
while cols > 2 and math.floor((w - (cols - 1) * gap) / cols) < px(170) do cols = cols - 1 end
local cw = math.floor((w - (cols - 1) * gap) / cols)
local illus = math.min(px(illus_max), math.floor(cw * 0.42))
local pad = px(14)
local name_of = function(binding)
local ok, name = pcall(function() return require("dispatcher"):menuTextFunc(binding) end)
return ok and name or _("another action")
end
local cards = {}
local entries = {}
for _i, g in ipairs(options) do table.insert(entries, { id = g.id, kind = g.kind, label = g.label() }) end
table.insert(entries, { id = false, kind = "none", label = _("No shortcut") })
local inners = {}
for _i, e in ipairs(entries) do
local on = (e.id or nil) == current
local note, warn
if e.id then
note, warn = Shortcut.describe(page.gestures_data, e.id, name_of)
else
note = _("Menu bar only")
end
local iw = cw - 2 * pad
local note_row = hstack({ w = iw, gap = px(5), valign = "top" }, {
warn and icon("triangle-alert", px(14), { sw = 2.25 }) or false,
text(note, { s = 12, col = INK.g4, b = warn, lines = 2, w = iw - (warn and px(19) or 0), data = true }),
})
local inner = vstack({ w = iw, pad = { pad, 0, pad, 0 } }, {
MenuUI.gesture(e.kind, illus),
space(nil, px(10)),
text(e.label, { s = 15, b = true, lines = 2, w = iw }),
space(nil, px(10)),
note_row,
})
table.insert(inners, { inner = inner, entry = e, on = on, warn = warn, note = note })
end
-- The cards of a row share the height of the tallest one.
local row_h = {}
for index, it in ipairs(inners) do
local r = math.floor((index - 1) / cols)
row_h[r] = math.max(row_h[r] or 0, it.inner:getSize().h)
end
for index, it in ipairs(inners) do
local inner, e, on, warn, note = it.inner, it.entry, it.on, it.warn, it.note
local h = row_h[math.floor((index - 1) / cols)]
local border = on and math.max(2, px(2.5)) or MenuUI.line()
local card = BoardUI.overlay(cw, h, {
{ widget = BoardUI.box({ w = cw, h = h, radius = px(12), border = border, border_col = on and INK.black or INK.g8, bg = INK.white }) },
{ widget = inner, x = pad, y = 0 },
on and { widget = MenuUI.check(), x = cw - px(10) - px(24), y = px(10) } or {},
})
table.insert(cards, BoardUI.hit(card, { kind = "shortcut", id = e.id, replaces = warn and note or nil }))
end
local rows = {}
for start = 1, #cards, cols do
local items = {}
for index = start, math.min(#cards, start + cols - 1) do table.insert(items, cards[index]) end
if #rows > 0 then table.insert(rows, space(nil, gap)) end
table.insert(rows, hstack({ gap = gap, valign = "top" }, items))
end
local iw = MenuUI.rowsIn(w)
if not with_note then return { vstack({ w = w }, rows) } end
return {
vstack({ w = w }, rows),
space(nil, px(16)),
MenuUI.card(w, {
hstack({ w = iw, gap = px(12), valign = "top" }, {
vstack({}, { space(nil, px(12)), icon("info", px(20), { sw = 2 }) }),
vstack({ w = iw - px(20) - px(12), pad = { px(12), 0, px(12), 0 } }, {
text(_("Top-left hold and top-right tap work in books only. Other choices also work in the file browser. Existing actions are named before replacement."), { s = 13, col = INK.g4, lines = 4, w = iw - px(20) - px(12), bg = INK.ge }),
}),
}),
}, { fill = true }),
}
end
-- The cards and the note at the largest drawing that fits avail: smaller
-- gesture drawings first, then without the note.
local function shortcutBody(page, w, avail)
local function height(items)
local h = 0
for _i, item in ipairs(items) do h = h + item:getSize().h end
return h
end
for _i, illus in ipairs({ 64, 48, 36 }) do
-- A draft in a sink of its own, so its cards are not tappable.
local items = BoardUI.collectHits(function() return shortcutCards(page, w, illus, true) end)
if height(items) <= avail then return shortcutCards(page, w, illus, true) end
for _j, item in ipairs(items) do if item.free then item:free() end end
end
return shortcutCards(page, w, 36, false)
end
-- ------------------------------------------------------------------ build
local GROUPS = { "opening", "sync", "home", "downloads", "account", "about" }
local function paneWidths(w)
local left_w = math.floor(math.min(px(300), w * 0.36))
local gutter = px(24)
return left_w, gutter, w - left_w - gutter
end
-- Wide screens show a group beside the list, when the list and every group
-- fit the screen side by side at this interface size; otherwise each group
-- is a page of its own. Decided once per screen size, so taps and Back
-- agree with what is drawn.
local function twoPane(page, w, avail)
if not MenuUI.wide() then return false end
local key = w .. "x" .. avail .. "@" .. BoardMetrics.get().dp
if page._two_pane_key == key then return page._two_pane end
local left_w, _gutter, right_w = paneWidths(w)
-- Measured in a sink of its own: these drafts are never drawn or tapped.
-- Two panes also need room across: a pane that cuts its own text does
-- not fit either.
local clean, fits = BoardUI.fits(function()
return BoardUI.collectHits(function()
local room = avail - MenuUI.pageHeader(w, _("Settings"), {}):getSize().h - px(8)
if vstack({ w = left_w }, settingsList(page, left_w, true)):getSize().h > room then return false end
for _i, view in ipairs(GROUPS) do
if vstack({ w = right_w }, bodyFor(page, view, right_w, avail - px(72), true)):getSize().h > room then return false end
end
return true
end)
end)
fits = clean and fits
page._two_pane_key, page._two_pane = key, fits
return fits
end
-- A page taller than the screen (a short landscape screen at a large
-- interface size) turns into pages under its header, like the other
-- BookOrbit pages, never running off the screen. items[1] and items[2] are
-- the header and the gap under it.
local function paged(page, view, items, w, avail)
local function h(item) return (not item or item._flex) and 0 or item:getSize().h end
local total = 0
for _i, item in ipairs(items) do total = total + h(item) end
if total <= avail then
page.sub = nil
return items
end
local TextPages = require("bookorbit_text_pages")
local blocks, heights = {}, {}
for index = 3, #items do
local item = items[index]
if item and not item._flex then
table.insert(blocks, item)
table.insert(heights, item:getSize().h)
end
end
local body_h = avail - h(items[1]) - h(items[2]) - px(8) - px(40)
local pages = TextPages.pack(heights, body_h, 0)
if not page.sub or page.sub.view ~= view then page.sub = { view = view, index = 1 } end
page.sub.count = #pages
page.sub.index = math.min(page.sub.index, #pages)
local range = pages[page.sub.index]
local out = { items[1], items[2] }
for index = range.first, range.last do
-- A page never starts on a gap.
if not (index == range.first and getmetatable(blocks[index]) == VerticalSpan) then table.insert(out, blocks[index]) end
end
-- Blocks on other pages were only measured.
for index, block in ipairs(blocks) do
if (index < range.first or index > range.last) and block.free then block:free() end
end
table.insert(out, flex(px(8)))
table.insert(out, TextPages.pager(w, page.sub.index, #pages))
return out
end
-- A page too tall for a wide screen: its blocks side by side, split where
-- the taller column is shortest, when both columns hold them at this
-- interface size. nil when they do not, and the page turns instead.
-- build(cw) returns the blocks at a column's width.
local function columns(build, w, body_h)
if not MenuUI.wide() then return nil end
local gutter, gap = px(24), px(16)
local cw = math.floor((w - gutter) / 2)
local function blocksAt()
local out = {}
for _i, item in ipairs(build(cw)) do
if item and not item._flex and getmetatable(item) ~= VerticalSpan then table.insert(out, item) end
end
return out
end
if not BoardUI.fits(function() return BoardUI.collectHits(blocksAt) end) then return nil end
local blocks = blocksAt()
local function span(first, last)
local h = 0
for index = first, last do h = h + blocks[index]:getSize().h + (index > first and gap or 0) end
return h
end
local split, split_h
for k = 1, #blocks - 1 do
local tall = math.max(span(1, k), span(k + 1, #blocks))
if tall <= body_h and (not split_h or tall < split_h) then split, split_h = k, tall end
end
if not split then
for _i, block in ipairs(blocks) do block:free() end
return nil
end
local function column(first, last)
local items = {}
for index = first, last do
if #items > 0 then table.insert(items, space(nil, gap)) end
table.insert(items, blocks[index])
end
return vstack({ w = cw }, items)
end
return hstack({ w = w, gap = gutter, valign = "top" }, { column(1, split), column(split + 1, #blocks) })
end
local function turn(page, delta)
local sub = page.sub
if not sub then return false end
local index = math.max(1, math.min(sub.count, sub.index + delta))
if index ~= sub.index then
sub.index = index
page:refresh(Device:hasEinkScreen() and "partial" or "ui")
end
return true
end
local function build(page, sw, sh)
local mx = MenuUI.mx()
local top, bottom = px(6), px(12)
local w = sw - 2 * mx
local avail = sh - top - bottom
page.compact = nil
local two_pane = twoPane(page, w, avail)
-- The group Settings was opened on: beside the list, or on top of it.
if page.open_view then
if two_pane then page.detail = page.open_view else table.insert(page.stack, page.open_view) end
page.open_view = nil
end
local view = page.stack[#page.stack]
local items
if view == "shortcut" then
local header = MenuUI.pageHeader(w, _("Shortcut"), { sub = _("Choose where to open BookOrbit") })
items = { header, space(nil, px(8)) }
for _i, item in ipairs(shortcutBody(page, w, avail - header:getSize().h - px(8))) do table.insert(items, item) end
elseif two_pane then
page.detail = view ~= "root" and view or page.detail or "opening"
local left_w, gutter, right_w = paneWidths(w)
local left = vstack({ w = left_w }, settingsList(page, left_w, true))
local right = vstack({ w = right_w }, bodyFor(page, page.detail, right_w, avail - px(72), true))
local split_h = math.max(left:getSize().h, right:getSize().h)
items = {
MenuUI.pageHeader(w, _("Settings"), {}),
space(nil, px(8)),
hstack({ w = w, h = split_h, valign = "top" }, {
left,
space(math.floor(gutter / 2), 1),
BoardUI.rect(MenuUI.hair(), split_h, INK.gc),
space(gutter - math.floor(gutter / 2) - MenuUI.hair(), 1),
right,
}),
}
elseif view == "root" then
local header = MenuUI.pageHeader(w, _("Settings"), { sub = _("BookOrbit") })
local footer = text(T(_("BookOrbit for KOReader %1"), page.plugin.PLUGIN_VERSION), { s = 13, col = INK.g4, w = w, align = "center" })
items = { header, space(nil, px(8)) }
local total = header:getSize().h + px(8) + px(8) + footer:getSize().h
for _i, item in ipairs(settingsList(page, w, false)) do
table.insert(items, item)
if item and not item._flex then total = total + item:getSize().h end
end
local side = total > avail and columns(function(cw) return settingsList(page, cw, false) end, w,
avail - header:getSize().h - px(8) - px(8) - footer:getSize().h)
if side then
for index = 3, #items do if items[index] and items[index].free then items[index]:free() end end
page.sub = nil
items = { header, space(nil, px(8)), side, flex(px(8)), footer }
else
table.insert(items, flex(px(8)))
table.insert(items, footer)
items = paged(page, view, items, w, avail)
end
else
local header = MenuUI.pageHeader(w, titleOf(view), { sub = _("Settings") })
items = { header, space(nil, px(8)) }
local total = header:getSize().h + px(8)
for _i, item in ipairs(bodyFor(page, view, w, avail - px(72), false)) do
table.insert(items, item)
if item and not item._flex then total = total + item:getSize().h end
end
local function columnsAt()
return columns(function(cw) return bodyFor(page, view, cw, avail - px(72), false) end, w, avail - header:getSize().h - px(8))
end
local side = total > avail and columnsAt()
-- Columns that cannot hold a block try its compact forms first.
for level = 1, 2 do
if side or total <= avail then break end
page.compact = level
side = columnsAt()
end
if not side then page.compact = nil end
if side then
for index = 3, #items do if items[index] and items[index].free then items[index]:free() end end
page.sub = nil
items = { header, space(nil, px(8)), side }
else
items = paged(page, view, items, w, avail)
end
end
page.panel_rect = Geom:new{ x = 0, y = 0, w = sw, h = sh }
return vstack({ w = sw, h = sh, pad = { top, mx, bottom, mx } }, items)
end
-- ---------------------------------------------------------------- actions
local function confirm(title, body, ok_text, ok, ok_icon)
require("bookorbit_dialog_ui").confirm{ title = title, text = body, ok_label = ok_text, ok_icon = ok_icon, on_ok = ok }
end
local function onAction(page, action)
local plugin, catalog = page.plugin, page.catalog
local settings = plugin.settings
local kind = action.kind
if kind == "page" then
turn(page, action.delta)
elseif kind == "view" then
if page._two_pane and action.view ~= "shortcut" then
page.detail = action.view
page.stack = { "root" }
else
table.insert(page.stack, action.view)
end
page:refresh()
elseif kind == "status" then
plugin:openSyncStatus(catalog)
elseif kind == "open-mode" then
settings.catalog_auto_open = action.mode
G_reader_settings:flush()
page:refresh()
elseif kind == "menu-bar" then
plugin:setMenuBarIconEnabled(not plugin:menuBarIconEnabled())
page:refresh()
elseif kind == "toggle-auto" then
plugin:onBookOrbitToggleAutoSync(nil, true, true)
page:refresh()
elseif kind == "push" then
local value = math.max(0, math.min(999, (settings.pages_before_update or 0) + action.delta))
plugin:setPagesBeforeUpdate(value)
G_reader_settings:flush()
page:refresh()
elseif kind == "flag" then
local current = settings[action.key]
if action.default_on then current = current ~= false end
settings[action.key] = not current
G_reader_settings:flush()
page:refresh()
elseif kind == "strategy" then
settings[action.key] = action.value
G_reader_settings:flush()
page:refresh()
elseif kind == "customize" then
page:close()
plugin:customizeHome(catalog)
elseif kind == "preset" then
if BoardStore.read(settings).preset == action.value then return true end
confirm(_("Start Home from this layout?"), _("Your pages are replaced by this layout's pages."), _("Replace"), function()
plugin:applyBoardSetting(catalog, function(s) return BoardStore.withPreset(s, action.value) end)
page:refresh()
end)
elseif kind == "tiles" then
plugin:applyBoardTiles(catalog, action.value)
page:refresh()
elseif kind == "colour" then
plugin:applyBoardColour(catalog, action.value)
page:refresh()
elseif kind == "ui-scale" then
if settings.ui_scale == action.value then return true end
local scale = BoardMetrics.setUserScale(action.value)
settings.ui_scale = scale
G_reader_settings:flush()
if catalog and catalog.interfaceScaleChanged then
catalog:interfaceScaleChanged()
elseif catalog and catalog.updateItems then
catalog:updateItems()
end
page:refresh()
elseif kind == "reset-home" then
confirm(_("Reset Home?"), _("Your pages and tile style go back to how they started."), _("Reset"), function()
plugin:applyBoardSetting(catalog, function(s)
local state = BoardStore.withPreset(s, BoardStore.read(s).preset)
state.tiles = "outline"
return state
end)
page:refresh()
end)
elseif kind == "download-dir" then
plugin:chooseDownloadFolder(catalog, function() page:refresh() end)
elseif kind == "server" then
plugin:setServerAddress(function() page:refresh() end)
elseif kind == "test" then
plugin:startPageTest(page, function() page:refresh() end)
elseif kind == "sign-out" then
confirm(_("Sign out of BookOrbit?"), _("This device stops syncing until you sign in again. Books already downloaded stay on it."), _("Sign out"), function()
page:close()
plugin:signOut(catalog)
end, "log-out")
elseif kind == "check-update" then
plugin:checkForUpdate()
elseif kind == "update" then
page:close()
plugin:applyUpdate(settings.update_latest_version)
elseif kind == "recheck" then
plugin:startPageSync(page, function() page:refresh() end, { full_recheck = true })
elseif kind == "diagnostics" then
plugin:showDiagnostics()
elseif kind == "shortcut" then
local apply = function()
if not plugin:applyShortcut(action.id or nil) then return end
page.gestures_data = plugin:gesturesData()
page:refresh()
end
if action.replaces then
confirm(_("Use this gesture for BookOrbit?"), T(_("%1."), action.replaces), _("Use it"), apply)
else
apply()
end
end
return true
end
-- Back steps out of the shortcut picker (or a group on a phone) before it
-- closes Settings.
local function onBack(page)
if #page.stack > 1 then
table.remove(page.stack)
page:refresh()
return true
end
return false
end
-- view: nil for the root, or a group id ("opening", "sync", "home",
-- "downloads", "account", "about", "shortcut").
function Settings.open(plugin, catalog, view)
-- The shortcut picker is always a page of its own; other groups open
-- beside the list when it is two panes (see twoPane).
local stack = { "root" }
if view == "shortcut" then table.insert(stack, view) end
local page = MenuPage:new{
name = "bookorbit_settings",
build = build,
on_action = onAction,
on_back = onBack,
on_swipe = function(page, ges)
if ges.direction == "west" then return turn(page, 1) end
if ges.direction == "east" then return turn(page, -1) end
return false
end,
on_page_key = function(page, delta) turn(page, delta) end,
plugin = plugin,
catalog = catalog,
stack = stack,
open_view = view and view ~= "root" and view ~= "shortcut" and view or nil,
gestures_data = plugin:gesturesData(),
}
UIManager:show(page, "ui")
return page
end
return Settings