--[[-- Parts for BookOrbit's own menus and settings pages, built on the dashboard's drawing primitives so they share its type, grays and density step. Every part is sized in dp through BoardMetrics.px. Tappable parts are wrapped with BoardUI.hit and carry an action table; the page that builds them routes taps and D-pad presses to its own action handler. ]] local DataStorage = require("datastorage") local IconWidget = require("ui/widget/iconwidget") local lfs = require("libs/libkoreader-lfs") local util = require("util") local BoardMetrics = require("bookorbit_board_metrics") local BoardUI = require("bookorbit_board_ui") local INK = BoardMetrics.INK local px, lh1 = BoardMetrics.px, BoardMetrics.lh1 local text, vstack, hstack, flex, space, icon = BoardUI.text, BoardUI.vstack, BoardUI.hstack, BoardUI.flex, BoardUI.space, BoardUI.icon local MenuUI = {} -- Page margins step with width, like the board's. function MenuUI.mx() local W = BoardMetrics.layoutW() return px(W >= 900 and 28 or W >= 700 and 24 or W >= 560 and 20 or 16) end function MenuUI.wide() return BoardMetrics.layoutW() >= 760 end function MenuUI.landscape() local m = BoardMetrics.get() return m.w > m.h end function MenuUI.hair() return math.max(1, math.floor(BoardMetrics.get().dp * 0.75 + 0.5)) end function MenuUI.line() return math.max(1, px(1)) end MenuUI.CARD_PAD = 16 -- The inner width of a card's rows. function MenuUI.rowsIn(w) return w - 2 * px(MenuUI.CARD_PAD) end -- An outline card around children laid out top to bottom. o: fill (gray -- surface, no border), strong (black border), pad (horizontal, dp), vpad. function MenuUI.card(w, children, o) o = o or {} local border = o.fill and 0 or MenuUI.line() local pad = px(o.pad or MenuUI.CARD_PAD) local vpad = px(o.vpad or 0) local inner = vstack({ w = w - 2 * border, pad = { vpad, pad - border, vpad, pad - border } }, children) local h = inner:getSize().h + 2 * border return BoardUI.box({ w = w, h = h, border = border, radius = px(12), border_col = o.strong and INK.black or INK.g8, bg = o.fill and INK.ge or INK.white, }, inner) end -- A hairline between rows, starting past the icon column unless inset is 0. function MenuUI.divider(w, inset) inset = inset or px(38) return hstack({ w = w, h = MenuUI.hair() }, { space(inset, 1), BoardUI.rect(w - inset, MenuUI.hair(), INK.gb) }) end -- A card of rows with hairlines between them. function MenuUI.group(w, rows, o) o = o or {} local iw = MenuUI.rowsIn(w) local children = {} for _i, r in ipairs(rows) do if r then if #children > 0 then table.insert(children, MenuUI.divider(iw, (o.inset == nil and r._no_icon) and 0 or o.inset)) end table.insert(children, r) end end return MenuUI.card(w, children, o) end function MenuUI.label(w, str, right) local l = text(str, { s = 13, b = true }) if right then return BoardUI.spread(w, l, right) end return hstack({ w = w }, { l, flex(0) }) end -- A section: its label, then the body. function MenuUI.section(w, title, body) if not title then return body end return vstack({ w = w }, { MenuUI.label(w, title), space(nil, px(8)), body }) end -- ------------------------------------------------------------- controls function MenuUI.toggle(on) local w, h, k, pad = px(48), px(28), px(20), px(4) local b = math.max(1, px(1.5)) local track = on and BoardUI.rect(w, h, INK.black, math.floor(h / 2)) or BoardUI.box({ w = w, h = h, radius = math.floor(h / 2), border = b, border_col = INK.g5, bg = INK.white }) local knob = BoardUI.rect(k, k, on and INK.white or INK.g5, math.floor(k / 2)) return BoardUI.overlay(w, h, { { widget = track }, { widget = knob, x = on and (w - pad - k) or pad, y = pad }, }) end function MenuUI.radio(on) local s = px(22) return BoardUI.circle(s, { border = math.max(1, px(1.75)), bg = INK.white }, on and BoardUI.rect(px(10), px(10), INK.black, px(5)) or space(1, 1)) end function MenuUI.check() return BoardUI.circle(px(24), { bg = INK.black }, icon("check", px(15), { col = "white", sw = 3 })) end -- A segmented control: one outlined box, the chosen segment inked. function MenuUI.segmented(w, options, selected, action_for) local h = px(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 = 14, 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(MenuUI.hair(), h - 2 * b, INK.g8) } or {}, }) end table.insert(segs, action_for and BoardUI.hit(seg, action_for(index)) or seg) 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 function MenuUI.stepper(value, unit, action_minus, action_plus) local s = px(36) local function btn(name, action) local c = BoardUI.circle(s, { border = math.max(1, px(1.5)), bg = INK.white }, icon(name, px(16), { sw = 2.75 })) return action and BoardUI.hit(c, action) or c end local mid = vstack({ w = px(52), align = "center" }, { text(tostring(value), { s = 18, b = true }), unit and text(unit, { s = 12, col = INK.g4 }) or false, }) return hstack({ gap = px(10) }, { btn("minus", action_minus), mid, btn("plus", action_plus) }) end -- A pill button. o: kind ("dark", "outline", "ghost"), icon, s, h, pad_x, -- w (fixed width), action. function MenuUI.pill(label, o) o = o or {} local h = px(o.h or 44) local dark = o.kind ~= "outline" and o.kind ~= "ghost" local fg = dark and INK.white or INK.black local t = text(label, { s = o.s or 15, b = true, col = fg, w = o.w and (o.w - 2 * px(o.pad_x or 18) - (o.icon and px(o.icon_size or 18) + px(8) or 0)) or nil, align = o.w and "center" or nil }) local items = {} if o.icon then table.insert(items, icon(o.icon, px(o.icon_size or 18), { col = dark and "white" or "black", sw = 2.5 })) end table.insert(items, t) local content = hstack({ gap = px(8) }, items) local w = o.w or (content:getSize().w + 2 * px(o.pad_x or 18)) local border = o.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) }) local pill = BoardUI.box({ w = w, h = h, radius = math.floor(h / 2), border = border, border_col = INK.black, bg = dark and INK.black or INK.white }, body) return o.action and BoardUI.hit(pill, o.action) or pill end -- A small status chip. Words and an icon, never shade alone. function MenuUI.chip(label, o) o = o or {} local h = px(o.h or 26) local items = {} if o.icon then table.insert(items, icon(o.icon, px(14), { sw = 2.5, col = o.ink and "white" or "black" })) end table.insert(items, text(label, { s = 12, b = true, col = o.ink and INK.white or INK.black })) local content = hstack({ gap = px(5) }, items) local w = content:getSize().w + px(o.icon and 7 or 10) + px(10) return BoardUI.box({ w = w, h = h, radius = math.floor(h / 2), bg = o.ink and INK.black or INK.ge }, hstack({ w = w, h = h }, { space(px(o.icon and 7 or 10), 1), content, flex(0) })) end function MenuUI.iconButton(name, action, 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 action and BoardUI.hit(b, action) or b end -- The 44dp tap box draws its icon inset; pulling the box out by this much -- lines the glyph up with the page margin. function MenuUI.tapInset(size) return math.floor((px(44) - px(size or 24)) / 2) end -- ------------------------------------------------------------------ rows -- A list row. o: icon (name or widget), label, sub, sublines, value, -- value_bold, toggle, radio, check, chevron, trail, bold, h, action, data. function MenuUI.row(w, o) local trailing = {} if o.value then table.insert(trailing, text(o.value, { s = 14, col = o.value_bold and INK.black or INK.g4, b = o.value_bold })) end if o.trail then table.insert(trailing, o.trail) 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.check then table.insert(trailing, MenuUI.check()) 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 (right:getSize().w + px(12)) or 0 local text_w = math.max(px(10), w - icon_w - right_w) local lines = o.sublines or 1 local s = o.s or 16 local label_s = BoardUI.fitSize(o.label, { s = s, b = o.bold }, text_w, s - 2) -- Like a subtitle, an interface label too long even two sizes down -- takes a second line rather than an ellipsis. local label_lines = (not o.data and BoardUI.fitSize(o.label, { s = s - 2, b = o.bold }, text_w, s - 3) < s - 2) and 2 or nil if label_lines then label_s = s - 2 end local col_items = { text(o.label, { s = label_s, b = o.bold, w = text_w, lines = label_lines, data = o.data }) } if o.sub then local sub_s = 13 if lines == 1 then sub_s = BoardUI.fitSize(o.sub, { s = 13 }, text_w, 12) -- Interface copy too long for one line even at 12 takes a second -- line rather than an ellipsis. if not o.sub_data and BoardUI.fitSize(o.sub, { s = 12 }, text_w, 11) < 12 then lines, sub_s = 2, 13 end end table.insert(col_items, text(o.sub, { s = sub_s, col = INK.g4, w = text_w, lines = lines, data = o.sub_data })) end local column = vstack({ w = text_w }, col_items) local min_h = px(o.h or (o.sub and (lines > 1 and 72 or 60) or 52)) local h = math.max(min_h, column:getSize().h + px(16)) local items = {} if o.icon then local glyph = type(o.icon) == "string" and icon(o.icon, px(22), { sw = 2 }) or o.icon table.insert(items, hstack({ w = px(24), h = h }, { flex(0), glyph, flex(0) })) table.insert(items, space(px(14), 1)) end table.insert(items, column) if right then table.insert(items, flex(px(12))) table.insert(items, right) end local r = hstack({ w = w, h = h }, items) local out = o.action and BoardUI.hit(r, o.action) or r out._no_icon = o.icon == nil return out end -- Full-screen page header: back, title, optional sub line and a right widget, -- in the same 64dp band as the board's header. o: back (false hides it), -- back_icon, back_action, sub (string or runs), right, right_inset (px the -- right widget may hang past the margin, for an icon button's tap box). function MenuUI.pageHeader(w, title, o) o = o or {} local has_back = o.back ~= false local inset = has_back and MenuUI.tapInset(24) or 0 local right = o.right local band_w = w + inset + (o.right_inset or 0) local back_w = has_back and (px(44) + px(4)) or 0 local right_w = right and right:getSize().w or 0 local title_w = band_w - back_w - right_w local column = vstack({ w = title_w }, { text(title, { s = 22, b = true, w = title_w }), o.sub and (type(o.sub) == "table" and BoardUI.runs(o.sub, { s = 13, col = INK.g4, w = title_w, data = true }) or text(o.sub, { s = 13, col = INK.g4, w = title_w, data = o.sub_data })) or false, }) local items = {} if has_back then table.insert(items, MenuUI.iconButton(o.back_icon or "chevron-left", o.back_action or { kind = "back" }, { size = 26, sw = 2.25 })) table.insert(items, space(px(4), 1)) end table.insert(items, column) if right then table.insert(items, right) end local band = hstack({ w = band_w, h = px(64) }, items) -- The back glyph and a right icon button sit on the margins, their tap -- boxes reaching past them. return BoardUI.overlay(w, px(64), { { widget = band, x = -inset, y = 0 } }) end -- ---------------------------------------------------------- illustrations -- Gesture diagrams for the shortcut picker: a reader outline with the touch -- drawn on it. Written once to the icon cache like the board's icons. local GESTURE_SVG = { frame = '' .. '' .. '', ["hold-tl"] = '', ["tap-br"] = '', ["tap-tr"] = '', ["two-tr"] = '', none = '', } local gesture_dir local function gestureFile(kind) if not gesture_dir then gesture_dir = DataStorage:getDataDir() .. "/cache/bookorbit/icons" util.makePath(gesture_dir) end local path = gesture_dir .. "/gesture-" .. kind .. "-v1.svg" if lfs.attributes(path, "mode") ~= "file" then local f = io.open(path, "w") if not f then return nil end f:write('' .. GESTURE_SVG.frame .. (GESTURE_SVG[kind] or "") .. "") f:close() end return path end function MenuUI.gesture(kind, w) local h = math.floor(w * 1.3 + 0.5) local file = gestureFile(kind) if not file then return space(w, h) end return IconWidget:new{ file = file, width = w, height = h, alpha = true } end -- The BookOrbit icon KOReader's menu bar shows, drawn from the shipped SVG. function MenuUI.tabIcon(size, path) if not path or lfs.attributes(path, "mode") ~= "file" then return space(size, size) end return IconWidget:new{ file = path, width = size, height = size, alpha = true } end -- ------------------------------------------------------------------ text -- "2 min ago", "3 h ago", "yesterday", "Oct 9". A timestamp of 0 is never. local _ = require("gettext") local T = require("ffi/util").template function MenuUI.ago(ts, now) ts = tonumber(ts) if not ts or ts <= 0 then return nil end now = now or os.time() local delta = math.max(0, now - ts) if delta < 60 then return _("just now") end if delta < 3600 then return T(_("%1 min ago"), math.floor(delta / 60)) end if delta < 86400 and os.date("%Y%m%d", ts) == os.date("%Y%m%d", now) then return T(_("%1 h ago"), math.floor(delta / 3600)) end if os.date("%Y%m%d", ts) == os.date("%Y%m%d", now - 86400) then return _("yesterday") end return os.date("%b %d", ts):gsub(" 0", " ") end -- The server's address without its scheme or API path, as people type it. function MenuUI.serverHost(url) if type(url) ~= "string" or url == "" then return nil end local host = url:gsub("^%a[%w+.-]*://", ""):gsub("/api/v1/?$", ""):gsub("/+$", "") return host end MenuUI.lh1 = lh1 return MenuUI