Ctrl K
ComunidadGuías…

koreader-plugin/bookorbit.koplugin/bookorbit_list_view.lua

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

Archivo original ↗Preguntar a KiBot
--[[--
The list pages: All books, Reading, Authors, Series, Shelves, a series, an
author, and search, drawn from the view model the catalog assembles.

Every list shares one frame: the board's 64dp header with a back chevron, a
toolbar (sort, filter, library, List / Grid), the content, and a pager whose
middle opens Jump. Rows all have one shape: a picture column (cover, portrait
or a stack of covers), two lines of text, and the status on the right.
Grids share one cell: a cover with at most two marks (a progress strip along
its foot while reading, a corner fold once read), a bold title and one gray
line. Alphabetical lists carry a dictionary thumb index down the right edge.

Sizes are the design mock's dp values through BoardMetrics, so the page and
the mock lay out with the same pixels. Every control is its own tappable,
focusable region; a D-pad press is a tap at the focused region's centre.
]]

local Blitbuffer = require("ffi/blitbuffer")
local DataStorage = require("datastorage")
local Geom = require("ui/geometry")
local GestureRange = require("ui/gesturerange")
local IconWidget = require("ui/widget/iconwidget")
local InputContainer = require("ui/widget/container/inputcontainer")
local RenderImage = require("ui/renderimage")
local Widget = require("ui/widget/widget")
local WidgetContainer = require("ui/widget/container/widgetcontainer")
local lfs = require("libs/libkoreader-lfs")
local util = require("util")
local T = require("ffi/util").template
local _ = require("gettext")

local BoardMetrics = require("bookorbit_board_metrics")
local BoardUI = require("bookorbit_board_ui")
local CatalogWidgets = require("bookorbit_catalog_widgets")
local ListData = require("bookorbit_list_data")
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 ListView = {}

local function hair() return MenuUI.hair() end
local function line1() return math.max(1, px(1)) end
local function wOf(widget) return widget:getSize().w end
local function hOf(widget) return widget:getSize().h end
local function clamp(v, lo, hi) if v < lo then return lo end if v > hi then return hi end return v end

-- The screen's width in dp, rounded as the design mock rounds it.
local function screenW()
    return BoardMetrics.layoutW()
end
ListView.screenW = screenW

function ListView.mx()
    local W = screenW()
    return px(W >= 900 and 28 or W >= 700 and 24 or W >= 560 and 20 or 16)
end

-- ----------------------------------------------------------------- regions
local Region = InputContainer:extend{ action = nil, run = nil }

function Region:init()
    local size = self[1]:getSize()
    self.dimen = Geom:new{ w = size.w, h = size.h }
    self.ges_events = {
        TapSelect = { GestureRange:new{ ges = "tap", range = function() return self.dimen end } },
        HoldSelect = { GestureRange:new{ ges = "hold", range = function() return self.dimen end } },
    }
end

function Region:handleEvent(event)
    if event.handler == "onTapSelect" or event.handler == "onHoldSelect" then
        return Widget.handleEvent(self, event)
    end
    return InputContainer.handleEvent(self, event)
end

function Region:onTapSelect()
    if self.action and self.run then self.run(self.action, self) end
    return true
end

function Region:onHoldSelect()
    if self.action and self.run then self.run(self.action, self, true) end
    return true
end

local env = nil

-- Wraps widget as a control in focus row `row`; key names it for the dump.
local function region(widget, action, row, key)
    local r = Region:new{ action = action, run = env and env.run, CatalogWidgets.focusable(widget) }
    if env and row then
        env.rows[row] = env.rows[row] or {}
        table.insert(env.rows[row], r)
        if not env.row_seen[row] then env.row_seen[row] = true; table.insert(env.row_list, row) end
    end
    if env and key then env.regions[key] = r end
    return r
end

-- A painted-position wrapper for the parity dump.
local Mark = WidgetContainer:extend{ key = nil }
function Mark:getSize() return self[1]:getSize() end
function Mark:paintTo(bb, x, y)
    local size = self[1]:getSize()
    self.dimen = Geom:new{ x = x, y = y, w = size.w, h = size.h }
    self[1]:paintTo(bb, x, y)
end
local function mark(key, widget)
    if not env or not env.marks or not widget then return widget end
    local m = Mark:new{ key = key, widget }
    env.regions[key] = m
    return m
end

-- ------------------------------------------------------------- text tools
local function textWidth(str, o)
    local w = text(str, o)
    local width = wOf(w)
    if w.free then w:free() end
    return width
end

local function runsWidth(parts, o)
    local w = runs(parts, o)
    local width = wOf(w)
    if w.free then w:free() end
    return width
end

local function rule(w, col, t)
    return BoardUI.rect(w, t or hair(), col or INK.gc)
end

-- --------------------------------------------------------------- the frame
-- Header, toolbar, content and pager. The content box is what lists size
-- their rows to, so a page fills the screen exactly; nothing scrolls.
function ListView.frame(screen_w, screen_h, o)
    o = o or {}
    local mx = ListView.mx()
    local w = screen_w - 2 * mx
    local top, bottom = px(6), px(8)
    local tb = o.toolbar and px(44) or 0
    local band = o.offline and (px(32) + px(8)) or 0
    local content_h = screen_h - top - bottom - px(64) - tb - band - px(8) - px(40)
    return { mx = mx, w = w, content_h = content_h, top = top, bottom = bottom }
end

-- ------------------------------------------------------------------ pieces
local function iconButton(name, action, key, o)
    o = o or {}
    local box = px(44)
    local b = hstack({ w = box, h = box }, { flex(0), icon(name, px(o.size or 23), { sw = o.sw or 2 }), flex(0) })
    return region(b, action, "header", key)
end

-- The board's 64dp header: back, title over one sub line, device status,
-- search and the menu. While selecting: no back, no search, ✕ ends it.
function ListView.header(w, o)
    local box = px(44)
    local tap = math.floor((box - px(24)) / 2)
    local select = o.select
    local status = BoardUI.statusCluster({ clock = o.clock })
    local right = {}
    table.insert(right, space(px(8), 1))
    table.insert(right, status)
    table.insert(right, space(px(6), 1))
    if select then
        table.insert(right, iconButton("menu", { kind = "select-menu" }, "select-menu"))
    else
        table.insert(right, iconButton("search", { kind = "search" }, "search"))
    end
    table.insert(right, iconButton(select and "x" or "menu", { kind = select and "select-done" or "menu" }, select and "select-done" or "menu"))
    local right_w = 0
    for _i, item in ipairs(right) do right_w = right_w + wOf(item) end
    -- The tap boxes reach into the margins so the glyphs sit on them.
    local row_w = w + tap + (select and 0 or tap)
    local lead = select and 0 or (box - px(4))
    local title_w = row_w - lead - right_w
    -- A long title steps down to 18 before it is cut.
    local title_s = 22
    while title_s > 18 and textWidth(o.title or "", { s = title_s, b = true }) > title_w do title_s = title_s - 1 end
    local title = text(o.title or "", { s = title_s, b = true, w = title_w, data = o.title_data })
    local sub
    if o.sub then
        if type(o.sub) == "table" then sub = runs(o.sub, { s = 13, col = INK.g4, w = title_w, data = true })
        else sub = text(o.sub, { s = 13, col = INK.g4, w = title_w, data = true }) end
    end
    local col = vstack({ w = title_w }, { title, sub or false })
    local items = { space(lead, 1), col, flex(0) }
    for _i, item in ipairs(right) do table.insert(items, item) end
    local row = hstack({ w = row_w, h = px(64) }, items)
    local layers = { { widget = row, x = 0, y = 0 } }
    if not select then
        local back = region(hstack({ w = box, h = box }, { flex(0), icon("chevron-left", px(26), { sw = 2.25 }), flex(0) }), { kind = "back" }, "header", "back")
        table.insert(layers, { widget = back, x = 0, y = math.floor((px(64) - box) / 2) })
    end
    local x0 = select and 0 or -tap
    return BoardUI.overlay(w, px(64), { { widget = BoardUI.overlay(row_w, px(64), layers), x = x0, y = 0 } })
end

-- A toolbar button: icon, value, a small down chevron; tinted when a filter
-- is on.
local function toolbarButton(b, with_icon)
    local items = {}
    if with_icon and b.icon then table.insert(items, icon(b.icon, px(16), { sw = 2.25 })) end
    table.insert(items, text(b.label, { s = 14, b = true, data = true }))
    table.insert(items, icon("chevron-down", px(13), { sw = 2.75, col = "g4" }))
    local inner = hstack({ h = b.on and px(32) or px(44), gap = px(6) }, items)
    if b.on then
        inner = BoardUI.box({ w = wOf(inner) + 2 * px(10), h = px(32), radius = px(16), bg = INK.ge },
            hstack({ w = wOf(inner) + 2 * px(10), h = px(32) }, { space(px(10), 1), inner, flex(0) }))
    end
    return inner
