Ctrl K
ComunidadGuías…

koreader-plugin/bookorbit.koplugin/bookorbit_settings_page.lua

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
--[[--
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