Ctrl K
ComunidadGuías…

koreader-plugin/bookorbit.koplugin/bookorbit_catalog_widgets.lua

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

Archivo original ↗Preguntar a KiBot
--[[--
Cover and book-item widgets for the BookOrbit catalog browser.

`buildCoverWidget`/`buildFakeCover` render a real cover image or a text
placeholder. `MosaicItem`/`ListItem` are the tappable cells the catalog Menu
lays out; they read cached thumbnails and labels back from the owning menu.
]]

local BD = require("ui/bidi")
local Blitbuffer = require("ffi/blitbuffer")
local CenterContainer = require("ui/widget/container/centercontainer")
local Device = require("device")
local FrameContainer = require("ui/widget/container/framecontainer")
local Geom = require("ui/geometry")
local GestureRange = require("ui/gesturerange")
local HorizontalGroup = require("ui/widget/horizontalgroup")
local HorizontalSpan = require("ui/widget/horizontalspan")
local IconWidget = require("ui/widget/iconwidget")
local ImageWidget = require("ui/widget/imagewidget")
local InputContainer = require("ui/widget/container/inputcontainer")
local LeftContainer = require("ui/widget/container/leftcontainer")
local LineWidget = require("ui/widget/linewidget")
local OverlapGroup = require("ui/widget/overlapgroup")
local ProgressWidget = require("ui/widget/progresswidget")
local RightContainer = require("ui/widget/container/rightcontainer")
local TextBoxWidget = require("ui/widget/textboxwidget")
local TextWidget = require("ui/widget/textwidget")
local VerticalGroup = require("ui/widget/verticalgroup")
local VerticalSpan = require("ui/widget/verticalspan")
local _ = require("gettext")

local CatalogUtil = require("bookorbit_catalog_util")
local BoardMetrics = require("bookorbit_board_metrics")

local px = BoardMetrics.px
local shortText = CatalogUtil.shortText
local firstAuthor = CatalogUtil.firstAuthor

local SELECTED_BACKGROUND = Blitbuffer.COLOR_LIGHT_GRAY
local SELECTED_TEXT = Blitbuffer.COLOR_DARK_GRAY
local READ_STATUS_BADGE_ICONS = {
    want_to_read = "bookmark",
    reading = "dogear.reading",
    on_hold = "dogear.abandoned",
    rereading = "cre.render.reload",
    read = "check",
    skimmed = "check",
    abandoned = "dogear.abandoned",
}

local CatalogWidgets = {}

-- Mirrors TextBoxWidget's own line height math so text blocks can be sized in
-- whole lines (same helper as the detail page uses).
local function lineHeight(face)
    return math.floor(1.3 * face.size + 0.5)
end

-- A device with a D-Pad but no touchscreen (Kindle 3/4 and friends) drives the
-- catalog entirely through FocusManager, so every tappable widget has to be
-- able to draw the cursor that says where the next Press will land. Touch
-- devices never show it, so they never pay for the extra frame.
local FOCUS_NAV = Device:hasDPad() and not Device:isTouchDevice()
--- Wraps a widget's root container in a frame that paints a focus box while the
--- FocusManager has it selected. The box is an *inner* border, drawn within the
--- frame's own bounds, so the widget measures the same focused or not: the
--- dashboard and detail pages lay themselves out from fixed dimensions, and a
--- cursor that changed a widget's size would reflow the page under it.
function CatalogWidgets.focusable(child)
    if not FOCUS_NAV then return child end
    return FrameContainer:new{
        margin = 0,
        padding = 0,
        bordersize = 0,
        focusable = true,
        focus_inner_border = true,
        focus_border_size = px(3),
        child,
    }
end

--- Whether the running device navigates by focus rather than by touch. Callers
--- use it to skip focus bookkeeping that would be dead weight on a touchscreen.
function CatalogWidgets.focusNavigation()
    return FOCUS_NAV
end

