koreader-plugin/bookorbit.koplugin/bookorbit_catalog_board.lua
Rama main · réplica de lectura del repositorio original.
--[[--
The widget dashboard, installed over the catalog's dashboard view.
Draws the board for the current page: a header (greeting, date, device
status, search and menu), the packed grid of widget tiles, and a footer that
moves between the pages. Swipes and page-turn keys flip pages too.
Every tile is one InputContainer, so a change refreshes only that tile's
rectangle. Widgets record the regions a tap can land on while they paint, so
a tap goes to exactly what was drawn there: a cover, a stepper, a button. A
D-pad press is a tap at the focused tile's centre, which lands on the tile's
own action.
]]
local Device = require("device")
local Event = require("ui/event")
local Geom = require("ui/geometry")
local GestureRange = require("ui/gesturerange")
local InputContainer = require("ui/widget/container/inputcontainer")
local NetworkMgr = require("ui/network/manager")
local UIManager = require("ui/uimanager")
local VerticalSpan = require("ui/widget/verticalspan")
local logger = require("logger")
local T = require("ffi/util").template
local _ = require("gettext")
local BoardColour = require("bookorbit_board_colour")
local BoardData = require("bookorbit_board_data")
local BoardEdit = require("bookorbit_board_edit")
local BoardLayout = require("bookorbit_board_layout")
local BoardMetrics = require("bookorbit_board_metrics")
local BoardStore = require("bookorbit_board_store")
local BoardUI = require("bookorbit_board_ui")
local BoardWidgets = require("bookorbit_board_widgets")
local CatalogWidgets = require("bookorbit_catalog_widgets")
local Screen = Device.screen
local INK = BoardMetrics.INK
local px, lh1 = BoardMetrics.px, BoardMetrics.lh1
local text, runs, vstack, hstack, flex, space, icon = BoardUI.text, BoardUI.runs, BoardUI.vstack, BoardUI.hstack, BoardUI.flex, BoardUI.space, BoardUI.icon
local CatalogBoard = {}
-- --------------------------------------------------------------- tiles
local BoardTile = InputContainer:extend{
item = nil,
menu = nil,
hits = nil,
}
function BoardTile:init()
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 BoardTile:actionAt(pos)
if pos and self.hits then
for index = #self.hits, 1, -1 do
local area = self.hits[index]
if area.dimen and pos:intersectWith(area.dimen) then return area.action end
end
end
return self.item.action
end
function BoardTile:onTapSelect(_, ges)
local action = self:actionAt(ges and ges.pos)
if action then self.menu:boardRun(action, self) end
return true
end
-- Holding a widget starts editing the board with that widget selected.
function BoardTile:onHoldSelect()
if self.item.index and not self.menu:boardEditing() then
self.menu:boardEnterEdit(self.item.index)
end
return true
end
-- A zero-height stand-in for KOReader's title bar while the board is shown:
-- the board draws its own header.
local TitleBarStub = VerticalSpan:extend{ width = 0 }
function TitleBarStub:getHeight() return 0 end
function TitleBarStub:setTitle() end
function TitleBarStub:setSubTitle() end
function TitleBarStub:setLeftIcon() end
function TitleBarStub:generateVerticalLayout() return {} end
function TitleBarStub:generateHorizontalLayout() return {} end
function CatalogBoard:boardTitleBar()
return TitleBarStub:new{}
end
-- -------------------------------------------------------------- context
function CatalogBoard:boardHasLight()
return Device:hasFrontlight()
end
function CatalogBoard:boardLandscape()
local m = BoardMetrics.get()
return m.w > m.h
end
function CatalogBoard:boardPages(cols)
return BoardStore.pages(self.settings, cols, self:boardHasLight(), self:boardLandscape())
end
-- The names of the pages on screen, in order: a Stats or Library starter puts
-- a different page first.
function CatalogBoard:boardPageNames(cols)
if self.board_edit then return BoardStore.pageNames(self.board_edit.pages) end
return BoardStore.pageNames(self:boardPages(cols))
end
-- The page shown, kept on the dashboard context so going into a book and
-- back returns to the same page.
function CatalogBoard:boardPage()
local context = self.current_context or {}
local page = tonumber(context.board_page) or 1
return math.max(1, math.min(BoardStore.PAGE_COUNT, page))
end
function CatalogBoard:boardMd5(book)
local path = self:dashboardResumePath(book)
if not path then return nil end
self._board_md5 = self._board_md5 or {}
local md5 = self._board_md5[path]
if md5 == nil then
local ok, util = pcall(require, "util")
md5 = ok and util.partialMD5 and util.partialMD5(path) or false
self._board_md5[path] = md5
end
return md5 or nil
end
CatalogBoard.boardBookMd5 = CatalogBoard.boardMd5
-- --------------------------------------------------------------- render
function CatalogBoard:recalculateDashboardDimen()
self.perpage = 1
self.page = 1
self.page_num = 1
local top_height, bottom_height = self:menuChromeHeight()
self.available_height = self.inner_dimen.h - top_height - bottom_height
self.item_dimen = Geom:new{ x = 0, y = 0, w = self.inner_dimen.w, h = self.available_height }
end
local statusCluster = BoardUI.statusCluster
function CatalogBoard:boardHeader(d, w, mode, page_name)
local box = px(44)
local tap_pad = math.floor((box - px(24)) / 2)
local function iconButton(name, action, nudge)
local btn = hstack({ w = box, h = box }, { flex(0), icon(name, px(23), { sw = 2 }), flex(0) })
return BoardUI.hit(btn, action), nudge
end
if mode == "edit" then
local done_inner = hstack({ h = px(40) }, { space(px(20), 1), text(_("Done"), { s = 15, b = true, col = INK.white }), space(px(20), 1) })
local done = BoardUI.hit(BoardUI.box({ w = done_inner:getSize().w, h = px(40), radius = px(20), bg = INK.black }, done_inner), { kind = "edit-done" })
local tw = w - done:getSize().w - px(12)
local edit_page = self.board_edit and self.board_edit.pages[self:boardPage()]
local hidden = edit_page and edit_page.hidden and #edit_page.hidden or 0
local hint = hidden > 0
and T(_.ngettext("%1 widget does not fit at this interface size", "%1 widgets do not fit at this interface size", hidden), hidden)
or _("Tap a widget to move, resize or remove it")
return hstack({ w = w, h = px(64) }, {
vstack({ w = tw }, {
text(T(_("Edit %1"), page_name), { s = 22, b = true, w = tw }),
text(hint, { s = 13, col = INK.g4, w = tw }),
}),
space(px(12), 1),
done,
})
end
local status = statusCluster(d)
local search = iconButton("search", { kind = "search" })
local menu = iconButton("menu", { kind = "menu" })
local right_w = status:getSize().w + px(6) + 2 * box - tap_pad
local tw = w - right_w
local function fits(parts) return runs(parts, { s = 13 }):getSize().w <= tw end
local parts
if d.online or not d.stale then
parts = { { t = d.date_long } }
else
-- Offline: the date gives way first, then when it was saved.
local offline = { t = _("Offline"), b = true, col = INK.black }
local saved = { t = d.updated and (", " .. T(_("saved %1"), d.updated)) or "" }
parts = { { t = d.date_long .. " · " }, offline, saved }
if not fits(parts) then parts = { offline, saved } end
if not fits(parts) then parts = { offline } end
end
local sub = runs(parts, { s = 13, col = INK.g4, w = tw })
-- A long greeting steps down to 18 before it is cut.
local greet_s = BoardUI.fitSize(d.greeting, { s = 22, b = true }, tw, 18)
return hstack({ w = w + tap_pad, h = px(64) }, {
vstack({ w = tw }, { text(d.greeting, { s = greet_s, b = true, w = tw }), sub }),
status,
space(px(6), 1),
search,
menu,
})
end
function CatalogBoard:boardFooter(w, page, mode)
local fh = px(40)
local names = self:boardPageNames(self._board_geometry and self._board_geometry.cols or BoardLayout.columnsFor(BoardMetrics.layoutW()))
local function chev(name, enabled, delta)
local btn = hstack({ w = px(44), h = fh }, { flex(0), icon(name, px(20), { sw = 2.5, col = enabled and "black" or "gb" }), flex(0) })
return BoardUI.hit(btn, enabled and { kind = "page", delta = delta } or { kind = "noop" })
end
local dot_items = {}
for index = 1, BoardStore.PAGE_COUNT do
table.insert(dot_items, BoardUI.dot(px(7), index == page, { off = INK.g8 }))
end
local dots = hstack({ gap = px(6) }, dot_items)
if mode == "edit" then
local add_inner = hstack({ h = px(38), gap = px(8) }, { space(px(6), 1), icon("plus", px(17), { col = "white", sw = 2.75 }), text(_("Add widget"), { s = 14, b = true, col = INK.white }), space(px(10), 1) })
local add = BoardUI.hit(BoardUI.box({ w = add_inner:getSize().w, h = px(38), radius = px(19), bg = INK.black }, add_inner), { kind = "edit-add" })
local side = px(120)
return hstack({ w = w, h = fh }, {
hstack({ w = side, gap = px(8) }, { dots, text(names[page], { s = 13, b = true }), flex(0) }),
flex(0), add, flex(0),
BoardUI.hit(hstack({ w = side, gap = px(4) }, { flex(0), icon("layout-grid", px(16), { sw = 2.25 }), text(_("Pages"), { s = 13, b = true }) }), { kind = "edit-pages" }),
})
end
return hstack({ w = w, h = fh }, {
flex(0),
chev("chevron-left", page > 1, -1), space(px(6), 1),
dots, space(px(6), 1), space(px(4), 1), space(px(6), 1),
text(names[page], { s = 13, b = true }), space(px(6), 1),
chev("chevron-right", page < BoardStore.PAGE_COUNT, 1),
flex(0),
})
end
-- Default tap action for each widget type. Widgets that draw their own
-- buttons (Resume, steppers, covers) route those taps themselves.
function CatalogBoard:boardItemAction(item, d)
local t = item.type
local lead = d.continue_reading[1]
local tap_resumes = self.settings.catalog_dashboard_tap_resumes ~= false
if t == "hero" or t == "forecast" then
if lead then return { kind = "book", book = lead, resume = tap_resumes, list = { label = _("Continue reading"), books = d.continue_reading } } end
return { kind = "books", params = { sort = "title" }, title = _("All books") }
elseif t == "also" or t == "goalpace" then
return { kind = "books", params = { sort = "recently_read", readStatus = "reading" }, title = _("In progress") }
elseif t == "highlight" and d.highlight then
return { kind = "highlight", highlight = d.highlight }
elseif t == "shelf" then
local source = item.props and item.props.source
if source == "want-to-read" and not (item.props and item.props.params) then return nil end
return { kind = "shelf", source = source, props = item.props }
elseif t == "link" then
return { kind = "link", link = item.props and item.props.link }
elseif t == "yearago" and d.year_ago and d.year_ago.book then
return { kind = "detail", book_id = d.year_ago.book.id }
elseif t == "word" and d.word then
return { kind = "lookup", word = d.word.word }
elseif t == "sync" then
return { kind = d.online and "sync" or "wifi" }
elseif t == "chapters" and lead then
return { kind = "book", book = lead, resume = tap_resumes, list = { label = _("Continue reading"), books = d.continue_reading } }
elseif t == "dust" then
local dust = d.dust
if dust and dust.book then return { kind = "detail", book_id = dust.book.id } end
end
return nil
end
function CatalogBoard:boardBuildTile(ctx, item, w, h, edit)
local frame, hits = BoardUI.collectHits(function() return BoardWidgets.tile(ctx, item, w, h) end)
return BoardTile:new{
dimen = Geom:new{ w = w, h = h },
item = item,
menu = self,
-- In edit mode a tap selects the widget, whatever is drawn under it.
hits = not edit and hits or nil,
CatalogWidgets.focusable(frame),
}
end
-- A tappable region that is not a widget tile: the header, the footer, the
-- edit bar, a sheet.
function CatalogBoard:boardRegion(build, action)
local widget, hits = BoardUI.collectHits(build)
local size = widget:getSize()
return BoardTile:new{
dimen = Geom:new{ w = size.w, h = size.h },
item = { action = action },
menu = self,
hits = hits,
CatalogWidgets.focusable(widget),
}
end
-- ---------------------------------------------------------- edit overlays
local SIZE_ORDER = { "S", "M", "L", "W", "XL" }
local SIZE_LABELS = { S = "S", M = "M", L = "L", W = function() return _("Wide") end, XL = "XL" }
local function sizeLabel(code)
local l = SIZE_LABELS[code]
return type(l) == "function" and l() or l
end
function CatalogBoard:boardEditBar(item, index, g)
local bar_h = px(52)
local function button(name, label, action)
local inner = vstack({ align = "center" }, {
icon(name, px(19), { col = "white", sw = 2.25 }),
space(nil, px(2)),
text(label, { s = 11, b = true, col = INK.white }),
})
return BoardUI.hit(hstack({ h = bar_h }, { space(px(10), 1), inner, space(px(10), 1) }), action)
end
local function divider()
return BoardUI.rect(math.max(1, px(1)), px(30), INK.g5)
end
local entry = BoardEdit.entryFor(item)
local current = BoardEdit.sizeCode(item, g.cols)
local segs = { text(_("Size"), { s = 11, b = true, col = INK.gb }) }
local offered = {}
for _i, code in ipairs(entry and entry.sizes or { current }) do offered[code] = true end
local seen_dims = {}
for _i, code in ipairs(SIZE_ORDER) do
local cw, rh = BoardLayout.sizeDims(code, g.cols)
local dims = cw .. "x" .. rh
if offered[code] and not seen_dims[dims] then
seen_dims[dims] = true
local on = cw == math.min(item.cw, g.cols) and rh == item.rh
local label = text(sizeLabel(code), { s = 13, b = true, col = on and INK.black or INK.white })
local w = math.max(px(30), label:getSize().w + 2 * px(6))
local seg = BoardUI.box({ w = w, h = px(30), radius = px(6), bg = on and INK.white or nil, border = on and 0 or math.max(1, px(1)), border_col = INK.g8 },
hstack({ w = w - (on and 0 or 2 * math.max(1, px(1))), h = px(30) - (on and 0 or 2 * math.max(1, px(1))) }, { flex(0), label, flex(0) }))
table.insert(segs, BoardUI.hit(seg, { kind = "edit-size", code = code }))
end
end
local inner = hstack({ h = bar_h, gap = px(2) }, {
space(px(6) - px(2), 1),
button("chevron-left", _("Earlier"), { kind = "edit-move", delta = -1 }),
button("chevron-right", _("Later"), { kind = "edit-move", delta = 1 }),
divider(),
hstack({ h = bar_h, gap = px(5) }, { space(px(8) - px(5), 1), unpack(segs) }),
space(px(8), 1),
divider(),
button("layout-grid", _("Page"), { kind = "edit-page" }),
button("x", _("Remove"), { kind = "edit-remove", index = index }),
space(px(6) - px(2), 1),
})
local w = inner:getSize().w
local ring = px(2)
return BoardUI.overlay(w + 2 * ring, bar_h + 2 * ring, {
{ widget = BoardUI.rect(w + 2 * ring, bar_h + 2 * ring, INK.white, px(12) + ring) },
{ widget = BoardUI.box({ w = w, h = bar_h, radius = px(12), bg = INK.black }, inner), x = ring, y = ring },
})
end
-- ------------------------------------------------------------ the sheet
function CatalogBoard:boardPreview(ctx, entry, box_w, box_h, g)
-- Previews are drawn at the narrowest real column (132dp, a 6" panel),
-- so a preview never shows a widget smaller than any board does.
local unit = px(132)
local cw, rh = BoardLayout.sizeDims(entry.sizes[1], 4)
local pw = cw == 1 and unit or cw * unit + (cw - 1) * px(10)
local ph = rh * unit + (rh - 1) * px(10)
local scale = math.min(1, (box_h - px(16)) / ph, (box_w - px(20)) / pw)
local sw, sh = math.floor(pw * scale + 0.5), math.floor(ph * scale + 0.5)
local item = { type = entry.type, cw = cw, rh = rh, chrome = entry.chrome or "line", props = entry.props }
local key = "preview:" .. entry.type .. ":" .. tostring(entry.props and (entry.props.source or entry.props.link or entry.props.animal) or "") .. ":" .. pw .. "x" .. ph .. ":" .. sw
local shot = BoardUI.snapshot(key, pw, ph, sw, sh, function()
return BoardWidgets.tile({ d = ctx.d, tiles = "outline", colour = ctx.colour, menu = self }, item, pw, ph)
end)
return BoardUI.box({ w = box_w, h = box_h, radius = px(12), bg = INK.ge }, hstack({ w = box_w, h = box_h }, { flex(0), shot, flex(0) }))
end
function CatalogBoard:boardSheet(ctx, g, screen_w, screen_h)
local edit = self.board_edit
local sheet_h = math.floor(screen_h * 0.7 + 0.5)
local mx = g.mx
local w = screen_w - 2 * mx
local pad = px(18)
local entries, new_count = {}, 0
for _i, entry in ipairs(BoardEdit.LIBRARY) do
if entry.new then new_count = new_count + 1 end
if (edit.sheet_tab == "new" and entry.new) or entry.tab == edit.sheet_tab then table.insert(entries, entry) end
end
local tabs = {}
for _i, tab in ipairs(BoardEdit.TABS) do
local on = tab.id == edit.sheet_tab
local label = text(tab.label(), { s = 14, b = on, col = on and INK.black or INK.g4 })
table.insert(tabs, BoardUI.hit(vstack({ w = label:getSize().w, h = px(40) }, {
flex(0), label, flex(0), on and BoardUI.rect(label:getSize().w, px(3), INK.black) or space(nil, px(3)),
}), { kind = "sheet-tab", tab = tab.id }))
end
local tab_row = vstack({ w = w }, {
hstack({ w = w, h = px(40), gap = px(BoardMetrics.layoutW() < 560 and 16 or 22), valign = "bottom" }, tabs),
BoardUI.rect(w, math.max(1, px(1)), INK.gb),
})
local cols_n = BoardMetrics.layoutW() >= 700 and 3 or 2
local gap = px(14)
local card_w = math.floor((w - (cols_n - 1) * gap) / cols_n)
local preview_h
local stacked = false
local function caption(entry)
local chips = {}
for _i, code in ipairs(entry.sizes) do
local label = text(sizeLabel(code), { s = 11, b = true, col = INK.g3 })
table.insert(chips, BoardUI.box({ w = label:getSize().w + 2 * px(5), h = label:getSize().h, radius = px(4), border = math.max(1, px(1)), border_col = INK.g8 },
hstack({ w = label:getSize().w + 2 * px(5) - 2 * math.max(1, px(1)) }, { flex(0), label, flex(0) })))
end
local chip_row = hstack({ gap = px(4) }, chips)
local name_w = card_w - chip_row:getSize().w - px(6)
local name_s = BoardUI.fitSize(entry.name(), { s = 14, b = true }, name_w, 12)
-- A name that does not fit beside its sizes even at 12 takes the
-- line, with the sizes under it, and then so does every name so the
-- cards line up on every tab.
if text(entry.name(), { s = name_s, b = true }):getSize().w > name_w then stacked = true end
local name_row
if not stacked then
name_row = BoardUI.spread(card_w, text(entry.name(), { s = name_s, b = true, w = name_w }), chip_row)
else
local s2 = BoardUI.fitSize(entry.name(), { s = 14, b = true }, card_w, 11)
name_row = vstack({ w = card_w }, { text(entry.name(), { s = s2, b = true, w = card_w }), space(nil, px(4)), chip_row })
end
return vstack({ w = card_w }, {
space(nil, px(6)),
name_row,
space(nil, px(6)),
text(entry.desc(), { s = 12, col = INK.g4, lines = 2, w = card_w }),
})
end
local function card(entry)
return BoardUI.hit(vstack({ w = card_w }, {
self:boardPreview(ctx, entry, card_w, preview_h, g),
caption(entry),
}), { kind = "sheet-add", entry = entry })
end
local head_h = pad + lh1(20) + lh1(13) + px(10) + px(40) + math.max(1, px(1)) + px(16)
local pager_h = px(52)
-- Rows are as tall as their tallest caption (a description can take two
-- lines). Every widget's caption is measured, not just this tab's, so
-- each tab keeps the same rows and cards per page.
local text_h = 0
local measured = BoardEdit.LIBRARY
for _i, entry in ipairs(measured) do
local sample = caption(entry)
if sample.free then sample:free() end
end
for _i, entry in ipairs(measured) do
local sample = caption(entry)
text_h = math.max(text_h, sample:getSize().h)
if sample.free then sample:free() end
end
local vgap = px(16)
-- As many rows as fit with previews at least 80dp tall, the previews then
-- grown to fill the sheet (up to a little taller than a card is wide), so
-- a larger interface size leaves no band of empty sheet under the last
-- row.
local grid_h = sheet_h - head_h - pager_h
local rows_fit = math.max(1, math.floor((grid_h + vgap) / (px(80) + text_h + vgap)))
local preview_max = math.max(px(150), math.floor(card_w * 1.2))
preview_h = math.max(px(64), math.min(preview_max, math.floor((grid_h + vgap) / rows_fit - vgap - text_h)))
local per_page = rows_fit * cols_n
local pages = math.max(1, math.ceil(#entries / per_page))
edit.sheet_page = math.min(edit.sheet_page or 1, pages)
local first = (edit.sheet_page - 1) * per_page + 1
local grid_rows = {}
for r = 0, rows_fit - 1 do
local cards = {}
for c = 0, cols_n - 1 do
local entry = entries[first + r * cols_n + c]
if entry then table.insert(cards, card(entry)) end
end
if #cards > 0 then
if #grid_rows > 0 then table.insert(grid_rows, space(nil, vgap)) end
table.insert(grid_rows, hstack({ gap = gap, valign = "top" }, cards))
end
end
local function chev(name, enabled, delta)
local b = hstack({ w = px(44), h = pager_h }, { flex(0), icon(name, px(22), { sw = 2.5, col = enabled and "black" or "gb" }), flex(0) })
return BoardUI.hit(b, enabled and { kind = "sheet-page", delta = delta } or { kind = "noop" })
end
local pager = vstack({ w = w }, {
BoardUI.rect(w, math.max(1, px(0.75)), INK.gc),
hstack({ w = w, h = pager_h - math.max(1, px(0.75)), gap = px(18) }, {
flex(0), chev("chevron-left", edit.sheet_page > 1, -1),
text(T(_("%1 of %2"), edit.sheet_page, pages), { s = 13, b = true }),
chev("chevron-right", edit.sheet_page < pages, 1), flex(0),
}),
})
local close = BoardUI.hit(BoardUI.circle(px(40), { border = math.max(1, px(1.5)), bg = INK.white }, icon("x", px(20), { sw = 2.5 })), { kind = "sheet-close" })
local title = vstack({}, {
text(_("Add a widget"), { s = 20, b = true }),
text(T(_("%1 widgets · %2 new"), #BoardEdit.LIBRARY, new_count), { s = 13, col = INK.g4 }),
})
local body = vstack({ w = screen_w, h = sheet_h, pad = { pad, mx, 0, mx } }, {
BoardUI.spread(w, title, close),
space(nil, px(10)),
tab_row,
space(nil, px(16)),
vstack({ w = w }, grid_rows),
flex(0),
pager,
})
local line = math.max(1, px(2))
local r = px(18)
return BoardUI.overlay(screen_w, sheet_h + line, {
{ widget = BoardUI.rect(screen_w, sheet_h + line + r, INK.black, r) },
{ widget = BoardUI.rect(screen_w, sheet_h + r, INK.white, r), y = line },
{ widget = body, y = line },
})
end
-- ----------------------------------------------------------- first run
function CatalogBoard:boardShowStarter(state, context)
return not state.started and context.dashboard ~= nil and not self.board_edit and not self.board_snapshotting
end
local PRESET_INFO = {
{ id = "reader", name = function() return _("Reader") end, desc = function() return _("Your book first, then your pile, highlight and shelves") end },
{ id = "stats", name = function() return _("Stats") end, desc = function() return _("Year in reading, records and forecast up front") end },
{ id = "library", name = function() return _("Library") end, desc = function() return _("Pick back up, Up next and Want to read first") end },
}
function CatalogBoard:boardBuildStarter(ctx, g, screen_w, screen_h)
local choice = self.board_starter_choice or "reader"
local mx = g.mx
local w = screen_w - 2 * mx
local top = px(24)
local head = vstack({ w = w }, {
text(_("Set up your dashboard"), { s = 24, b = true, w = w }),
space(nil, px(6)),
text(_("Pick a starting layout. Each comes with four pages, Home, Insights, Library and Decor, that you can change any time: hold a widget to edit it."),
{ s = 14, col = INK.g3, lines = 3, w = w }),
})
local gap = px(BoardMetrics.layoutW() < 560 and 10 or 16)
local card_w = math.floor((w - 2 * gap) / 3)
local btn_h = px(48)
-- The descriptions take the lines the longest needs on these cards (a
-- phone at a large interface size needs four).
local desc_lines = 3
for _i, info in ipairs(PRESET_INFO) do
local tb = require("ui/widget/textboxwidget"):new{ text = info.desc(), face = BoardMetrics.face(12), width = card_w - 2 * px(8) }
desc_lines = math.max(desc_lines, math.min(5, math.floor(tb:getSize().h / BoardMetrics.lh(12) + 0.5)))
tb:free()
end
local text_h = px(10) + lh1(16) + px(2) + desc_lines * BoardMetrics.lh(12) + px(8)
local avail_prev = screen_h - top - head:getSize().h - px(24) - text_h - px(24) - btn_h - px(24)
local scale = math.min((card_w - px(16)) / screen_w, avail_prev / screen_h)
local pw, ph = math.floor(screen_w * scale + 0.5), math.floor(screen_h * scale + 0.5)
local function snapshotFor(preset, page, sw, sh)
return BoardUI.snapshot("starter:" .. preset .. ":" .. page .. ":" .. sw .. "x" .. sh, screen_w, screen_h, sw, sh, function()
self.board_snapshotting = true
local board = self:boardBuildScreen(ctx, g, page, screen_w, screen_h, { preset = preset, snapshot = true })
self.board_snapshotting = false
return board
end)
end
local cards = {}
local selected_name
for _i, info in ipairs(PRESET_INFO) do
local sel = info.id == choice
if sel then selected_name = info.name() end
local prev = BoardUI.overlay(pw, ph, {
{ widget = snapshotFor(info.id, 1, pw, ph) },
{ widget = BoardUI.box({ w = pw, h = ph, radius = px(6), border = math.max(1, px(1)), border_col = INK.gb }) },
})
local radio = BoardUI.circle(px(20), { border = math.max(1, px(1.5)), bg = INK.white },
sel and BoardUI.circle(px(10), { bg = INK.black }, space(1, 1)) or space(1, 1))
local iw = card_w - 2 * px(8)
local inner = vstack({ w = card_w, pad = px(8), align = "center" }, {
prev,
space(nil, px(10)),
hstack({ w = iw, gap = px(8) }, { radio, text(info.name(), { s = 16, b = true, w = iw - px(20) - px(8) }) }),
space(nil, px(2)),
text(info.desc(), { s = 12, col = INK.g4, lines = desc_lines, w = iw }),
space(nil, px(8)),
})
local size = inner:getSize()
local card_box = BoardUI.overlay(card_w, size.h, {
{ widget = BoardUI.rect(card_w, size.h, sel and INK.white or INK.ge, px(12)) },
{ widget = inner },
{ widget = BoardUI.box({ w = card_w, h = size.h, radius = px(12), border = sel and px(2.5) or math.max(1, px(1)), border_col = sel and INK.black or INK.gb }) },
})
table.insert(cards, self:boardRegion(function() return card_box end,
{ kind = "starter-pick", preset = info.id }))
end
local cards_row = hstack({ gap = gap, valign = "top" }, cards)
local strip_label_h = lh1(13) + px(8)
local strip_cap_h = px(6) + lh1(12)
local room = screen_h - 2 * top - head:getSize().h - px(24) - cards_row:getSize().h - px(28) - strip_label_h - strip_cap_h - px(24) - btn_h
local strip = false
if room >= px(110) then
local sg = px(14)
local page_count = BoardStore.PAGE_COUNT
local s2 = math.min((w - (page_count - 1) * sg) / page_count / screen_w, room / screen_h)
local pw2, ph2 = math.floor(screen_w * s2 + 0.5), math.floor(screen_h * s2 + 0.5)
local thumbs = {}
for page, name in ipairs(BoardStore.pageNames(BoardStore.starterPages(g.cols, choice, self:boardHasLight(), self:boardLandscape()))) do
table.insert(thumbs, vstack({ align = "center" }, {
BoardUI.overlay(pw2, ph2, {
{ widget = snapshotFor(choice, page, pw2, ph2) },
{ widget = BoardUI.box({ w = pw2, h = ph2, radius = px(4), border = math.max(1, px(1)), border_col = INK.gb }) },
}),
space(nil, px(6)),
text(page .. " " .. name, { s = 12, b = true, col = INK.g3 }),
}))
end
strip = vstack({ w = w, align = "center" }, {
text(T(_("%1 comes with four pages"), selected_name), { s = 13, b = true }),
space(nil, px(8)),
hstack({ gap = sg }, thumbs),
})
end
local btn_label = text(T(_("Start with %1"), selected_name), { s = 16, b = true, col = INK.white })
local btn_w = btn_label:getSize().w + 2 * px(28)
local button = self:boardRegion(function()
return BoardUI.box({ w = btn_w, h = btn_h, radius = math.floor(btn_h / 2), bg = INK.black },
hstack({ w = btn_w, h = btn_h }, { flex(0), btn_label, flex(0) }))
end, { kind = "starter-start", preset = choice })
local screen = vstack({ w = screen_w, h = screen_h, pad = { top, mx, top, mx } }, {
head,
space(nil, px(24)),
cards_row,
flex(px(16)),
strip,
strip and flex(px(16)) or false,
hstack({ w = w }, { flex(0), button, flex(0) }),
})
return screen, { cards, { button } }
end
-- ------------------------------------------------------------ the board
-- Builds one board screen. opts.preset draws a starter layout instead of the
-- reader's pages; opts.snapshot builds it for a thumbnail and records
-- nothing on the catalog.
function CatalogBoard:boardBuildScreen(ctx, g, page, screen_w, screen_h, opts)
opts = opts or {}
local d = ctx.d
local edit = not opts.snapshot and self.board_edit or nil
local packed
if edit then
packed = BoardLayout.pack(edit.pages[page].items, g.cols)
packed.row_h = math.min(g.max_row, BoardLayout.rowHeight(g, packed.rows))
packed.grid_h = packed.rows * packed.row_h + math.max(0, packed.rows - 1) * g.gap
else
local pages
if opts.preset then
pages = BoardStore.starterPages(g.cols, opts.preset, self:boardHasLight(), self:boardLandscape())
else
pages = self:boardPages(g.cols)
end
packed = BoardLayout.fitPage(g, pages[page].base, pages[page].extras)
end
local tiles = {}
local layers = {}
local rows = {}
local selected
for index, item in ipairs(packed.placed) do
local tw = BoardLayout.spanWidth(g, item.x, item.cw)
local th = item.rh * packed.row_h + (item.rh - 1) * g.gap
local x, y = BoardLayout.cellX(g, item.x), item.y * (packed.row_h + g.gap)
local placed = {}
for k, v in pairs(item) do placed[k] = v end
placed.index = index
placed.action = edit and { kind = "edit-select", index = index } or self:boardItemAction(item, d)
local tile = self:boardBuildTile(ctx, placed, tw, th, edit ~= nil)
if edit and edit.selected == index then
-- The selection ring: white, then black, outside the tile.
local r = px(12)
table.insert(layers, { widget = BoardUI.rect(tw + 2 * px(6), th + 2 * px(6), INK.black, r + px(6)), x = x - px(6), y = y - px(6) })
table.insert(layers, { widget = BoardUI.rect(tw + 2 * px(3), th + 2 * px(3), INK.white, r + px(3)), x = x - px(3), y = y - px(3) })
selected = { item = placed, index = index, x = x, y = y, w = tw, h = th }
end
table.insert(tiles, { tile = tile, item = placed, x = x, y = y, w = tw, h = th })
table.insert(layers, { widget = tile, x = x, y = y })
rows[item.y + 1] = rows[item.y + 1] or {}
table.insert(rows[item.y + 1], tile)
end
if edit then
local badge = px(26)
for _i, entry in ipairs(tiles) do
local b = self:boardRegion(function()
return BoardUI.circle(badge, { border = math.max(1, px(1.5)), bg = INK.white }, icon("x", px(15), { sw = 3 }))
end, { kind = "edit-remove", index = entry.item.index })
table.insert(layers, { widget = b, x = entry.x - px(7), y = entry.y - px(7) })
end
-- The Add widget sheet covers the bottom of the board; the bar for the
-- selected widget comes back when it closes.
if selected and not edit.sheet then
local bar = self:boardRegion(function() return self:boardEditBar(selected.item, selected.index, g) end, { kind = "noop" })
local bw, bh = bar:getSize().w, bar:getSize().h
local left = math.max(0, math.min(g.w - bw, selected.x + math.floor((selected.w - bw) / 2)))
local below = selected.y + selected.h + px(12)
local top = BoardLayout.editBarTop(selected.y, selected.h, bh, px(12), g.avail)
packed.grid_h = math.max(packed.grid_h, top + bh)
table.insert(layers, { widget = bar, x = left, y = top })
end
end
local grid = BoardUI.overlay(g.w, packed.grid_h, layers)
local mode = edit and "edit" or "normal"
local header = self:boardRegion(function() return self:boardHeader(d, g.w, mode, self:boardPageNames(g.cols)[page]) end)
local footer = self:boardRegion(function() return self:boardFooter(g.w, page, mode) end)
local board = vstack({ w = screen_w, h = screen_h, pad = { g.top, g.mx, g.bottom, g.mx } }, {
header,
space(nil, g.header_gap),
grid,
flex(g.footer_gap),
footer,
})
if not opts.snapshot then
self._board_tiles = tiles
self._board_header = header
self._board_origin = { x = g.mx, y = g.top + g.header_h + g.header_gap }
end
return board, rows, packed
end
local PROFILE = os.getenv("BO_BOARD_PROFILE") ~= nil
function CatalogBoard:updateDashboardItems(select_number, no_recalculate_dimen)
-- However the board started (the starter screen, or a layout picked in
-- the menu while the catalog was closed), the old picked shelves join it
-- before its first draw. The refresh that follows fetches their books.
self:boardMigrateLegacyShelves()
local t0 = PROFILE and os.clock()
local old_dimen = self:prepareCustomUpdate(no_recalculate_dimen)
self:ensureOnDeviceCurrent()
BoardMetrics.refresh()
local context = self.current_context or {}
local m = BoardMetrics.get()
local screen_w = self.inner_dimen.w
local screen_h = self.available_height
local g = BoardLayout.geometry(px, screen_w, screen_h, m.LW)
local page = self:boardPage()
local d = BoardData.build(self, context, os.time())
local state = BoardStore.read(self.settings)
local ctx = { d = d, tiles = state.tiles, colour = state.colour, menu = self, tap_resumes = self.settings.catalog_dashboard_tap_resumes ~= false }
self._board_ctx = ctx
self._board_geometry = g
local t_data = PROFILE and os.clock()
local screen, rows, packed
if self:boardShowStarter(state, context) then
screen, rows = self:boardBuildStarter(ctx, g, screen_w, screen_h)
self._board_tiles, self._board_header = {}, nil
else
BoardUI.clearSnapshots("starter:")
screen, rows, packed = self:boardBuildScreen(ctx, g, page, screen_w, screen_h)
if self.board_edit and self.board_edit.sheet then
local sheet = self:boardRegion(function() return self:boardSheet(ctx, g, screen_w, screen_h) end, { kind = "noop" })
local sh = sheet:getSize().h
local backdrop = self:boardRegion(function()
return space(screen_w, screen_h - sh)
end, { kind = "sheet-close" })
screen = BoardUI.overlay(screen_w, screen_h, {
{ widget = screen },
{ widget = backdrop },
{ widget = sheet, x = 0, y = screen_h - sh },
})
else
BoardUI.clearSnapshots("preview:")
end
end
table.insert(self.item_group, screen)
-- D-pad order: the grid row by row.
for row_index = 1, #rows do
if rows[row_index] and #rows[row_index] > 0 then table.insert(self.layout, rows[row_index]) end
end
if PROFILE and packed then
logger.info(string.format("BOARDPROF page=%d data=%.1fms build=%.1fms tiles=%d", page, (t_data - t0) * 1000, (os.clock() - t_data) * 1000, #packed.placed))
local timings = {}
for _i, entry in ipairs(self._board_tiles) do
local t1 = os.clock()
BoardWidgets.tile(ctx, entry.item, entry.w, entry.h)
table.insert(timings, string.format("%s=%.1f", entry.item.type, (os.clock() - t1) * 1000))
end
logger.info("BOARDPROF tiles " .. table.concat(timings, " "))
end
if packed then self:boardDump(d, page) end
self:boardScheduleClock()
self:finishCustomUpdate(old_dimen, select_number)
self:boardRefreshPets()
end
-- A colour screen shows colour only through a dithered refresh (on Kaleido,
-- its colour waveform), and the board's own "ui" refresh is not one. With the
-- Colour setting on, the whole board gets one; with it off, only the pets,
-- which are colour pictures, get their own.
function CatalogBoard:boardRefreshPets()
if not Screen:isColorEnabled() then return end
if BoardColour.on(self._board_ctx) then
UIManager:setDirty(nil, function()
if self.dimen and self.dimen.x then return "partial", self.dimen, true end
end)
return
end
for _i, entry in ipairs(self._board_tiles or {}) do
if entry.item.type == "buddy" then
local tile = entry.tile
UIManager:setDirty(nil, function()
if tile.dimen and tile.dimen.x then return "partial", tile.dimen, true end
end)
end
end
end
-- Repaints one tile in place: rebuilds its content and refreshes only its
-- rectangle, so a stepper or a hero page does not flash the whole board. A
-- coloured board's tiles keep their colours through the dithered refresh.
function CatalogBoard:boardRefreshTile(entry, mode, dither)
if not entry or not self._board_ctx then return end
-- The digital clock is black and white unless tiles are tinted, so its
-- minute stays on the fast refresh.
local plain_clock = entry.item.type == "clock" and not BoardColour.tinted(self._board_ctx)
if BoardColour.on(self._board_ctx) and not plain_clock and (mode == nil or mode == "ui" or mode == "fast") then
mode, dither = "partial", true
end
self._board_ctx.d = BoardData.build(self, self.current_context or {}, os.time())
local fresh = self:boardBuildTile(self._board_ctx, entry.item, entry.w, entry.h)
entry.tile[1] = fresh[1]
entry.tile.hits = fresh.hits
local dimen = entry.tile.dimen
if dimen and dimen.x then
UIManager:setDirty(self.show_parent, function() return mode or "ui", dimen, dither end)
else
self:updateItems(nil, true)
end
end
-- ------------------------------------------------------------- clock
-- Once a minute the header's clock and any Clock or Analog clock widget are
-- redrawn in place, and on the hour the Reading buddy and Moon phase.
-- Nothing else on the board changes on a timer.
function CatalogBoard:boardScheduleClock()
if self._board_clock_task then UIManager:unschedule(self._board_clock_task) end
self._board_clock_task = function()
self._board_clock_task = nil
if self.catalog_closed or not self:dashboardMode() then return end
if self:boardIsTopmost() then self:boardRefreshClock() end
self:boardScheduleClock()
end
UIManager:scheduleIn(60 - os.time() % 60 + 1, self._board_clock_task)
end
function CatalogBoard:boardRefreshClock()
if self.board_edit then return end
local ctx = self._board_ctx
local header_tile = self._board_header
if not ctx or not header_tile or not header_tile.dimen or not header_tile.dimen.x then return end
ctx.d = BoardData.build(self, self.current_context or {}, os.time())
local g = self._board_geometry
local header, hits = BoardUI.collectHits(function() return self:boardHeader(ctx.d, g.w, "normal", self:boardPageNames(g.cols)[self:boardPage()]) end)
header_tile[1] = header
header_tile.hits = hits
local dimen = header_tile.dimen
UIManager:setDirty(self.show_parent, function() return "fast", dimen end)
local hour = os.date("%H")
local new_hour = self._board_clock_hour ~= nil and self._board_clock_hour ~= hour
self._board_clock_hour = hour
for _i, entry in ipairs(self._board_tiles or {}) do
local t = entry.item.type
-- The buddy yawns at night and the moon moves on: both change at most
-- by the hour, so they are redrawn only when one turns.
if new_hour and t == "buddy" then
-- "fast" is black and white only: a shaded pet needs its greys, or
-- its colours through the dithered colour refresh.
local colour = Screen:isColorEnabled()
self:boardRefreshTile(entry, colour and "partial" or "ui", colour or nil)
elseif t == "clock" or t == "analog" or (new_hour and t == "moon") then
self:boardRefreshTile(entry, "fast")
end
end
end
function CatalogBoard:boardIsTopmost()
local top = UIManager.getTopmostVisibleWidget and UIManager:getTopmostVisibleWidget()
return top == nil or top == self or top == self.show_parent
end
-- ------------------------------------------------------------- actions
function CatalogBoard:boardRun(action, tile)
local kind = action.kind
if kind == "noop" then return end
if kind:match("^edit%-") or kind:match("^sheet%-") then
return self:boardEditRun(action)
elseif kind == "starter-pick" then
self.board_starter_choice = action.preset
return self:updateItems(nil, true)
elseif kind == "starter-start" then
self:persistSetting(BoardStore.SETTING_KEY, BoardStore.withPreset(self.settings, action.preset))
BoardUI.clearSnapshots("starter:")
if self.current_context then self.current_context.board_page = 1 end
return self:boardRefreshIfMissing()
elseif kind == "book-actions" then
return self:showBookActionSheetForEntry({ kind = "dashboard-book", book_id = action.book.id, book = action.book })
end
if kind == "page" then
return self:boardFlip(action.delta)
elseif kind == "menu" then
if self.show_dashboard_menu then self.show_dashboard_menu(self) end
elseif kind == "search" then
self:promptSearch({})
elseif kind == "book" then
self:onMenuSelect({ kind = "dashboard-book", book_id = action.book.id, book = action.book, resume = action.resume, list = action.list })
elseif kind == "detail" then
self:loadBookDetail(action.book_id, { list = action.list })
elseif kind == "highlight" then
require("bookorbit_highlight_sheet").open(self, action.highlight)
elseif kind == "books" then
self:loadBooks(action.params or {}, action.title)
elseif kind == "shelf" then
self:boardOpenShelf(action)
elseif kind == "shuffle" then
self:boardShuffle()
elseif kind == "link" then
self:boardOpenLink(action.link)
elseif kind == "lookup" then
self:boardLookup(action.word)
elseif kind == "light" then
self:boardAdjustLight(action.what, action.delta)
elseif kind == "wifi" then
self:boardToggleWifi()
elseif kind == "night" then
UIManager:broadcastEvent(Event:new("ToggleNightMode"))
self:updateItems(nil, true)
elseif kind == "frontlight" then
UIManager:broadcastEvent(Event:new("ShowFlDialog"))
elseif kind == "rotate" then
UIManager:broadcastEvent(Event:new("IterateRotation"))
elseif kind == "sync" then
if self._manager and self._manager.startSweep then self._manager:startSweep() end
elseif kind == "dust-next" then
self:boardDustNext()
elseif kind == "dust-remove" then
self:boardDustRemove(action.book)
end
end
-- Gathering dust: show the next unopened book for the rest of today.
function CatalogBoard:boardDustNext()
local today = os.date("%Y-%m-%d")
local state = self.settings.catalog_board_dust
local skip = type(state) == "table" and state.day == today and tonumber(state.skip) or 0
self:persistSetting("catalog_board_dust", { day = today, skip = skip + 1 })
for _i, entry in ipairs(self._board_tiles or {}) do
if entry.item.type == "dust" then self:boardRefreshTile(entry) end
end
end
-- Removes an unopened book's file through KOReader's file manager, which
-- asks first. The book stays in the library.
function CatalogBoard:boardDustRemove(book)
local path = book and self:onDeviceBookPath(book)
if not path then return end
local FileManager = require("apps/filemanager/filemanager")
local fm = FileManager.instance
if not fm or not fm.showDeleteFileDialog then
require("bookorbit_toast").error(_("Open the file browser to remove this book."))
return
end
fm:showDeleteFileDialog(path, function()
self:refreshOnDevice()
BoardData.invalidateUnopened()
self:updateItems(nil, true)
end)
end
function CatalogBoard:boardFlip(delta)
local context = self.current_context
if not context then return true end
local page = self:boardPage() + (delta or 0)
if page < 1 or page > BoardStore.PAGE_COUNT then return true end
context.board_page = page
self:updateItems(nil, true)
return true
end
local LINK_ACTIONS = {
library = function(self) self:loadBooks({ sort = "title" }, _("All books")) end,
series = function(self) self:loadSection("series") end,
authors = function(self) self:loadSection("authors") end,
collections = function(self) self:loadSection("collections") end,
scopes = function(self) self:loadSection("smart-scopes") end,
device = function(self) self:loadOnDevice() end,
search = function(self) self:promptSearch({}) end,
}
function CatalogBoard:boardOpenLink(link)
local fn = LINK_ACTIONS[link]
if fn then fn(self) end
end
local SHELF_PARAMS = {
["recently-added"] = { sort = "recently_added" },
["pick-back-up"] = { readStatus = "reading", sort = "recently_read", order = "asc" },
["finished"] = { readStatus = "finished", sort = "recently_read" },
["in-progress"] = { sort = "recently_read", readStatus = "reading" },
["random"] = { sort = "title" },
}
function CatalogBoard:boardOpenShelf(action)
if action.source == "up-next-in-series" then
return self:loadSection("series")
end
local params = (action.props and action.props.params) or SHELF_PARAMS[action.source] or { sort = "title" }
self:loadBooks(params, BoardWidgets.shelfTitle(action.props or {}))
end
function CatalogBoard:boardShuffle()
self:runConnected(function()
local body, err = self:fetch(_("Finding books..."), function()
return self.client:catalogDiscover()
end)
if body and body.discover then
local dashboard = self.current_context and self.current_context.dashboard
if dashboard then
dashboard.boardShelves = dashboard.boardShelves or {}
dashboard.boardShelves.random = body.discover
dashboard.discover = body.discover
self:cacheDashboard(dashboard)
self:scheduleThumbnailDownloads(body.discover)
end
self:updateItems(nil, true)
elseif err and err ~= "cancelled" then
self:showServerError(err)
end
end)
end
function CatalogBoard:boardLookup(word)
local FileManager = require("apps/filemanager/filemanager")
local fm = FileManager.instance
if fm and fm.dictionary and fm.dictionary.onLookupWord then
fm.dictionary:onLookupWord(word)
end
end
function CatalogBoard:boardAdjustLight(what, delta)
if not Device:hasFrontlight() then return end
local powerd = Device:getPowerDevice()
if what == "warmth" and Device:hasNaturalLight() then
powerd:setWarmth(math.max(0, math.min(100, (powerd:frontlightWarmth() or 0) + delta)))
else
powerd:setIntensity(math.max(0, math.min(100, (powerd:frontlightIntensity() or 0) + delta)))
end
for _i, entry in ipairs(self._board_tiles or {}) do
if entry.item.type == "light" then self:boardRefreshTile(entry) end
end
end
function CatalogBoard:boardToggleWifi()
if NetworkMgr:isWifiOn() then
NetworkMgr:toggleWifiOff(function() self:updateItems(nil, true) end)
else
NetworkMgr:toggleWifiOn(function() self:updateItems(nil, true) end)
end
end
-- ------------------------------------------------------------ sources
-- The shelves and names the board's pages show, so a refresh fetches only
-- what some widget will draw.
function CatalogBoard:boardSources()
local m = BoardMetrics.get()
local cols = BoardLayout.columnsFor(m.LW)
local sources, names = {}, {}
local all_pages = {}
for _i, page in ipairs(self:boardPages(cols)) do table.insert(all_pages, page) end
-- Before the first run every starter layout is previewed, so each one
-- needs its shelves.
if not BoardStore.read(self.settings).started then
for _i, preset in ipairs(BoardStore.PRESETS) do
for _j, page in ipairs(BoardStore.starterPages(cols, preset, self:boardHasLight(), self:boardLandscape())) do table.insert(all_pages, page) end
end
end
-- Widgets added while editing count too.
if self.board_edit then
for _i, page in ipairs(self.board_edit.pages) do table.insert(all_pages, { base = page.items }) end
end
for _i, page in ipairs(all_pages) do
for _i, list in ipairs({ page.base, page.extras }) do
for _i, item in ipairs(list or {}) do
if item.type == "shelf" and item.props and item.props.source then
sources[BoardStore.shelfKey(item.props)] = item.props
elseif item.type == "link" and item.props then
if item.props.link == "collections" or item.props.link == "scopes" then names[item.props.link] = true end
if item.props.link == "series" then sources["up-next-in-series"] = sources["up-next-in-series"] or { source = "up-next-in-series" } end
if item.props.link == "library" or item.props.link == "authors" then sources["recently-added"] = sources["recently-added"] or { source = "recently-added" } end
elseif item.type == "hero" then
sources["random"] = sources["random"] or { source = "random" }
elseif item.type == "dust" then
sources["unopened"] = sources["unopened"] or { source = "unopened" }
end
end
end
end
return sources, names
end
-- ------------------------------------------------------------ parity dump
-- With BO_BOARD_DUMP set (a directory), each render writes the data and page
-- it drew in the design mock's shape, so the mock can draw the same board
-- and the two can be compared pixel for pixel. Development only.
local DUMP_DIR = os.getenv("BO_BOARD_DUMP")
local function jsonValue(v, depth)
depth = depth or 0
local t = type(v)
if v == nil then return "null" end
if t == "boolean" then return v and "true" or "false" end
if t == "number" then
if v ~= v or v == math.huge or v == -math.huge then return "null" end
return tostring(v)
end
if t == "string" then
return '"' .. v:gsub('[%c"\\]', function(ch)
if ch == '"' then return '\\"' end
if ch == "\\" then return "\\\\" end
if ch == "\n" then return "\\n" end
return string.format("\\u%04x", ch:byte())
end) .. '"'
end
if t == "table" then
if depth > 8 then return "null" end
local n = #v
if n > 0 or next(v) == nil then
local parts = {}
for i = 1, n do parts[i] = jsonValue(v[i], depth + 1) end
return "[" .. table.concat(parts, ",") .. "]"
end
local parts = {}
for k, val in pairs(v) do
if type(k) == "string" and type(val) ~= "function" then
table.insert(parts, jsonValue(k) .. ":" .. jsonValue(val, depth + 1))
end
end
return "{" .. table.concat(parts, ",") .. "}"
end
return "null"
end
local function mockBook(b)
if type(b) ~= "table" then return nil end
return {
id = b.id, title = b.title, authors = b.authors, seriesName = b.seriesName, seriesIndex = b.seriesIndex,
progressPercentage = b.progressPercentage, lastReadAt = b.lastReadAt, readStatus = b.readStatus,
onDevice = b.on_device == true, minutesLeft = b.minutes_left and math.floor(b.minutes_left + 0.5) or nil,
lastRead = b.last_read, coverPath = b.cover_path,
coverAspect = b.cover_path and BoardUI.nativeAspect(b.cover_path) or nil,
}
end
local function mockBooks(list)
local out = {}
for _i, b in ipairs(list or {}) do table.insert(out, mockBook(b)) end
return out
end
function CatalogBoard:boardDump(d, page)
if not DUMP_DIR then return end
local now = d.now
local t = os.date("*t", now)
local first = os.time({ year = t.year, month = t.month, day = 1, hour = 12 })
local days = tonumber(os.date("%d", os.time({ year = t.year, month = t.month + 1, day = 0, hour = 12 })))
local read = {}
for day = 1, days do
local key_t = os.time({ year = t.year, month = t.month, day = day, hour = 12 })
local idx = math.floor((key_t - d.heatmap_start) / 86400 + 0.5) + 1
read[day] = day <= t.day and (d.heatmap[idx] or 0) > 0
end
local heat = {}
for i = 1, BoardData.HEATMAP_DAYS do heat[i] = d.heatmap[i] == nil and false or d.heatmap[i] end
local f = d.forecast
local y = d.year_ago
local powerd = Device:getPowerDevice()
local data = {
state = "normal",
greetingFull = d.greeting, displayName = d.display_name, dateLong = d.date_long, clock = d.clock,
weekday = d.weekday, dayMonth = d.day_month,
battery = Device:hasBattery() and powerd:getCapacity() or nil,
sync = { online = d.online, label = "", short = "" },
syncWhen = d.last_sync_at and BoardData.relativeDay(os.date("!%Y-%m-%dT%H:%M:%SZ", d.last_sync_at), now) or (d.updated or "not yet"),
updated = d.updated,
totalBooks = d.counts.library,
counts = { onDevice = d.counts.device, inProgress = d.counts.in_progress, libraries = d.counts.libraries, authors = d.counts.authors,
series = d.counts.series, collections = d.counts.collections, smartScopes = d.counts.scopes },
continueReading = mockBooks(d.continue_reading),
discover = mockBooks(d.shelves["random"]),
discover2 = mockBooks(d.shelves["random"]),
recentlyAdded = mockBooks(d.shelves["recently-added"]),
upNext = mockBooks(d.shelves["up-next-in-series"]),
upNextAll = mockBooks(d.shelves["up-next-in-series"]),
wantToRead = mockBooks(d.shelves["want-to-read"]),
pickUp = mockBooks(d.shelves["pick-back-up"]),
finished = mockBooks(d.shelves["finished"]),
hours = d.hours or {},
records = d.records and { longestSessionMin = d.records.longest_min, avgSessionMin = d.records.avg_min,
sessions = d.records.sessions, pagesPerHour = d.records.pages_per_hour } or nil,
goal = { goalBooks = d.goal.books, completedBooks = d.goal.done, year = d.goal.year },
streak = { currentStreak = d.streak.current, longestStreak = d.streak.longest, lastSevenDays = d.streak_week },
highlight = d.highlight,
heatmap = heat,
heatmapStart = os.date("%Y-%m-%d", d.heatmap_start),
week = d.week, weekdayInitials = d.week_initials, todayMin = d.today_min, weekMin = d.week_min,
forecast = f and { book = mockBook(f.book), day = f.day, short = f.short, date = f.date, daysOut = f.days_out, perDay = math.floor(f.per_day + 0.5),
initials = (function()
local out = {}
for i = 1, math.min(8, f.days_out + 1) do out[i] = BoardData.shortWeekday(now + (i - 1) * 86400):sub(1, 1) end
return out
end)() } or nil,
yearAgo = y and { book = mockBook(y.book), when = y.when, minutes = y.minutes, days = y.days, stoppedAt = y.stopped_at } or nil,
word = d.word,
light = d.light,
dayOfYear = d.day_of_year,
collectionNames = d.names.collections or {},
scopeNames = d.names.scopes or {},
recentSearches = d.recent_searches or {},
storage = d.storage and { free = tonumber(string.format("%.1f", d.storage.free_gb)), total = math.floor(d.storage.total_gb + 0.5) } or nil,
calendar = { lead = tonumber(os.date("%w", first)), days = days, today = t.day, read = read, month = BoardData.longMonth(now) },
}
local m = BoardMetrics.get()
local rects = {}
for _i, entry in ipairs(self._board_tiles or {}) do
local o = self._board_origin
table.insert(rects, { type = entry.item.type, x = o.x + entry.x, y = o.y + entry.y, w = entry.w, h = entry.h })
end
local doc = { data = data, page = page, w = m.w, h = m.h, dpi = Screen:getDPI(), tiles = BoardStore.read(self.settings).tiles,
preset = BoardStore.read(self.settings).preset, rects = rects }
local f_out = io.open(DUMP_DIR .. "/board-dump.json", "w")
if f_out then f_out:write(jsonValue(doc)); f_out:close() end
end
CatalogBoard.TitleBarStub = TitleBarStub
function CatalogBoard.install(Catalog)
BoardEdit.install(Catalog)
for name, fn in pairs(CatalogBoard) do
if name ~= "install" and type(fn) == "function" then
Catalog[name] = fn
end
end
end
return CatalogBoard