koreader-plugin/bookorbit.koplugin/bookorbit_list_pages.lua
Rama main · réplica de lectura del repositorio original.
--[[--
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