--- Gives a stock KOReader menu row the same focus box as the widgets above.
---
--- Menu marks its focused row with a hairline underneath it. Upstream calls that
--- line "really, really, really thin" and declines to thicken it because doing
--- so shifts the row's text; on a list of rows that already have separators it
--- is not a cursor a reader can find. An inner border is as visible as the boxes
--- everywhere else in the catalog and changes no geometry, so it does not carry
--- that cost. Applied to the item's own frame, which answers the Focus event
--- before the row does and so replaces the underline rather than adding to it.
function CatalogWidgets.focusableMenuItem(item)
    if not FOCUS_NAV then return end
    local frame = item and item[1]
    if type(frame) ~= "table" or frame.focusable then return end
    frame.focusable = true
    frame.focus_inner_border = true
    frame.focus_border_size = px(3)
end

-- Absolute paths to the plugin's own SVG icons, resolved once by the catalog
-- (widgets cannot know the plugin directory). A missing entry falls back to
-- the stock KOReader glyph named alongside it.
local asset_icon_files = {}

function CatalogWidgets.setAssetIconFiles(files)
    asset_icon_files = type(files) == "table" and files or {}
end

function CatalogWidgets.assetIconFile(name)
    return asset_icon_files[name]
end

local function readStatusBadgeIcon(book)
    local status = book and book.readStatus
    if not status or status == "" or status == "unread" then return nil end
    return READ_STATUS_BADGE_ICONS[status] or "dogear.reading"
end

-- The dogear assets are drawn as a top-left page fold, and the badge sits in
-- the cover's top-right corner, so those need a quarter turn. The rest of the
-- set are upright glyphs that must not be turned with them.
local function readStatusBadgeRotation(icon)
    return icon:match("^dogear%.") and 270 or 0
end

local function rowFontSize(nominal, max_size, row_h)
    local size = math.floor(nominal * row_h / px(64))
    if max_size and size > max_size then return max_size end
    return math.max(8, size)
end

