Files
crosspoint-reader-mod/src/activities/util/KeyboardEntryActivity.cpp
Tannay dd280bdc97 Rotation Support (#77)
•  What is the goal of this PR?  
Implement a horizontal EPUB reading mode so books can be read in
landscape orientation (both 90° and 270°), while keeping the rest of the
UI in portrait.

•  What changes are included?
◦  Rendering / Display
▪ Added an orientation model to GfxRenderer (Portrait, LandscapeNormal,
LandscapeFlipped) and made:
▪ drawPixel, drawImage, displayWindow map logical coordinates
differently depending on orientation.
▪ getScreenWidth() / getScreenHeight() return orientation‑aware logical
dimensions (480×800 in portrait, 800×480 in landscape).
◦  Settings / Configuration
▪  Extended CrossPointSettings with:
▪  landscapeReading (toggle for portrait vs. landscape EPUB reading).
▪ landscapeFlipped (toggle to flip landscape 180° so both horizontal
holding directions are supported).
▪ Updated settings serialization/deserialization to persist these fields
while remaining backward‑compatible with existing settings files.
▪  Updated SettingsActivity to expose two new toggles:
▪  “Landscape Reading”
▪  “Flip Landscape (swap top/bottom)”
◦  EPUB Reader
▪  In EpubReaderActivity:
▪ On onEnter, set GfxRenderer orientation based on the new settings
(Portrait, LandscapeNormal, or LandscapeFlipped).
▪ On onExit, reset orientation back to Portrait so Home, WiFi, Settings,
etc. continue to render as before.
▪ Adjusted renderStatusBar to position the status bar and battery
indicator relative to GfxRenderer::getScreenHeight() instead of
hard‑coded Y coordinates, so it stays correctly at the bottom in both
portrait and landscape.
◦  EPUB Caching / Layout
▪ Extended Section cache metadata (section.bin) to include the logical
screenWidth and screenHeight used when pages were generated; bumped
SECTION_FILE_VERSION.
▪  Updated loadCacheMetadata to compare:
▪ font/margins/line compression/extraParagraphSpacing and screen
dimensions; mismatches now invalidate and clear the cache.
▪ Updated persistPageDataToSD and all call sites in EpubReaderActivity
to pass the current GfxRenderer::getScreenWidth() / getScreenHeight() so
portrait and landscape caches are kept separate and correctly sized.



Additional Context

•  Cache behavior / migration
◦ Existing section.bin files (old SECTION_FILE_VERSION) will be detected
as incompatible and their caches cleared and rebuilt once per chapter
when first opened after this change.
◦ Within a given orientation, caches will be reused as before. Switching
orientation (portrait ↔ landscape) will cause a one‑time re‑index of
each chapter in the new orientation.
•  Scope and risks
◦ Orientation changes are scoped to the EPUB reader; the Home screen,
Settings, WiFi selection, sleep screens, and web server UI continue to
assume portrait orientation.
◦ The renderer’s orientation is a static/global setting; if future code
uses GfxRenderer outside the reader while a reader instance is active,
it should be aware that orientation is no longer implicitly fixed.
◦ All drawing primitives now go through orientation‑aware coordinate
transforms; any code that previously relied on edge‑case behavior or
out‑of‑bounds writes might surface as logged “Outside range” warnings
instead.
•  Testing suggestions / areas to focus on
◦  Verify in hardware:
▪ Portrait mode still renders correctly (boot, home, settings, WiFi,
reader).
▪  Landscape reading in both directions:
▪  Landscape Reading = ON, Flip Landscape = OFF.
▪  Landscape Reading = ON, Flip Landscape = ON.
▪ Status bar (page X/Y, % progress, battery icon) is fully visible and
aligned at the bottom in all three combinations.
◦  Open the same book:
▪  In portrait first, then switch to landscape and reopen it.
▪  Confirm that:
▪ Old portrait caches are rebuilt once for landscape (you should see the
“Indexing…” page).
▪ Progress save/restore still works (resume opens to the correct page in
the current orientation).
◦ Ensure grayscale rendering (the secondary pass in
EpubReaderActivity::renderContents) still looks correct in both
orientations.

---------

Co-authored-by: Dave Allie <dave@daveallie.com>
2025-12-28 21:33:20 +11:00

346 lines
11 KiB
C++

#include "KeyboardEntryActivity.h"
#include "../../config.h"
// Keyboard layouts - lowercase
const char* const KeyboardEntryActivity::keyboard[NUM_ROWS] = {
"`1234567890-=", "qwertyuiop[]\\", "asdfghjkl;'", "zxcvbnm,./",
"^ _____<OK" // ^ = shift, _ = space, < = backspace, OK = done
};
// Keyboard layouts - uppercase/symbols
const char* const KeyboardEntryActivity::keyboardShift[NUM_ROWS] = {"~!@#$%^&*()_+", "QWERTYUIOP{}|", "ASDFGHJKL:\"",
"ZXCVBNM<>?", "SPECIAL ROW"};
void KeyboardEntryActivity::taskTrampoline(void* param) {
auto* self = static_cast<KeyboardEntryActivity*>(param);
self->displayTaskLoop();
}
void KeyboardEntryActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
render();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void KeyboardEntryActivity::onEnter() {
Activity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
// Trigger first update
updateRequired = true;
xTaskCreate(&KeyboardEntryActivity::taskTrampoline, "KeyboardEntryActivity",
2048, // Stack size
this, // Parameters
1, // Priority
&displayTaskHandle // Task handle
);
}
void KeyboardEntryActivity::onExit() {
Activity::onExit();
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
}
int KeyboardEntryActivity::getRowLength(const int row) const {
if (row < 0 || row >= NUM_ROWS) return 0;
// Return actual length of each row based on keyboard layout
switch (row) {
case 0:
return 13; // `1234567890-=
case 1:
return 13; // qwertyuiop[]backslash
case 2:
return 11; // asdfghjkl;'
case 3:
return 10; // zxcvbnm,./
case 4:
return 10; // caps (2 wide), space (5 wide), backspace (2 wide), OK
default:
return 0;
}
}
char KeyboardEntryActivity::getSelectedChar() const {
const char* const* layout = shiftActive ? keyboardShift : keyboard;
if (selectedRow < 0 || selectedRow >= NUM_ROWS) return '\0';
if (selectedCol < 0 || selectedCol >= getRowLength(selectedRow)) return '\0';
return layout[selectedRow][selectedCol];
}
void KeyboardEntryActivity::handleKeyPress() {
// Handle special row (bottom row with shift, space, backspace, done)
if (selectedRow == SPECIAL_ROW) {
if (selectedCol >= SHIFT_COL && selectedCol < SPACE_COL) {
// Shift toggle
shiftActive = !shiftActive;
return;
}
if (selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL) {
// Space bar
if (maxLength == 0 || text.length() < maxLength) {
text += ' ';
}
return;
}
if (selectedCol >= BACKSPACE_COL && selectedCol < DONE_COL) {
// Backspace
if (!text.empty()) {
text.pop_back();
}
return;
}
if (selectedCol >= DONE_COL) {
// Done button
if (onComplete) {
onComplete(text);
}
return;
}
}
// Regular character
const char c = getSelectedChar();
if (c == '\0') {
return;
}
if (maxLength == 0 || text.length() < maxLength) {
text += c;
// Auto-disable shift after typing a letter
if (shiftActive && ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'))) {
shiftActive = false;
}
}
}
void KeyboardEntryActivity::loop() {
// Navigation
if (inputManager.wasPressed(InputManager::BTN_UP)) {
if (selectedRow > 0) {
selectedRow--;
// Clamp column to valid range for new row
const int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol;
}
updateRequired = true;
}
if (inputManager.wasPressed(InputManager::BTN_DOWN)) {
if (selectedRow < NUM_ROWS - 1) {
selectedRow++;
const int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol;
}
updateRequired = true;
}
if (inputManager.wasPressed(InputManager::BTN_LEFT)) {
// Special bottom row case
if (selectedRow == SPECIAL_ROW) {
// Bottom row has special key widths
if (selectedCol >= SHIFT_COL && selectedCol < SPACE_COL) {
// In shift key, do nothing
} else if (selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL) {
// In space bar, move to shift
selectedCol = SHIFT_COL;
} else if (selectedCol >= BACKSPACE_COL && selectedCol < DONE_COL) {
// In backspace, move to space
selectedCol = SPACE_COL;
} else if (selectedCol >= DONE_COL) {
// At done button, move to backspace
selectedCol = BACKSPACE_COL;
}
updateRequired = true;
return;
}
if (selectedCol > 0) {
selectedCol--;
} else if (selectedRow > 0) {
// Wrap to previous row
selectedRow--;
selectedCol = getRowLength(selectedRow) - 1;
}
updateRequired = true;
}
if (inputManager.wasPressed(InputManager::BTN_RIGHT)) {
const int maxCol = getRowLength(selectedRow) - 1;
// Special bottom row case
if (selectedRow == SPECIAL_ROW) {
// Bottom row has special key widths
if (selectedCol >= SHIFT_COL && selectedCol < SPACE_COL) {
// In shift key, move to space
selectedCol = SPACE_COL;
} else if (selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL) {
// In space bar, move to backspace
selectedCol = BACKSPACE_COL;
} else if (selectedCol >= BACKSPACE_COL && selectedCol < DONE_COL) {
// In backspace, move to done
selectedCol = DONE_COL;
} else if (selectedCol >= DONE_COL) {
// At done button, do nothing
}
updateRequired = true;
return;
}
if (selectedCol < maxCol) {
selectedCol++;
} else if (selectedRow < NUM_ROWS - 1) {
// Wrap to next row
selectedRow++;
selectedCol = 0;
}
updateRequired = true;
}
// Selection
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
handleKeyPress();
updateRequired = true;
}
// Cancel
if (inputManager.wasPressed(InputManager::BTN_BACK)) {
if (onCancel) {
onCancel();
}
updateRequired = true;
}
}
void KeyboardEntryActivity::render() const {
const auto pageWidth = renderer.getScreenWidth();
renderer.clearScreen();
// Draw title
renderer.drawCenteredText(UI_FONT_ID, startY, title.c_str(), true, REGULAR);
// Draw input field
const int inputY = startY + 22;
renderer.drawText(UI_FONT_ID, 10, inputY, "[");
std::string displayText;
if (isPassword) {
displayText = std::string(text.length(), '*');
} else {
displayText = text;
}
// Show cursor at end
displayText += "_";
// Truncate if too long for display - use actual character width from font
int approxCharWidth = renderer.getSpaceWidth(UI_FONT_ID);
if (approxCharWidth < 1) approxCharWidth = 8; // Fallback to approximate width
const int maxDisplayLen = (pageWidth - 40) / approxCharWidth;
if (displayText.length() > static_cast<size_t>(maxDisplayLen)) {
displayText = "..." + displayText.substr(displayText.length() - maxDisplayLen + 3);
}
renderer.drawText(UI_FONT_ID, 20, inputY, displayText.c_str());
renderer.drawText(UI_FONT_ID, pageWidth - 15, inputY, "]");
// Draw keyboard - use compact spacing to fit 5 rows on screen
const int keyboardStartY = inputY + 25;
constexpr int keyWidth = 18;
constexpr int keyHeight = 18;
constexpr int keySpacing = 3;
const char* const* layout = shiftActive ? keyboardShift : keyboard;
// Calculate left margin to center the longest row (13 keys)
constexpr int maxRowWidth = KEYS_PER_ROW * (keyWidth + keySpacing);
const int leftMargin = (pageWidth - maxRowWidth) / 2;
for (int row = 0; row < NUM_ROWS; row++) {
const int rowY = keyboardStartY + row * (keyHeight + keySpacing);
// Left-align all rows for consistent navigation
const int startX = leftMargin;
// Handle bottom row (row 4) specially with proper multi-column keys
if (row == 4) {
// Bottom row layout: CAPS (2 cols) | SPACE (5 cols) | <- (2 cols) | OK (2 cols)
// Total: 11 visual columns, but we use logical positions for selection
int currentX = startX;
// CAPS key (logical col 0, spans 2 key widths)
const bool capsSelected = (selectedRow == 4 && selectedCol >= SHIFT_COL && selectedCol < SPACE_COL);
renderItemWithSelector(currentX + 2, rowY, shiftActive ? "CAPS" : "caps", capsSelected);
currentX += 2 * (keyWidth + keySpacing);
// Space bar (logical cols 2-6, spans 5 key widths)
const bool spaceSelected = (selectedRow == 4 && selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL);
const int spaceTextWidth = renderer.getTextWidth(UI_FONT_ID, "_____");
const int spaceXWidth = 5 * (keyWidth + keySpacing);
const int spaceXPos = currentX + (spaceXWidth - spaceTextWidth) / 2;
renderItemWithSelector(spaceXPos, rowY, "_____", spaceSelected);
currentX += spaceXWidth;
// Backspace key (logical col 7, spans 2 key widths)
const bool bsSelected = (selectedRow == 4 && selectedCol >= BACKSPACE_COL && selectedCol < DONE_COL);
renderItemWithSelector(currentX + 2, rowY, "<-", bsSelected);
currentX += 2 * (keyWidth + keySpacing);
// OK button (logical col 9, spans 2 key widths)
const bool okSelected = (selectedRow == 4 && selectedCol >= DONE_COL);
renderItemWithSelector(currentX + 2, rowY, "OK", okSelected);
} else {
// Regular rows: render each key individually
for (int col = 0; col < getRowLength(row); col++) {
// Get the character to display
const char c = layout[row][col];
std::string keyLabel(1, c);
const int charWidth = renderer.getTextWidth(UI_FONT_ID, keyLabel.c_str());
const int keyX = startX + col * (keyWidth + keySpacing) + (keyWidth - charWidth) / 2;
const bool isSelected = row == selectedRow && col == selectedCol;
renderItemWithSelector(keyX, rowY, keyLabel.c_str(), isSelected);
}
}
}
// Draw help text at absolute bottom of screen (consistent with other screens)
const auto pageHeight = renderer.getScreenHeight();
renderer.drawText(SMALL_FONT_ID, 10, pageHeight - 30, "Navigate: D-pad | Select: OK | Cancel: BACK");
renderer.displayBuffer();
}
void KeyboardEntryActivity::renderItemWithSelector(const int x, const int y, const char* item,
const bool isSelected) const {
if (isSelected) {
const int itemWidth = renderer.getTextWidth(UI_FONT_ID, item);
renderer.drawText(UI_FONT_ID, x - 6, y, "[");
renderer.drawText(UI_FONT_ID, x + itemWidth, y, "]");
}
renderer.drawText(UI_FONT_ID, x, y, item);
}