Ctrl K
ComunidadGuías…

koreader-plugin/bookorbit.koplugin/bookorbit_list_pages.lua

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

Archivo original ↗Preguntar a KiBot
--[[--
The list pages composed from the list kit: what each page holds and how many
entries fit it. The catalog asks `capacity` before it fetches, so a server
page is exactly one screen.
]]

local T = require("ffi/util").template
local _ = require("gettext")

local BoardMetrics = require("bookorbit_board_metrics")
local BoardUI = require("bookorbit_board_ui")
local ListData = require("bookorbit_list_data")
local ListView = require("bookorbit_list_view")
local MenuUI = require("bookorbit_menu_ui")

local INK = BoardMetrics.INK
local px, lh1, lh = BoardMetrics.px, BoardMetrics.lh1, BoardMetrics.lh
local text, runs, vstack, hstack, flex, space, icon = BoardUI.text, BoardUI.runs, BoardUI.vstack, BoardUI.hstack, BoardUI.flex, BoardUI.space, BoardUI.icon
local region = ListView.region

local Pages = {}

local function wOf(w) return w:getSize().w end
local function hOf(w) return w:getSize().h end

-- ------------------------------------------------------------ geometry
-- How a list page is laid out on this screen. o: toolbar, offline, rail,
-- view ("list"/"grid"), kind, min_row, gutter.
function Pages.geometry(screen_w, screen_h, o)
    local f = ListView.frame(screen_w, screen_h, { toolbar = o.toolbar ~= false, offline = o.offline })
    if o.view == "grid" then
        local gw = f.w - (o.rail and ListView.RAIL_W() or 0)
        local g = ListView.gridGeom(gw, f.content_h - px(6), { cols = ListView.gridCols(gw), aspect = o.kind == "authors" and 1 or 1.5 })
        return { f = f, g = g, gw = gw, per = g.per }
    end
    local total = f.w - (o.rail and ListView.RAIL_W() or 0)
    local cols = o.cols or ListView.indexColumns(total)
    local rows = ListData.fillRows(f.content_h, px(o.min_row or ListView.ROW))
    return { f = f, cols = cols, n = rows.n, rh = rows.rh, per = rows.n * cols }
end

-- Entries per screen for a list (the server page size).
function Pages.capacity(screen_w, screen_h, o)
    return Pages.geometry(screen_w, screen_h, o).per
end

-- ------------------------------------------------------------- toolbars
function Pages.toolbar(m)
    return m.toolbar and { buttons = m.toolbar, view = m.view_toggle and m.view or nil } or nil
end

-- --------------------------------------------------------------- lists
-- A paged list of books, authors or series as rows or a grid. m: kind, view,
-- entries, rail, letters, present, carried, groupOf, gutter, row_kind,
-- select, selected(book), action(entry, i).
function Pages.listBody(m, f)
    if #m.entries == 0 and m.empty then
        return ListView.empty(f.w, f.content_h, m.empty)
    end
    if m.view == "grid" then
        local geo = Pages.geometry(m.screen_w, m.screen_h, { toolbar = m.toolbar ~= nil, offline = m.offline ~= nil, rail = m.rail, view = "grid", kind = m.kind })
        local g = geo.g
        local build
        if m.kind == "authors" then build = function(e) return ListView.authorCell(g, e) end
        elseif m.kind == "series" then build = function(e) return ListView.seriesCell(g, e) end
        else build = function(b) return ListView.bookCell(g, b, { select = m.select, selected = m.selected and m.selected(b) }) end end
        local grid = ListView.cellsGrid(geo.gw, g, m.entries, build, m.action)
        local body = vstack({ w = geo.gw }, { space(nil, px(6)), grid })
        if m.rail then return ListView.withRail(f.w, f.content_h, body, m.letters, m.present) end
        return body
    end
    local geo = Pages.geometry(m.screen_w, m.screen_h, { toolbar = m.toolbar ~= nil, offline = m.offline ~= nil, rail = m.rail, view = "list", min_row = m.min_row })
    local row
    if m.kind == "authors" then row = function(w, hh, e) return ListView.authorRow(w, hh, e) end
    elseif m.kind == "series" then row = function(w, hh, e) return ListView.seriesRow(w, hh, e) end
    else row = function(w, hh, b) return ListView.bookRow(w, hh, b, { select = m.select, selected = m.selected and m.selected(b), context = m.row_context }) end end
    return ListView.indexList(f.w, f.content_h, {
        entries = m.entries, rows_per_col = geo.n, row_h = geo.rh, cols = geo.cols, gutter = m.gutter or 34,
        rail = m.rail, groupOf = m.groupOf, carried = m.carried, row = row, action = m.action,
        present = m.present, letters = m.letters, first_only = m.first_only,
    })
end

-- --------------------------------------------------------------- shelves
-- Libraries, collections and scopes. Wide screens set the sections in two
-- columns; more shelves than a screen holds at a readable row run on to
-- further pages, a section that continues repeating its heading.
function Pages.shelvesLayout(m, f)
    local groups = {
        { _("Libraries"), "library", m.libraries or {} },
        { _("Collections"), "folder-open", m.collections or {} },
        { _("SmartScopes"), "aperture", m.scopes or {} },
    }
    local wide = ListView.screenW() >= 760
    local layout = ListData.shelfPages({ #groups[1][3], #groups[2][3], #groups[3][3] }, {
        content_h = f.content_h, head_h = px(36), gap = px(14), row_h = px(72), min_row_h = px(56),
        columns = wide and { { 1 }, { 2, 3 } } or { { 1, 2, 3 } }, cols = wide and 2 or 1,
    })
    layout.groups, layout.wide = groups, wide
    return layout