end

-- List / Grid, the current one inked.
function ListView.viewToggle(view, row_key)
    local s = px(32)
    local b = math.max(1, px(1.25))
    local function seg(name, on, which)
        local body = hstack({ w = s, h = s - 2 * b }, { flex(0), icon(name, px(16), { sw = 2.25, col = on and "white" or "black" }), flex(0) })
        local cell = on and BoardUI.box({ w = s, h = s - 2 * b, radius = px(7), bg = INK.black }, body) or body
        return region(cell, { kind = "view", view = which }, row_key or "toolbar", "view-" .. which)
    end
    return BoardUI.box({ w = 2 * s + 2 * b, h = s, radius = px(9), border = b, border_col = INK.black, bg = INK.white },
        hstack({ w = 2 * s, h = s - 2 * b }, { seg("list", view == "list", "list"), seg("layout-grid", view == "grid", "grid") }))
end

-- Controls on the left, List / Grid on the right, a black rule under.
-- Buttons drop their icons, then the last button, before a label is cut.
function ListView.toolbar(w, buttons, view)
    local right = view and ListView.viewToggle(view) or nil
    local rw = right and (wOf(right) + px(12)) or 0
    local gap = px(18)
    local function width(list, with_icon)
        local total = 0
        for i, b in ipairs(list) do
            local tb = toolbarButton(b, with_icon)
            total = total + wOf(tb) + (i > 1 and gap or 0)
        end
        return total
    end
    local chosen, with_icon = buttons, true
    if width(buttons, true) > w - rw then
        with_icon = false
        if width(buttons, false) > w - rw then
            chosen = {}
            for i = 1, #buttons - 1 do chosen[i] = buttons[i] end
        end
    end
    local left = {}
    for _i, b in ipairs(chosen) do table.insert(left, region(toolbarButton(b, with_icon), b.action, "toolbar", b.key)) end
    local row = hstack({ w = w, h = px(43) }, { hstack({ gap = gap }, left), flex(0), right or false })
    return mark("toolbar", vstack({ w = w }, { row, rule(w, INK.black, line1()) }))
end

-- ---------------------------------------------------------------- the pager
-- Chevrons turn pages (page keys and swipes do too); the middle says where
-- you are in the list's own terms and opens Jump.
function ListView.pager(w, o)
    local fh = px(40)
    local function chev(name, on, delta)
        local c = hstack({ w = px(44), h = fh }, { flex(0), icon(name, px(20), { sw = 2.5, col = on and "black" or "gb" }), flex(0) })
        return on and region(c, { kind = "page", delta = delta }, "pager", delta < 0 and "page-prev" or "page-next") or c
    end
    local parts = {}
    if o.range then table.insert(parts, { t = o.range, b = true }) end
    local count = o.pages and T(_("%1 of %2"), o.page, o.pages) or T(_("Page %1"), o.page)
    if (o.pages or 2) > 1 or not o.range then
        if o.range then table.insert(parts, { t = "   " }) end
        table.insert(parts, { t = count, b = not o.range, col = o.range and INK.g4 or INK.black })
    end
    local mid_items = { runs(parts, { s = 13 }) }
    if o.jump then table.insert(mid_items, icon("chevron-up", px(14), { sw = 2.75, col = "g4" })) end
    local mid = hstack({ h = fh, gap = px(6) }, { space(px(10) - px(6), 1), hstack({ gap = px(6) }, mid_items), space(px(10) - px(6), 1) })
    if o.jump then mid = region(mid, { kind = "jump-sheet" }, "pager", "jump") end
    local more_prev = o.page > 1 or o.has_prev
    local more_next = (o.pages and o.page < o.pages) or o.has_next
    return mark("pager", hstack({ w = w, h = fh, gap = px(4) }, { flex(0), chev("chevron-left", more_prev, -1), mid, chev("chevron-right", more_next, 1), flex(0) }))
end

-- --------------------------------------------------------- the thumb index
-- A dictionary's cut-in tabs, two letters a tab. The tab with this page's
-- letters is inked; tabs with nothing in the list are gray.
local TABS = { "#A", "BC", "DE", "FG", "HI", "JK", "LM", "NO", "PQ", "RS", "TU", "VW", "XYZ" }
ListView.TABS = TABS

local function chars(s)
    local out = {}
    for ch in s:gmatch(".") do table.insert(out, ch) end
    return out
end

function ListView.thumbIndex(hh, current, present)
    -- The rail is px(30) wide with its black edge inside that width.
    local b = line1()
    local tw = px(30) - b
    local n = #TABS
    local th = math.floor(hh / n)
    local cur = {}
    for _i, l in ipairs(current or {}) do cur[l] = true end
    local tabs = {}
    local prev_here = false
    for i, tab in ipairs(TABS) do
        local letters = chars(tab)
        local here, has, first_has = false, false, nil
        -- A tab of several letters goes to whichever comes first in the list
        -- ("#" can sort after Z).
        for _j, l in ipairs(letters) do
            if cur[l] then here = true end
            if present[l] then
                has = true
                local at = tonumber(present[l])
                if not first_has or (at and tonumber(present[first_has]) and at < tonumber(present[first_has])) then first_has = l end
            end
        end
        local h = i == n and (hh - th * (n - 1)) or th
        local labels = #letters > 2 and { letters[1] .. letters[2], letters[3] } or letters
        local stack = {}
        for _j, l in ipairs(labels) do
            table.insert(stack, text(l, { s = 11, b = true, col = here and INK.white or has and INK.black or INK.gb, w = tw, align = "center" }))
        end
        local body = vstack({ w = tw, h = h }, { flex(0), vstack({ w = tw }, stack), flex(0) })
        local cell
        if here then
            -- Rounded on the left only: a square right half covers the right corners.
            cell = BoardUI.overlay(tw, h, {
                { widget = BoardUI.rect(tw, h, INK.black, px(6)) },
                { widget = BoardUI.rect(math.ceil(tw / 2), h, INK.black), x = tw - math.ceil(tw / 2) },
                { widget = body },
            })
        else
            local top_line = i > 1 and not prev_here
            cell = BoardUI.overlay(tw, h, { top_line and { widget = rule(tw, INK.gc) } or {}, { widget = body } })
        end
        prev_here = here
        table.insert(tabs, has and region(cell, { kind = "jump-letter", letter = first_has }, "rail", "tab-" .. i) or cell)
    end
    return mark("rail", hstack({ w = tw + b, h = hh, valign = "top" }, { BoardUI.rect(b, hh, INK.black), vstack({ w = tw }, tabs) }))
end
ListView.RAIL_W = function() return px(30) + px(12) end

-- -------------------------------------------------------------- the covers
-- A cover in a hairline frame. Covers within 30% of 2:3 are cropped to fill;
-- other shapes sit centred on a gray plate. Dimmed when offline and not on
-- this device, since it cannot open until the server is back.
local function cover(book, w, h, o)
    o = o or {}
    local path = env and env.thumb and env.thumb(book) or nil
    local dim = env and env.offline and not (env.on_device and env.on_device(book))
    if path then
        return BoardUI.cover(book, w, h, { path = path, border = math.max(1, px(0.75)), border_col = INK.gb, crop_tol = 0.3, plate = true, dim = dim })
    end
    return BoardUI.box({ w = w, h = h, border = math.max(1, px(0.75)), border_col = INK.gb, bg = INK.ge }, space(1, 1))
end

-- Paints children at offsets; each child may carry a white ring around it.
local Layered = Widget:extend{ layers = nil }
function Layered:getSize() return self.dimen end
function Layered:paintTo(bb, x, y)
    self.dimen.x, self.dimen.y = x, y
    for _i, l in ipairs(self.layers) do
        if l.ring and l.ring > 0 then
            local s = l.widget:getSize()
            bb:paintRect(x + l.x - l.ring, y + l.y - l.ring, s.w + 2 * l.ring, s.h + 2 * l.ring, INK.white)
        end
        l.widget:paintTo(bb, x + l.x, y + l.y)
    end
end
local function layered(w, h, layers)
    return Layered:new{ dimen = Geom:new{ w = w, h = h }, layers = layers }
end

