Files
crosspoint-reader-mod/src/activities/home/BookInfoActivity.cpp
cottongin 630fb56a11 feat: side-by-side cover layout for BookInfo in landscape
In landscape orientation the cover is pinned on the left panel
(filling the content height) while metadata fields scroll
independently on the right. Portrait layout is unchanged.

Made-with: Cursor
2026-03-09 04:00:41 -04:00

352 lines
12 KiB
C++

#include "BookInfoActivity.h"
#include <algorithm>
#include <Bitmap.h>
#include <Epub.h>
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <I18n.h>
#include <Logging.h>
#include <PlaceholderCoverGenerator.h>
#include <Xtc.h>
#include "components/UITheme.h"
#include "fontIds.h"
namespace {
constexpr int LABEL_VALUE_GAP = 4;
constexpr int SECTION_GAP = 14;
constexpr int MAX_WRAPPED_LINES = 60;
constexpr int COVER_GAP = 16;
std::string normalizeWhitespace(const std::string& s) {
std::string out;
out.reserve(s.size());
bool prevSpace = false;
for (const char c : s) {
if (c == '\n' || c == '\r' || c == '\t') {
if (!prevSpace) {
out += ' ';
prevSpace = true;
}
} else {
out += c;
prevSpace = (c == ' ');
}
}
return out;
}
} // namespace
void BookInfoActivity::onEnter() {
Activity::onEnter();
const auto orient = renderer.getOrientation();
isLandscape = orient == GfxRenderer::Orientation::LandscapeClockwise ||
orient == GfxRenderer::Orientation::LandscapeCounterClockwise;
std::string fileName = filePath;
const size_t lastSlash = filePath.rfind('/');
if (lastSlash != std::string::npos) {
fileName = filePath.substr(lastSlash + 1);
}
size_t fileSize = 0;
{
FsFile file;
if (Storage.openFileForRead("BIF", filePath, file)) {
fileSize = file.fileSize();
file.close();
}
}
const auto& metrics = UITheme::getInstance().getMetrics();
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const int contentBottom = renderer.getScreenHeight() - metrics.buttonHintsHeight - metrics.verticalSpacing;
const int coverH = isLandscape ? (contentBottom - contentTop) : (renderer.getScreenHeight() * 2 / 5);
BookMetadataCache::BookMetadata meta;
if (FsHelpers::hasEpubExtension(fileName)) {
Epub epub(filePath, "/.crosspoint");
bool needsBuild = !epub.load(false, true);
Rect popupRect{};
if (needsBuild) {
popupRect = GUI.drawPopup(renderer, tr(STR_LOADING));
GUI.fillPopupProgress(renderer, popupRect, 10);
epub.load(true, true);
GUI.fillPopupProgress(renderer, popupRect, 50);
}
meta = epub.getMetadata();
meta.description = normalizeWhitespace(meta.description);
if (epub.generateThumbBmp(coverH)) {
coverBmpPath = epub.getThumbBmpPath(coverH);
} else {
const int thumbW = static_cast<int>(coverH * 0.6);
const std::string placeholderPath = epub.getCachePath() + "/placeholder_" + std::to_string(coverH) + ".bmp";
if (PlaceholderCoverGenerator::generate(placeholderPath, meta.title.empty() ? fileName : meta.title, meta.author,
thumbW, coverH)) {
coverBmpPath = placeholderPath;
}
}
if (needsBuild) {
GUI.fillPopupProgress(renderer, popupRect, 100);
}
} else if (FsHelpers::hasXtcExtension(fileName)) {
Xtc xtc(filePath, "/.crosspoint");
bool needsBuild = !Storage.exists(xtc.getCachePath().c_str());
Rect popupRect{};
if (needsBuild) {
popupRect = GUI.drawPopup(renderer, tr(STR_LOADING));
GUI.fillPopupProgress(renderer, popupRect, 10);
}
if (xtc.load()) {
if (needsBuild) {
GUI.fillPopupProgress(renderer, popupRect, 50);
}
meta.title = xtc.getTitle();
meta.author = xtc.getAuthor();
if (xtc.generateThumbBmp(coverH)) {
coverBmpPath = xtc.getThumbBmpPath(coverH);
} else {
const int thumbW = static_cast<int>(coverH * 0.6);
const std::string placeholderPath = xtc.getCachePath() + "/placeholder_" + std::to_string(coverH) + ".bmp";
if (PlaceholderCoverGenerator::generate(placeholderPath, meta.title.empty() ? fileName : meta.title, meta.author,
thumbW, coverH)) {
coverBmpPath = placeholderPath;
}
}
}
if (needsBuild) {
GUI.fillPopupProgress(renderer, popupRect, 100);
}
}
if (meta.title.empty()) {
meta.title = fileName;
}
buildLayout(meta, fileSize);
requestUpdate();
}
void BookInfoActivity::onExit() { Activity::onExit(); }
void BookInfoActivity::buildLayout(const BookMetadataCache::BookMetadata& meta, size_t fileSize) {
const auto& metrics = UITheme::getInstance().getMetrics();
const int pageW = renderer.getScreenWidth();
const int sidePad = metrics.contentSidePadding;
if (!coverBmpPath.empty()) {
FsFile file;
if (Storage.openFileForRead("BIF", coverBmpPath, file)) {
Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
coverDisplayHeight = bitmap.getHeight();
coverDisplayWidth = bitmap.getWidth();
}
file.close();
}
}
coverPanelWidth = 0;
if (isLandscape && coverDisplayWidth > 0) {
coverPanelWidth = std::min(coverDisplayWidth + sidePad * 2, pageW * 2 / 5);
}
const int contentW = isLandscape && coverPanelWidth > 0
? pageW - coverPanelWidth - sidePad
: pageW - sidePad * 2;
fields.reserve(13);
auto addField = [&](const char* label, const std::string& text, bool bold, EpdFontFamily::Style style) {
if (text.empty()) return;
InfoField field;
field.label = label;
field.bold = bold;
field.lines = renderer.wrappedText(UI_12_FONT_ID, text.c_str(), contentW, MAX_WRAPPED_LINES, style);
fields.push_back(std::move(field));
};
addField(nullptr, meta.title, true, EpdFontFamily::BOLD);
addField(tr(STR_AUTHOR), meta.author, false, EpdFontFamily::REGULAR);
if (!meta.series.empty()) {
std::string seriesStr = meta.series;
if (!meta.seriesIndex.empty()) {
seriesStr += " #" + meta.seriesIndex;
}
addField(tr(STR_SERIES), seriesStr, false, EpdFontFamily::REGULAR);
}
addField(tr(STR_PUBLISHER), meta.publisher, false, EpdFontFamily::REGULAR);
addField(tr(STR_DATE), meta.date, false, EpdFontFamily::REGULAR);
addField(tr(STR_SUBJECTS), meta.subjects, false, EpdFontFamily::REGULAR);
if (!meta.rating.empty()) {
int ratingVal = atoi(meta.rating.c_str());
if (ratingVal > 0 && ratingVal <= 10) {
char ratingBuf[8];
snprintf(ratingBuf, sizeof(ratingBuf), "%d / 5", (ratingVal + 1) / 2);
addField(tr(STR_RATING), std::string(ratingBuf), false, EpdFontFamily::REGULAR);
}
}
addField(tr(STR_LANGUAGE), meta.language, false, EpdFontFamily::REGULAR);
addField(tr(STR_ISBN), meta.identifier, false, EpdFontFamily::REGULAR);
addField(tr(STR_CONTRIBUTOR), meta.contributor, false, EpdFontFamily::REGULAR);
if (fileSize > 0) {
addField(tr(STR_FILE_SIZE), formatFileSize(fileSize), false, EpdFontFamily::REGULAR);
}
addField(tr(STR_RIGHTS), meta.rights, false, EpdFontFamily::REGULAR);
addField(tr(STR_DESCRIPTION), meta.description, false, EpdFontFamily::REGULAR);
const int lineH10 = renderer.getLineHeight(UI_10_FONT_ID);
const int lineH12 = renderer.getLineHeight(UI_12_FONT_ID);
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
int h = contentTop;
if (!isLandscape && coverDisplayHeight > 0 && !coverBmpPath.empty()) {
h += coverDisplayHeight + COVER_GAP;
}
for (const auto& field : fields) {
if (field.label) {
h += lineH10 + LABEL_VALUE_GAP;
}
h += static_cast<int>(field.lines.size()) * lineH12;
h += SECTION_GAP;
}
h += metrics.buttonHintsHeight + metrics.verticalSpacing;
contentHeight = h;
}
void BookInfoActivity::loop() {
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
ActivityResult r;
r.isCancelled = true;
setResult(std::move(r));
finish();
return;
}
const int pageH = renderer.getScreenHeight();
const int scrollStep = pageH / 3;
if (mappedInput.wasReleased(MappedInputManager::Button::Down) ||
mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
mappedInput.wasReleased(MappedInputManager::Button::Left)) {
if (scrollOffset + pageH < contentHeight) {
scrollOffset += scrollStep;
requestUpdate();
}
}
if (mappedInput.wasReleased(MappedInputManager::Button::Up) ||
mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
mappedInput.wasReleased(MappedInputManager::Button::Right)) {
if (scrollOffset > 0) {
scrollOffset -= scrollStep;
if (scrollOffset < 0) scrollOffset = 0;
requestUpdate();
}
}
}
void BookInfoActivity::render(RenderLock&&) {
renderer.clearScreen();
const auto& metrics = UITheme::getInstance().getMetrics();
const int pageW = renderer.getScreenWidth();
const int pageH = renderer.getScreenHeight();
const int sidePad = metrics.contentSidePadding;
const int lineH10 = renderer.getLineHeight(UI_10_FONT_ID);
const int lineH12 = renderer.getLineHeight(UI_12_FONT_ID);
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const int contentBottom = pageH - metrics.buttonHintsHeight - metrics.verticalSpacing;
if (isLandscape && coverPanelWidth > 0 && !coverBmpPath.empty() && coverDisplayHeight > 0) {
FsFile file;
if (Storage.openFileForRead("BIF", coverBmpPath, file)) {
Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
const int availH = contentBottom - contentTop;
const int coverX = (coverPanelWidth - coverDisplayWidth) / 2;
renderer.drawBitmap1Bit(bitmap, coverX, contentTop, coverDisplayWidth, availH);
}
file.close();
}
}
const int fieldX = (isLandscape && coverPanelWidth > 0) ? coverPanelWidth : sidePad;
int y = contentTop - scrollOffset;
if (!isLandscape && !coverBmpPath.empty() && coverDisplayHeight > 0) {
if (y + coverDisplayHeight > 0 && y < contentBottom) {
FsFile file;
if (Storage.openFileForRead("BIF", coverBmpPath, file)) {
Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
const int coverX = (pageW - coverDisplayWidth) / 2;
renderer.drawBitmap1Bit(bitmap, coverX, y, coverDisplayWidth, coverDisplayHeight);
}
file.close();
}
}
y += coverDisplayHeight + COVER_GAP;
}
for (const auto& field : fields) {
if (y >= contentBottom) break;
if (field.label) {
if (y + lineH10 > contentTop && y < contentBottom) {
renderer.drawText(UI_10_FONT_ID, fieldX, y, field.label, true, EpdFontFamily::BOLD);
}
y += lineH10 + LABEL_VALUE_GAP;
}
const auto style = field.bold ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR;
for (const auto& line : field.lines) {
if (y >= contentBottom) break;
if (y + lineH12 > contentTop) {
renderer.drawText(UI_12_FONT_ID, fieldX, y, line.c_str(), true, style);
}
y += lineH12;
}
y += SECTION_GAP;
}
renderer.fillRect(0, 0, pageW, contentTop, false);
GUI.drawHeader(renderer, Rect(0, metrics.topPadding, pageW, metrics.headerHeight), tr(STR_BOOK_INFO));
const bool canScrollDown = scrollOffset + pageH < contentHeight;
const bool canScrollUp = scrollOffset > 0;
const char* downHint = canScrollDown ? tr(STR_DIR_DOWN) : "";
const char* upHint = canScrollUp ? tr(STR_DIR_UP) : "";
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", downHint, upHint);
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}
std::string BookInfoActivity::formatFileSize(size_t bytes) {
char buf[32];
if (bytes < 1024) {
snprintf(buf, sizeof(buf), "%u B", static_cast<unsigned>(bytes));
} else if (bytes < 1024 * 1024) {
snprintf(buf, sizeof(buf), "%.1f KB", static_cast<float>(bytes) / 1024.0f);
} else {
snprintf(buf, sizeof(buf), "%.1f MB", static_cast<float>(bytes) / (1024.0f * 1024.0f));
}
return buf;
}