local function longestLineLength(text)
    local longest = 0
    for line in (tostring(text or "") .. "\n"):gmatch("(.-)\n") do
        longest = math.max(longest, #line)
    end
    return math.max(1, longest)
end

local function mosaicLabelFontSize(text, width, height)
    local longest = longestLineLength(text)
    local dp = BoardMetrics.get().dp
    local height_size = math.floor(height / dp * 0.46)
    local width_size = math.floor(width / dp / math.max(8, math.min(longest, 16)) * 2.05)
    local card_size = math.floor(width / dp * (1 / 9.5))
    return math.max(13, math.min(20, height_size, width_size, card_size))
end

-- A slim borderless progress bar, e-ink friendly (no animation): a light-gray
-- track with a solid black fill, used to visualize reading progress on cards.
function CatalogWidgets.buildProgressBar(percentage, width)
    if not percentage or percentage <= 0 then return nil end
    local bar = ProgressWidget:new{
        width = width,
        height = px(3),
        percentage = math.min(1, percentage / 100),
        margin_h = 0,
        margin_v = 0,
        bordersize = 0,
        bgcolor = Blitbuffer.COLOR_LIGHT_GRAY,
        fillcolor = Blitbuffer.COLOR_BLACK,
    }
    return bar
end

function CatalogWidgets.buildFakeCover(book, width, height, footer, quiet, bordersize)
    bordersize = bordersize or math.max(1, px(0.5))
    local padding = px(5)
    local inner_w = math.max(1, width - 2 * padding - 2 * bordersize)
    local inner_h = math.max(1, height - 2 * padding - 2 * bordersize)
    if quiet then
        local label = TextWidget:new{
            text = footer or _("No cover"),
            face = BoardMetrics.namedFace("xx_smallinfofont", 18),
            fgcolor = Blitbuffer.COLOR_DARK_GRAY,
            max_width = inner_w,
        }
        -- On a narrow cover the label survives only as an initial and an
        -- ellipsis, which reads as damage; an empty frame says "no cover"
        -- better than "C..." does.
        if label:isTruncated() then
            label:free()
            label = HorizontalSpan:new{ width = inner_w }
        end
        return FrameContainer:new{
            width = width,
            height = height,
            margin = 0,
            padding = padding,
            bordersize = bordersize,
            background = Blitbuffer.COLOR_WHITE,
            CenterContainer:new{
                dimen = Geom:new{ w = inner_w, h = inner_h },
                label,
            },
        }
    end

    -- Blocks are measured in whole lines and each takes only what it needs, so
    -- the group can be centred in the cover box. Splitting the box into fixed
    -- proportions instead left a short title floating above a band of white and
    -- clipped a two-word author to "Arkady...". Anything that still does not fit
    -- is dropped from the bottom up rather than ellipsized into a fragment.
    -- Sized explicitly: the face defaults run 22/20/18, so leaving the author
    -- and footer unsized set them larger than a title asked for at 16 and stood
    -- the hierarchy on its head.
    local title_face = BoardMetrics.namedFace("smallinfofont", 17)
    local author_face = BoardMetrics.namedFace("x_smallinfofont", 14)
    local footer_face = BoardMetrics.namedFace("xx_smallinfofont", 12)
    local gap = px(2)
    local author = book and firstAuthor(book) or nil
    local has_footer = footer ~= nil and footer ~= ""

    local function textBlock(text, face, max_lines)
        return TextBoxWidget:new{
            text = text,
            width = inner_w,
            height = max_lines * lineHeight(face),
            height_adjust = true,
            alignment = "center",
            face = face,
            height_overflow_show_ellipsis = true,
        }
    end

    local function buildContent(with_author, with_footer)
        local group = VerticalGroup:new{ align = "center" }
        table.insert(group, textBlock(
            BD.auto(shortText(book and book.title or _("Untitled"), 60)), title_face, 3))
        if with_author then
            table.insert(group, VerticalSpan:new{ width = gap })
            table.insert(group, textBlock(BD.auto(shortText(author, 44)), author_face, 2))
        end
        if with_footer then
            table.insert(group, VerticalSpan:new{ width = gap })
            table.insert(group, textBlock(footer, footer_face, 2))
        end
        return group
    end

    local content = buildContent(author ~= nil, has_footer)
    if content:getSize().h > inner_h and has_footer then
        content = buildContent(author ~= nil, false)
    end
    if content:getSize().h > inner_h and author then
        content = buildContent(false, false)
    end

    return FrameContainer:new{
        width = width,
        height = height,
        margin = 0,
        padding = padding,
        bordersize = bordersize,
        background = Blitbuffer.COLOR_WHITE,
        CenterContainer:new{
            dimen = Geom:new{ w = inner_w, h = inner_h },
            content,
        },
    }
end

-- opts.no_border drops the cover's own outline for callers that already draw
-- one around it (the dashboard cards), so a card is not ringed twice.
-- opts.quiet_placeholder swaps the titled fake cover for a bare one while a
-- cover is still downloading.
function CatalogWidgets.buildCoverWidget(book, width, height, path, state, opts)
    opts = opts or {}
    local bordersize = opts.no_border and 0 or math.max(1, px(0.5))
    if path then
        local inner_w = math.max(1, width - 2 * bordersize)
        local inner_h = math.max(1, height - 2 * bordersize)
        return FrameContainer:new{
            width = width,
            height = height,
            margin = 0,
            padding = 0,
            bordersize = bordersize,
            background = Blitbuffer.COLOR_WHITE,
            CenterContainer:new{
                dimen = Geom:new{ w = inner_w, h = inner_h },
                ImageWidget:new{
                    file = path,
                    width = inner_w,
                    height = inner_h,
                    scale_factor = 0,
                },
            },
        }
    end

    local footer
    if state == "loading" then
        footer = _("Loading cover")
    elseif state == "failed" then
        footer = _("Cover unavailable")
    else
        footer = _("No cover")
    end
    -- Quiet placeholders are reserved for covers still loading; a cover that is
    -- missing or failed keeps the titled fake cover so the book stays
    -- identifiable on caption-less shelves. always_quiet is for callers whose
    -- own row already names the book, where a titled placeholder would only
    -- repeat it - badly, since those covers are far too narrow to set it in.
    local quiet = opts.always_quiet == true
        or (opts.quiet_placeholder == true and state == "loading")
    return CatalogWidgets.buildFakeCover(book, width, height, footer, quiet, bordersize)
end

-- Badges sit on a small white plate so they stay legible over busy cover art;
-- a bare glyph disappears against dark covers on e-ink.
local function badgeChip(icon_widget)
    return FrameContainer:new{
        margin = 0,
        padding = px(1),
        bordersize = math.max(1, px(0.5)),
        radius = px(2),
        background = Blitbuffer.COLOR_WHITE,
        icon_widget,
    }
end

function CatalogWidgets.buildReadStatusBadge(book, max_width)
    local icon = readStatusBadgeIcon(book)
    if not icon then return nil end

    local size = math.max(px(10), math.min(max_width, px(16)))
    return badgeChip(IconWidget:new{
        icon = icon,
        rotation_angle = readStatusBadgeRotation(icon),
        width = size,
        height = size,
    })
end

function CatalogWidgets.buildSelectionBadge(max_width)
    local size = math.max(px(13), math.min(max_width, px(22)))
    return badgeChip(IconWidget:new{
        icon = "check",
        width = size,
        height = size,
    })
end

-- IconWidget resolves either a stock glyph name or an absolute file path;
-- never hand it both.
local function iconOrFile(icon, file)
    if file then return { file = file } end
    return { icon = icon }
end

function CatalogWidgets.buildDownloadedBadge(max_width)
    local size = math.max(px(11), math.min(max_width, px(18)))
    local opts = iconOrFile("appbar.filebrowser", CatalogWidgets.assetIconFile("on_device"))
    opts.width = size
    opts.height = size
    return badgeChip(IconWidget:new(opts))
end

function CatalogWidgets.buildCoverWithStateBadges(book, width, height, path, state, downloaded, selected, cover_opts)
    local cover = CatalogWidgets.buildCoverWidget(book, width, height, path, state, cover_opts)
    if not downloaded and not selected then return cover end

    local group = OverlapGroup:new{
        dimen = Geom:new{ w = width, h = height },
        allow_mirroring = false,
        cover,
    }
    if downloaded then
        local badge = CatalogWidgets.buildDownloadedBadge(math.floor(math.min(width, height) * 0.18))
        badge.overlap_align = "left"
        table.insert(group, badge)
    end
    if selected then
        local badge = CatalogWidgets.buildSelectionBadge(math.floor(math.min(width, height) * 0.18))
        badge.overlap_align = "right"
        table.insert(group, badge)
    end
    return group
end

local function selectedTextColor(selected)
    return selected and SELECTED_TEXT or nil
end

local function selectedBackground(selected)
    return selected and SELECTED_BACKGROUND or Blitbuffer.COLOR_WHITE
end

local function selectedTextBgColor(selected)
    return selected and SELECTED_BACKGROUND or nil
end

local MosaicItem = InputContainer:extend{
    entry = nil,
    dimen = nil,
    menu = nil,
    text = nil,
}

function MosaicItem:init()
    self.ges_events = {
        TapSelect = {
            GestureRange:new{
                ges = "tap",
                range = self.dimen,
            },
        },
        HoldSelect = {
            GestureRange:new{
                ges = "hold",
                range = self.dimen,
            },
        },
    }

    local book = self.entry.book
    local show_label = self.menu.mosaic_show_titles == true
    -- Reserved for every cell, drawn only where there is progress: sizing the
    -- cell from whether this particular book has a bar left covers in the same
    -- row sitting a few pixels off each other.
    local progress_h = px(3)
    local bar_reserve = progress_h + px(2)
    local label_h = show_label and math.max(px(44), math.floor(self.dimen.h * 0.24)) or 0
    local label_gap = show_label and px(2) or 0
    local max_cover_w = math.max(1, self.dimen.w - 2 * px(5))
    local available_cover_h = math.max(1, self.dimen.h - label_h - label_gap - bar_reserve)
    local cover_h = math.min(available_cover_h, math.floor(max_cover_w / 0.68))
    cover_h = math.max(math.min(px(60), available_cover_h), cover_h)
    local cover_w = math.min(max_cover_w, math.floor(cover_h * 0.68))

    local path, state = self.menu:thumbnailDisplay(book)
    local downloaded = self.menu:isOnDevice(book)
    local selected = self.menu.bulkIsBookSelected and self.menu:bulkIsBookSelected(book)
    local content = VerticalGroup:new{ align = "center" }
    table.insert(
        content,
        -- With labels on, the cell already names the book below the cover, so a
        -- titled placeholder would just say it twice.
        CatalogWidgets.buildCoverWithStateBadges(
            book, cover_w, cover_h, path, state, downloaded, selected,
            { always_quiet = show_label }))
    local bar = CatalogWidgets.buildProgressBar(book and book.progressPercentage, cover_w)
    table.insert(content, VerticalSpan:new{ width = px(2) })
    table.insert(content, bar or VerticalSpan:new{ width = progress_h })
    if show_label then
        local label_text = shortText(book and book.title or _("Untitled"), 30)
        local label_w = math.max(1, self.dimen.w - 2 * px(1))
        table.insert(content, VerticalSpan:new{ width = px(2) })
        table.insert(content, TextBoxWidget:new{
            text = label_text,
            width = label_w,
            height = label_h,
            alignment = "center",
            fgcolor = selectedTextColor(selected),
            bgcolor = selectedTextBgColor(selected),
            face = BoardMetrics.namedFace("cfont", mosaicLabelFontSize(label_text, label_w, label_h)),
            height_overflow_show_ellipsis = true,
        })
    end

    local body = CenterContainer:new{
        dimen = Geom:new{ w = self.dimen.w, h = self.dimen.h },
        content,
    }
    self[1] = CatalogWidgets.focusable(FrameContainer:new{
        width = self.dimen.w,
        height = self.dimen.h,
        margin = 0,
        padding = 0,
        bordersize = 0,
        background = selectedBackground(selected),
        body,
    })
end

function MosaicItem:onTapSelect()
    self.menu:onMenuSelect(self.entry)
    return true
end

function MosaicItem:onHoldSelect()
    if self.menu.onMenuHoldSelect then
        self.menu:onMenuHoldSelect(self.entry)
    else
        self.menu:onMenuSelect(self.entry)
    end
    return true
end

local ListItem = InputContainer:extend{
    entry = nil,
    dimen = nil,
    menu = nil,
}

function ListItem:init()
    self.ges_events = {
        TapSelect = {
            GestureRange:new{
                ges = "tap",
                range = self.dimen,
            },
        },
        HoldSelect = {
            GestureRange:new{
                ges = "hold",
                range = self.dimen,
            },
        },
    }

    local book = self.entry.book
    local pad = px(2)
    local gap = px(10)
    local separator_h = math.max(1, px(1))
    local inner_h = math.max(1, self.dimen.h - separator_h)
    local content_h = math.max(1, inner_h - 2 * px(2))
    local cover_h = math.max(px(38), math.min(px(74), content_h))
    local cover_w = math.floor(cover_h * 0.68)
    -- Reserved on every row so the title and subtitle sit on the same lines
    -- whether or not this book has a progress bar under them.
    local progress_h = px(3)
    local bar_reserve = progress_h + px(2)
    local left_w = cover_w + 2 * pad
    local side_meta_text = self.menu:listSideMetaText(book)
    local show_side_meta = side_meta_text ~= "" and self.dimen.w >= px(520)
    local right_w = show_side_meta and math.min(px(168), math.floor(self.dimen.w * 0.20)) or 0
    local right_pad = show_side_meta and pad or 0
    local main_x = left_w + gap
    local main_w = math.max(1, self.dimen.w - main_x - right_w - right_pad - gap)
    local text_h = math.max(1, content_h - bar_reserve)
    local title_font = rowFontSize(18, 20, inner_h)
    local subtitle_font = rowFontSize(14, 16, inner_h)
    local side_font = rowFontSize(13, 15, inner_h)
    local title_h = math.max(1, math.floor(text_h * 0.52))
    local subtitle_h = math.max(1, text_h - title_h)

    local path, state = self.menu:thumbnailDisplay(book)
    local downloaded = self.menu:isOnDevice(book)
    local selected = self.menu.bulkIsBookSelected and self.menu:bulkIsBookSelected(book)

    local text_col = VerticalGroup:new{ align = "left" }
    table.insert(text_col, TextBoxWidget:new{
        text = BD.auto(shortText(book and book.title or _("Untitled"), 58)),
        width = main_w,
        height = title_h,
        height_adjust = true,
        alignment = "left",
        bold = true,
        fgcolor = selectedTextColor(selected),
        bgcolor = selectedTextBgColor(selected),
        face = BoardMetrics.namedFace("cfont", title_font),
        height_overflow_show_ellipsis = true,
    })
    local subtitle = self.menu:listSubtitleLine(book)
    if subtitle then
        table.insert(text_col, TextBoxWidget:new{
            text = BD.auto(subtitle),
            width = main_w,
            height = subtitle_h,
            height_adjust = true,
            alignment = "left",
            fgcolor = selectedTextColor(selected),
            bgcolor = selectedTextBgColor(selected),
            face = BoardMetrics.namedFace("cfont", subtitle_font),
            height_overflow_show_ellipsis = true,
        })
    end

    local body_col = VerticalGroup:new{ align = "left" }
    table.insert(body_col, text_col)
    local bar = CatalogWidgets.buildProgressBar(book and book.progressPercentage, main_w)
    table.insert(body_col, VerticalSpan:new{ width = px(2) })
    table.insert(body_col, bar or VerticalSpan:new{ width = progress_h })

    local row_dimen = Geom:new{ w = self.dimen.w, h = inner_h }
    local row = OverlapGroup:new{
        dimen = row_dimen:copy(),
        LeftContainer:new{
            dimen = row_dimen:copy(),
            CenterContainer:new{
                dimen = Geom:new{ w = left_w, h = inner_h },
                -- The meta column already spells out "On device", so the badge
                -- would only repeat it on a cover far too small to spare the
                -- room; it stays for the narrow layouts that hide that column.
                CatalogWidgets.buildCoverWithStateBadges(
                    book, cover_w, cover_h, path, state,
                    downloaded and not show_side_meta, selected,
                    { always_quiet = true }),
            },
        },
        LeftContainer:new{
            dimen = row_dimen:copy(),
            HorizontalGroup:new{
                HorizontalSpan:new{ width = main_x },
                body_col,
            },
        },
    }
    if show_side_meta then
        table.insert(row, RightContainer:new{
            dimen = row_dimen:copy(),
            HorizontalGroup:new{
                TextBoxWidget:new{
                    text = side_meta_text,
                    width = right_w,
                    height = text_h,
                    height_adjust = true,
                    alignment = "right",
                    fgcolor = selectedTextColor(selected),
                    bgcolor = selectedTextBgColor(selected),
                    face = BoardMetrics.namedFace("cfont", side_font),
                    height_overflow_show_ellipsis = true,
                },
                HorizontalSpan:new{ width = right_pad },
            },
        })
    end
    local content = VerticalGroup:new{ align = "left" }
    table.insert(content, row)
    table.insert(content, LineWidget:new{
        background = Blitbuffer.COLOR_LIGHT_GRAY,
        dimen = Geom:new{ w = self.dimen.w, h = separator_h },
    })

    self[1] = CatalogWidgets.focusable(FrameContainer:new{
        width = self.dimen.w,
        height = self.dimen.h,
        margin = 0,
        padding = 0,
        bordersize = 0,
        background = selectedBackground(selected),
        content,
    })
end

function ListItem:onTapSelect()
    self.menu:onMenuSelect(self.entry)
    return true
end

function ListItem:onHoldSelect()
    if self.menu.onMenuHoldSelect then
        self.menu:onMenuHoldSelect(self.entry)
    else
        self.menu:onMenuSelect(self.entry)
    end
    return true
end

-- Shared card chrome: thin rounded border on a white fill.
CatalogWidgets.MosaicItem = MosaicItem
CatalogWidgets.ListItem = ListItem

return CatalogWidgets