-- Covers stacked up and to the right, each 2:3, the first in front.
local function stackFit(books, w, hh)
    local list = {}
    for i = 1, math.min(3, #(books or {})) do list[i] = books[i] end
    local n = math.max(1, #list)
    local dy = n > 1 and math.max(1, math.floor(hh * 0.07 + 0.5)) or 0
    local ch = hh - dy * (n - 1)
    local cw = math.min(w, math.floor(ch * 2 / 3 + 0.5))
    local dx = n > 1 and math.min(math.floor(cw * 0.42 + 0.5), math.floor((w - cw) / (n - 1))) or 0
    local total = cw + dx * (n - 1)
    local ring = math.max(1, px(1.25))
    local layers = {}
    if #list == 0 then
        table.insert(layers, { widget = BoardUI.rect(cw, ch, INK.ge, px(4)), x = 0, y = 0 })
    end
    for i = #list, 1, -1 do
        table.insert(layers, { widget = cover(list[i], cw, ch), x = (i - 1) * dx, y = (n - i) * dy, ring = ring })
    end
    return layered(total, hh, layers)
end
ListView.stackFit = stackFit

-- Overlapping covers, the first in front, each a little smaller behind.
local function fan(books, cover_h, o)
    o = o or {}
    local cw = math.floor(cover_h * 2 / 3 + 0.5)
    local list = {}
    for i = 1, math.min(o.max or 3, #(books or {})) do list[i] = books[i] end
    local step = math.floor(cw * (o.step or 0.42) + 0.5)
    local width = cw + step * math.max(0, #list - 1)
    local ring = math.max(1, px(1.25))
    local shrink = o.shrink or 0.08
    local layers = {}
    for i = #list, 1, -1 do
        local s = 1 - shrink * (i - 1)
        local ch, cww = math.floor(cover_h * s + 0.5), math.floor(cw * s + 0.5)
        table.insert(layers, { widget = cover(list[i], cww, ch), x = (i - 1) * step, y = math.floor((cover_h - ch) / 2 + 0.5), ring = ring })
    end
    return layered(width, cover_h, layers)
end

-- A portrait in a circle, or nil when the author has none.
-- A portrait clipped to a rounded rectangle (a circle when the radius is
-- half the side), drawn row by row so nothing shows past the corners.
local PhotoClip = Widget:extend{ path = nil, w = 0, h = 0, r = 0, ring = false }
local photo_cache = {}
function PhotoClip:getSize() return self.dimen end
function PhotoClip:paintTo(bb, x, y)
    self.dimen.x, self.dimen.y = x, y
    local w, h, r = self.w, self.h, self.r
    local key = self.path .. "|" .. w .. "x" .. h
    local img = photo_cache[key]
    if not img then
        local ok, native = pcall(RenderImage.renderImageFile, RenderImage, self.path, false)
        if ok and native then
            local iw, ih = native:getWidth(), native:getHeight()
            local scale = math.max(w / iw, h / ih)
            local sw, sh = math.max(w, math.floor(iw * scale + 0.5)), math.max(h, math.floor(ih * scale + 0.5))
            img = { bb = RenderImage:scaleBlitBuffer(native, sw, sh, true), w = sw, h = sh }
            photo_cache[key] = img
        end
    end
    if not img then
        if self.ring then bb:paintCircle(x + math.floor(w / 2), y + math.floor(w / 2), math.floor(w / 2), INK.ge)
        else bb:paintRoundedRect(x, y, w, h, INK.ge, r) end
        return
    end
    -- Faces sit high in a portrait: crop from 22% down, as the web does.
    local ox = math.floor((img.w - w) / 2)
    local oy = math.floor((img.h - h) * 0.22)
    for row = 0, h - 1 do
        local inset = 0
        local dy = row < r and (r - row - 0.5) or row >= h - r and (row + 0.5 - (h - r)) or nil
        if dy then inset = math.floor(r - math.sqrt(math.max(0, r * r - dy * dy)) + 0.5) end
        if w - 2 * inset > 0 then bb:blitFrom(img.bb, x + inset, y + row, ox + inset, oy + row, w - 2 * inset, 1) end
    end
    if self.ring then bb:paintCircle(x + math.floor(w / 2), y + math.floor(w / 2), math.floor(w / 2), INK.g9, math.max(1, px(0.75))) end
end
local function photo(entry, d)
    local path = env and env.photo and env.photo(entry) or nil
    if not path then return nil end
    return PhotoClip:new{ path = path, w = d, h = d, r = d / 2, ring = true, dimen = Geom:new{ w = d, h = d } }
end
function ListView.photoRect(path, w, h, r)
    return PhotoClip:new{ path = path, w = w, h = h, r = r, dimen = Geom:new{ w = w, h = h } }
end
ListView.photo = photo

-- ----------------------------------------------------------- small marks
local function bar(w, h, pct, o)
    o = o or {}
    return BoardUI.bar(w, h, pct, { track = o.track or INK.gd, fill = INK.black, r = math.floor(h / 2) })
end

local function doneDot(s)
    return BoardUI.circle(s, { bg = INK.black }, icon("check", math.floor(s * 0.66 + 0.5), { col = "white", sw = 3.25 }))
end

-- The read mark: a black fold in the cover's top-right corner with a white
-- check, set off from the art by a white edge along its diagonal. Drawn as
-- an SVG once per size.
local function svgFile(name, body, size)
    local dir = DataStorage:getDataDir() .. "/cache/bookorbit/icons"
    util.makePath(dir)
    local path = dir .. "/" .. name .. ".svg"
    if lfs.attributes(path, "mode") ~= "file" then
        local f = io.open(path, "w")
        if f then
            f:write(string.format('<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d">%s</svg>', size, size, size, size, body))
            f:close()
        end
    end
    return path
end

local function checkPath(cx, cy, k)
    return string.format("M%.2f %.2fL%.2f %.2fL%.2f %.2f", cx - 0.42 * k, cy + 0.02 * k, cx - 0.12 * k, cy + 0.32 * k, cx + 0.44 * k, cy - 0.3 * k)
end

local function readMark(cover_w)
    local L = math.floor(clamp(cover_w * 0.27, px(24), px(36)) + 0.5)
    local edge = math.max(2, math.floor(px(1.5) + 0.5))
    local S = L + 2 * edge
    local k = L * 0.34
    local body = string.format('<path d="M0 0H%dV%dZ" fill="#ffffff"/><path d="M%d 0H%dV%dZ" fill="#000000"/><path d="%s" fill="none" stroke="#ffffff" stroke-width="%.2f" stroke-linecap="round" stroke-linejoin="round"/>',
        S, S, S - L, S, L, checkPath(S - L * 0.3, L * 0.3, k), math.max(2, L * 0.085))
    local path = svgFile(string.format("bo-readmark-%d-%d", S, L), body, S)
    return IconWidget:new{ file = path, width = S, height = S, alpha = true }, S
end
ListView.readMark = readMark

-- A strip flush with the cover's foot: progress while reading.
local function progressStrip(w, pct)
    local h = math.max(3, px(5))
    local edge = math.max(1, px(0.75))
    local fill = math.floor(w * clamp(pct or 0, 0, 100) / 100 + 0.5)
    fill = math.max(fill, math.floor(w * 0.02 + 0.5))
    return vstack({ w = w }, { BoardUI.rect(w, edge, INK.black), BoardUI.overlay(w, h, { { widget = BoardUI.rect(w, h, INK.white) }, { widget = BoardUI.rect(fill, h, INK.black) } }) }), h + edge
end

-- One segment per book: read black, reading gray, not started white.
local function seriesStrip(w, states)
    local h = math.max(3, px(5))
    local edge = math.max(1, px(0.75))
    local n = math.max(1, #states)
    local gap = n <= 14 and math.max(1, px(1.5)) or 0
    local seg = (w - (n - 1) * gap) / n
    local layers = { { widget = BoardUI.rect(w, h, INK.white) } }
    for i, s in ipairs(states) do
        local x0 = math.floor((i - 1) * (seg + gap) + 0.5)
        local x1 = math.floor((i - 1) * (seg + gap) + seg + 0.5)
        local col = s == "finished" and INK.black or s == "reading" and INK.g8 or nil
        if col then table.insert(layers, { widget = BoardUI.rect(x1 - x0, h, col), x = x0 }) end
    end
    return vstack({ w = w }, { BoardUI.rect(w, edge, INK.black), BoardUI.overlay(w, h, layers) }), h + edge
end

-- The checkbox, the same on rows and covers: a rounded square with a full
-- black border; chosen, it is inked with a white check.
local function checkbox(on, on_cover)
    local sz = px(24)
    local bw = math.max(2, math.floor(px(2) + 0.5))
    local box = on and BoardUI.box({ w = sz, h = sz, radius = px(6), bg = INK.black }, hstack({ w = sz, h = sz }, { flex(0), icon("check", px(16), { col = "white", sw = 3.25 }), flex(0) }))
        or BoardUI.box({ w = sz, h = sz, radius = px(6), border = bw, border_col = INK.black, bg = INK.white }, space(1, 1))
    if not on_cover then return box end
    -- On a cover it sits on a white halo, so dark art never eats its edge.
    local halo = math.max(2, math.floor(px(2) + 0.5))
    return BoardUI.overlay(sz + 2 * halo, sz + 2 * halo, {
        { widget = BoardUI.rect(sz + 2 * halo, sz + 2 * halo, INK.white, px(6) + halo) },
        { widget = box, x = halo, y = halo },
    }), halo
end

-- A cover with its marks: a strip while reading, the corner fold once read,
-- or the checkbox and an inked frame while selecting.
local function markedCover(book, w, h, o)
    o = o or {}
    local layers = { { widget = cover(book, w, h) } }
    local state = ListData.stateOf(book)
    if state == "reading" then
        local st, sh = progressStrip(w, tonumber(book.progressPercentage) or 0)
        table.insert(layers, { widget = st, x = 0, y = h - sh })
    end
    if o.select then
        if o.selected then
            local f = px(3)
            table.insert(layers, { widget = BoardUI.box({ w = w, h = h, border = f, border_col = INK.black }, space(1, 1)) })
        end
        local cb, halo = checkbox(o.selected, true)
        table.insert(layers, { widget = cb, x = w - px(8) - px(24) - halo, y = px(8) - halo })
    elseif state == "finished" then
        local m, S = readMark(w)
        table.insert(layers, { widget = m, x = w - S, y = 0 })
    end
    return BoardUI.overlay(w, h, layers)
end
ListView.markedCover = markedCover

-- --------------------------------------------------------------- the rows
local VW = function() return px(44) end
local ROW = 62
ListView.ROW = ROW

-- The status column narrows with its row so a two-column list keeps titles.
local function statusWidth(w)
    local wdp = w / BoardMetrics.get().dp
    if wdp >= 560 then return px(112) end
    if wdp < 400 then return px(80) end
    return px(92)
end

local function pictureCol(hh, widget)
    return hstack({ w = VW(), h = hh - 1 }, { flex(0), widget, flex(0) })
end

local function textCol(tw, title, second, o)
    o = o or {}
    local items = { text(title, { s = 15, b = true, w = tw, data = true }) }
    if second then
        if type(second) == "table" then table.insert(items, runs(second, { s = 13, col = INK.g4, w = tw, data = true }))
        else table.insert(items, text(second, { s = 13, col = INK.g3, w = tw, data = true })) end
    end
    return vstack({ w = tw }, { items[1], items[2] and space(nil, px(2)) or false, items[2] or false })
end

-- The right-hand cell: the value on top, words under it.
local function cell(sw, top, bottom)
    return vstack({ w = sw, align = "right" }, { top, bottom and space(nil, px(3)) or false, bottom and text(bottom, { s = 12, col = INK.g4, w = sw, align = "right", data = true }) or false })
end

local function doneTop(sw, label)
    return hstack({ w = sw }, { flex(0), doneDot(px(14)), space(px(5), 1), text(label, { s = 14, b = true }) })
end

local function statusCell(book, sw)
    local c = ListData.statusCell(book, { minutes_left = env and env.minutes_left and env.minutes_left(book), narrow = sw < px(90), now = env and env.now })
    if c.kind == "reading" then
        return cell(sw, hstack({ w = sw }, { flex(0), bar(px(30), px(4), c.pct), space(px(6), 1), text(c.label, { s = 14, b = true }) }), c.bottom)
    elseif c.kind == "finished" then
        return cell(sw, doneTop(sw, c.label), c.bottom)
    elseif c.kind == "want" then
        return cell(sw, text(c.label, { s = 14, b = true, w = sw, align = "right" }), c.bottom)
    end
    return cell(sw, text(c.label, { s = 13, col = INK.g4, w = sw, align = "right", data = true }), c.bottom)
end
ListView.statusCell = statusCell

function ListView.bookRow(w, hh, b, o)
    o = o or {}
    local ch = math.min(px(54), hh - px(14))
    local sel = o.select and checkbox(o.selected) or nil
    local sel_w = sel and (px(24) + px(12)) or 0
    local mark_w = px(17) + px(10)
    local sw = statusWidth(w)
    local tw = w - VW() - px(12) - sel_w - sw - px(10) - mark_w
    local series = b.seriesName and (b.seriesIndex and (b.seriesName .. " #" .. tostring(b.seriesIndex)) or b.seriesName) or nil
    local second
    if o.context == "series" then second = nil
    elseif o.context == "author" then second = series
    else
        local parts = {}
        if b.authors and b.authors[1] then table.insert(parts, b.authors[1]) end
        if series then table.insert(parts, series) end
        second = #parts > 0 and table.concat(parts, " · ") or nil
    end
    local on_device = env and env.on_device and env.on_device(b)
    local items = {}
    if sel then table.insert(items, sel); table.insert(items, space(px(12), 1)) end
    table.insert(items, pictureCol(hh, cover(b, math.floor(ch * 2 / 3 + 0.5), ch)))
    table.insert(items, space(px(12), 1))
    table.insert(items, textCol(tw, b.title or "", second))
    table.insert(items, space(px(10), 1))
    table.insert(items, statusCell(b, sw))
    table.insert(items, space(px(10), 1))
    table.insert(items, on_device and icon("bo-device", px(17), { sw = 2 }) or space(px(17), 1))
    return hstack({ w = w, h = hh - 1 }, items)
end

function ListView.authorRow(w, hh, a)
    local vh = hh - px(14)
    local books = a.previewBooks or {}
    local pic = photo(a, math.min(VW(), vh)) or stackFit(books, VW(), vh)
    local sw = statusWidth(w)
    local c = ListData.authorCell(a)
    local right
    if c then
        local top
        if c.kind == "finished" then top = doneTop(sw, c.label)
        else top = text(c.label, { s = 14, b = true, w = sw, align = "right" }) end
        right = cell(sw, top, c.bottom)
    end
    local tw = w - VW() - px(12) - (right and (sw + px(10)) or 0)
    return hstack({ w = w, h = hh - 1 }, { pictureCol(hh, pic), space(px(12), 1), textCol(tw, a.title or a.name or "", ListData.authorSecond(a)), right and space(px(10), 1) or false, right or false })
end

-- A series bar: one segment a book, read black, reading gray.
local function seriesBar(bw, states)
    local n = math.max(1, #states)
    local gap = n <= 14 and math.max(1, px(2)) or 0
    local seg = (bw - (n - 1) * gap) / n
    local layers = {}
    for i, s in ipairs(states) do
        local x0 = math.floor((i - 1) * (seg + gap) + 0.5)
        local x1 = math.floor((i - 1) * (seg + gap) + seg + 0.5)
        local col = s == "finished" and INK.black or s == "reading" and INK.g8 or INK.gd
        table.insert(layers, { widget = BoardUI.rect(x1 - x0, px(6), col, px(2)), x = x0 })
    end
    return BoardUI.overlay(bw, px(6), layers)
end

function ListView.seriesRow(w, hh, s)
    local sw = statusWidth(w)
    local p = ListData.seriesProgress(s)
    local right
    if p.count > 1 and (p.finished > 0 or p.reading > 0) then
        local label, strong = ListData.seriesBarLabel(p)
        right = vstack({ w = sw, align = "right" }, {
            seriesBar(math.min(sw, px(64)), p.states),
            space(nil, px(6)),
            text(label, { s = 12, b = strong, col = strong and INK.black or INK.g4, w = sw, align = "right" }),
        })
    elseif p.count == 1 and s.previewBooks and s.previewBooks[1] and ListData.stateOf(s.previewBooks[1]) ~= "unread" then
        right = statusCell(s.previewBooks[1], sw)
    end
    local tw = w - VW() - px(12) - (right and (sw + px(10)) or 0)
    local who = ListData.seriesWho(s)
    local second = (who and (who .. " · ") or "") .. ListData.seriesCounts(s)
    return hstack({ w = w, h = hh - 1 }, { pictureCol(hh, stackFit(s.previewBooks or {}, VW(), hh - px(14))), space(px(12), 1), textCol(tw, s.title or "", second), right and space(px(10), 1) or false, right or false })
end

function ListView.shelfRow(w, hh, s)
    local sw = statusWidth(w)
    local reading = tonumber(s.readingCount) or 0
    local right = reading > 0 and cell(sw, text(tostring(reading), { s = 14, b = true, w = sw, align = "right" }), _("reading")) or nil
    local tw = w - VW() - px(12) - (right and (sw + px(10)) or 0)
    local second = s.count ~= nil and ListData.bookCount(tonumber(s.count) or 0) or _("Updates as books change")
    return hstack({ w = w, h = hh - 1 }, { pictureCol(hh, stackFit(s.previewBooks or {}, VW(), hh - px(14))), space(px(12), 1), textCol(tw, s.title or "", second), right and space(px(10), 1) or false, right or false })
end

-- ------------------------------------------------------------ index lists
-- Entries on hairlines, the group mark set in the margin where its group
-- starts. Wide screens run it in two columns, like a printed index, when
-- each column keeps a readable title (440dp).
function ListView.indexColumns(total_w)
    local W = BoardMetrics.layoutDp(total_w)
    return ((W - 28) / 2 >= 440) and 2 or 1
end

local function gutterMark(gw, label, carried)
    if gw <= 0 then return nil end
    local items = { space(nil, px(9)) }
    if label and label ~= "" then
        table.insert(items, text(label, { s = #label > 1 and 13 or 22, b = true, serif = #label == 1, col = carried and INK.g9 or INK.black, w = gw - px(4), data = true }))
    end
    return vstack({ w = gw }, items)
end

-- o: entries (this page), rows_per_col, row_h, gutter (dp), rail, groupOf,
-- carried (the first entry's group started on an earlier page), row(w, hh,
-- entry, index), action(entry, index), cols, present, letters, group_rules.
function ListView.indexList(w, hh, o)
    local gw = px(o.gutter or 34)
    if o.gutter == 0 then gw = 0 end
    local rail = o.rail
    local total = w - (rail and ListView.RAIL_W() or 0)
    local ncols = o.cols or ListView.indexColumns(total)
    local col_gap = px(28)
    local lw = ncols > 1 and math.floor((total - (ncols - 1) * col_gap) / ncols) or total
    local n, rh = o.rows_per_col, o.row_h
    local columns = {}
    local prev = nil
    local index = 0
    local seen = {}
    for k = 1, ncols do
        local rows = {}
        for i = 1, n do
            index = index + 1
            local entry = o.entries[index]
            if not entry then break end
            local g = o.groupOf(entry)
            -- A group already labelled on this page joins the previous one.
            if o.first_only and seen[g] and g ~= prev then g = prev end
            seen[g] = true
            local label, carried
            if i == 1 and k == 1 then label = g; carried = o.carried == true
            elseif i == 1 then label = g; carried = (g == prev)
            elseif g ~= prev then label = g end
            local new_group = i > 1 and label ~= nil and o.group_rules ~= false and g ~= prev
            prev = g
            local body = o.row(lw - gw, rh, entry, index)
            local top = i > 1 and (new_group and BoardUI.rect(lw - gw, line1(), INK.black) or BoardUI.rect(lw - gw, hair(), INK.gc)) or nil
            local inner = vstack({ w = lw - gw, h = rh }, { top or false, flex(0), body, flex(0) })
            local r = region(inner, o.action(entry, index), "row-" .. ((k - 1) * n + i), "row-" .. index)
            table.insert(rows, hstack({ w = lw, h = rh, valign = "top" }, { gutterMark(gw, label, carried) or false, r }))
        end
        columns[k] = vstack({ w = lw }, rows)
    end
    local list = hstack({ w = total, valign = "top", gap = col_gap }, columns)
    local items = { list }
    if rail then
        table.insert(items, flex(0))
        table.insert(items, ListView.thumbIndex(hh, o.letters or {}, o.present or {}))
    end
    return hstack({ w = w, h = hh, valign = "top" }, items)
end

-- ------------------------------------------------------------------ grids
local function CAP() return px(8) + lh1(13) + px(1) + lh1(11) end
ListView.CAP = CAP

function ListView.gridCols(gw_px)
    local gw = BoardMetrics.layoutDp(gw_px)
    return gw >= 860 and 6 or gw >= 640 and 5 or gw >= 440 and 4 or 3
end

-- Columns by width, rows by height: covers grow until the page is full, and
-- a row that would leave half a cell of air takes one more, smaller row.
function ListView.gridGeom(w, hh, o)
    o = o or {}
    local cap = o.cap or CAP()
    local cols = o.cols or ListView.gridCols(w)
    local gx, gy = px(o.gx or 14), px(o.gy or 18)
    local cw = math.floor((w - (cols - 1) * gx) / cols)
    local aspect = o.aspect or 1.5
    local cover_h = math.floor(cw * aspect + 0.5)
    local rows = math.max(1, math.floor((hh + gy) / (cover_h + cap + gy)))
    if hh - rows * (cover_h + cap + gy) > (cover_h + cap) * 0.42 then
        rows = rows + 1
        cover_h = math.floor((hh + gy) / rows - cap - gy)
    else
        cover_h = math.min(math.floor(cw * aspect + 0.5), math.floor((hh + gy) / rows - cap - gy))
    end
    local cover_w = math.min(cw, math.floor(cover_h / aspect + 0.5))
    return { cols = cols, rows = rows, cw = cw, gx = gx, gy = gy, cover_h = cover_h, cover_w = cover_w, per = cols * rows }
end

-- The All books cover size on this screen; every grid of books uses it, so a
-- cover is one size everywhere.
function ListView.stdGeom(screen_w, screen_h)
    local f = ListView.frame(screen_w, screen_h, { toolbar = true })
    local gw = f.w - ListView.RAIL_W()
    return ListView.gridGeom(gw, f.content_h - px(6), { cols = ListView.gridCols(gw) })
end

-- n books at the standard size in a box w wide; covers shrink only when the
-- box cannot hold their rows.
function ListView.fitStd(screen_w, screen_h, w, hh, n)
    local std = ListView.stdGeom(screen_w, screen_h)
    local cols = ListView.gridCols(w)
    local gx, gy = px(14), px(18)
    local cw = math.floor((w - (cols - 1) * gx) / cols)
    local cover_w = math.min(cw, std.cover_w)
    local cover_h = math.floor(cover_w * 1.5 + 0.5)
    local rows = math.max(1, math.ceil(n / cols))
    local function need(ch) return rows * (ch + CAP()) + (rows - 1) * gy end
    if hh and need(cover_h) > hh then
        cover_h = math.max(px(60), math.floor((hh - (rows - 1) * gy) / rows - CAP()))
        cover_w = math.floor(cover_h / 1.5 + 0.5)
    end
    return { cols = cols, cw = cw, gx = gx, gy = gy, cover_w = cover_w, cover_h = cover_h, rows = rows, per = cols * rows, h = need(cover_h) }
end

-- The bold title and the gray line under a cover. The gray line drops its
-- second part before it would be cut; the device mark sits at its end unless
-- the line already says time left (which only this device knows).
local function caption(w, title, meta, o)
    o = o or {}
    local dev_w = px(13) + px(6)
    local dev = o.device
    local fits = function(m, room) return runsWidth(m, { s = 11 }) <= room end
    local m = meta
    if dev and o.implied and fits(meta, w) then dev = false
    elseif #m > 1 and not fits(m, w - (dev and dev_w or 0)) then m = { m[1] } end
    local dev_icon = dev and icon("bo-device", px(13), { sw = 2.25, col = "g3" }) or nil
    local styled = {}
    for _i, part in ipairs(m) do table.insert(styled, { t = part.t, b = part.b, col = part.b and INK.black or INK.g4 }) end
    local line
    if o.center then
        line = runs(styled, { s = 11, col = INK.g4, w = w, align = "center", data = true })
    else
        line = hstack({ w = w, gap = px(6) }, { runs(styled, { s = 11, col = INK.g4, w = w - (dev_icon and dev_w or 0), data = true }), dev_icon or false })
    end
    return vstack({ w = w }, { text(title, { s = 13, b = true, w = w, align = o.center and "center" or "left", data = true }), space(nil, px(1)), line })
end

function ListView.bookCell(g, b, o)
    o = o or {}
    local cv = markedCover(b, g.cover_w, g.cover_h, { select = o.select, selected = o.selected })
    local minutes = env and env.minutes_left and env.minutes_left(b)
    local meta = o.meta and o.meta(b) or ListData.bookMeta(b, { minutes_left = minutes, now = env and env.now, author = o.author })
    local on_device = env and env.on_device and env.on_device(b)
    local inner = vstack({ w = g.cover_w }, { cv, space(nil, px(8)), caption(g.cover_w, o.title and o.title(b) or b.title or "", meta, { device = on_device, implied = minutes ~= nil and not o.meta }) })
    return hstack({ w = g.cw }, { flex(0), inner, flex(0) })
end

function ListView.seriesCell(g, s)
    local p = ListData.seriesProgress(s)
    local previews = s.previewBooks or {}
    local box
    if (tonumber(s.count) or #previews) < 2 then
        box = previews[1] and markedCover(previews[1], g.cover_w, g.cover_h) or cover(nil, g.cover_w, g.cover_h)
    else
        -- The next books sit behind the first as neat sheets, up and right.
        local off = math.max(2, math.floor(g.cover_w * 0.05 + 0.5))
        local fw, fh = g.cover_w - 2 * off, g.cover_h - 2 * off
        local ring = math.max(1, px(1.25))
        local layers = {}
        for i = math.min(3, #previews), 1, -1 do
            local w
            if i == 1 then
                local front = { { widget = cover(previews[1], fw, fh) } }
                if not p.all_done and (p.finished > 0 or p.reading > 0) then
                    local st, sh = seriesStrip(fw, p.states)
                    table.insert(front, { widget = st, x = 0, y = fh - sh })
                end
                if p.all_done then
                    local m, S = readMark(fw)
                    table.insert(front, { widget = m, x = fw - S, y = 0 })
                end
                w = BoardUI.overlay(fw, fh, front)
            else
                w = cover(previews[i], fw, fh)
            end
            table.insert(layers, { widget = w, x = (i - 1) * off, y = (3 - i) * off, ring = ring })
        end
        box = layered(g.cover_w, g.cover_h, layers)
    end
    local inner = vstack({ w = g.cover_w }, { box, space(nil, px(8)), caption(g.cover_w, s.title or "", ListData.seriesMeta(s)) })
    return hstack({ w = g.cw }, { flex(0), inner, flex(0) })
end

function ListView.authorCell(g, a)
    local side = g.cover_w
    local pic = photo(a, side)
    if not pic then
        local books = a.previewBooks or {}
        local n = math.min(3, #books)
        local cw = math.floor(side * 2 / 3 + 0.5)
        local step = n > 1 and ((side - cw) / (n - 1) / cw) or 0.42
        pic = fan(books, side, { step = step, shrink = 0.06 })
    end
    local variants = ListData.authorMetaVariants(a)
    local meta = variants[#variants]
    for _i, v in ipairs(variants) do
        if runsWidth(v, { s = 11 }) <= side then meta = v; break end
    end
    local inner = vstack({ w = side, align = "center" }, { hstack({ w = side, h = side, valign = "bottom" }, { flex(0), pic, flex(0) }), space(nil, px(8)), caption(side, a.title or "", meta, { center = true }) })
    return hstack({ w = g.cw }, { flex(0), inner, flex(0) })
end

-- Cells in rows; o.action(entry, index) makes each a region.
function ListView.cellsGrid(w, g, entries, build, action)
    local rows = {}
    local index = 0
    for start = 1, #entries, g.cols do
        local cells = {}
        for i = start, math.min(#entries, start + g.cols - 1) do
            index = index + 1
            table.insert(cells, region(build(entries[i], i), action(entries[i], i), "grid-" .. math.ceil(i / g.cols), "cell-" .. i))
        end
        if #rows > 0 then table.insert(rows, space(nil, g.gy)) end
        table.insert(rows, hstack({ gap = g.gx, valign = "top" }, cells))
    end
    return vstack({ w = w }, rows)
end

function ListView.withRail(w, hh, body, letters, present)
    return hstack({ w = w, h = hh, valign = "top" }, { body, flex(0), ListView.thumbIndex(hh, letters or {}, present or {}) })
end

-- --------------------------------------------------------- section heads
function ListView.sectionHead(w, o)
    local items = {}
    if o.icon then table.insert(items, icon(o.icon, px(16), { sw = 2.25 })) end
    table.insert(items, text(o.label, { s = 14, b = true, data = true }))
    if o.count then table.insert(items, text(tostring(o.count), { s = 13, col = INK.g4 })) end
    local left = hstack({ gap = px(8), valign = "bottom" }, items)
    local row = hstack({ w = w, h = px(36) - px(6) - line1(), valign = "bottom" }, { left, flex(0), o.right or false })
    return vstack({ w = w }, { row, space(nil, px(6)), BoardUI.rect(w, line1(), INK.black) })
end

-- ------------------------------------------------------------------ pills
-- o.disabled draws any kind as a gray outline: nothing to act on yet.
function ListView.pill(label, o)
    o = o or {}
    local h = px(o.h or 44)
    local kind = o.disabled and "outline" or o.kind
    local dark = kind ~= "outline" and kind ~= "ghost"
    local fg = o.disabled and "gb" or dark and "white" or "black"
    local items = {}
    if o.icon then table.insert(items, icon(o.icon, px(o.icon_size or 18), { col = fg, sw = 2.5 })) end
    table.insert(items, text(label, { s = o.s or 15, b = true, col = o.disabled and INK.gb or dark and INK.white or INK.black }))
    local content = hstack({ gap = px(8) }, items)
    local w = wOf(content) + 2 * px(o.pad_x or 18)
    local border = kind == "outline" and math.max(1, px(1.5)) or 0
    local body = hstack({ w = w - 2 * border, h = h - 2 * border }, { flex(0), content, flex(0) })
    return BoardUI.box({ w = w, h = h, radius = math.floor(h / 2), border = border, border_col = o.disabled and INK.gb or INK.black, bg = dark and INK.black or INK.white }, body)
end

-- --------------------------------------------------------------- states
function ListView.empty(w, hh, o)
    local action = o.action and region(ListView.pill(o.action.label, { kind = "outline", h = 40, s = 14 }), o.action.action, "empty", "empty-action") or nil
    local items = {
        flex(0),
        BoardUI.circle(px(64), { bg = INK.ge }, icon(o.icon, px(28), { sw = 2 })),
        space(nil, px(10) + px(4)),
        text(o.title, { s = 18, b = true, w = math.min(w, px(420)), align = "center" }),
        space(nil, px(10)),
        text(o.body, { s = 14, col = INK.g4, w = math.min(w, px(380)), align = "center", lines = 3 }),
    }
    if action then
        table.insert(items, space(nil, px(10) + px(6)))
        table.insert(items, action)
    end
    table.insert(items, flex(0))
    return vstack({ w = w, h = hh, align = "center" }, items)
end

function ListView.offlineBand(w, note)
    local inner = hstack({ h = px(32), gap = px(8) }, {
        space(px(12) - px(8), 1),
        icon("cloud-off", px(16), { sw = 2.25, col = "g3" }),
        runs({ { t = _("Offline"), b = true, col = INK.black }, { t = " · " .. (note or _("saved list. Books on this device open.")), col = INK.g3 } }, { s = 12, w = w - px(48) }),
    })
    return BoardUI.box({ w = w, h = px(32), radius = px(16), bg = INK.ge }, inner)
end

-- Replaces the pager while books are chosen.
function ListView.selectBar(w, count, actions)
    -- On a narrow screen the buttons lose their icons, then the count its
    -- word, then the buttons tighten, before anything runs off the edge.
    local steps = {
        { icons = true, word = true, s = 14, pad = 14 },
        { icons = false, word = true, s = 14, pad = 14 },
        { icons = false, word = false, s = 14, pad = 12 },
        { icons = false, word = false, s = 13, pad = 10 },
    }
    local function count_label(step)
        return runs({ { t = tostring(count), b = true }, step.word and { t = " " .. _("selected") } or nil }, { s = 15 })
    end
    local function pill(a, step)
        return ListView.pill(a.label, { kind = a.kind or "outline", icon = step.icons and a.icon or nil, h = 40, s = step.s, pad_x = step.pad, disabled = a.disabled })
    end
    local chosen = steps[#steps]
    for _i, step in ipairs(steps) do
        local need = wOf(count_label(step)) + px(8) + (#actions - 1) * px(8)
        for _j, a in ipairs(actions) do need = need + wOf(pill(a, step)) end
        if need <= w then chosen = step; break end
    end
    local items = { count_label(chosen), flex(px(8)) }
    for i, a in ipairs(actions) do
        if i > 1 then table.insert(items, space(px(8), 1)) end
        local p = pill(a, chosen)
        table.insert(items, a.disabled and p or region(p, a.action, "select-bar", "select-" .. i))
    end
    return mark("pager", hstack({ w = w, h = px(48) }, items))
end

-- ------------------------------------------------------------------ sheets
-- Lightens what is under a sheet.
local Scrim = Widget:extend{}
function Scrim:getSize() return self.dimen end
function Scrim:paintTo(bb, x, y)
    self.dimen.x, self.dimen.y = x, y
    bb:lightenRect(x, y, self.dimen.w, self.dimen.h, 0.6)
end
ListView.Scrim = Scrim

-- A segmented control whose segments are regions.
function ListView.segmented(w, options, selected, action_for, o)
    o = o or {}
    local h = px(o.h or 40)
    local b = math.max(1, px(1.5))
    local n = #options
    local inner = w - 2 * b
    local seg_w = math.floor(inner / n)
    local segs = {}
    for index, label in ipairs(options) do
        local sw = index == n and (inner - seg_w * (n - 1)) or seg_w
        local on = index == selected
        local t = text(label, { s = o.s or 13, b = on, col = on and INK.white or INK.black, w = sw - px(8), align = "center" })
        local body = hstack({ w = sw, h = h - 2 * b }, { flex(0), t, flex(0) })
        local seg
        if on then
            seg = BoardUI.box({ w = sw, h = h - 2 * b, radius = px(8), bg = INK.black }, body)
        else
            local left_line = index > 1 and selected ~= index - 1
            seg = BoardUI.overlay(sw, h - 2 * b, { { widget = body }, left_line and { widget = BoardUI.rect(hair(), h - 2 * b, INK.g8) } or {} })
        end
        table.insert(segs, region(seg, action_for(index), o.row or ("seg-" .. tostring(options[1])), o.key and (o.key .. "-" .. index) or nil))
    end
    return BoardUI.box({ w = w, h = h, radius = px(10), border = b, border_col = INK.black, bg = INK.white }, hstack({ w = inner, h = h - 2 * b }, segs))
end

-- A sheet head: title and a control on the right.
local function sheetHead(w, title, right)
    local h = math.max(px(32), right and hOf(right) or 0)
    return hstack({ w = w, h = h }, { text(title, { s = 18, b = true }), flex(px(8)), right or false })
end
local function sheetLabel(w, t) return text(t, { s = 13, b = true, col = INK.g4, w = w }) end
ListView.sheetHead, ListView.sheetLabel = sheetHead, sheetLabel

-- A row inside a sheet's group card.
local function groupRow(w, o)
    local trailing = {}
    if o.value then table.insert(trailing, text(o.value, { s = 14, col = INK.g4, data = true })) end
    if o.toggle ~= nil then table.insert(trailing, MenuUI.toggle(o.toggle)) end
    if o.radio ~= nil then table.insert(trailing, MenuUI.radio(o.radio)) end
    if o.chevron then table.insert(trailing, icon("chevron-right", px(18), { sw = 2.25 })) end
    local right = #trailing > 0 and hstack({ gap = px(10) }, trailing) or nil
    local icon_w = o.icon and (px(24) + px(14)) or 0
    local right_w = right and (wOf(right) + px(12)) or 0
    local items = {}
    local s = o.s or 15
    local label_w = w - icon_w - right_w
    local label_s = BoardUI.fitSize(o.label, { s = s, b = o.bold }, label_w, s - 2)
    -- A label too long even two sizes down takes a second line, and the
    -- row grows to hold it.
    local lines = BoardUI.fitSize(o.label, { s = s - 2, b = o.bold }, label_w, s - 3) < s - 2 and 2 or nil
    local label = text(o.label, { s = label_s, b = o.bold, w = label_w, lines = lines })
    local h = math.max(px(o.h or 50), label:getSize().h + px(12))
    if o.icon then
        table.insert(items, hstack({ w = px(24), h = h }, { flex(0), icon(o.icon, px(22), { sw = 2 }), flex(0) }))
        table.insert(items, space(px(14), 1))
    end
    table.insert(items, label)
    if right then table.insert(items, flex(px(12))); table.insert(items, right) end
    return region(hstack({ w = w, h = h }, items), o.action, o.row or ("sheet-row-" .. o.label), o.key)
end
ListView.groupRow = groupRow

local function groupCard(w, rows)
    local iw = w - 2 * px(16)
    local children = {}
    for _i, r in ipairs(rows) do
        if #children > 0 then table.insert(children, BoardUI.rect(iw, hair(), INK.gb)) end
        table.insert(children, r)
    end
    return MenuUI.card(w, children)
end
ListView.groupCard = groupCard

-- The sheet's frame: a top sheet (from the toolbar) or a bottom sheet (from
-- the pager); a popover on wide screens.
-- o: kind ("top"/"bottom"), pop_w (dp), top (px, popover), build(inner_w).
function ListView.sheet(screen_w, screen_h, o)
    local mx = ListView.mx()
    local pad = px(16)
    local wide = screenW() >= 760
    local pw = wide and math.min(px(o.pop_w or 460), screen_w - 2 * mx) or screen_w
    local pad_x = wide and pad or mx
    local inner = pw - 2 * pad_x
    -- The tallest body that fits between the header and the bottom edge.
    local header_bottom = px(6) + px(64)
    local max_body = screen_h - header_bottom - px(8) - 2 * pad
    local gap = px(14)
    local function stack(items, width)
        local spaced = {}
        for _i, item in ipairs(items) do
            if item then
                if #spaced > 0 then table.insert(spaced, space(nil, gap)) end
                table.insert(spaced, item)
            end
        end
        return vstack({ w = width }, spaced)
    end
    -- What the build registers, so a draft can be taken back.
    local hit_mark = BoardUI.hitMark()
    local rows_mark = {}
    if env then for key, list in pairs(env.rows) do rows_mark[key] = #list end end
    local row_list_mark = env and #env.row_list or 0
    local body = stack(o.build(inner, max_body), inner)
    local function takeBack()
        BoardUI.hitTrim(hit_mark)
        if env then
            for key, list in pairs(env.rows) do
                for i = #list, (rows_mark[key] or 0) + 1, -1 do table.remove(list, i) end
                if #list == 0 and not rows_mark[key] then env.rows[key] = nil; env.row_seen[key] = nil end
            end
            for i = #env.row_list, row_list_mark + 1, -1 do table.remove(env.row_list, i) end
        end
    end
    local col_gap = px(24)
    local pw2 = math.min(2 * px(o.pop_w or 460) + col_gap, screen_w - 2 * mx)
    local col_w = math.floor((pw2 - 2 * pad - col_gap) / 2)
    -- The two columns, split where they come out most even; nil when
    -- something in them would be cut.
    local function columns(checked)
        local function draft()
            local items = {}
            for _i, item in ipairs(o.build(col_w, max_body)) do if item then table.insert(items, item) end end
            local heights, total = {}, 0
            for i, item in ipairs(items) do heights[i] = hOf(item); total = total + heights[i] end
            local best, best_h, run = #items, math.huge, 0
            for k = 1, #items - 1 do
                run = run + heights[k]
                local tallest = math.max(run + (k - 1) * gap, total - run + (#items - k - 1) * gap)
                if tallest < best_h then best, best_h = k, tallest end
            end
            local left, right = {}, {}
            for i, item in ipairs(items) do table.insert(i <= best and left or right, item) end
            return hstack({ w = pw2 - 2 * pad, gap = col_gap, valign = "top" }, { stack(left, col_w), stack(right, col_w) })
        end
        if checked then
            local clean, probe = BoardUI.fits(function() return BoardUI.collectHits(draft) end)
            if probe and probe.free then probe:free() end
            if not clean then return nil end
        end
        return draft()
    end
    -- A wide, short screen (landscape) sets a sheet too tall for it in two
    -- columns. Narrower columns are tried too when one column would not fit
    -- even raised to the header, as long as nothing in them is cut.
    local raised_max = screen_h - px(8) - header_bottom - 2 * pad
    if wide and hOf(body) > max_body and (col_w >= px(300) or (hOf(body) > raised_max and col_w >= px(220))) then
        body:free()
        takeBack()
        local split = columns(col_w < px(300))
        if split then
            pw, inner = pw2, pw2 - 2 * pad
            body = split
        else
            body = stack(o.build(inner, max_body), inner)
        end
    end
    local line = math.max(1, px(1.5))
    local r = px(16)
    local layers, x, y, ph
    if wide then
        ph = hOf(body) + 2 * pad
        local content = vstack({ w = pw - 2 * line, pad = { pad - line, pad - line, pad - line, pad - line } }, { body })
        local panel = BoardUI.box({ w = pw, h = ph, radius = r, border = line, border_col = INK.black, bg = INK.white }, content)
        x = o.kind == "top" and (screen_w - mx - pw) or math.floor((screen_w - pw) / 2)
        y = o.kind == "top" and (o.top or px(112)) or (screen_h - px(56) - ph)
        -- On a short screen a tall sheet rises toward the header rather than
        -- run off the bottom, and over it when it must.
        if y + ph > screen_h - px(8) then
            local lowest = screen_h - px(8) - ph
            y = lowest >= header_bottom and lowest or math.max(px(8), lowest)
        end
        layers = { { widget = panel, x = x, y = y } }
    else
        local pad_t = o.kind == "top" and px(10) or pad
        local pad_b = o.kind == "top" and pad or px(14)
        ph = pad_t + hOf(body) + pad_b
        local content = vstack({ w = pw, pad = { pad_t, pad_x, pad_b, pad_x } }, { body })
        x = 0
        if o.kind == "top" then
            y = 0
            -- Square at the top edge, rounded at the bottom with a black rule along it.
            layers = {
                { widget = BoardUI.rect(pw, ph + line, INK.black, r), x = 0, y = 0 },
                { widget = BoardUI.rect(pw, r, INK.black), x = 0, y = 0 },
                { widget = BoardUI.rect(pw, ph, INK.white, r), x = 0, y = 0 },
                { widget = BoardUI.rect(pw, r, INK.white), x = 0, y = 0 },
                { widget = content, x = 0, y = 0 },
            }
        else
            y = screen_h - ph
            layers = {
                { widget = BoardUI.rect(pw, ph + line, INK.black, r), x = 0, y = y - line },
                { widget = BoardUI.rect(pw, r, INK.black), x = 0, y = screen_h - r },
                { widget = BoardUI.rect(pw, ph, INK.white, r), x = 0, y = y },
                { widget = BoardUI.rect(pw, r, INK.white), x = 0, y = screen_h - r },
                { widget = content, x = 0, y = y },
            }
        end
    end
    return layers, { x = x, y = y, w = pw, h = ph }
end

-- Every letter as a key; letters with nothing in the list are drawn but
-- cannot be pressed. The current page's letters are inked.
function ListView.jumpKeys(w, present, current)
    local keys = { "#" }
    for c = string.byte("A"), string.byte("Z") do table.insert(keys, string.char(c)) end
    local cols = w >= BoardMetrics.lpx(560) and 9 or 7
    local gap = px(6)
    local kw = math.floor((w - (cols - 1) * gap) / cols)
    local kh = px(44)
    local cur = {}
    for _i, l in ipairs(current or {}) do cur[l] = true end
    local rows = {}
    for start = 1, #keys, cols do
        local cells = {}
        for i = start, math.min(#keys, start + cols - 1) do
            local k = keys[i]
            local has = present[k] ~= nil
            local here = cur[k]
            local label = text(k, { s = 17, b = true, col = here and INK.white or has and INK.black or INK.ga, w = kw, align = "center" })
            local body = hstack({ w = kw, h = kh }, { flex(0), label, flex(0) })
            local key
            if here then key = BoardUI.box({ w = kw, h = kh, radius = px(10), bg = INK.black }, body)
            elseif has then key = BoardUI.box({ w = kw, h = kh, radius = px(10), border = line1(), border_col = INK.g8, bg = INK.white }, hstack({ w = kw - 2 * line1(), h = kh - 2 * line1() }, { flex(0), label, flex(0) }))
            else key = BoardUI.box({ w = kw, h = kh, radius = px(10), bg = INK.ge }, body) end
            table.insert(cells, has and region(key, { kind = "jump-letter", letter = k, close = true }, "jump-row-" .. math.ceil(i / cols), "key-" .. k) or key)
        end
        if #rows > 0 then table.insert(rows, space(nil, gap)) end
        table.insert(rows, hstack({ gap = gap }, cells))
    end
    return vstack({ w = w }, rows)
end

function ListView.pageStepper(w, page, pages)
    local s = px(36)
    local function btn(name, delta, on)
        local c = BoardUI.circle(s, { border = math.max(1, px(1.5)), border_col = on and INK.black or INK.gb, bg = INK.white }, icon(name, px(16), { sw = 2.75, col = on and "black" or "gb" }))
        return on and region(c, { kind = "page", delta = delta }, "jump-stepper", "step-" .. (delta < 0 and "minus" or "plus")) or c
    end
    local mid = vstack({ w = px(52), align = "center" }, { text(tostring(page), { s = 18, b = true, w = px(52), align = "center" }), text(T(_("of %1"), pages), { s = 12, col = INK.g4, w = px(52), align = "center" }) })
    local stepper = hstack({ gap = px(10) }, { btn("minus", -1, page > 1), mid, btn("plus", 1, page < pages) })
    local first = region(ListView.pill(_("First"), { kind = "outline", icon = "chevrons-left", h = 40, s = 14, pad_x = 14 }), { kind = "page-to", page = 1, close = true }, "jump-stepper", "first")
    local last = region(ListView.pill(_("Last"), { kind = "outline", icon = "chevrons-right", h = 40, s = 14, pad_x = 14 }), { kind = "page-to", page = pages, close = true }, "jump-stepper", "last")
    return hstack({ w = w }, { first, flex(0), stepper, flex(0), last })
end

-- ------------------------------------------------------------- the screen
-- Builds one page. ctx: screen_w, screen_h, run, marks, env (thumb, photo,
-- on_device, minutes_left, offline, now), header ({ title, sub, title_data,
-- select, clock }), toolbar ({ buttons, view }), body(f) -> widget, pager
-- ({ page, pages, range, jump, has_prev, has_next }) or select_bar, sheet
-- (function(screen_w, screen_h) -> layers).
-- Returns the screen, the focus rows (top to bottom) and regions by key.
function ListView.build(ctx)
    BoardMetrics.refresh()
    local screen_w, screen_h = ctx.screen_w, ctx.screen_h
    env = {
        rows = {}, row_seen = {}, row_list = {}, regions = {}, run = ctx.run, marks = ctx.marks,
        thumb = ctx.env and ctx.env.thumb, photo = ctx.env and ctx.env.photo, on_device = ctx.env and ctx.env.on_device,
        minutes_left = ctx.env and ctx.env.minutes_left, offline = ctx.env and ctx.env.offline, now = ctx.env and ctx.env.now,
    }
    local ok, result = pcall(function()
        local f = ListView.frame(screen_w, screen_h, { toolbar = ctx.toolbar ~= nil, offline = ctx.offline ~= nil })
        local items = { mark("header", ListView.header(f.w, ctx.header)) }
        if ctx.toolbar then table.insert(items, ListView.toolbar(f.w, ctx.toolbar.buttons, ctx.toolbar.view)) end
        if ctx.offline then
            table.insert(items, space(nil, px(4)))
            local band = ListView.offlineBand(f.w, ctx.offline.note)
            table.insert(items, ctx.offline.action and region(band, ctx.offline.action, "offline", "offline-band") or band)
            table.insert(items, space(nil, px(4)))
        end
        table.insert(items, mark("content", ctx.body(f)))
        table.insert(items, flex(px(8)))
        if ctx.select_bar then
            table.insert(items, ListView.selectBar(f.w, ctx.select_bar.count, ctx.select_bar.actions))
        else
            table.insert(items, ListView.pager(f.w, ctx.pager or { page = 1, pages = 1 }))
        end
        local screen = vstack({ w = screen_w, h = screen_h, pad = { f.top, f.mx, f.bottom, f.mx } }, items)
        local layers = { { widget = screen } }
        if ctx.sheet then
            -- With a sheet open, a tap anywhere outside it closes it and reaches
            -- nothing underneath.
            table.insert(layers, { widget = region(space(screen_w, screen_h), { kind = "close-sheet" }, nil, "outside") })
            table.insert(layers, { widget = Scrim:new{ dimen = Geom:new{ w = screen_w, h = screen_h } } })
            local sheet_layers, rect = ctx.sheet(screen_w, screen_h)
            -- The sheet itself swallows taps that land between its controls.
            table.insert(layers, { widget = region(space(rect.w, rect.h), { kind = "noop" }, nil, "sheet"), x = rect.x, y = rect.y })
            for _i, l in ipairs(sheet_layers) do table.insert(layers, l) end
        end
        return BoardUI.overlay(screen_w, screen_h, layers)
    end)
    local out = env
    env = nil
    if not ok then error(result, 0) end
    local rows = {}
    for _i, key in ipairs(out.row_list) do
        local list = out.rows[key]
        if list and #list > 0 then list.id = key; table.insert(rows, list) end
    end
    return result, rows, out.regions
end

-- For pieces built outside a page build (search blocks, the series hero).
ListView.region = function(...) return region(...) end
ListView.cover = function(...) return cover(...) end
ListView.fan = function(...) return fan(...) end
ListView.layered = layered
ListView.bar = bar
ListView.doneDot = doneDot
ListView.textWidth = textWidth
ListView.runsWidth = runsWidth
ListView.caption = function(...) return caption(...) end
ListView.checkbox = checkbox
ListView.seriesBar = seriesBar
ListView.mark = function(...) return mark(...) end
ListView.rule = rule

return ListView