Properly distinguish double / single clicks

This commit is contained in:
jpirnay
2026-05-14 16:15:32 +02:00
parent da9b3fe60d
commit 3eeee72d23
2 changed files with 230 additions and 183 deletions
+218 -182
View File
@@ -4,6 +4,7 @@
#include <Epub.h>
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <HalGPIO.h>
#include <HalStorage.h>
#include <I18n.h>
#include <JpegToBmpConverter.h>
@@ -25,8 +26,6 @@
#include "fontIds.h"
namespace {
// Mirror of HomeActivity::convertSidecarToBmp — converts a JPG/PNG sidecar cover to a
// 1-bit BMP at the requested WxH size and caches it under /.crosspoint/sidecar_<hash>/.
std::string convertSidecarToBmp(const std::string& bookPath, const std::string& sidecarPath, int width, int height,
const std::string& fileName) {
const std::string cacheDir = "/.crosspoint/sidecar_" + std::to_string(std::hash<std::string>{}(bookPath));
@@ -57,14 +56,12 @@ std::string convertSidecarToBmp(const std::string& bookPath, const std::string&
return bmpPath;
}
// Compute the grid thumbnail width from the available content width.
int gridThumbWidth(int contentWidth) {
const int margin = RecentBooksActivity::GRID_THUMB_MARGIN;
const int cols = RecentBooksActivity::GRID_COLS;
return (contentWidth - (cols + 1) * margin) / cols;
}
// Resolve the [HEIGHT] placeholder in a cover BMP path to the WxH thumbnail path.
std::string gridThumbPath(const std::string& coverBmpPath, int tw, int th) {
return UITheme::getCoverThumbPath(coverBmpPath, tw, th);
}
@@ -98,8 +95,7 @@ bool RecentBooksActivity::loadNextCover() {
book.coverBmpPath = "";
continue;
}
const std::string cacheBase =
"/.crosspoint/sidecar_" + std::to_string(std::hash<std::string>{}(book.path));
const std::string cacheBase = "/.crosspoint/sidecar_" + std::to_string(std::hash<std::string>{}(book.path));
const std::string placeholder = cacheBase + "/[HEIGHT].bmp";
const std::string name = std::to_string(tw) + "x" + std::to_string(th) + ".bmp";
const std::string result = convertSidecarToBmp(book.path, book.coverBmpPath, tw, th, name);
@@ -108,7 +104,7 @@ bool RecentBooksActivity::loadNextCover() {
book.coverBmpPath = placeholder;
}
nextCoverIndex++;
return false; // yield — one conversion per loop tick
return false;
}
const std::string thumbPath = gridThumbPath(book.coverBmpPath, tw, th);
@@ -127,11 +123,11 @@ bool RecentBooksActivity::loadNextCover() {
book.coverBmpPath = "";
}
nextCoverIndex++;
return false; // yield — one generation per loop tick
return false;
}
}
return true; // all thumbnails are ready
return true;
}
void RecentBooksActivity::onEnter() {
@@ -149,6 +145,8 @@ void RecentBooksActivity::onEnter() {
coversLoading = false;
firstRenderDone = false;
nextCoverIndex = 0;
prevSelectorIndex = -1;
fullRedrawNeeded = true;
requestUpdate();
}
@@ -158,13 +156,57 @@ void RecentBooksActivity::onExit() {
recentBooks.clear();
}
void RecentBooksActivity::switchViewMode(bool grid) {
APP_STATE.recentBooksGridView = grid;
APP_STATE.saveToFile();
coversLoaded = false;
coversLoading = false;
firstRenderDone = false;
nextCoverIndex = 0;
prevSelectorIndex = -1;
fullRedrawNeeded = true;
requestUpdate(true);
}
void RecentBooksActivity::removeSelectedBook() {
if (recentBooks.empty() || selectorIndex >= static_cast<int>(recentBooks.size())) return;
const std::string bookPath = recentBooks[selectorIndex].path;
const std::string bookTitle = recentBooks[selectorIndex].title;
auto handler = [this, bookPath](const ActivityResult& res) {
if (!res.isCancelled) {
LOG_DBG("RBA", "Removing from recent books: %s", bookPath.c_str());
RECENT_BOOKS.removeBook(bookPath);
loadRecentBooks();
if (recentBooks.empty()) {
selectorIndex = 0;
} else if (selectorIndex >= static_cast<int>(recentBooks.size())) {
selectorIndex = static_cast<int>(recentBooks.size()) - 1;
}
prevSelectorIndex = -1;
fullRedrawNeeded = true;
requestUpdate(true);
}
};
std::string heading = tr(STR_REMOVE) + std::string("? ");
startActivityForResult(std::make_unique<ConfirmationActivity>(renderer, mappedInput, heading, bookTitle), handler);
}
void RecentBooksActivity::showSelectedBookInfo() {
if (recentBooks.empty() || selectorIndex >= static_cast<int>(recentBooks.size())) return;
const std::string& path = recentBooks[selectorIndex].path;
if (FsHelpers::hasEpubExtension(path) || FsHelpers::hasXtcExtension(path)) {
startActivityForResult(std::make_unique<BookInfoActivity>(renderer, mappedInput, path),
[this](const ActivityResult&) { requestUpdate(); });
}
}
void RecentBooksActivity::loop() {
const bool gridView = APP_STATE.recentBooksGridView;
const int listSize = static_cast<int>(recentBooks.size());
// --- Consume named button events first ---
ButtonEventManager::ButtonEvent ev;
while (buttonEvents.consumeEvent(ev)) {
// Open book (Confirm short/long)
// Confirm short/long: open book (long = KOReader sync for EPUBs)
if (ev.button == MappedInputManager::Button::Confirm &&
(ev.type == ButtonEventManager::PressType::Short || ev.type == ButtonEventManager::PressType::Long)) {
if (recentBooks.empty() || selectorIndex >= static_cast<int>(recentBooks.size())) return;
@@ -186,113 +228,69 @@ void RecentBooksActivity::loop() {
return;
}
// Back home
// Back short: go home
if (ev.button == MappedInputManager::Button::Back && ev.type == ButtonEventManager::PressType::Short) {
onGoHome();
return;
}
// Left short: switch view or (in list view) remove book
if (ev.button == MappedInputManager::Button::Left && ev.type == ButtonEventManager::PressType::Short) {
if (!gridView) {
// List view → switch to grid view
APP_STATE.recentBooksGridView = true;
APP_STATE.saveToFile();
coversLoaded = false;
coversLoading = false;
firstRenderDone = false;
nextCoverIndex = 0;
requestUpdate(true);
return;
// Up short: navigate (row up in grid, previous in list)
if (ev.button == MappedInputManager::Button::Up && ev.type == ButtonEventManager::PressType::Short) {
if (!recentBooks.empty()) {
if (gridView) {
selectorIndex = std::max(0, selectorIndex - GRID_COLS);
} else {
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, listSize);
}
requestUpdate();
}
// Grid view: Left navigates to the previous item (column nav). The event is already
// consumed from the queue, so handle it directly here.
if (recentBooks.empty()) return;
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, static_cast<int>(recentBooks.size()));
requestUpdate();
continue;
}
// Down short: navigate (row down in grid, next in list)
if (ev.button == MappedInputManager::Button::Down && ev.type == ButtonEventManager::PressType::Short) {
if (!recentBooks.empty()) {
if (gridView) {
selectorIndex = std::min(listSize - 1, selectorIndex + GRID_COLS);
} else {
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, listSize);
}
requestUpdate();
}
continue;
}
// Up long: toggle between list and grid view
if (ev.button == MappedInputManager::Button::Up && ev.type == ButtonEventManager::PressType::Long) {
switchViewMode(!gridView);
return;
}
// Right short (list view only): show book info
if (ev.button == MappedInputManager::Button::Right && ev.type == ButtonEventManager::PressType::Short) {
if (!gridView) {
if (recentBooks.empty() || selectorIndex >= static_cast<int>(recentBooks.size())) return;
const std::string& path = recentBooks[selectorIndex].path;
if (FsHelpers::hasEpubExtension(path) || FsHelpers::hasXtcExtension(path)) {
startActivityForResult(std::make_unique<BookInfoActivity>(renderer, mappedInput, path),
[this](const ActivityResult&) { requestUpdate(); });
return;
}
} else {
// Grid view: Right → move to next item (column navigation)
if (recentBooks.empty()) return;
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, static_cast<int>(recentBooks.size()));
requestUpdate();
return;
}
// Left long: remove selected book (both views)
if (ev.button == MappedInputManager::Button::Left && ev.type == ButtonEventManager::PressType::Long) {
removeSelectedBook();
return;
}
// Left long (list view only): remove book
if (ev.button == MappedInputManager::Button::Left && ev.type == ButtonEventManager::PressType::Long) {
if (!gridView) {
if (recentBooks.empty() || selectorIndex >= static_cast<int>(recentBooks.size())) return;
const std::string bookPath = recentBooks[selectorIndex].path;
const std::string bookTitle = recentBooks[selectorIndex].title;
auto handler = [this, bookPath](const ActivityResult& res) {
if (!res.isCancelled) {
LOG_DBG("RBA", "Removing from recent books: %s", bookPath.c_str());
RECENT_BOOKS.removeBook(bookPath);
loadRecentBooks();
if (recentBooks.empty()) {
selectorIndex = 0;
} else if (selectorIndex >= static_cast<int>(recentBooks.size())) {
selectorIndex = static_cast<int>(recentBooks.size()) - 1;
}
requestUpdate(true);
}
};
std::string heading = tr(STR_REMOVE) + std::string("? ");
startActivityForResult(
std::make_unique<ConfirmationActivity>(renderer, mappedInput, heading, bookTitle), handler);
return;
}
// Right long: show book info (both views)
if (ev.button == MappedInputManager::Button::Right && ev.type == ButtonEventManager::PressType::Long) {
showSelectedBookInfo();
return;
}
}
// --- ButtonNavigator for Up/Down (both views), and list-view Remove shortcut ---
const int listSize = static_cast<int>(recentBooks.size());
if (gridView) {
// Up/Down navigate by full row (GRID_COLS items at a time)
if (mappedInput.wasPressed(MappedInputManager::Button::Up)) {
if (!recentBooks.empty()) {
selectorIndex = std::max(0, selectorIndex - GRID_COLS);
requestUpdate();
}
}
if (mappedInput.wasPressed(MappedInputManager::Button::Down)) {
if (!recentBooks.empty()) {
selectorIndex = std::min(listSize - 1, selectorIndex + GRID_COLS);
requestUpdate();
}
}
// Lazy cover loading after first render
if (firstRenderDone && !coversLoaded && !coversLoading) {
coversLoading = true;
if (loadNextCover()) {
coversLoaded = true;
} else {
requestUpdate();
}
coversLoading = false;
}
} else {
// List view: Up/Down with double-click and long-press special behaviour
buttonNavigator.onNextList({MappedInputManager::Button::Down}, selectorIndex, listSize,
// Left/Right short: column navigation (no long-press conflict on these buttons)
buttonNavigator.onNextList({MappedInputManager::Button::Right}, selectorIndex, listSize,
[this] { requestUpdate(); });
buttonNavigator.onPreviousList({MappedInputManager::Button::Up}, selectorIndex, listSize,
buttonNavigator.onPreviousList({MappedInputManager::Button::Left}, selectorIndex, listSize,
[this] { requestUpdate(); });
// (Up/Down handled via buttonEvents Short above)
} else {
// Left/Right short: consumed cleanly (no-op in list view but prevents phantom events)
buttonNavigator.onNextList({MappedInputManager::Button::Right}, selectorIndex, listSize,
[this] { requestUpdate(); });
buttonNavigator.onPreviousList({MappedInputManager::Button::Left}, selectorIndex, listSize,
[this] { requestUpdate(); });
}
}
@@ -303,6 +301,15 @@ void RecentBooksActivity::render(RenderLock&& lock) {
if (!firstRenderDone) {
firstRenderDone = true;
requestUpdate();
} else if (!coversLoaded && !coversLoading) {
coversLoading = true;
if (loadNextCover()) {
coversLoaded = true;
} else {
fullRedrawNeeded = true;
requestUpdate();
}
coversLoading = false;
}
} else {
renderListView(std::move(lock));
@@ -327,8 +334,7 @@ void RecentBooksActivity::renderListView(RenderLock&&) {
} else {
GUI.drawList(
renderer, Rect{contentRect.x, contentTop, contentRect.width, contentHeight},
static_cast<int>(recentBooks.size()), selectorIndex,
[this](int index) { return recentBooks[index].title; },
static_cast<int>(recentBooks.size()), selectorIndex, [this](int index) { return recentBooks[index].title; },
[this](int index) {
const auto& book = recentBooks[index];
if (!book.author.empty() && !book.series.empty()) return book.author + "\n" + book.series;
@@ -338,113 +344,134 @@ void RecentBooksActivity::renderListView(RenderLock&&) {
[this](int index) { return UITheme::getFileIcon(recentBooks[index].path); });
}
const bool hasInfo = !recentBooks.empty() && selectorIndex < static_cast<int>(recentBooks.size()) &&
(FsHelpers::hasEpubExtension(recentBooks[selectorIndex].path) ||
FsHelpers::hasXtcExtension(recentBooks[selectorIndex].path));
const bool hasBooks = !recentBooks.empty();
// Left short switches to grid view; label it accordingly. Long Left = remove.
const auto labels =
mappedInput.mapLabels(tr(STR_HOME), hasBooks ? tr(STR_OPEN) : "", tr(STR_VIEW_GRID),
hasInfo ? tr(STR_INFO) : "");
const auto labels = mappedInput.mapLabels(tr(STR_HOME), hasBooks ? tr(STR_OPEN) : "", "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
GUI.drawSideButtonHints(renderer, tr(STR_DIR_UP), tr(STR_DIR_DOWN));
renderer.displayBuffer();
}
void RecentBooksActivity::renderGridView(RenderLock&&) {
renderer.clearScreen();
void RecentBooksActivity::renderGridCell(int index, bool selected, int cellX, int cellY, int tw, int th, int labelW) {
const auto& book = recentBooks[index];
const int labelY = cellY + th + 3;
const int cellFillHeight = th + GRID_LABEL_HEIGHT + 3;
if (selected) {
renderer.fillRect(cellX, cellY, tw, cellFillHeight);
renderer.drawRect(cellX, cellY, tw, th, false);
} else {
// Clear to white before redrawing (needed when deselecting)
renderer.fillRect(cellX, cellY, tw, cellFillHeight, false);
renderer.drawRect(cellX, cellY, tw, th);
}
// White prefill inside thumbnail frame so 1-bit BMP transparent pixels show white
renderer.fillRect(cellX + 1, cellY + 1, tw - 2, th - 2, false);
if (!book.coverBmpPath.empty()) {
const std::string thumbPath = gridThumbPath(book.coverBmpPath, tw, th);
FsFile file;
if (Storage.openFileForRead("RBA", thumbPath, file)) {
Bitmap bmp(file);
if (bmp.parseHeaders() == BmpReaderError::Ok) {
const int imgW = bmp.getWidth();
const int imgH = bmp.getHeight();
const int innerW = tw - 2;
const int innerH = th - 2;
if (imgW > 0 && imgH > 0) {
// Mirror drawBitmap1Bit scale = min(maxW/imgW, maxH/imgH) to get rendered size,
// then center the image within the frame.
const float scaleX = static_cast<float>(innerW) / imgW;
const float scaleY = static_cast<float>(innerH) / imgH;
const float scale = std::min(scaleX, scaleY);
const int rendW = static_cast<int>(imgW * scale);
const int rendH = static_cast<int>(imgH * scale);
const int offsetX = std::max(1, (tw - rendW) / 2);
const int offsetY = std::max(1, (th - rendH) / 2);
renderer.drawBitmap1Bit(bmp, cellX + offsetX, cellY + offsetY, innerW, innerH);
}
}
file.close();
}
}
// Label: title line 1, author line 2; white text on black for selected, black on white otherwise
const bool black = !selected;
std::string titleStr = renderer.truncatedText(SMALL_FONT_ID, book.title.c_str(), labelW);
renderer.drawText(SMALL_FONT_ID, cellX + 2, labelY, titleStr.c_str(), black);
if (!book.author.empty()) {
std::string authorStr = renderer.truncatedText(SMALL_FONT_ID, book.author.c_str(), labelW);
renderer.drawText(SMALL_FONT_ID, cellX + 2, labelY + 17, authorStr.c_str(), black);
}
}
void RecentBooksActivity::renderGridView(RenderLock&&) {
const auto& metrics = UITheme::getInstance().getMetrics();
const Rect contentRect = UITheme::getContentRect(renderer, true, true);
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const int contentHeight = contentRect.height - contentTop - metrics.verticalSpacing;
const int margin = GRID_THUMB_MARGIN;
const int tw = gridThumbWidth(contentRect.width);
const int th = GRID_THUMB_HEIGHT;
const int cellHeight = th + GRID_LABEL_HEIGHT + margin;
const int visibleRows = std::max(1, contentHeight / cellHeight);
const int totalRows = (static_cast<int>(recentBooks.size()) + GRID_COLS - 1) / GRID_COLS;
const int selectedRow = selectorIndex / GRID_COLS;
const int pageStartRow = (selectedRow / visibleRows) * visibleRows;
const int startIndex = pageStartRow * GRID_COLS;
const int labelW = tw - 4;
auto cellPos = [&](int i, int& cx, int& cy) {
const int row = (i / GRID_COLS) - pageStartRow;
const int col = i % GRID_COLS;
cx = contentRect.x + margin + col * (tw + margin);
cy = contentTop + row * cellHeight;
};
// Partial fast path: only the selection changed within the same page
const int prevPage = prevSelectorIndex >= 0 ? (prevSelectorIndex / GRID_COLS / visibleRows) : -1;
const int curPage = selectedRow / visibleRows;
if (!fullRedrawNeeded && prevSelectorIndex >= 0 && prevSelectorIndex != selectorIndex && prevPage == curPage) {
int cx, cy;
cellPos(prevSelectorIndex, cx, cy);
renderGridCell(prevSelectorIndex, false, cx, cy, tw, th, labelW);
cellPos(selectorIndex, cx, cy);
renderGridCell(selectorIndex, true, cx, cy, tw, th, labelW);
prevSelectorIndex = selectorIndex;
renderer.displayBuffer();
return;
}
// Full redraw
fullRedrawNeeded = false;
prevSelectorIndex = selectorIndex;
renderer.clearScreen();
GUI.drawHeader(renderer, Rect{contentRect.x, metrics.topPadding, contentRect.width, metrics.headerHeight},
tr(STR_MENU_RECENT_BOOKS));
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const int contentHeight = contentRect.height - contentTop - metrics.verticalSpacing;
if (recentBooks.empty()) {
renderer.drawText(UI_10_FONT_ID, contentRect.x + metrics.contentSidePadding, contentTop + 20,
tr(STR_NO_RECENT_BOOKS));
const auto labels = mappedInput.mapLabels(tr(STR_HOME), "", tr(STR_VIEW_LIST), "");
const auto labels = mappedInput.mapLabels(tr(STR_HOME), "", "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
return;
}
const int margin = GRID_THUMB_MARGIN;
const int tw = gridThumbWidth(contentRect.width);
const int th = GRID_THUMB_HEIGHT;
const int cellHeight = th + GRID_LABEL_HEIGHT + margin;
const int visibleRows = std::max(1, contentHeight / cellHeight);
const int totalRows = (static_cast<int>(recentBooks.size()) + GRID_COLS - 1) / GRID_COLS;
const int selectedRow = selectorIndex / GRID_COLS;
const int pageStartRow = (selectedRow / visibleRows) * visibleRows;
const int startIndex = pageStartRow * GRID_COLS;
const int endIndex = std::min(startIndex + visibleRows * GRID_COLS, static_cast<int>(recentBooks.size()));
for (int i = startIndex; i < endIndex; i++) {
const int row = (i / GRID_COLS) - pageStartRow;
const int col = i % GRID_COLS;
const int cellX = contentRect.x + margin + col * (tw + margin);
const int cellY = contentTop + row * cellHeight;
const bool selected = (i == selectorIndex);
// Draw thumbnail border (double rect when selected)
if (selected) {
renderer.drawRect(cellX - 2, cellY - 2, tw + 4, th + 4);
renderer.drawRect(cellX - 1, cellY - 1, tw + 2, th + 2);
} else {
renderer.drawRect(cellX, cellY, tw, th);
}
// Draw cover BMP thumbnail
const auto& book = recentBooks[i];
bool coverDrawn = false;
if (!book.coverBmpPath.empty()) {
const std::string thumbPath = gridThumbPath(book.coverBmpPath, tw, th);
FsFile file;
if (Storage.openFileForRead("RBA", thumbPath, file)) {
Bitmap bmp(file);
if (bmp.parseHeaders() == BmpReaderError::Ok) {
renderer.drawBitmap1Bit(bmp, cellX, cellY, tw, th);
coverDrawn = true;
}
file.close();
}
}
if (!coverDrawn) {
// Empty placeholder — just a white box inside the border
renderer.fillRect(cellX + 1, cellY + 1, tw - 2, th - 2, false);
}
// Label: title on line 1, author on line 2
const int labelY = cellY + th + 3;
const int labelW = tw - 4;
const bool invert = !selected;
std::string titleStr = renderer.truncatedText(SMALL_FONT_ID, book.title.c_str(), labelW);
renderer.drawText(SMALL_FONT_ID, cellX + 2, labelY, titleStr.c_str(), invert);
if (!book.author.empty()) {
std::string authorStr = renderer.truncatedText(SMALL_FONT_ID, book.author.c_str(), labelW);
renderer.drawText(SMALL_FONT_ID, cellX + 2, labelY + 17, authorStr.c_str(), invert);
}
int cx, cy;
cellPos(i, cx, cy);
renderGridCell(i, i == selectorIndex, cx, cy, tw, th, labelW);
}
// Scroll arrows when content spans multiple pages
if (totalRows > visibleRows) {
constexpr int arrowSize = 6;
const int centerX = contentRect.x + contentRect.width / 2;
if (pageStartRow > 0) {
const int arrowY = contentTop + 2;
for (int j = 0; j < arrowSize; ++j) {
@@ -460,7 +487,16 @@ void RecentBooksActivity::renderGridView(RenderLock&&) {
}
}
const auto labels = mappedInput.mapLabels(tr(STR_HOME), tr(STR_OPEN), tr(STR_VIEW_LIST), "");
// On X4 (taller screen) there is room for a one-line gesture hint below the grid.
if (!gpio.deviceIsX3()) {
const int hintY = contentRect.y + contentRect.height - metrics.verticalSpacing - 14;
const std::string hint = std::string(tr(STR_DIR_UP)) + "+L: " + tr(STR_VIEW_GRID) + "/" + tr(STR_VIEW_LIST) +
" " + tr(STR_DIR_LEFT) + "+L: " + tr(STR_REMOVE) + " " + tr(STR_DIR_RIGHT) +
"+L: " + tr(STR_INFO);
renderer.drawText(SMALL_FONT_ID, contentRect.x + metrics.contentSidePadding, hintY, hint.c_str());
}
const auto labels = mappedInput.mapLabels(tr(STR_HOME), tr(STR_OPEN), "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
GUI.drawSideButtonHints(renderer, tr(STR_DIR_UP), tr(STR_DIR_DOWN));
+12 -1
View File
@@ -13,7 +13,7 @@
class RecentBooksActivity final : public Activity {
public:
static constexpr int GRID_COLS = 3;
static constexpr int GRID_THUMB_HEIGHT = 180;
static constexpr int GRID_THUMB_HEIGHT = 160;
static constexpr int GRID_THUMB_MARGIN = 10;
static constexpr int GRID_LABEL_HEIGHT = 36; // two small-font lines below each thumbnail
@@ -31,10 +31,21 @@ class RecentBooksActivity final : public Activity {
bool firstRenderDone = false;
size_t nextCoverIndex = 0;
// Partial selection repaint: track previous index so we only redraw two cells
int prevSelectorIndex = -1;
bool fullRedrawNeeded = true;
void loadRecentBooks();
// Generates the next missing grid thumbnail (one per call). Returns true when all done.
bool loadNextCover();
void switchViewMode(bool grid);
void removeSelectedBook();
void showSelectedBookInfo();
// Draws a single grid cell (used for both full render and partial selection update).
void renderGridCell(int index, bool selected, int cellX, int cellY, int tw, int th, int labelW);
void renderListView(RenderLock&&);
void renderGridView(RenderLock&&);