end

function Pages.shelvesBody(m, f)
    local layout = Pages.shelvesLayout(m, f)
    local page = math.max(1, math.min(#layout.pages, m.local_page or 1))
    local col_w = layout.wide and math.floor((f.w - px(28)) / 2) or f.w
    local rh = layout.row_h
    local col_els = {}
    for _ci, chunks in ipairs(layout.pages[page]) do
        if #chunks > 0 then
            local kids = {}
            for ki, chunk in ipairs(chunks) do
                local g = layout.groups[chunk.section]
                if ki > 1 then table.insert(kids, space(nil, px(14))) end
                table.insert(kids, ListView.sectionHead(col_w, { icon = g[2], label = g[1], count = #g[3] }))
                for i = chunk.from, chunk.to do
                    local s = g[3][i]
                    local inner = vstack({ w = col_w, h = rh }, { i > chunk.from and ListView.rule(col_w) or false, flex(0), ListView.shelfRow(col_w, rh, s), flex(0) })
                    table.insert(kids, region(inner, { kind = "open-shelf", shelf = s }, "shelf-" .. g[1] .. "-" .. i, "shelf-" .. s.section .. "-" .. tostring(s.id)))
                end
            end
            table.insert(col_els, vstack({ w = col_w }, kids))
        end
    end
    return hstack({ w = f.w, valign = "top", gap = px(28) }, col_els)
end

-- ------------------------------------------------------------ a series
-- Where a series page's books go: page 1 under the summary, later pages
-- with the list alone (landscape keeps the summary column on every page).
-- A grid shrinks its covers to keep a series on one page only down to three
-- quarters of the All books size; past that it pages at full size.
function Pages.seriesLayout(screen_w, screen_h, view, n, offline, toolbar)
    local f = ListView.frame(screen_w, screen_h, { toolbar = toolbar == true, offline = offline })
    local side = BoardMetrics.get().w > BoardMetrics.get().h
    local side_w = side and math.floor(f.w * 0.38 + 0.5) or f.w
    local list_w = side and (f.w - side_w - px(28)) or f.w
    local hero_h = side and f.content_h or math.max(px(156), math.min(px(200), math.floor(f.content_h * 0.27 + 0.5)))
    local head = px(44) + px(8)
    local h1 = (side and f.content_h or (f.content_h - hero_h - px(16))) - head
    local hn = side and h1 or (f.content_h - head)
    local cap1, capn, g1, gn
    if view == "grid" then
        local std = ListView.stdGeom(screen_w, screen_h)
        local cols = ListView.gridCols(list_w)
        local function cap(h) return cols * math.max(1, math.floor((h + px(18)) / (std.cover_h + ListView.CAP() + px(18)))) end
        local fit = ListView.fitStd(screen_w, screen_h, list_w, h1, n)
        if fit.cover_h >= std.cover_h * 0.75 then
            return { pages = 1, first = n, rest = n, list_w = list_w, list_h1 = h1, list_hn = hn, g1 = fit, single = true }
        end
        cap1, capn = cap(h1), cap(hn)
        g1 = ListView.fitStd(screen_w, screen_h, list_w, nil, 1)
        gn = g1
    else
        cap1 = ListData.fillRows(h1, px(56)).n
        capn = ListData.fillRows(hn, px(56)).n
    end
    local pages = n <= cap1 and 1 or (1 + math.ceil((n - cap1) / capn))
    return { pages = pages, first = cap1, rest = capn, list_w = list_w, list_h1 = h1, list_hn = hn, g1 = g1, gn = gn }
end

-- The stack, how far through it you are as a ruler of its books, Up next and
-- Continue; then its books in reading order, as rows or covers. Landscape
-- puts the summary beside the books.
function Pages.seriesBody(m, f)
    local all_books = m.entries
    local layout = Pages.seriesLayout(m.screen_w, m.screen_h, m.view, #all_books, m.offline ~= nil, m.toolbar == true)
    local page = math.max(1, math.min(layout.pages, m.local_page or 1))
    local books = {}
    local start = page == 1 and 1 or (layout.first + (page - 2) * layout.rest + 1)
    local stop = page == 1 and layout.first or (start + layout.rest - 1)
    for i = start, math.min(#all_books, stop) do table.insert(books, all_books[i]) end
    local next_book = m.next_book
    local side = BoardMetrics.get().w > BoardMetrics.get().h
    local side_w = side and math.floor(f.w * 0.38 + 0.5) or f.w
    local list_w = side and (f.w - side_w - px(28)) or f.w
    local hero_h = side and f.content_h or math.max(px(156), math.min(px(200), math.floor(f.content_h * 0.27 + 0.5)))
    local deck_h = side and math.min(px(220), math.floor(f.content_h * 0.42 + 0.5)) or hero_h
    local deck_w = math.floor(deck_h * 0.74 + 0.5)
    local tw = side and side_w or (f.w - deck_w - px(20))
    local states = m.states or {}
    local n = math.max(1, #states)
    local gap = px(4)
    local seg_w = math.floor((tw - (n - 1) * gap) / n)
    local cur = m.current_index
    local segs, nums = {}, {}
    for i, s in ipairs(states) do
        local h = px(i == cur and 10 or 8)
        local col = s == "finished" and INK.black or s == "reading" and INK.g8 or INK.gd
        local seg = BoardUI.rect(seg_w, h, col, px(5))
        if i == cur then
            local r1, r2 = math.max(1, px(1.5)), math.max(2, px(3))
            seg = BoardUI.overlay(seg_w + 2 * r2, h + 2 * r2, {
                { widget = BoardUI.rect(seg_w + 2 * r2, h + 2 * r2, INK.black, px(5) + r2) },
                { widget = BoardUI.rect(seg_w + 2 * r1, h + 2 * r1, INK.white, px(5) + r1), x = r2 - r1, y = r2 - r1 },
                { widget = BoardUI.rect(seg_w, h, col, px(5)), x = r2, y = r2 },
            })
        end
        table.insert(segs, seg)
        if seg_w >= px(16) then
            table.insert(nums, text(tostring(m.indexes and m.indexes[i] or i), { s = 11, b = i == cur, col = i == cur and INK.black or INK.g4, w = seg_w, align = "center" }))
        end
    end
    local seg_row = {}
    for i, s in ipairs(segs) do
        local sw = wOf(s)
        local pad = math.floor((sw - seg_w) / 2)
        table.insert(seg_row, BoardUI.overlay(seg_w, px(10), { { widget = s, x = -pad, y = math.floor((px(10) - hOf(s)) / 2) } }))
    end
    local p = m.progress
    -- level 1 gives the next title one line, level 2 also drops the ruler's
    -- numbers: a short screen at a large interface size keeps the summary
    -- inside its band instead of pushing it into the header. live = false
    -- measures without making the button tappable.
    local function summaryOf(level, live)
        local with_nums = #nums > 0 and level < 2
        local ruler = vstack({ w = tw }, {
            hstack({ gap = gap, h = px(10) }, seg_row),
            with_nums and space(nil, px(4)) or false,
            with_nums and hstack({ gap = gap }, nums) or false,
        })
        local summary = {
            runs({ { t = T(_("%1 of %2 read"), p.finished, p.count), b = true, col = INK.black, s = 19 }, { t = "   " .. T(_("%1 in progress"), p.reading) } }, { s = 13, col = INK.g4, w = tw }),
            space(nil, px(8)), ruler, space(nil, level > 0 and px(12) or (px(8) + px(4) + px(8))),
        }
        if next_book then
            table.insert(summary, text(next_book.seriesIndex and T(_("Up next · Book %1"), tostring(next_book.seriesIndex)) or _("Up next"), { s = 12, b = true, col = INK.g4, w = tw }))
            table.insert(summary, space(nil, px(level > 0 and 4 or 8)))
            table.insert(summary, text(next_book.title or "", { s = 16, b = true, lines = level == 0 and 2 or nil, w = tw, data = true }))
            table.insert(summary, space(nil, px(8)))
            local pill = ListView.pill(ListData.stateOf(next_book) == "reading" and _("Continue") or (next_book.seriesIndex and T(_("Start book %1"), tostring(next_book.seriesIndex)) or _("Start book")), { icon = "book-open", h = 40, s = 14, pad_x = 18 })
            local cont = live and region(pill, { kind = "open-book", book = next_book }, "series-hero", "continue") or pill
            local right = nil
            if ListData.stateOf(next_book) == "reading" then
                local left = m.next_minutes and (" · " .. T(_("%1 left"), ListData.duration(m.next_minutes))) or (" · " .. (require("bookorbit_board_data").relativeDay(next_book.lastReadAt) or ""))
                right = runs({ { t = ListData.pctLabel(next_book) or "0%", b = true, col = INK.black }, { t = left } }, { s = 13, col = INK.g4, data = true })
            end
            table.insert(summary, hstack({ gap = px(12) }, { cont, right or false }))
        end
        return vstack({ w = tw }, summary)
    end
    local level = 0
    if not side then
        while level < 2 do
            -- Measured only; its ruler pieces are reused, so it is not freed.
            if hOf(summaryOf(level, false)) <= hero_h then break end
            level = level + 1
        end
    end
    local summary_col = summaryOf(level, true)
    local deck = region(hstack({ w = side and side_w or deck_w, h = deck_h, valign = "bottom" }, { side and space(1, 1) or flex(0), ListView.stackFit(m.deck or books, deck_w, deck_h), flex(0) }),
        next_book and { kind = "open-book", book = next_book } or { kind = "noop" }, "series-hero", "deck")
    local hero
    if side then
        hero = vstack({ w = side_w, h = hero_h }, { space(nil, px(6)), deck, space(nil, px(18)), summary_col, flex(0) })
    else
        hero = hstack({ w = f.w, h = hero_h, gap = px(20) }, { deck, vstack({ w = tw, h = hero_h }, { flex(0), summary_col, flex(0) }) })
    end
    local head = ListView.sectionHead(list_w, { icon = "layers-2", label = m.order_label or _("Reading order"), count = #all_books, right = ListView.viewToggle(m.view, "series-head") })
    local list_h = page == 1 and layout.list_h1 or layout.list_hn
    local content
    if m.view == "grid" then
        local g = layout.single and layout.g1 or ListView.fitStd(m.screen_w, m.screen_h, list_w, nil, 1)
        local short = ListData.distinctTitles(books)
        content = ListView.cellsGrid(list_w, g, books, function(b)
            return ListView.bookCell(g, b, {
                select = m.select,
                selected = m.selected and m.selected(b),
                title = function(x) return x.seriesIndex and (tostring(x.seriesIndex) .. ". " .. (short[x.id] or x.title or "")) or (short[x.id] or x.title or "") end,
                meta = function(x)
                    local st = ListData.stateOf(x)
                    if st == "reading" then
                        local ml = m.minutes_left and m.minutes_left(x)
                        local day = not ml and require("bookorbit_board_data").relativeDay(x.lastReadAt)
                        return { { t = ListData.pctLabel(x) or "0%", b = true }, { t = ml and T(_(" · %1 left"), ListData.shortDuration(ml)) or day and (" · " .. day) or "" } }
                    elseif st == "finished" then return { { t = _("Read"), b = true } } end
                    return { { t = _("Not started") } }
                end,
            })
        end, m.action)
    else
        local rows = ListData.fillRows(list_h, px(56))
        content = ListView.indexList(list_w, list_h, {
            entries = books, rows_per_col = rows.n, row_h = rows.rh, cols = 1, gutter = 34, rail = false,
            groupOf = function(b) return "#" .. tostring(b.seriesIndex or "") end, group_rules = false,
            row = function(w, hh, b) return ListView.bookRow(w, hh, b, {
                select = m.select,
                selected = m.selected and m.selected(b),
                context = "series",
            }) end,
            action = m.action,
        })
    end
    local list = vstack({ w = list_w }, { head, space(nil, px(8)), content })
    if side then return hstack({ w = f.w, valign = "top", gap = px(28) }, { hero, list }) end
    if page > 1 then return vstack({ w = f.w }, { list }) end
    return vstack({ w = f.w }, { space(nil, px(4)), hero, space(nil, px(12)), list })
end

-- ------------------------------------------------------------ an author
-- Who they are (photo, what you have read, the bio when the library has
-- one, Download all), then their books grouped by series, as covers or rows.
-- Landscape sets the card in a column on the left. Books that do not fit go
-- on further pages, a group split across pages repeating its head; a grid
-- shrinks its covers to stay on one page only down to three quarters size.
local function authorGeometry(m, f)
    local side = BoardMetrics.get().w > BoardMetrics.get().h
    local gap_s = px(28)
    local card_w = side and math.floor(f.w * 0.36 + 0.5) or f.w
    local book_w = side and (f.w - card_w - gap_s) or f.w
    local pad = px(14)
    local ph = px(side and 88 or 64)
    local photo_h = (m.author and m.author.hasPhoto) and ph or 0
    local top_h = side and math.max(photo_h, lh1(15) + 3 * lh1(13) + px(8)) or math.max(photo_h, lh1(15) + lh1(13) + px(4))
    local card_fixed = 2 * pad + top_h + px(12) + px(34)
    return { side = side, gap_s = gap_s, card_w = card_w, book_w = book_w, pad = pad, ph = ph, top_h = top_h, card_fixed = card_fixed }
end

function Pages.authorLayout(m, f)
    local geo = authorGeometry(m, f)
    local head_h = px(36) + px(10)
    local avail1 = geo.side and f.content_h or (f.content_h - geo.card_fixed - px(16))
    local availn = f.content_h
    local groups = m.groups
    local blocks = {}
    if m.view == "list" then
        local function two(n) return geo.book_w / BoardMetrics.get().dp >= 760 and n > 3 end
        local row_h = px(ListView.ROW)
        local pages, cur, used = {}, { blocks = {} }, 0
        local avail = avail1
        for _gi, gr in ipairs(groups) do
            local per_row = two(#gr.books) and 2 or 1
            local i = 1
            while i <= #gr.books do
                local gap = #cur.blocks > 0 and px(10) or 0
                local room_rows = math.floor((avail - used - gap - head_h) / row_h)
                if room_rows < 1 then
                    table.insert(pages, cur); cur = { blocks = {} }; used = 0; avail = availn
                    gap = 0
                    room_rows = math.floor((avail - head_h) / row_h)
                end
                local take = math.min(#gr.books - i + 1, math.max(1, room_rows) * per_row)
                local part = {}
                for k = i, i + take - 1 do table.insert(part, gr.books[k]) end
                table.insert(cur.blocks, { group = gr, books = part })
                used = used + gap + head_h + math.ceil(take / per_row) * row_h
                i = i + take
            end
        end
        table.insert(pages, cur)
        -- Rows stretch a little (up to 62dp from 56dp) only on a single page.
        local list_row_h = row_h
        return { geo = geo, pages = pages, row_h = list_row_h, avail1 = avail1, head_h = head_h }
    end
    local std = ListView.fitStd(m.screen_w, m.screen_h, geo.book_w, nil, 1)
    local function shelvesH(g)
        local total = 0
        for gi, gr in ipairs(groups) do
            local rows = math.ceil(#gr.books / g.cols)
            total = total + head_h + rows * (g.cover_h + ListView.CAP()) + (rows - 1) * g.gy + (gi > 1 and px(10) or 0)
        end
        return total
    end
    local g = std
    if shelvesH(std) > avail1 then
        local rows_all = 0
        for _i, gr in ipairs(groups) do rows_all = rows_all + math.ceil(#gr.books / std.cols) end
        local ch = math.floor((avail1 - #groups * head_h - (#groups - 1) * px(10) - (rows_all - #groups) * std.gy) / rows_all - ListView.CAP())
        if ch >= std.cover_h * 0.75 then
            g = {}
            for k, v in pairs(std) do g[k] = v end
            g.cover_h, g.cover_w = ch, math.floor(ch / 1.5 + 0.5)
        end
    end
    local cell_h = g.cover_h + ListView.CAP()
    local pages, cur, used = {}, { blocks = {} }, 0
    local avail = avail1
    for _gi, gr in ipairs(groups) do
        local i = 1
        while i <= #gr.books do
            local gap = #cur.blocks > 0 and px(10) or 0
            local room_rows = math.floor((avail - used - gap - head_h + g.gy) / (cell_h + g.gy))
            if room_rows < 1 then
                table.insert(pages, cur); cur = { blocks = {} }; used = 0; avail = availn
                gap = 0
                room_rows = math.max(1, math.floor((avail - head_h + g.gy) / (cell_h + g.gy)))
            end
            local take = math.min(#gr.books - i + 1, room_rows * g.cols)
            local part = {}
            for k = i, i + take - 1 do table.insert(part, gr.books[k]) end
            table.insert(cur.blocks, { group = gr, books = part })
            local rows = math.ceil(take / g.cols)
            used = used + gap + head_h + rows * cell_h + (rows - 1) * g.gy
            i = i + take
        end
    end
    table.insert(pages, cur)
    return { geo = geo, pages = pages, g = g, avail1 = avail1, head_h = head_h }
end

-- The most bio lines (up to max) whose text box fits in room.
local function bioLines(room, max)
    local n = math.max(0, math.min(max, math.floor(room / lh(13))))
    while n > 0 and BoardMetrics.textBoxHeight(n, 13) > room do n = n - 1 end
    return n
end

function Pages.authorBody(m, f)
    local a = m.author
    local layout = Pages.authorLayout(m, f)
    local geo = layout.geo
    local side = geo.side
    local page = math.max(1, math.min(#layout.pages, m.local_page or 1))
    local blocks = layout.pages[page].blocks
    local card_w, book_w, pad = geo.card_w, geo.book_w, geo.pad
    local iw = card_w - 2 * pad
    local ph = geo.ph
    local pic = ListView.photo(a, ph)
    local head_h = layout.head_h
    local count = a.count or 0
    local facts = { ListData.bookCount(count) }
    if (a.finished or 0) > 0 then table.insert(facts, T(_("%1 read"), a.finished)) end
    if (a.reading or 0) > 0 then table.insert(facts, T(_("%1 reading"), a.reading)) end
    local on_dev = (a.on_device or 0) > 0 and T(_("%1 on this device"), a.on_device) or _("None on this device yet")
    local function topRow(w)
        local tw = w - (pic and (ph + px(14)) or 0)
        local col
        if side then
            local rest = #facts > 1 and table.concat({ unpack(facts, 2) }, " · ") or nil
            local lines = {
                { ListData.bookCount(count), { s = 15, b = true } },
                rest and { rest, { s = 13, b = true } } or false,
                { on_dev, { s = 13, col = INK.g4 } },
                a.birth_year and { T(_("Born %1"), a.birth_year), { s = 13, col = INK.g4 } } or false,
            }
            -- The narrow side card: each line steps down before it is cut,
            -- and the photo gives way (to 56dp, then out) before a line is.
            if pic then
                local need = 0
                for _i, l in ipairs(lines) do
                    if l then
                        local probe = text(l[1], { s = l[2].s - 2, b = l[2].b })
                        need = math.max(need, probe:getSize().w)
                        if probe.free then probe:free() end
                    end
                end
                if need > tw then
                    local small = w - px(14) - need
                    if pic.free then pic:free() end
                    if small >= px(56) then
                        pic = ListView.photo(a, math.min(ph, small))
                        tw = w - math.min(ph, small) - px(14)
                    else
                        pic = nil
                        tw = w
                    end
                end
            end
            local function line(l)
                local str, o = l[1], l[2]
                return text(str, { s = BoardUI.fitSize(str, o, tw, o.s - 2), b = o.b, col = o.col, w = tw })
            end
            col = vstack({ w = tw }, {
                line(lines[1]),
                lines[2] and line(lines[2]) or false,
                space(nil, px(4)),
                line(lines[3]),
                lines[4] and line(lines[4]) or false,
            })
        else
            local sub = on_dev .. (a.birth_year and (" · " .. T(_("born %1"), a.birth_year)) or "")
            col = vstack({ w = tw }, { text(table.concat(facts, " · "), { s = 15, b = true, w = tw }), space(nil, px(4)), text(sub, { s = 13, col = INK.g4, w = tw }) })
        end
        return hstack({ w = w, gap = px(14) }, { pic or false, col })
    end
    -- Download all and Full bio side by side, Full bio tighter when the row
    -- is short of room, and under Download all when it still is. Measured
    -- with plain pills: a region joins the D-pad rows once it is built.
    local dl_o = { kind = "outline", icon = "download", h = 34, s = 13, pad_x = 14 }
    local bio_pad
    do
        local probe = ListView.pill(T(_("Download all %1"), count), dl_o)
        local room = iw - probe:getSize().w - px(8)
        if probe.free then probe:free() end
        for _i, pad_x in ipairs({ 10, 4 }) do
            local pill = ListView.pill(_("Full bio"), { kind = "ghost", h = 34, s = 13, pad_x = pad_x })
            if not bio_pad and pill:getSize().w <= room then bio_pad = pad_x end
            if pill.free then pill:free() end
        end
    end
    local buttons_h = bio_pad and px(34) or (2 * px(34) + px(4))
    local function buttons(with_bio)
        local dl = region(ListView.pill(T(_("Download all %1"), count), dl_o), { kind = "download-all" }, "author-card", "download-all")
        if not with_bio then return dl end
        local bio = region(ListView.pill(_("Full bio"), { kind = "ghost", h = 34, s = 13, pad_x = bio_pad or 4 }), { kind = "full-bio" }, "author-card", "full-bio")
        if bio_pad then return hstack({ gap = px(8) }, { dl, bio }) end
        return vstack({}, { dl, space(nil, px(4)), bio })
    end
    -- What the page's books take, so the bio can have the rest.
    local function blocksH()
        local total = 0
        for bi, blk in ipairs(blocks) do
            local n = #blk.books
            if m.view == "list" then
                local per_row = (book_w / BoardMetrics.get().dp >= 760 and #blk.group.books > 3) and 2 or 1
                total = total + head_h + math.ceil(n / per_row) * px(ListView.ROW)
            else
                local rows = math.ceil(n / layout.g.cols)
                total = total + head_h + rows * (layout.g.cover_h + ListView.CAP()) + (rows - 1) * layout.g.gy
            end
            if bi > 1 then total = total + px(10) end
        end
        return total
    end
    local card, card_h
    if page == 1 or side then
        if side then
            card_h = f.content_h
            local room = card_h - 2 * pad - 2 * MenuUI.line() - geo.top_h - px(16) - buttons_h - px(16)
            local desc_lines = a.description and bioLines(room, 18) or 0
            local bio = desc_lines >= 2 and text(a.description, { s = 13, serif = true, col = INK.g2, lines = desc_lines, w = iw, data = true }) or nil
            card = MenuUI.card(card_w, { vstack({ w = iw, h = card_h - 2 * pad - 2 * MenuUI.line() }, { topRow(iw), bio and space(nil, px(16)) or false, bio or false, flex(px(16)), buttons(bio ~= nil) }) }, { pad = 14, vpad = 14 })
        else
            local room = f.content_h - geo.card_fixed - px(16) - blocksH() - px(12)
            local desc_lines = a.description and bioLines(room, 8) or 0
            if desc_lines < 2 then desc_lines = 0 end
            -- A short bio takes only the lines it needs.
            if desc_lines > 0 then
                desc_lines = math.min(desc_lines, require("bookorbit_text_pages").linesNeeded(a.description, { s = 13, serif = true }, iw))
            end
            card_h = geo.card_fixed + (desc_lines > 0 and (px(12) + BoardMetrics.textBoxHeight(desc_lines, 13)) or 0)
            local bio = desc_lines > 0 and text(a.description, { s = 13, serif = true, col = INK.g2, lines = desc_lines, w = iw, data = true }) or nil
            card = MenuUI.card(card_w, { vstack({ w = iw, h = card_h - 2 * pad - 2 * MenuUI.line() }, { flex(0), topRow(iw), bio and space(nil, px(12)) or false, bio or false, space(nil, px(12)), buttons(bio ~= nil), flex(0) }) }, { pad = 14, vpad = 14 })
        end
    end
    local kids = {}
    for bi, blk in ipairs(blocks) do
        local gr = blk.group
        if bi > 1 then table.insert(kids, space(nil, px(10))) end
        table.insert(kids, ListView.sectionHead(book_w, { icon = gr.standalone and "book" or "layers-2", label = gr.key, count = #gr.books, right = bi == 1 and ListView.viewToggle(m.view, "author-head") or nil }))
        table.insert(kids, space(nil, px(10)))
        if m.view == "list" then
            local two = book_w / BoardMetrics.get().dp >= 760 and #gr.books > 3
            local cw2 = two and math.floor((book_w - px(28)) / 2) or book_w
            local rh = px(ListView.ROW)
            local function rowsOf(part, offset)
                local out = {}
                for k, b in ipairs(part) do
                    local inner = vstack({ w = cw2, h = rh }, { k > 1 and ListView.rule(cw2) or false, flex(0), ListView.bookRow(cw2, rh, b, { context = "author" }), flex(0) })
                    table.insert(out, region(inner, m.action(b), "author-" .. bi .. "-" .. (offset + k), "book-" .. tostring(b.id)))
                end
                return out
            end
            if two then
                local half = math.ceil(#blk.books / 2)
                table.insert(kids, hstack({ w = book_w, valign = "top", gap = px(28) }, { vstack({ w = cw2 }, rowsOf({ unpack(blk.books, 1, half) }, 0)), vstack({ w = cw2 }, rowsOf({ unpack(blk.books, half + 1) }, half)) }))
            else
                table.insert(kids, vstack({ w = book_w }, rowsOf(blk.books, 0)))
            end
        else
            local short = ListData.distinctTitles(gr.books)
            local g = layout.g
            table.insert(kids, ListView.cellsGrid(book_w, g, blk.books, function(b)
                return ListView.bookCell(g, b, {
                    title = function(x) return (x.seriesIndex and not gr.standalone) and (tostring(x.seriesIndex) .. ". " .. (short[x.id] or x.title or "")) or (x.title or "") end,
                    author = false,
                })
            end, m.action))
        end
    end
    local books = vstack({ w = book_w }, kids)
    if side then return hstack({ w = f.w, valign = "top", gap = geo.gap_s }, { card, books }) end
    if card then return vstack({ w = f.w }, { card, space(nil, px(16)), books }) end
    return vstack({ w = f.w }, { books })
end

-- -------------------------------------------------------------- search
-- Authors, series and books are the list rows their own lists use, so a
-- search reads like the lists it opens. The rows that fit are shared out
-- evenly between the sections that found something, a spare row going to
-- books first; each section says See all when it holds more. Landscape sets
-- authors and series in one column and books in the other.

function Pages.searchBody(m, f)
    local r = m.results
    local field_h = px(44)
    local field_inner = hstack({ w = f.w - 2 * math.max(1, px(1.5)), h = field_h - 2 * math.max(1, px(1.5)) }, {
        space(px(16) - math.max(1, px(1.5)), 1), icon("search", px(18), { sw = 2.5 }), space(px(10), 1),
        text(m.query or "", { s = 16, b = true, w = f.w - px(16) - px(18) - px(10) - px(44) - px(4), data = true }),
        flex(0),
        region(hstack({ w = px(44), h = px(40) }, { flex(0), icon("x", px(18), { sw = 2 }), flex(0) }), { kind = "search-clear" }, "search-field", "search-clear"),
    })
    local field = region(BoardUI.box({ w = f.w, h = field_h, radius = math.floor(field_h / 2), border = math.max(1, px(1.5)), border_col = INK.black, bg = INK.white }, field_inner), { kind = "search-edit" }, "search-field", "search-field")
    local books_total = r.books_total or #r.books
    local total = #r.authors + #r.series + books_total
    local function chip(label, on, scope)
        return region(MenuUI.chip(label, { h = 30, ink = on }), { kind = "search-scope", scope = scope }, "search-chips", "chip-" .. scope)
    end
    local chips = hstack({ gap = px(8) }, {
        chip(T(_("Everything %1"), total), m.scope == "all", "all"),
        chip(T(_("Authors %1"), #r.authors), m.scope == "authors", "authors"),
        chip(T(_("Series %1"), #r.series), m.scope == "series", "series"),
        chip(T(_("Books %1"), books_total), m.scope == "books", "books"),
    })
    local kids = { field, space(nil, px(10)), chips, space(nil, px(6)) }
    local room = f.content_h - field_h - px(10) - hOf(chips) - px(6)

    local row_h = px(ListView.ROW)
    local head_h = px(36)
    local gap_s = px(14)
    local sections = {
        { kind = "authors", list = r.authors, total = #r.authors, icon = "feather", label = _("Authors"),
            row = function(w, e) return ListView.authorRow(w, row_h, e) end,
            action = function(e) return { kind = "open-author", entry = e } end },
        { kind = "series", list = r.series, total = #r.series, icon = "layers-2", label = _("Series"),
            row = function(w, e) return ListView.seriesRow(w, row_h, e) end,
            action = function(e) return { kind = "open-series", entry = e } end },
        { kind = "books", list = r.books, total = books_total, icon = "book", label = _("Books"),
            row = function(w, b) return ListView.bookRow(w, row_h, b) end,
            action = m.action },
    }
    local found = {}
    for _i, sec in ipairs(sections) do if #sec.list > 0 then table.insert(found, sec) end end

    -- When not every section gets a row, series step aside, then authors.
    local function rowsFor(list, col_room)
        local function rowsLeft() return math.floor((col_room - #list * head_h - math.max(0, #list - 1) * gap_s) / row_h) end
        for _i, kind in ipairs({ "series", "authors" }) do
            if #list > 1 and rowsLeft() < #list then
                for i = #list, 1, -1 do if list[i].kind == kind then table.remove(list, i) end end
            end
        end
        local n = math.max(1, rowsLeft())
        -- Books take a spare row first, then authors, then series.
        local order = {}
        for _i, sec in ipairs(list) do if sec.kind == "books" then table.insert(order, sec) end end
        for _i, sec in ipairs(list) do if sec.kind ~= "books" then table.insert(order, sec) end end
        local counts = {}
        for i, sec in ipairs(order) do counts[i] = #sec.list end
        local share = ListData.shareRows(n, counts)
        local used = 0
        for i, sec in ipairs(order) do sec.n = share[i]; used = used + share[i] end
        -- Rows the room limits grow to fill it, as a list's rows do; rows
        -- the results run out of keep their height.
        local rh = row_h
        if used == rowsLeft() then
            rh = math.floor((col_room - #list * head_h - math.max(0, #list - 1) * gap_s) / used)
        end
        for _i, sec in ipairs(list) do sec.rh = rh end
    end
    local function sectionBlock(sec, w)
        local items = { ListView.sectionHead(w, {
            icon = sec.icon, label = sec.label, count = sec.total,
            right = sec.total > sec.n and region(hstack({ gap = px(2) }, { text(_("See all"), { s = 13, b = true }), icon("chevron-right", px(14), { sw = 2.5 }) }),
                { kind = "search-scope", scope = sec.kind }, "search-" .. sec.kind .. "-head", "see-all-" .. sec.kind) or nil,
        }) }
        for i = 1, sec.n do
            local e = sec.list[i]
            local inner = vstack({ w = w, h = sec.rh or row_h }, { i > 1 and ListView.rule(w) or false, flex(0), sec.row(w, e), flex(0) })
            table.insert(items, region(inner, sec.action(e), "search-" .. sec.kind .. "-" .. i, sec.kind .. "-" .. i))
        end
        return vstack({ w = w }, items)
    end
    local function column(list, w)
        local items = {}
        for i, sec in ipairs(list) do
            if i > 1 then table.insert(items, space(nil, gap_s)) end
            table.insert(items, sectionBlock(sec, w))
        end
        return vstack({ w = w }, items)
    end

    local side = BoardMetrics.get().w > BoardMetrics.get().h
    local people, books = {}, {}
    for _i, sec in ipairs(found) do table.insert(sec.kind == "books" and books or people, sec) end
    if side and #people > 0 and #books > 0 then
        local gutter = px(28)
        local lw = math.floor((f.w - gutter) / 2)
        local rw = f.w - gutter - lw
        rowsFor(people, room)
        rowsFor(books, room)
        table.insert(kids, hstack({ w = f.w, valign = "top", gap = gutter }, { column(people, lw), column(books, rw) }))
    elseif #found > 0 then
        rowsFor(found, room)
        table.insert(kids, column(found, f.w))
    else
        table.insert(kids, ListView.empty(f.w, room, { icon = "search", title = T(_("Nothing matches “%1”"), m.query or ""),
            body = _("Check the spelling, or try fewer or different words.") }))
    end
    return vstack({ w = f.w }, kids)
end

-- --------------------------------------------------------------- sheets
-- Sort and filter, from the toolbar. sheet: { sorts = { labels, selected },
-- order = { labels, selected } or nil, shows = { labels, selected }, rows =
-- { { icon, label, value, toggle, action } }, footer = true }.
function Pages.sortSheet(sheet)
    return function(screen_w, screen_h)
        return ListView.sheet(screen_w, screen_h, { kind = "top", pop_w = 460, top = px(112), build = function(inner)
            local items = {
                ListView.sheetHead(inner, sheet.title, region(ListView.pill(_("Done"), { h = 36, s = 14, pad_x = 16 }), { kind = "sheet-done" }, "sheet-head", "done")),
                ListView.sheetLabel(inner, _("Sort by")),
                ListView.segmented(inner, sheet.sorts.labels, sheet.sorts.selected, function(i) return { kind = "sheet-sort", index = i } end, { h = 40, s = 13, row = "sheet-sort", key = "sort" }),
            }
            if sheet.order then
                table.insert(items, ListView.segmented(inner, sheet.order.labels, sheet.order.selected, function(i) return { kind = "sheet-order", index = i } end, { h = 36, s = 13, row = "sheet-order", key = "order" }))
            end
            table.insert(items, ListView.sheetLabel(inner, _("Show")))
            table.insert(items, ListView.segmented(inner, sheet.shows.labels, sheet.shows.selected, function(i) return { kind = "sheet-show", index = i } end, { h = 40, s = 13, row = "sheet-show", key = "show" }))
            if sheet.rows and #sheet.rows > 0 then
                local rw = inner - 2 * px(16)
                local rows = {}
                for i, r in ipairs(sheet.rows) do
                    table.insert(rows, ListView.groupRow(rw, { icon = r.icon, label = r.label, value = r.value, toggle = r.toggle, chevron = r.chevron, action = r.action, row = "sheet-row-" .. i, key = r.key, h = 50, s = 15 }))
                end
                table.insert(items, ListView.groupCard(inner, rows))
            end
            local reset = region(text(_("Reset to defaults"), { s = 14, b = true }), { kind = "sheet-reset" }, "sheet-foot", "reset")
            table.insert(items, hstack({ w = inner }, { reset, flex(px(8)), text(sheet.note or _("Saved for every list"), { s = 13, col = INK.g4 }) }))
            return items
        end })
    end
end

-- A list of choices (a library, a format), replacing the sort sheet.
function Pages.pickSheet(sheet)
    return function(screen_w, screen_h)
        return ListView.sheet(screen_w, screen_h, { kind = "top", pop_w = 460, top = px(112), build = function(inner, max_body)
            local back = region(hstack({ h = px(32), gap = px(2) }, { icon("chevron-left", px(22), { sw = 2.25 }), text(_("Back"), { s = 14, b = true }) }), { kind = "sheet-back" }, "sheet-head", "pick-back")
            local head = hstack({ w = inner, h = px(32) }, { back, flex(px(8)), text(sheet.title, { s = 18, b = true }), flex(px(8)), space(wOf(back), 1) })
            -- A long list that cannot fit in one column takes two.
            local n = #sheet.options
            local two = max_body and n > 1 and px(32) + px(14) + n * px(46) + px(8) > max_body
            local gap = px(12)
            local card_w = two and math.floor((inner - gap) / 2) or inner
            local rw = card_w - 2 * px(16)
            local function card(first, last)
                local rows = {}
                for i = first, last do
                    local o = sheet.options[i]
                    table.insert(rows, ListView.groupRow(rw, { label = o.label, radio = o.on, action = { kind = "sheet-pick", value = o.value }, row = "pick-" .. i, key = "pick-" .. i, h = 46, s = 15, bold = o.on }))
                end
                return ListView.groupCard(card_w, rows)
            end
            if not two then return { head, card(1, n) } end
            local half = math.ceil(n / 2)
            return { head, hstack({ w = inner, valign = "top", gap = gap }, { card(1, half), card(half + 1, n) }) }
        end })
    end
end

function Pages.jumpSheet(sheet)
    return function(screen_w, screen_h)
        return ListView.sheet(screen_w, screen_h, { kind = "bottom", pop_w = 460, build = function(inner)
            local close = region(hstack({ w = px(44), h = px(44) }, { flex(0), icon("x", px(22), { sw = 2 }), flex(0) }), { kind = "close-sheet" }, "sheet-head", "jump-close")
            local items = { ListView.sheetHead(inner, _("Jump to"), close) }
            if sheet.present then
                table.insert(items, ListView.jumpKeys(inner, sheet.present, sheet.current))
                table.insert(items, ListView.rule(inner))
            end
            table.insert(items, ListView.pageStepper(inner, sheet.page, sheet.pages))
            return items
        end })
    end
end

return Pages