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
This commit is contained in:
cottongin
2026-03-09 04:00:41 -04:00
parent 2aba348070
commit 630fb56a11
3 changed files with 79 additions and 19 deletions

View File

@@ -0,0 +1,26 @@
# BookInfo: Landscape Side-by-Side Layout
**Date**: 2026-03-09
**Task**: Implement a side-by-side layout for BookInfo in landscape orientation -- cover fixed on the left, scrollable metadata fields on the right. Portrait layout unchanged.
## Changes Made
### `src/activities/home/BookInfoActivity.h`
- Added `bool isLandscape` and `int coverPanelWidth` member variables.
### `src/activities/home/BookInfoActivity.cpp`
- **`onEnter()`**: Detects orientation via `renderer.getOrientation()`. In landscape, the cover thumbnail height fills the full content area (between header and button hints) instead of 2/5 of screen height.
- **`buildLayout()`**: Reads cover bitmap dimensions first. In landscape with a cover, computes `coverPanelWidth` (cover width + padding, capped at 40% of screen). Text fields are wrapped to the narrower right-panel width. Cover height is excluded from `contentHeight` (it sits beside, not above, the fields).
- **`render()`**: In landscape, draws the cover at a fixed position in the left panel (unaffected by scroll). Fields render in the right panel at `x = coverPanelWidth`. In portrait, existing behavior is preserved (cover above fields, both scroll together). The header fill + draw-on-top pattern continues to prevent content bleeding into the header zone.
## Behavior Summary
- **Portrait** (480x800): Cover on top, fields below, everything scrolls vertically (no change).
- **Landscape** (800x480): Cover pinned on left (centered vertically, fills content height), metadata fields scroll independently on the right.
- If no cover exists in landscape, the full screen width is used for fields (same as portrait but in landscape dimensions).
## Follow-up
None -- ready for hardware testing in all 4 orientations.

View File

@@ -43,6 +43,10 @@ std::string normalizeWhitespace(const std::string& s) {
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) {
@@ -58,6 +62,11 @@ void BookInfoActivity::onEnter() {
}
}
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)) {
@@ -74,7 +83,6 @@ void BookInfoActivity::onEnter() {
meta = epub.getMetadata();
meta.description = normalizeWhitespace(meta.description);
const int coverH = renderer.getScreenHeight() * 2 / 5;
if (epub.generateThumbBmp(coverH)) {
coverBmpPath = epub.getThumbBmpPath(coverH);
} else {
@@ -103,7 +111,6 @@ void BookInfoActivity::onEnter() {
meta.title = xtc.getTitle();
meta.author = xtc.getAuthor();
const int coverH = renderer.getScreenHeight() * 2 / 5;
if (xtc.generateThumbBmp(coverH)) {
coverBmpPath = xtc.getThumbBmpPath(coverH);
} else {
@@ -132,8 +139,30 @@ 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;
const int contentW = renderer.getScreenWidth() - sidePad * 2;
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) {
@@ -185,19 +214,8 @@ void BookInfoActivity::buildLayout(const BookMetadataCache::BookMetadata& meta,
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
int h = contentTop;
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();
}
if (coverDisplayHeight > 0) {
h += coverDisplayHeight + COVER_GAP;
}
if (!isLandscape && coverDisplayHeight > 0 && !coverBmpPath.empty()) {
h += coverDisplayHeight + COVER_GAP;
}
for (const auto& field : fields) {
@@ -255,9 +273,23 @@ void BookInfoActivity::render(RenderLock&&) {
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 (!coverBmpPath.empty() && coverDisplayHeight > 0) {
if (!isLandscape && !coverBmpPath.empty() && coverDisplayHeight > 0) {
if (y + coverDisplayHeight > 0 && y < contentBottom) {
FsFile file;
if (Storage.openFileForRead("BIF", coverBmpPath, file)) {
@@ -277,7 +309,7 @@ void BookInfoActivity::render(RenderLock&&) {
if (field.label) {
if (y + lineH10 > contentTop && y < contentBottom) {
renderer.drawText(UI_10_FONT_ID, sidePad, y, field.label, true, EpdFontFamily::BOLD);
renderer.drawText(UI_10_FONT_ID, fieldX, y, field.label, true, EpdFontFamily::BOLD);
}
y += lineH10 + LABEL_VALUE_GAP;
}
@@ -286,7 +318,7 @@ void BookInfoActivity::render(RenderLock&&) {
for (const auto& line : field.lines) {
if (y >= contentBottom) break;
if (y + lineH12 > contentTop) {
renderer.drawText(UI_12_FONT_ID, sidePad, y, line.c_str(), true, style);
renderer.drawText(UI_12_FONT_ID, fieldX, y, line.c_str(), true, style);
}
y += lineH12;
}

View File

@@ -32,6 +32,8 @@ class BookInfoActivity final : public Activity {
int contentHeight = 0;
int coverDisplayHeight = 0;
int coverDisplayWidth = 0;
int coverPanelWidth = 0;
bool isLandscape = false;
void buildLayout(const BookMetadataCache::BookMetadata& meta, size_t fileSize);
static std::string formatFileSize(size_t bytes);