Adds Search tab to MyLibraryActivity with character picker for building search queries, result navigation with long press jump-to-end support, and Bookmarks tab integration. Implements consistent tab bar navigation across all tabs - pressing Up from top of any list enters tab bar mode with visible cursor indicators, Left/Right switches tabs, Down enters list at top, and Up jumps to bottom of list.
2167 lines
77 KiB
C++
2167 lines
77 KiB
C++
#include "MyLibraryActivity.h"
|
|
|
|
#include <Bitmap.h>
|
|
#include <GfxRenderer.h>
|
|
#include <SDCardManager.h>
|
|
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
#include <cstring>
|
|
#include <set>
|
|
|
|
#include "BookListStore.h"
|
|
#include "BookManager.h"
|
|
#include "BookmarkStore.h"
|
|
#include "CrossPointSettings.h"
|
|
#include "HomeActivity.h"
|
|
#include "MappedInputManager.h"
|
|
#include "RecentBooksStore.h"
|
|
#include "ScreenComponents.h"
|
|
#include "fontIds.h"
|
|
#include "util/StringUtils.h"
|
|
|
|
// Static thumbnail existence cache definition
|
|
ThumbExistsCache MyLibraryActivity::thumbExistsCache[MyLibraryActivity::MAX_THUMB_CACHE];
|
|
|
|
void MyLibraryActivity::clearThumbExistsCache() {
|
|
for (int i = 0; i < MAX_THUMB_CACHE; i++) {
|
|
thumbExistsCache[i].bookPath.clear();
|
|
thumbExistsCache[i].thumbPath.clear();
|
|
thumbExistsCache[i].checked = false;
|
|
thumbExistsCache[i].exists = false;
|
|
}
|
|
Serial.printf("[%lu] [MYLIB] Thumbnail existence cache cleared\n", millis());
|
|
}
|
|
|
|
namespace {
|
|
// Base layout constants (bezel offsets added at render time)
|
|
constexpr int BASE_TAB_BAR_Y = 15;
|
|
constexpr int BASE_CONTENT_START_Y = 60;
|
|
constexpr int LINE_HEIGHT = 30;
|
|
constexpr int RECENTS_LINE_HEIGHT = 65; // Increased for two-line items
|
|
constexpr int BASE_LEFT_MARGIN = 20;
|
|
constexpr int BASE_RIGHT_MARGIN = 40; // Extra space for scroll indicator
|
|
constexpr int MICRO_THUMB_WIDTH = 45;
|
|
constexpr int MICRO_THUMB_HEIGHT = 60;
|
|
constexpr int BASE_THUMB_RIGHT_MARGIN = 50; // Space from right edge for thumbnail
|
|
|
|
// Helper function to get the micro-thumb path for a book based on its file path
|
|
std::string getMicroThumbPathForBook(const std::string& bookPath) {
|
|
// Calculate cache path using same hash method as Epub/Txt classes
|
|
const size_t hash = std::hash<std::string>{}(bookPath);
|
|
|
|
if (StringUtils::checkFileExtension(bookPath, ".epub")) {
|
|
return "/.crosspoint/epub_" + std::to_string(hash) + "/micro_thumb.bmp";
|
|
} else if (StringUtils::checkFileExtension(bookPath, ".txt") || StringUtils::checkFileExtension(bookPath, ".TXT")) {
|
|
return "/.crosspoint/txt_" + std::to_string(hash) + "/micro_thumb.bmp";
|
|
}
|
|
return "";
|
|
}
|
|
|
|
// Timing thresholds
|
|
constexpr int SKIP_PAGE_MS = 700;
|
|
constexpr unsigned long GO_HOME_MS = 1000;
|
|
constexpr unsigned long ACTION_MENU_MS = 700; // Long press to open action menu
|
|
|
|
// Special key indices for character picker (appended after regular characters)
|
|
constexpr int SEARCH_SPECIAL_SPACE = -1;
|
|
constexpr int SEARCH_SPECIAL_BACKSPACE = -2;
|
|
constexpr int SEARCH_SPECIAL_CLEAR = -3;
|
|
|
|
void sortFileList(std::vector<std::string>& strs) {
|
|
std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) {
|
|
if (str1.back() == '/' && str2.back() != '/') return true;
|
|
if (str1.back() != '/' && str2.back() == '/') return false;
|
|
return lexicographical_compare(
|
|
begin(str1), end(str1), begin(str2), end(str2),
|
|
[](const char& char1, const char& char2) { return tolower(char1) < tolower(char2); });
|
|
});
|
|
}
|
|
} // namespace
|
|
|
|
int MyLibraryActivity::getPageItems() const {
|
|
const int screenHeight = renderer.getScreenHeight();
|
|
const int bottomBarHeight = 60; // Space for button hints
|
|
const int bezelTop = renderer.getBezelOffsetTop();
|
|
const int bezelBottom = renderer.getBezelOffsetBottom();
|
|
|
|
// Search tab has compact layout: character picker (~30px) + query (~25px) + results
|
|
if (currentTab == Tab::Search) {
|
|
// Character picker: ~30px, Query: ~25px = 55px overhead
|
|
// Much more room for results than the old 5-row keyboard
|
|
constexpr int SEARCH_OVERHEAD = 55;
|
|
const int availableHeight = screenHeight - (BASE_CONTENT_START_Y + bezelTop) - bottomBarHeight - bezelBottom - SEARCH_OVERHEAD;
|
|
int items = availableHeight / RECENTS_LINE_HEIGHT;
|
|
if (items < 1) items = 1;
|
|
return items;
|
|
}
|
|
|
|
const int availableHeight = screenHeight - (BASE_CONTENT_START_Y + bezelTop) - bottomBarHeight - bezelBottom;
|
|
// Recent and Bookmarks tabs use taller items (title + author), Lists and Files use single-line items
|
|
const int lineHeight = (currentTab == Tab::Recent || currentTab == Tab::Bookmarks)
|
|
? RECENTS_LINE_HEIGHT : LINE_HEIGHT;
|
|
int items = availableHeight / lineHeight;
|
|
if (items < 1) {
|
|
items = 1;
|
|
}
|
|
return items;
|
|
}
|
|
|
|
int MyLibraryActivity::getCurrentItemCount() const {
|
|
// Add +1 for "Search..." shortcut in tabs that support it (all except Search itself)
|
|
if (currentTab == Tab::Recent) {
|
|
return static_cast<int>(recentBooks.size()) + 1; // +1 for Search shortcut
|
|
} else if (currentTab == Tab::Lists) {
|
|
return static_cast<int>(lists.size()) + 1; // +1 for Search shortcut
|
|
} else if (currentTab == Tab::Bookmarks) {
|
|
return static_cast<int>(bookmarkedBooks.size()) + 1; // +1 for Search shortcut
|
|
} else if (currentTab == Tab::Search) {
|
|
return static_cast<int>(searchResults.size()); // No shortcut in Search tab
|
|
}
|
|
return static_cast<int>(files.size()) + 1; // +1 for Search shortcut
|
|
}
|
|
|
|
int MyLibraryActivity::getTotalPages() const {
|
|
const int itemCount = getCurrentItemCount();
|
|
const int pageItems = getPageItems();
|
|
if (itemCount == 0) return 1;
|
|
return (itemCount + pageItems - 1) / pageItems;
|
|
}
|
|
|
|
int MyLibraryActivity::getCurrentPage() const {
|
|
const int pageItems = getPageItems();
|
|
return selectorIndex / pageItems + 1;
|
|
}
|
|
|
|
void MyLibraryActivity::loadRecentBooks() {
|
|
recentBooks.clear();
|
|
const auto& books = RECENT_BOOKS.getBooks();
|
|
recentBooks.reserve(books.size());
|
|
|
|
for (const auto& book : books) {
|
|
// Skip if file no longer exists
|
|
if (!SdMan.exists(book.path.c_str())) {
|
|
continue;
|
|
}
|
|
recentBooks.push_back(book);
|
|
}
|
|
}
|
|
|
|
void MyLibraryActivity::loadLists() { lists = BookListStore::listAllLists(); }
|
|
|
|
void MyLibraryActivity::loadBookmarkedBooks() {
|
|
bookmarkedBooks = BookmarkStore::getBooksWithBookmarks();
|
|
|
|
// Try to get better metadata from recent books
|
|
for (auto& book : bookmarkedBooks) {
|
|
for (const auto& recent : recentBooks) {
|
|
if (recent.path == book.path) {
|
|
if (!recent.title.empty()) book.title = recent.title;
|
|
if (!recent.author.empty()) book.author = recent.author;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void MyLibraryActivity::loadAllBooks() {
|
|
// Build index of all books on SD card for search
|
|
allBooks.clear();
|
|
|
|
// Helper lambda to recursively scan directories
|
|
std::function<void(const std::string&)> scanDirectory = [&](const std::string& path) {
|
|
auto dir = SdMan.open(path.c_str());
|
|
if (!dir || !dir.isDirectory()) {
|
|
if (dir) dir.close();
|
|
return;
|
|
}
|
|
|
|
dir.rewindDirectory();
|
|
char name[500];
|
|
|
|
for (auto file = dir.openNextFile(); file; file = dir.openNextFile()) {
|
|
file.getName(name, sizeof(name));
|
|
if (name[0] == '.' || strcmp(name, "System Volume Information") == 0) {
|
|
file.close();
|
|
continue;
|
|
}
|
|
|
|
std::string fullPath = (path.back() == '/') ? path + name : path + "/" + name;
|
|
|
|
if (file.isDirectory()) {
|
|
file.close();
|
|
scanDirectory(fullPath);
|
|
} else {
|
|
auto filename = std::string(name);
|
|
if (StringUtils::checkFileExtension(filename, ".epub") ||
|
|
StringUtils::checkFileExtension(filename, ".txt") ||
|
|
StringUtils::checkFileExtension(filename, ".md")) {
|
|
SearchResult result;
|
|
result.path = fullPath;
|
|
|
|
// Extract title from filename (remove extension)
|
|
result.title = filename;
|
|
const size_t dot = result.title.find_last_of('.');
|
|
if (dot != std::string::npos) {
|
|
result.title.resize(dot);
|
|
}
|
|
|
|
// Try to get metadata from recent books if available
|
|
for (const auto& recent : recentBooks) {
|
|
if (recent.path == fullPath) {
|
|
if (!recent.title.empty()) result.title = recent.title;
|
|
if (!recent.author.empty()) result.author = recent.author;
|
|
break;
|
|
}
|
|
}
|
|
|
|
allBooks.push_back(result);
|
|
}
|
|
file.close();
|
|
}
|
|
}
|
|
dir.close();
|
|
};
|
|
|
|
scanDirectory("/");
|
|
|
|
// Sort alphabetically by title
|
|
std::sort(allBooks.begin(), allBooks.end(), [](const SearchResult& a, const SearchResult& b) {
|
|
return lexicographical_compare(
|
|
a.title.begin(), a.title.end(), b.title.begin(), b.title.end(),
|
|
[](char c1, char c2) { return tolower(c1) < tolower(c2); });
|
|
});
|
|
|
|
// Build character set after loading books
|
|
buildSearchCharacters();
|
|
}
|
|
|
|
void MyLibraryActivity::buildSearchCharacters() {
|
|
// Build a set of unique characters from all book titles and authors
|
|
std::set<char> charSet;
|
|
|
|
for (const auto& book : allBooks) {
|
|
for (char c : book.title) {
|
|
// Convert to uppercase for display, store as uppercase
|
|
if (std::isalpha(static_cast<unsigned char>(c))) {
|
|
charSet.insert(std::toupper(static_cast<unsigned char>(c)));
|
|
} else if (std::isdigit(static_cast<unsigned char>(c))) {
|
|
charSet.insert(c);
|
|
} else if (c == ' ') {
|
|
// Space handled separately as special key
|
|
} else if (std::ispunct(static_cast<unsigned char>(c))) {
|
|
charSet.insert(c);
|
|
}
|
|
}
|
|
for (char c : book.author) {
|
|
if (std::isalpha(static_cast<unsigned char>(c))) {
|
|
charSet.insert(std::toupper(static_cast<unsigned char>(c)));
|
|
} else if (std::isdigit(static_cast<unsigned char>(c))) {
|
|
charSet.insert(c);
|
|
} else if (std::ispunct(static_cast<unsigned char>(c))) {
|
|
charSet.insert(c);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Convert set to vector, sorted: A-Z, then 0-9, then symbols
|
|
searchCharacters.clear();
|
|
|
|
// Add letters A-Z
|
|
for (char c = 'A'; c <= 'Z'; c++) {
|
|
if (charSet.count(c)) {
|
|
searchCharacters.push_back(c);
|
|
}
|
|
}
|
|
|
|
// Add digits 0-9
|
|
for (char c = '0'; c <= '9'; c++) {
|
|
if (charSet.count(c)) {
|
|
searchCharacters.push_back(c);
|
|
}
|
|
}
|
|
|
|
// Add symbols (anything else in the set)
|
|
for (char c : charSet) {
|
|
if (!std::isalpha(static_cast<unsigned char>(c)) && !std::isdigit(static_cast<unsigned char>(c))) {
|
|
searchCharacters.push_back(c);
|
|
}
|
|
}
|
|
|
|
// Reset character index if it's out of bounds
|
|
if (searchCharIndex >= static_cast<int>(searchCharacters.size()) + 3) { // +3 for special keys
|
|
searchCharIndex = 0;
|
|
}
|
|
}
|
|
|
|
void MyLibraryActivity::updateSearchResults() {
|
|
searchResults.clear();
|
|
|
|
if (searchQuery.empty()) {
|
|
// Don't show any results when query is empty - user needs to type something
|
|
return;
|
|
}
|
|
|
|
// Convert query to lowercase for case-insensitive matching
|
|
std::string queryLower = searchQuery;
|
|
for (char& c : queryLower) c = tolower(c);
|
|
|
|
for (const auto& book : allBooks) {
|
|
// Convert title, author, and path to lowercase
|
|
std::string titleLower = book.title;
|
|
std::string authorLower = book.author;
|
|
std::string pathLower = book.path;
|
|
for (char& c : titleLower) c = tolower(c);
|
|
for (char& c : authorLower) c = tolower(c);
|
|
for (char& c : pathLower) c = tolower(c);
|
|
|
|
int score = 0;
|
|
|
|
// Check for matches
|
|
if (titleLower.find(queryLower) != std::string::npos) {
|
|
score += 100;
|
|
// Bonus for match at start
|
|
if (titleLower.find(queryLower) == 0) score += 50;
|
|
}
|
|
if (!authorLower.empty() && authorLower.find(queryLower) != std::string::npos) {
|
|
score += 80;
|
|
if (authorLower.find(queryLower) == 0) score += 40;
|
|
}
|
|
if (pathLower.find(queryLower) != std::string::npos) {
|
|
score += 30;
|
|
}
|
|
|
|
if (score > 0) {
|
|
SearchResult result = book;
|
|
result.matchScore = score;
|
|
searchResults.push_back(result);
|
|
}
|
|
}
|
|
|
|
// Sort by match score (descending)
|
|
std::sort(searchResults.begin(), searchResults.end(),
|
|
[](const SearchResult& a, const SearchResult& b) {
|
|
return a.matchScore > b.matchScore;
|
|
});
|
|
}
|
|
|
|
void MyLibraryActivity::loadFiles() {
|
|
files.clear();
|
|
|
|
auto root = SdMan.open(basepath.c_str());
|
|
if (!root || !root.isDirectory()) {
|
|
if (root) root.close();
|
|
return;
|
|
}
|
|
|
|
root.rewindDirectory();
|
|
|
|
char name[500];
|
|
for (auto file = root.openNextFile(); file; file = root.openNextFile()) {
|
|
file.getName(name, sizeof(name));
|
|
if (name[0] == '.' || strcmp(name, "System Volume Information") == 0) {
|
|
file.close();
|
|
continue;
|
|
}
|
|
|
|
if (file.isDirectory()) {
|
|
files.emplace_back(std::string(name) + "/");
|
|
} else {
|
|
auto filename = std::string(name);
|
|
if (StringUtils::checkFileExtension(filename, ".epub") || StringUtils::checkFileExtension(filename, ".xtch") ||
|
|
StringUtils::checkFileExtension(filename, ".xtc") || StringUtils::checkFileExtension(filename, ".txt") ||
|
|
StringUtils::checkFileExtension(filename, ".md")) {
|
|
files.emplace_back(filename);
|
|
}
|
|
}
|
|
file.close();
|
|
}
|
|
root.close();
|
|
sortFileList(files);
|
|
}
|
|
|
|
size_t MyLibraryActivity::findEntry(const std::string& name) const {
|
|
for (size_t i = 0; i < files.size(); i++) {
|
|
if (files[i] == name) return i;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
void MyLibraryActivity::taskTrampoline(void* param) {
|
|
auto* self = static_cast<MyLibraryActivity*>(param);
|
|
self->displayTaskLoop();
|
|
}
|
|
|
|
void MyLibraryActivity::onEnter() {
|
|
Activity::onEnter();
|
|
|
|
renderingMutex = xSemaphoreCreateMutex();
|
|
|
|
// Load data for all tabs
|
|
loadRecentBooks();
|
|
loadLists();
|
|
loadBookmarkedBooks();
|
|
loadAllBooks();
|
|
updateSearchResults();
|
|
loadFiles();
|
|
|
|
selectorIndex = 0;
|
|
|
|
// If entering Search tab, start in character picker mode
|
|
if (currentTab == Tab::Search) {
|
|
searchInResults = false;
|
|
inTabBar = false;
|
|
searchCharIndex = 0;
|
|
}
|
|
|
|
updateRequired = true;
|
|
|
|
xTaskCreate(&MyLibraryActivity::taskTrampoline, "MyLibraryActivityTask",
|
|
4096, // Stack size (increased for epub metadata loading)
|
|
this, // Parameters
|
|
1, // Priority
|
|
&displayTaskHandle // Task handle
|
|
);
|
|
}
|
|
|
|
void MyLibraryActivity::onExit() {
|
|
Activity::onExit();
|
|
|
|
// Wait until not rendering to delete task to avoid killing mid-instruction to
|
|
// EPD
|
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
|
if (displayTaskHandle) {
|
|
// Log stack high-water mark before deleting task (stack size: 4096 bytes)
|
|
LOG_STACK_WATERMARK("MyLibraryActivity", displayTaskHandle);
|
|
vTaskDelete(displayTaskHandle);
|
|
displayTaskHandle = nullptr;
|
|
vTaskDelay(10 / portTICK_PERIOD_MS); // Let idle task free stack
|
|
}
|
|
vSemaphoreDelete(renderingMutex);
|
|
renderingMutex = nullptr;
|
|
|
|
recentBooks.clear();
|
|
lists.clear();
|
|
bookmarkedBooks.clear();
|
|
searchResults.clear();
|
|
allBooks.clear();
|
|
files.clear();
|
|
}
|
|
|
|
bool MyLibraryActivity::isSelectedItemAFile() const {
|
|
if (currentTab == Tab::Recent) {
|
|
// Don't count "Search..." shortcut as a file
|
|
return !recentBooks.empty() && selectorIndex < static_cast<int>(recentBooks.size());
|
|
} else if (currentTab == Tab::Files) {
|
|
// Files tab - check if it's a file (not a directory) and not "Search..." shortcut
|
|
if (files.empty() || selectorIndex >= static_cast<int>(files.size())) {
|
|
return false;
|
|
}
|
|
return files[selectorIndex].back() != '/';
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void MyLibraryActivity::openActionMenu() {
|
|
if (!isSelectedItemAFile()) {
|
|
return;
|
|
}
|
|
|
|
if (currentTab == Tab::Recent) {
|
|
const auto& book = recentBooks[selectorIndex];
|
|
actionTargetPath = book.path;
|
|
// Use title if available, otherwise extract from path
|
|
if (!book.title.empty()) {
|
|
actionTargetName = book.title;
|
|
} else {
|
|
actionTargetName = book.path;
|
|
const size_t lastSlash = actionTargetName.find_last_of('/');
|
|
if (lastSlash != std::string::npos) {
|
|
actionTargetName = actionTargetName.substr(lastSlash + 1);
|
|
}
|
|
}
|
|
} else {
|
|
if (basepath.back() != '/') {
|
|
actionTargetPath = basepath + "/" + files[selectorIndex];
|
|
} else {
|
|
actionTargetPath = basepath + files[selectorIndex];
|
|
}
|
|
actionTargetName = files[selectorIndex];
|
|
}
|
|
|
|
uiState = UIState::ActionMenu;
|
|
menuSelection = 0; // Default to Archive
|
|
ignoreNextConfirmRelease = true; // Ignore the release from the long-press that opened this menu
|
|
updateRequired = true;
|
|
}
|
|
|
|
void MyLibraryActivity::executeAction() {
|
|
bool success = false;
|
|
|
|
if (selectedAction == ActionType::Archive) {
|
|
success = BookManager::archiveBook(actionTargetPath);
|
|
} else if (selectedAction == ActionType::Delete) {
|
|
success = BookManager::deleteBook(actionTargetPath);
|
|
} else if (selectedAction == ActionType::RemoveFromRecents) {
|
|
// Just remove from recents list, don't touch the file
|
|
success = RECENT_BOOKS.removeBook(actionTargetPath);
|
|
}
|
|
// Note: ClearAllRecents is handled directly in loop() via ClearAllRecentsConfirming state
|
|
|
|
if (success) {
|
|
// Reload data
|
|
loadRecentBooks();
|
|
if (selectedAction != ActionType::RemoveFromRecents) {
|
|
loadFiles(); // Only reload files for Archive/Delete
|
|
}
|
|
|
|
// Adjust selector if needed
|
|
const int itemCount = getCurrentItemCount();
|
|
if (selectorIndex >= itemCount && itemCount > 0) {
|
|
selectorIndex = itemCount - 1;
|
|
} else if (itemCount == 0) {
|
|
selectorIndex = 0;
|
|
}
|
|
}
|
|
|
|
uiState = UIState::Normal;
|
|
updateRequired = true;
|
|
}
|
|
|
|
void MyLibraryActivity::togglePinForSelectedList() {
|
|
if (lists.empty() || selectorIndex >= static_cast<int>(lists.size())) return;
|
|
|
|
const std::string& selected = lists[selectorIndex];
|
|
if (selected == SETTINGS.pinnedListName) {
|
|
// Unpin - clear the pinned list
|
|
SETTINGS.pinnedListName[0] = '\0';
|
|
} else {
|
|
// Pin this list (replaces any previously pinned list)
|
|
strncpy(SETTINGS.pinnedListName, selected.c_str(), sizeof(SETTINGS.pinnedListName) - 1);
|
|
SETTINGS.pinnedListName[sizeof(SETTINGS.pinnedListName) - 1] = '\0';
|
|
}
|
|
SETTINGS.saveToFile();
|
|
updateRequired = true;
|
|
}
|
|
|
|
void MyLibraryActivity::openListActionMenu() {
|
|
listActionTargetName = lists[selectorIndex];
|
|
listMenuSelection = 0; // Default to Pin/Unpin
|
|
uiState = UIState::ListActionMenu;
|
|
ignoreNextConfirmRelease = true;
|
|
updateRequired = true;
|
|
}
|
|
|
|
void MyLibraryActivity::executeListAction() {
|
|
// Clear pinned status if deleting the pinned list
|
|
if (listActionTargetName == SETTINGS.pinnedListName) {
|
|
SETTINGS.pinnedListName[0] = '\0';
|
|
SETTINGS.saveToFile();
|
|
}
|
|
|
|
BookListStore::deleteList(listActionTargetName);
|
|
|
|
// Reload lists
|
|
loadLists();
|
|
|
|
// Adjust selector if needed
|
|
if (selectorIndex >= static_cast<int>(lists.size()) && !lists.empty()) {
|
|
selectorIndex = static_cast<int>(lists.size()) - 1;
|
|
} else if (lists.empty()) {
|
|
selectorIndex = 0;
|
|
}
|
|
|
|
uiState = UIState::Normal;
|
|
updateRequired = true;
|
|
}
|
|
|
|
void MyLibraryActivity::loop() {
|
|
// Handle action menu state
|
|
if (uiState == UIState::ActionMenu) {
|
|
// Menu has 4 options in Recent tab, 2 options in Files tab
|
|
const int maxMenuSelection = (currentTab == Tab::Recent) ? 3 : 1;
|
|
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
|
uiState = UIState::Normal;
|
|
ignoreNextConfirmRelease = false;
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Up)) {
|
|
menuSelection = (menuSelection + maxMenuSelection) % (maxMenuSelection + 1);
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Down)) {
|
|
menuSelection = (menuSelection + 1) % (maxMenuSelection + 1);
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
|
// Ignore the release from the long-press that opened this menu
|
|
if (ignoreNextConfirmRelease) {
|
|
ignoreNextConfirmRelease = false;
|
|
return;
|
|
}
|
|
|
|
// Map menu selection to action type
|
|
if (currentTab == Tab::Recent) {
|
|
// Recent tab: Archive(0), Delete(1), Remove from Recents(2), Clear All Recents(3)
|
|
switch (menuSelection) {
|
|
case 0:
|
|
selectedAction = ActionType::Archive;
|
|
break;
|
|
case 1:
|
|
selectedAction = ActionType::Delete;
|
|
break;
|
|
case 2:
|
|
selectedAction = ActionType::RemoveFromRecents;
|
|
break;
|
|
case 3:
|
|
selectedAction = ActionType::ClearAllRecents;
|
|
break;
|
|
}
|
|
} else {
|
|
// Files tab: Archive(0), Delete(1)
|
|
selectedAction = (menuSelection == 0) ? ActionType::Archive : ActionType::Delete;
|
|
}
|
|
|
|
// Clear All Recents needs its own confirmation dialog
|
|
if (selectedAction == ActionType::ClearAllRecents) {
|
|
uiState = UIState::ClearAllRecentsConfirming;
|
|
} else {
|
|
uiState = UIState::Confirming;
|
|
}
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// Handle confirmation state
|
|
if (uiState == UIState::Confirming) {
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
|
uiState = UIState::ActionMenu;
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
|
executeAction();
|
|
return;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// Handle list action menu state
|
|
if (uiState == UIState::ListActionMenu) {
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
|
uiState = UIState::Normal;
|
|
ignoreNextConfirmRelease = false;
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Up)) {
|
|
listMenuSelection = 0; // Pin/Unpin
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Down)) {
|
|
listMenuSelection = 1; // Delete
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
|
// Ignore the release from the long-press that opened this menu
|
|
if (ignoreNextConfirmRelease) {
|
|
ignoreNextConfirmRelease = false;
|
|
return;
|
|
}
|
|
if (listMenuSelection == 0) {
|
|
// Pin/Unpin - toggle and return to normal
|
|
togglePinForSelectedList();
|
|
uiState = UIState::Normal;
|
|
} else {
|
|
// Delete - go to confirmation
|
|
uiState = UIState::ListConfirmingDelete;
|
|
}
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// Handle list delete confirmation state
|
|
if (uiState == UIState::ListConfirmingDelete) {
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
|
uiState = UIState::Normal;
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
|
executeListAction();
|
|
return;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// Handle clear all recents confirmation state
|
|
if (uiState == UIState::ClearAllRecentsConfirming) {
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
|
uiState = UIState::ActionMenu;
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
|
RECENT_BOOKS.clearAll();
|
|
loadRecentBooks();
|
|
selectorIndex = 0;
|
|
uiState = UIState::Normal;
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// Normal state handling
|
|
const int itemCount = getCurrentItemCount();
|
|
const int pageItems = getPageItems();
|
|
|
|
// Handle tab bar navigation for non-Search tabs
|
|
if (inTabBar && currentTab != Tab::Search) {
|
|
// Left/Right switch tabs while staying in tab bar
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Left)) {
|
|
switch (currentTab) {
|
|
case Tab::Recent:
|
|
currentTab = Tab::Files; // Wrap from first to last
|
|
break;
|
|
case Tab::Lists:
|
|
currentTab = Tab::Recent;
|
|
break;
|
|
case Tab::Bookmarks:
|
|
currentTab = Tab::Lists;
|
|
break;
|
|
case Tab::Files:
|
|
currentTab = Tab::Search;
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
selectorIndex = 0;
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Right)) {
|
|
switch (currentTab) {
|
|
case Tab::Recent:
|
|
currentTab = Tab::Lists;
|
|
break;
|
|
case Tab::Lists:
|
|
currentTab = Tab::Bookmarks;
|
|
break;
|
|
case Tab::Bookmarks:
|
|
currentTab = Tab::Search;
|
|
break;
|
|
case Tab::Files:
|
|
currentTab = Tab::Recent; // Wrap from last to first
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
selectorIndex = 0;
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
|
|
// Down exits tab bar, enters list at top
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Down)) {
|
|
inTabBar = false;
|
|
selectorIndex = 0;
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
|
|
// Up exits tab bar, jumps to bottom of list
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Up)) {
|
|
inTabBar = false;
|
|
if (itemCount > 0) {
|
|
selectorIndex = itemCount - 1;
|
|
}
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
|
|
// Back goes home
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
|
onGoHome();
|
|
return;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// Handle Search tab navigation
|
|
if (currentTab == Tab::Search) {
|
|
const int charCount = static_cast<int>(searchCharacters.size());
|
|
const int totalPickerItems = charCount + 3; // +3 for SPC, <-, CLR
|
|
|
|
if (inTabBar) {
|
|
// In tab bar mode - Left/Right switch tabs, Down goes to picker
|
|
// Use wasReleased for consistency with other tab switching code
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Left)) {
|
|
currentTab = Tab::Bookmarks;
|
|
selectorIndex = 0;
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Right)) {
|
|
currentTab = Tab::Files;
|
|
selectorIndex = 0;
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
|
|
// Down exits tab bar, goes to character picker
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Down)) {
|
|
inTabBar = false;
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
|
|
// Up exits tab bar, jumps to bottom of results (if any)
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Up)) {
|
|
inTabBar = false;
|
|
if (!searchResults.empty()) {
|
|
searchInResults = true;
|
|
selectorIndex = static_cast<int>(searchResults.size()) - 1;
|
|
}
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
|
|
// Back goes home
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
|
onGoHome();
|
|
return;
|
|
}
|
|
|
|
return;
|
|
} else if (!searchInResults) {
|
|
// In character picker mode
|
|
|
|
// Long press Left = jump to start
|
|
if (mappedInput.isPressed(MappedInputManager::Button::Left) &&
|
|
mappedInput.getHeldTime() >= 700) {
|
|
searchCharIndex = 0;
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
|
|
// Long press Right = jump to end
|
|
if (mappedInput.isPressed(MappedInputManager::Button::Right) &&
|
|
mappedInput.getHeldTime() >= 700) {
|
|
searchCharIndex = totalPickerItems - 1;
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
|
|
// Left/Right navigate through characters (with wrap)
|
|
if (mappedInput.wasPressed(MappedInputManager::Button::Left)) {
|
|
if (searchCharIndex > 0) {
|
|
searchCharIndex--;
|
|
} else {
|
|
searchCharIndex = totalPickerItems - 1; // Wrap to end
|
|
}
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
|
|
if (mappedInput.wasPressed(MappedInputManager::Button::Right)) {
|
|
if (searchCharIndex < totalPickerItems - 1) {
|
|
searchCharIndex++;
|
|
} else {
|
|
searchCharIndex = 0; // Wrap to start
|
|
}
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
|
|
// Down moves to results (if any exist)
|
|
if (mappedInput.wasPressed(MappedInputManager::Button::Down)) {
|
|
if (!searchResults.empty()) {
|
|
searchInResults = true;
|
|
selectorIndex = 0;
|
|
updateRequired = true;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Up moves to tab bar
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Up)) {
|
|
inTabBar = true;
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
|
|
// Confirm adds selected character or performs special action
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
|
if (searchCharIndex < charCount) {
|
|
// Regular character - add to query (as lowercase for search)
|
|
searchQuery += std::tolower(static_cast<unsigned char>(searchCharacters[searchCharIndex]));
|
|
updateSearchResults();
|
|
} else if (searchCharIndex == charCount) {
|
|
// SPC - add space
|
|
searchQuery += ' ';
|
|
updateSearchResults();
|
|
} else if (searchCharIndex == charCount + 1) {
|
|
// <- Backspace
|
|
if (!searchQuery.empty()) {
|
|
searchQuery.pop_back();
|
|
updateSearchResults();
|
|
}
|
|
} else if (searchCharIndex == charCount + 2) {
|
|
// CLR - clear query
|
|
searchQuery.clear();
|
|
updateSearchResults();
|
|
}
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
|
|
// Long press Back = clear entire query
|
|
if (mappedInput.isPressed(MappedInputManager::Button::Back) &&
|
|
mappedInput.getHeldTime() >= 700) {
|
|
if (!searchQuery.empty()) {
|
|
searchQuery.clear();
|
|
updateSearchResults();
|
|
updateRequired = true;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Short press Back = backspace (delete one char)
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
|
if (mappedInput.getHeldTime() >= 700) {
|
|
// Already handled by long press above, ignore release
|
|
return;
|
|
}
|
|
if (!searchQuery.empty()) {
|
|
searchQuery.pop_back();
|
|
updateSearchResults();
|
|
updateRequired = true;
|
|
} else {
|
|
// If query already empty, go home
|
|
onGoHome();
|
|
}
|
|
return;
|
|
}
|
|
|
|
return; // Don't process other input while in picker
|
|
} else {
|
|
// In results mode
|
|
|
|
// Long press PageBack (side button) = jump to first result
|
|
if (mappedInput.isPressed(MappedInputManager::Button::PageBack) &&
|
|
mappedInput.getHeldTime() >= 700) {
|
|
selectorIndex = 0;
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
|
|
// Long press PageForward (side button) = jump to last result
|
|
if (mappedInput.isPressed(MappedInputManager::Button::PageForward) &&
|
|
mappedInput.getHeldTime() >= 700) {
|
|
if (!searchResults.empty()) {
|
|
selectorIndex = static_cast<int>(searchResults.size()) - 1;
|
|
}
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
|
|
// Up/Down navigate through results
|
|
if (mappedInput.wasPressed(MappedInputManager::Button::Up)) {
|
|
if (selectorIndex > 0) {
|
|
selectorIndex--;
|
|
} else {
|
|
// At first result, move back to character picker
|
|
searchInResults = false;
|
|
}
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
|
|
if (mappedInput.wasPressed(MappedInputManager::Button::Down)) {
|
|
if (selectorIndex < static_cast<int>(searchResults.size()) - 1) {
|
|
selectorIndex++;
|
|
} else {
|
|
// At last result, wrap to character picker
|
|
searchInResults = false;
|
|
}
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
|
|
// Left/Right do nothing in results (or could page?)
|
|
if (mappedInput.wasPressed(MappedInputManager::Button::Left) ||
|
|
mappedInput.wasPressed(MappedInputManager::Button::Right)) {
|
|
return;
|
|
}
|
|
|
|
// Confirm opens the selected book
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
|
if (!searchResults.empty() && selectorIndex < static_cast<int>(searchResults.size())) {
|
|
onSelectBook(searchResults[selectorIndex].path, currentTab);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Back button - go back to character picker
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
|
searchInResults = false;
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
|
|
return; // Don't process other input
|
|
}
|
|
}
|
|
|
|
// Long press BACK (1s+) in Files tab goes to root folder
|
|
if (currentTab == Tab::Files && mappedInput.isPressed(MappedInputManager::Button::Back) &&
|
|
mappedInput.getHeldTime() >= GO_HOME_MS) {
|
|
if (basepath != "/") {
|
|
basepath = "/";
|
|
loadFiles();
|
|
selectorIndex = 0;
|
|
updateRequired = true;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Long press Confirm to open list action menu (only on Lists tab)
|
|
constexpr unsigned long LIST_ACTION_MENU_MS = 700;
|
|
if (currentTab == Tab::Lists && mappedInput.isPressed(MappedInputManager::Button::Confirm) &&
|
|
mappedInput.getHeldTime() >= LIST_ACTION_MENU_MS && !lists.empty() &&
|
|
selectorIndex < static_cast<int>(lists.size())) {
|
|
openListActionMenu();
|
|
return;
|
|
}
|
|
|
|
// Long press Confirm to open action menu (only for files, not directories)
|
|
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) &&
|
|
mappedInput.getHeldTime() >= ACTION_MENU_MS && isSelectedItemAFile()) {
|
|
openActionMenu();
|
|
return;
|
|
}
|
|
|
|
const bool upReleased = mappedInput.wasReleased(MappedInputManager::Button::Up);
|
|
const bool downReleased = mappedInput.wasReleased(MappedInputManager::Button::Down);
|
|
const bool leftReleased = mappedInput.wasReleased(MappedInputManager::Button::Left);
|
|
const bool rightReleased = mappedInput.wasReleased(MappedInputManager::Button::Right);
|
|
|
|
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
|
|
|
|
// Confirm button - open selected item (short press)
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
|
// Ignore if it was a long press that triggered the action menu
|
|
if (mappedInput.getHeldTime() >= ACTION_MENU_MS) {
|
|
return;
|
|
}
|
|
|
|
// Check if "Search..." shortcut is selected (last item in non-Search tabs)
|
|
bool isSearchShortcut = false;
|
|
if (currentTab == Tab::Recent && selectorIndex == static_cast<int>(recentBooks.size())) {
|
|
isSearchShortcut = true;
|
|
} else if (currentTab == Tab::Lists && selectorIndex == static_cast<int>(lists.size())) {
|
|
isSearchShortcut = true;
|
|
} else if (currentTab == Tab::Bookmarks && selectorIndex == static_cast<int>(bookmarkedBooks.size())) {
|
|
isSearchShortcut = true;
|
|
} else if (currentTab == Tab::Files && selectorIndex == static_cast<int>(files.size())) {
|
|
isSearchShortcut = true;
|
|
}
|
|
|
|
if (isSearchShortcut) {
|
|
// Switch to Search tab with character picker active
|
|
currentTab = Tab::Search;
|
|
selectorIndex = 0;
|
|
searchInResults = false;
|
|
inTabBar = false;
|
|
searchCharIndex = 0;
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
|
|
if (currentTab == Tab::Recent) {
|
|
if (!recentBooks.empty() && selectorIndex < static_cast<int>(recentBooks.size())) {
|
|
onSelectBook(recentBooks[selectorIndex].path, currentTab);
|
|
}
|
|
} else if (currentTab == Tab::Lists) {
|
|
// Lists tab - open selected list
|
|
if (!lists.empty() && selectorIndex < static_cast<int>(lists.size())) {
|
|
if (onSelectList) {
|
|
onSelectList(lists[selectorIndex]);
|
|
}
|
|
}
|
|
} else if (currentTab == Tab::Bookmarks) {
|
|
// Bookmarks tab - open BookmarkListActivity for the selected book
|
|
if (!bookmarkedBooks.empty() && selectorIndex < static_cast<int>(bookmarkedBooks.size())) {
|
|
const auto& book = bookmarkedBooks[selectorIndex];
|
|
if (onSelectBookmarkedBook) {
|
|
onSelectBookmarkedBook(book.path, book.title);
|
|
}
|
|
}
|
|
} else if (currentTab == Tab::Search) {
|
|
// Search tab - open selected result
|
|
if (!searchResults.empty() && selectorIndex < static_cast<int>(searchResults.size())) {
|
|
onSelectBook(searchResults[selectorIndex].path, currentTab);
|
|
}
|
|
} else {
|
|
// Files tab
|
|
if (!files.empty() && selectorIndex < static_cast<int>(files.size())) {
|
|
if (basepath.back() != '/') basepath += "/";
|
|
if (files[selectorIndex].back() == '/') {
|
|
// Enter directory
|
|
basepath += files[selectorIndex].substr(0, files[selectorIndex].length() - 1);
|
|
loadFiles();
|
|
selectorIndex = 0;
|
|
updateRequired = true;
|
|
} else {
|
|
// Open file
|
|
onSelectBook(basepath + files[selectorIndex], currentTab);
|
|
}
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Back button
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
|
if (mappedInput.getHeldTime() < GO_HOME_MS) {
|
|
if (currentTab == Tab::Files && basepath != "/") {
|
|
// Go up one directory, remembering the directory we came from
|
|
const std::string oldPath = basepath;
|
|
basepath.replace(basepath.find_last_of('/'), std::string::npos, "");
|
|
if (basepath.empty()) basepath = "/";
|
|
loadFiles();
|
|
|
|
// Select the directory we just came from
|
|
const auto pos = oldPath.find_last_of('/');
|
|
const std::string dirName = oldPath.substr(pos + 1) + "/";
|
|
selectorIndex = static_cast<int>(findEntry(dirName));
|
|
|
|
updateRequired = true;
|
|
} else if (currentTab == Tab::Search && searchInResults) {
|
|
// In Search tab viewing results, go back to character picker
|
|
searchInResults = false;
|
|
updateRequired = true;
|
|
} else {
|
|
// Go home
|
|
onGoHome();
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Tab switching: Left/Right control tabs with wrapping (except in Search tab where they navigate picker)
|
|
// Order: Recent <-> Lists <-> Bookmarks <-> Search <-> Files
|
|
if (leftReleased && currentTab != Tab::Search) {
|
|
switch (currentTab) {
|
|
case Tab::Recent:
|
|
currentTab = Tab::Files; // Wrap from first to last
|
|
break;
|
|
case Tab::Lists:
|
|
currentTab = Tab::Recent;
|
|
break;
|
|
case Tab::Bookmarks:
|
|
currentTab = Tab::Lists;
|
|
break;
|
|
case Tab::Search:
|
|
currentTab = Tab::Bookmarks;
|
|
break;
|
|
case Tab::Files:
|
|
currentTab = Tab::Search;
|
|
inTabBar = true; // Stay in tab bar mode when cycling to Search
|
|
break;
|
|
}
|
|
selectorIndex = 0;
|
|
// Don't auto-activate keyboard when tab-switching - user can press Down to enter search
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
if (rightReleased && currentTab != Tab::Search) {
|
|
switch (currentTab) {
|
|
case Tab::Recent:
|
|
currentTab = Tab::Lists;
|
|
break;
|
|
case Tab::Lists:
|
|
currentTab = Tab::Bookmarks;
|
|
break;
|
|
case Tab::Bookmarks:
|
|
currentTab = Tab::Search;
|
|
inTabBar = true; // Stay in tab bar mode when cycling to Search
|
|
break;
|
|
case Tab::Search:
|
|
currentTab = Tab::Files;
|
|
break;
|
|
case Tab::Files:
|
|
currentTab = Tab::Recent; // Wrap from last to first
|
|
break;
|
|
}
|
|
selectorIndex = 0;
|
|
// Don't auto-activate keyboard when tab-switching - user can press Down to enter search
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
|
|
// Navigation: Up/Down moves through items only
|
|
const bool prevReleased = upReleased;
|
|
const bool nextReleased = downReleased;
|
|
|
|
if (prevReleased && itemCount > 0) {
|
|
if (skipPage) {
|
|
// Long press - page up
|
|
selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + itemCount) % itemCount;
|
|
} else if (selectorIndex == 0) {
|
|
// At top of list, enter tab bar
|
|
inTabBar = true;
|
|
} else {
|
|
// Normal up navigation
|
|
selectorIndex = selectorIndex - 1;
|
|
}
|
|
updateRequired = true;
|
|
} else if (nextReleased && itemCount > 0) {
|
|
if (skipPage) {
|
|
selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % itemCount;
|
|
} else {
|
|
selectorIndex = (selectorIndex + 1) % itemCount;
|
|
}
|
|
updateRequired = true;
|
|
}
|
|
}
|
|
|
|
void MyLibraryActivity::displayTaskLoop() {
|
|
bool coverPreloaded = false;
|
|
|
|
while (true) {
|
|
if (updateRequired) {
|
|
updateRequired = false;
|
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
|
render();
|
|
xSemaphoreGive(renderingMutex);
|
|
|
|
// After first render, pre-allocate cover buffer for Home screen
|
|
// This happens in background so Home screen loads faster when user navigates there
|
|
if (!coverPreloaded) {
|
|
coverPreloaded = true;
|
|
HomeActivity::preloadCoverBuffer();
|
|
}
|
|
}
|
|
vTaskDelay(10 / portTICK_PERIOD_MS);
|
|
}
|
|
}
|
|
|
|
void MyLibraryActivity::render() const {
|
|
renderer.clearScreen();
|
|
|
|
// Handle different UI states
|
|
if (uiState == UIState::ActionMenu) {
|
|
renderActionMenu();
|
|
renderer.displayBuffer();
|
|
return;
|
|
}
|
|
|
|
if (uiState == UIState::Confirming) {
|
|
renderConfirmation();
|
|
renderer.displayBuffer();
|
|
return;
|
|
}
|
|
|
|
if (uiState == UIState::ListActionMenu) {
|
|
renderListActionMenu();
|
|
renderer.displayBuffer();
|
|
return;
|
|
}
|
|
|
|
if (uiState == UIState::ListConfirmingDelete) {
|
|
renderListDeleteConfirmation();
|
|
renderer.displayBuffer();
|
|
return;
|
|
}
|
|
|
|
if (uiState == UIState::ClearAllRecentsConfirming) {
|
|
renderClearAllRecentsConfirmation();
|
|
renderer.displayBuffer();
|
|
return;
|
|
}
|
|
|
|
// Calculate bezel-adjusted margins
|
|
const int bezelTop = renderer.getBezelOffsetTop();
|
|
const int bezelBottom = renderer.getBezelOffsetBottom();
|
|
const int TAB_BAR_Y = BASE_TAB_BAR_Y + bezelTop;
|
|
const int CONTENT_START_Y = BASE_CONTENT_START_Y + bezelTop;
|
|
|
|
// Normal state - draw library view
|
|
// Draw tab bar
|
|
std::vector<TabInfo> tabs = {{"Recent", currentTab == Tab::Recent},
|
|
{"Lists", currentTab == Tab::Lists},
|
|
{"Bookmarks", currentTab == Tab::Bookmarks},
|
|
{"Search", currentTab == Tab::Search},
|
|
{"Files", currentTab == Tab::Files}};
|
|
const int selectedTabIndex = static_cast<int>(currentTab);
|
|
ScreenComponents::drawTabBar(renderer, TAB_BAR_Y, tabs, selectedTabIndex, inTabBar);
|
|
|
|
// Draw content based on current tab
|
|
if (currentTab == Tab::Recent) {
|
|
renderRecentTab();
|
|
} else if (currentTab == Tab::Lists) {
|
|
renderListsTab();
|
|
} else if (currentTab == Tab::Bookmarks) {
|
|
renderBookmarksTab();
|
|
} else if (currentTab == Tab::Search) {
|
|
renderSearchTab();
|
|
} else {
|
|
renderFilesTab();
|
|
}
|
|
|
|
// Draw scroll indicator
|
|
const int screenHeight = renderer.getScreenHeight();
|
|
const int contentHeight = screenHeight - CONTENT_START_Y - 60 - bezelBottom; // 60 for bottom bar
|
|
ScreenComponents::drawScrollIndicator(renderer, getCurrentPage(), getTotalPages(), CONTENT_START_Y, contentHeight);
|
|
|
|
// Draw side button hints (up/down navigation on right side)
|
|
// Note: text is rotated 90° CW, so ">" appears as "^" and "<" appears as "v"
|
|
renderer.drawSideButtonHints(UI_10_FONT_ID, ">", "<");
|
|
|
|
// Draw bottom button hints - customize for Search tab states
|
|
std::string backLabel = "« Back";
|
|
std::string confirmLabel = "Open";
|
|
if (currentTab == Tab::Search) {
|
|
if (inTabBar) {
|
|
backLabel = "« Back";
|
|
confirmLabel = ""; // No action in tab bar
|
|
} else if (!searchInResults) {
|
|
backLabel = "BKSP"; // Back = backspace (short), clear (long)
|
|
confirmLabel = "Select";
|
|
} else {
|
|
backLabel = "« Back";
|
|
confirmLabel = "Open";
|
|
}
|
|
}
|
|
const auto labels = mappedInput.mapLabels(backLabel.c_str(), confirmLabel.c_str(), "<", ">");
|
|
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
|
|
|
renderer.displayBuffer();
|
|
}
|
|
|
|
void MyLibraryActivity::renderRecentTab() const {
|
|
const auto pageWidth = renderer.getScreenWidth();
|
|
const int pageItems = getPageItems();
|
|
const int bookCount = static_cast<int>(recentBooks.size());
|
|
const int totalItems = bookCount + 1; // +1 for "Search..." shortcut
|
|
|
|
// Calculate bezel-adjusted margins
|
|
const int bezelTop = renderer.getBezelOffsetTop();
|
|
const int bezelLeft = renderer.getBezelOffsetLeft();
|
|
const int bezelRight = renderer.getBezelOffsetRight();
|
|
const int CONTENT_START_Y = BASE_CONTENT_START_Y + bezelTop;
|
|
const int LEFT_MARGIN = BASE_LEFT_MARGIN + bezelLeft;
|
|
const int RIGHT_MARGIN = BASE_RIGHT_MARGIN + bezelRight;
|
|
const int THUMB_RIGHT_MARGIN = BASE_THUMB_RIGHT_MARGIN + bezelRight;
|
|
|
|
if (bookCount == 0) {
|
|
// Still show "Search..." even when empty
|
|
const bool searchSelected = (selectorIndex == 0);
|
|
if (searchSelected) {
|
|
renderer.fillRect(bezelLeft, CONTENT_START_Y - 2, pageWidth - RIGHT_MARGIN - bezelLeft, RECENTS_LINE_HEIGHT);
|
|
}
|
|
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "Search...", !searchSelected);
|
|
return;
|
|
}
|
|
|
|
const auto pageStartIndex = selectorIndex / pageItems * pageItems;
|
|
|
|
// Draw selection highlight
|
|
renderer.fillRect(bezelLeft, CONTENT_START_Y + (selectorIndex % pageItems) * RECENTS_LINE_HEIGHT - 2,
|
|
pageWidth - RIGHT_MARGIN - bezelLeft, RECENTS_LINE_HEIGHT);
|
|
|
|
// Calculate available text width (leaving space for thumbnail on the right)
|
|
const int textMaxWidth = pageWidth - LEFT_MARGIN - RIGHT_MARGIN - MICRO_THUMB_WIDTH - 10;
|
|
const int thumbX = pageWidth - THUMB_RIGHT_MARGIN - MICRO_THUMB_WIDTH;
|
|
|
|
// Draw items
|
|
for (int i = pageStartIndex; i < bookCount && i < pageStartIndex + pageItems; i++) {
|
|
const auto& book = recentBooks[i];
|
|
const int y = CONTENT_START_Y + (i % pageItems) * RECENTS_LINE_HEIGHT;
|
|
const bool isSelected = (i == selectorIndex);
|
|
|
|
// Try to load and draw micro-thumbnail (with existence caching)
|
|
bool hasThumb = false;
|
|
|
|
// Check if we have cached existence info for this book
|
|
ThumbExistsCache* existsCache = nullptr;
|
|
std::string microThumbPath;
|
|
bool thumbExists = false;
|
|
bool existsCacheHit = false;
|
|
|
|
if (i < MAX_THUMB_CACHE) {
|
|
existsCache = &thumbExistsCache[i];
|
|
if (existsCache->checked && existsCache->bookPath == book.path) {
|
|
// Use cached existence info
|
|
existsCacheHit = true;
|
|
thumbExists = existsCache->exists;
|
|
microThumbPath = existsCache->thumbPath;
|
|
}
|
|
}
|
|
|
|
// If not cached, check existence and cache the result
|
|
if (!existsCacheHit) {
|
|
microThumbPath = getMicroThumbPathForBook(book.path);
|
|
thumbExists = !microThumbPath.empty() && SdMan.exists(microThumbPath.c_str());
|
|
|
|
// Cache the result
|
|
if (existsCache != nullptr) {
|
|
existsCache->bookPath = book.path;
|
|
existsCache->thumbPath = microThumbPath;
|
|
existsCache->exists = thumbExists;
|
|
existsCache->checked = true;
|
|
}
|
|
}
|
|
|
|
// Load and render thumbnail if it exists
|
|
if (thumbExists) {
|
|
FsFile thumbFile;
|
|
if (SdMan.openFileForRead("MYL", microThumbPath, thumbFile)) {
|
|
Bitmap bitmap(thumbFile);
|
|
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
|
const int bmpW = bitmap.getWidth();
|
|
const int bmpH = bitmap.getHeight();
|
|
const float scaleX = static_cast<float>(MICRO_THUMB_WIDTH) / static_cast<float>(bmpW);
|
|
const float scaleY = static_cast<float>(MICRO_THUMB_HEIGHT) / static_cast<float>(bmpH);
|
|
const float scale = std::min(scaleX, scaleY);
|
|
const int drawnW = static_cast<int>(bmpW * scale);
|
|
const int drawnH = static_cast<int>(bmpH * scale);
|
|
|
|
const int thumbY = y + (RECENTS_LINE_HEIGHT - drawnH) / 2;
|
|
if (isSelected) {
|
|
renderer.fillRect(thumbX, thumbY, drawnW, drawnH, false);
|
|
}
|
|
renderer.drawBitmap(bitmap, thumbX, thumbY, MICRO_THUMB_WIDTH, MICRO_THUMB_HEIGHT, 0, 0, isSelected);
|
|
hasThumb = true;
|
|
}
|
|
thumbFile.close();
|
|
}
|
|
}
|
|
|
|
// Use full width if no thumbnail, otherwise use reduced width
|
|
const int baseAvailableWidth = hasThumb ? textMaxWidth : (pageWidth - LEFT_MARGIN - RIGHT_MARGIN);
|
|
|
|
// Line 1: Title
|
|
std::string title = book.title;
|
|
if (title.empty()) {
|
|
// Fallback for older entries or files without metadata
|
|
title = book.path;
|
|
const size_t lastSlash = title.find_last_of('/');
|
|
if (lastSlash != std::string::npos) {
|
|
title = title.substr(lastSlash + 1);
|
|
}
|
|
const size_t dot = title.find_last_of('.');
|
|
if (dot != std::string::npos) {
|
|
title.resize(dot);
|
|
}
|
|
}
|
|
|
|
// Extract tags for badges (only if we'll show them - when NOT selected)
|
|
constexpr int badgeSpacing = 4; // Gap between badges
|
|
constexpr int badgePadding = 10; // Horizontal padding inside badge (5 each side)
|
|
constexpr int badgeToThumbGap = 8; // Gap between rightmost badge and cover art
|
|
int totalBadgeWidth = 0;
|
|
BookTags tags;
|
|
|
|
if (!isSelected) {
|
|
tags = StringUtils::extractBookTags(book.path);
|
|
if (!tags.extensionTag.empty()) {
|
|
totalBadgeWidth += renderer.getTextWidth(SMALL_FONT_ID, tags.extensionTag.c_str()) + badgePadding;
|
|
}
|
|
if (!tags.suffixTag.empty()) {
|
|
if (totalBadgeWidth > 0) {
|
|
totalBadgeWidth += badgeSpacing;
|
|
}
|
|
totalBadgeWidth += renderer.getTextWidth(SMALL_FONT_ID, tags.suffixTag.c_str()) + badgePadding;
|
|
}
|
|
}
|
|
|
|
// When selected, use full width (no badges shown)
|
|
// When not selected, reserve space for badges at the right edge (plus gap to thumbnail)
|
|
const int badgeReservedWidth = totalBadgeWidth > 0 ? (totalBadgeWidth + badgeSpacing + badgeToThumbGap) : 0;
|
|
const int availableWidth = isSelected ? baseAvailableWidth : (baseAvailableWidth - badgeReservedWidth);
|
|
auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, title.c_str(), availableWidth);
|
|
renderer.drawText(UI_12_FONT_ID, LEFT_MARGIN, y + 2, truncatedTitle.c_str(), !isSelected);
|
|
|
|
// Draw badges right-aligned (near thumbnail or right edge) - only when NOT selected
|
|
if (!isSelected && totalBadgeWidth > 0) {
|
|
// Position badges at the right edge of the available text area (with gap to thumbnail)
|
|
const int badgeAreaRight = LEFT_MARGIN + baseAvailableWidth - badgeToThumbGap;
|
|
int badgeX = badgeAreaRight - totalBadgeWidth;
|
|
|
|
// Center badge vertically within title line height
|
|
const int titleLineHeight = renderer.getLineHeight(UI_12_FONT_ID);
|
|
const int badgeLineHeight = renderer.getLineHeight(SMALL_FONT_ID);
|
|
const int badgeVerticalPadding = 4; // 2px padding top + bottom in badge
|
|
const int badgeHeight = badgeLineHeight + badgeVerticalPadding;
|
|
const int badgeY = y + 2 + (titleLineHeight - badgeHeight) / 2;
|
|
|
|
if (!tags.extensionTag.empty()) {
|
|
int badgeWidth = ScreenComponents::drawPillBadge(renderer, badgeX, badgeY, tags.extensionTag.c_str(),
|
|
SMALL_FONT_ID, false);
|
|
badgeX += badgeWidth + badgeSpacing;
|
|
}
|
|
if (!tags.suffixTag.empty()) {
|
|
ScreenComponents::drawPillBadge(renderer, badgeX, badgeY, tags.suffixTag.c_str(), SMALL_FONT_ID, false);
|
|
}
|
|
}
|
|
|
|
// Line 2: Author
|
|
if (!book.author.empty()) {
|
|
auto truncatedAuthor = renderer.truncatedText(UI_10_FONT_ID, book.author.c_str(), baseAvailableWidth);
|
|
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y + 32, truncatedAuthor.c_str(), !isSelected);
|
|
}
|
|
}
|
|
|
|
// Draw "Search..." shortcut if it's on the current page
|
|
const int searchIndex = bookCount; // Last item
|
|
if (searchIndex >= pageStartIndex && searchIndex < pageStartIndex + pageItems) {
|
|
const int y = CONTENT_START_Y + (searchIndex % pageItems) * RECENTS_LINE_HEIGHT;
|
|
const bool isSelected = (selectorIndex == searchIndex);
|
|
if (isSelected) {
|
|
renderer.fillRect(bezelLeft, y - 2, pageWidth - RIGHT_MARGIN - bezelLeft, RECENTS_LINE_HEIGHT);
|
|
}
|
|
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y + 2, "Search...", !isSelected);
|
|
}
|
|
}
|
|
|
|
void MyLibraryActivity::renderListsTab() const {
|
|
const auto pageWidth = renderer.getScreenWidth();
|
|
const int pageItems = getPageItems();
|
|
const int listCount = static_cast<int>(lists.size());
|
|
const int totalItems = listCount + 1; // +1 for "Search..." shortcut
|
|
|
|
// Calculate bezel-adjusted margins
|
|
const int bezelTop = renderer.getBezelOffsetTop();
|
|
const int bezelLeft = renderer.getBezelOffsetLeft();
|
|
const int bezelRight = renderer.getBezelOffsetRight();
|
|
const int CONTENT_START_Y = BASE_CONTENT_START_Y + bezelTop;
|
|
const int LEFT_MARGIN = BASE_LEFT_MARGIN + bezelLeft;
|
|
const int RIGHT_MARGIN = BASE_RIGHT_MARGIN + bezelRight;
|
|
|
|
if (listCount == 0) {
|
|
// Still show "Search..." even when empty
|
|
const bool searchSelected = (selectorIndex == 0);
|
|
if (searchSelected) {
|
|
renderer.fillRect(bezelLeft, CONTENT_START_Y - 2, pageWidth - RIGHT_MARGIN - bezelLeft, LINE_HEIGHT);
|
|
}
|
|
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "Search...", !searchSelected);
|
|
return;
|
|
}
|
|
|
|
const auto pageStartIndex = selectorIndex / pageItems * pageItems;
|
|
|
|
// Draw selection highlight
|
|
renderer.fillRect(bezelLeft, CONTENT_START_Y + (selectorIndex % pageItems) * LINE_HEIGHT - 2, pageWidth - RIGHT_MARGIN - bezelLeft,
|
|
LINE_HEIGHT);
|
|
|
|
// Draw items
|
|
for (int i = pageStartIndex; i < listCount && i < pageStartIndex + pageItems; i++) {
|
|
// Add indicator for pinned list
|
|
std::string displayName = lists[i];
|
|
if (displayName == SETTINGS.pinnedListName) {
|
|
displayName = "• " + displayName + " •";
|
|
}
|
|
auto item = renderer.truncatedText(UI_10_FONT_ID, displayName.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN);
|
|
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y + (i % pageItems) * LINE_HEIGHT, item.c_str(),
|
|
i != selectorIndex);
|
|
}
|
|
|
|
// Draw "Search..." shortcut if it's on the current page
|
|
const int searchIndex = listCount; // Last item
|
|
if (searchIndex >= pageStartIndex && searchIndex < pageStartIndex + pageItems) {
|
|
const int y = CONTENT_START_Y + (searchIndex % pageItems) * LINE_HEIGHT;
|
|
const bool isSelected = (selectorIndex == searchIndex);
|
|
// Selection highlight already drawn above, but need to handle if Search is selected
|
|
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y, "Search...", !isSelected);
|
|
}
|
|
}
|
|
|
|
void MyLibraryActivity::renderFilesTab() const {
|
|
const auto pageWidth = renderer.getScreenWidth();
|
|
const int pageItems = getPageItems();
|
|
const int fileCount = static_cast<int>(files.size());
|
|
const int totalItems = fileCount + 1; // +1 for "Search..." shortcut
|
|
|
|
// Calculate bezel-adjusted margins
|
|
const int bezelTop = renderer.getBezelOffsetTop();
|
|
const int bezelLeft = renderer.getBezelOffsetLeft();
|
|
const int bezelRight = renderer.getBezelOffsetRight();
|
|
const int CONTENT_START_Y = BASE_CONTENT_START_Y + bezelTop;
|
|
const int LEFT_MARGIN = BASE_LEFT_MARGIN + bezelLeft;
|
|
const int RIGHT_MARGIN = BASE_RIGHT_MARGIN + bezelRight;
|
|
|
|
if (fileCount == 0) {
|
|
// Still show "Search..." even when empty
|
|
const bool searchSelected = (selectorIndex == 0);
|
|
if (searchSelected) {
|
|
renderer.fillRect(bezelLeft, CONTENT_START_Y - 2, pageWidth - RIGHT_MARGIN - bezelLeft, LINE_HEIGHT);
|
|
}
|
|
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "Search...", !searchSelected);
|
|
return;
|
|
}
|
|
|
|
const auto pageStartIndex = selectorIndex / pageItems * pageItems;
|
|
|
|
// Draw selection highlight
|
|
renderer.fillRect(bezelLeft, CONTENT_START_Y + (selectorIndex % pageItems) * LINE_HEIGHT - 2, pageWidth - RIGHT_MARGIN - bezelLeft,
|
|
LINE_HEIGHT);
|
|
|
|
// Draw items
|
|
for (int i = pageStartIndex; i < fileCount && i < pageStartIndex + pageItems; i++) {
|
|
auto item = renderer.truncatedText(UI_10_FONT_ID, files[i].c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN);
|
|
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y + (i % pageItems) * LINE_HEIGHT, item.c_str(),
|
|
i != selectorIndex);
|
|
}
|
|
|
|
// Draw "Search..." shortcut if it's on the current page
|
|
const int searchIndex = fileCount; // Last item
|
|
if (searchIndex >= pageStartIndex && searchIndex < pageStartIndex + pageItems) {
|
|
const int y = CONTENT_START_Y + (searchIndex % pageItems) * LINE_HEIGHT;
|
|
const bool isSelected = (selectorIndex == searchIndex);
|
|
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y, "Search...", !isSelected);
|
|
}
|
|
}
|
|
|
|
void MyLibraryActivity::renderActionMenu() const {
|
|
const auto pageWidth = renderer.getScreenWidth();
|
|
const auto pageHeight = renderer.getScreenHeight();
|
|
|
|
// Bezel compensation
|
|
const int bezelTop = renderer.getBezelOffsetTop();
|
|
const int bezelLeft = renderer.getBezelOffsetLeft();
|
|
const int bezelRight = renderer.getBezelOffsetRight();
|
|
|
|
// Title
|
|
renderer.drawCenteredText(UI_12_FONT_ID, 20 + bezelTop, "Book Actions", true, EpdFontFamily::BOLD);
|
|
|
|
// Show filename
|
|
const int filenameY = 70 + bezelTop;
|
|
auto truncatedName = renderer.truncatedText(UI_10_FONT_ID, actionTargetName.c_str(), pageWidth - 40 - bezelLeft - bezelRight);
|
|
renderer.drawCenteredText(UI_10_FONT_ID, filenameY, truncatedName.c_str());
|
|
|
|
// Menu options - 4 for Recent tab, 2 for Files tab
|
|
const bool isRecentTab = (currentTab == Tab::Recent);
|
|
const int menuItemCount = isRecentTab ? 4 : 2;
|
|
constexpr int menuLineHeight = 35;
|
|
constexpr int menuItemWidth = 160;
|
|
const int menuX = (pageWidth - menuItemWidth) / 2;
|
|
const int menuStartY = pageHeight / 2 - (menuItemCount * menuLineHeight) / 2;
|
|
|
|
// Archive option
|
|
if (menuSelection == 0) {
|
|
renderer.fillRect(menuX - 10, menuStartY - 5, menuItemWidth + 20, menuLineHeight);
|
|
}
|
|
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY, "Archive", menuSelection != 0);
|
|
|
|
// Delete option
|
|
if (menuSelection == 1) {
|
|
renderer.fillRect(menuX - 10, menuStartY + menuLineHeight - 5, menuItemWidth + 20, menuLineHeight);
|
|
}
|
|
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight, "Delete", menuSelection != 1);
|
|
|
|
// Recent tab only: Remove from Recents and Clear All Recents
|
|
if (isRecentTab) {
|
|
// Remove from Recents option
|
|
if (menuSelection == 2) {
|
|
renderer.fillRect(menuX - 10, menuStartY + menuLineHeight * 2 - 5, menuItemWidth + 20, menuLineHeight);
|
|
}
|
|
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight * 2, "Remove from Recents", menuSelection != 2);
|
|
|
|
// Clear All Recents option
|
|
if (menuSelection == 3) {
|
|
renderer.fillRect(menuX - 10, menuStartY + menuLineHeight * 3 - 5, menuItemWidth + 20, menuLineHeight);
|
|
}
|
|
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight * 3, "Clear All Recents", menuSelection != 3);
|
|
}
|
|
|
|
// Draw side button hints (up/down navigation)
|
|
renderer.drawSideButtonHints(UI_10_FONT_ID, ">", "<");
|
|
|
|
// Draw bottom button hints
|
|
const auto labels = mappedInput.mapLabels("« Cancel", "Select", "", "");
|
|
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
|
}
|
|
|
|
void MyLibraryActivity::renderConfirmation() const {
|
|
const auto pageWidth = renderer.getScreenWidth();
|
|
const auto pageHeight = renderer.getScreenHeight();
|
|
|
|
// Title based on action
|
|
const char* actionTitle;
|
|
switch (selectedAction) {
|
|
case ActionType::Archive:
|
|
actionTitle = "Archive Book?";
|
|
break;
|
|
case ActionType::Delete:
|
|
actionTitle = "Delete Book?";
|
|
break;
|
|
case ActionType::RemoveFromRecents:
|
|
actionTitle = "Remove from Recents?";
|
|
break;
|
|
default:
|
|
actionTitle = "Confirm Action";
|
|
break;
|
|
}
|
|
renderer.drawCenteredText(UI_12_FONT_ID, 20, actionTitle, true, EpdFontFamily::BOLD);
|
|
|
|
// Show filename
|
|
const int filenameY = pageHeight / 2 - 40;
|
|
auto truncatedName = renderer.truncatedText(UI_10_FONT_ID, actionTargetName.c_str(), pageWidth - 40);
|
|
renderer.drawCenteredText(UI_10_FONT_ID, filenameY, truncatedName.c_str());
|
|
|
|
// Warning text
|
|
const int warningY = pageHeight / 2;
|
|
if (selectedAction == ActionType::Archive) {
|
|
renderer.drawCenteredText(UI_10_FONT_ID, warningY, "Book will be moved to archive.");
|
|
renderer.drawCenteredText(UI_10_FONT_ID, warningY + 25, "Reading progress will be saved.");
|
|
} else if (selectedAction == ActionType::Delete) {
|
|
renderer.drawCenteredText(UI_10_FONT_ID, warningY, "Book will be permanently deleted!", true, EpdFontFamily::BOLD);
|
|
renderer.drawCenteredText(UI_10_FONT_ID, warningY + 25, "This cannot be undone.");
|
|
} else if (selectedAction == ActionType::RemoveFromRecents) {
|
|
renderer.drawCenteredText(UI_10_FONT_ID, warningY, "Book will be removed from recents.");
|
|
renderer.drawCenteredText(UI_10_FONT_ID, warningY + 25, "The file will not be deleted.");
|
|
}
|
|
|
|
// Draw bottom button hints
|
|
const auto labels = mappedInput.mapLabels("« Cancel", "Confirm", "", "");
|
|
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
|
}
|
|
|
|
void MyLibraryActivity::renderListActionMenu() const {
|
|
const auto pageWidth = renderer.getScreenWidth();
|
|
const auto pageHeight = renderer.getScreenHeight();
|
|
|
|
// Title
|
|
renderer.drawCenteredText(UI_12_FONT_ID, 20, "List Actions", true, EpdFontFamily::BOLD);
|
|
|
|
// Show list name
|
|
auto truncatedName = renderer.truncatedText(UI_10_FONT_ID, listActionTargetName.c_str(), pageWidth - 40);
|
|
renderer.drawCenteredText(UI_10_FONT_ID, 70, truncatedName.c_str());
|
|
|
|
// Menu options
|
|
const int menuStartY = pageHeight / 2 - 30;
|
|
constexpr int menuLineHeight = 40;
|
|
constexpr int menuItemWidth = 120;
|
|
const int menuX = (pageWidth - menuItemWidth) / 2;
|
|
|
|
// Pin/Unpin option (dynamic label)
|
|
const bool isPinned = (listActionTargetName == SETTINGS.pinnedListName);
|
|
const char* pinLabel = isPinned ? "Unpin" : "Pin";
|
|
|
|
if (listMenuSelection == 0) {
|
|
renderer.fillRect(menuX - 10, menuStartY - 5, menuItemWidth + 20, menuLineHeight);
|
|
}
|
|
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY, pinLabel, listMenuSelection != 0);
|
|
|
|
// Delete option
|
|
if (listMenuSelection == 1) {
|
|
renderer.fillRect(menuX - 10, menuStartY + menuLineHeight - 5, menuItemWidth + 20, menuLineHeight);
|
|
}
|
|
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight, "Delete", listMenuSelection != 1);
|
|
|
|
// Draw side button hints (up/down navigation)
|
|
renderer.drawSideButtonHints(UI_10_FONT_ID, ">", "<");
|
|
|
|
// Draw bottom button hints
|
|
const auto labels = mappedInput.mapLabels("« Cancel", "Select", "", "");
|
|
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
|
}
|
|
|
|
void MyLibraryActivity::renderListDeleteConfirmation() const {
|
|
const auto pageWidth = renderer.getScreenWidth();
|
|
const auto pageHeight = renderer.getScreenHeight();
|
|
|
|
// Title
|
|
renderer.drawCenteredText(UI_12_FONT_ID, 20, "Delete List?", true, EpdFontFamily::BOLD);
|
|
|
|
// Show list name
|
|
auto truncatedName = renderer.truncatedText(UI_10_FONT_ID, listActionTargetName.c_str(), pageWidth - 40);
|
|
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 40, truncatedName.c_str());
|
|
|
|
// Warning text
|
|
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "List will be permanently deleted!", true, EpdFontFamily::BOLD);
|
|
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 25, "This cannot be undone.");
|
|
|
|
// Draw bottom button hints
|
|
const auto labels = mappedInput.mapLabels("« Cancel", "Confirm", "", "");
|
|
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
|
}
|
|
|
|
void MyLibraryActivity::renderClearAllRecentsConfirmation() const {
|
|
const auto pageHeight = renderer.getScreenHeight();
|
|
|
|
// Title
|
|
renderer.drawCenteredText(UI_12_FONT_ID, 20, "Clear All Recents?", true, EpdFontFamily::BOLD);
|
|
|
|
// Warning text
|
|
const int warningY = pageHeight / 2 - 20;
|
|
renderer.drawCenteredText(UI_10_FONT_ID, warningY, "All books will be removed from");
|
|
renderer.drawCenteredText(UI_10_FONT_ID, warningY + 25, "the recent list.");
|
|
renderer.drawCenteredText(UI_10_FONT_ID, warningY + 60, "Book files will not be deleted.");
|
|
|
|
// Draw bottom button hints
|
|
const auto labels = mappedInput.mapLabels("« Cancel", "Confirm", "", "");
|
|
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
|
}
|
|
|
|
void MyLibraryActivity::renderBookmarksTab() const {
|
|
const auto pageWidth = renderer.getScreenWidth();
|
|
const int pageItems = getPageItems();
|
|
const int bookCount = static_cast<int>(bookmarkedBooks.size());
|
|
const int totalItems = bookCount + 1; // +1 for "Search..." shortcut
|
|
|
|
// Calculate bezel-adjusted margins
|
|
const int bezelTop = renderer.getBezelOffsetTop();
|
|
const int bezelLeft = renderer.getBezelOffsetLeft();
|
|
const int bezelRight = renderer.getBezelOffsetRight();
|
|
const int CONTENT_START_Y = BASE_CONTENT_START_Y + bezelTop;
|
|
const int LEFT_MARGIN = BASE_LEFT_MARGIN + bezelLeft;
|
|
const int RIGHT_MARGIN = BASE_RIGHT_MARGIN + bezelRight;
|
|
|
|
if (bookCount == 0) {
|
|
// Still show "Search..." even when empty
|
|
const bool searchSelected = (selectorIndex == 0);
|
|
if (searchSelected) {
|
|
renderer.fillRect(bezelLeft, CONTENT_START_Y - 2, pageWidth - RIGHT_MARGIN - bezelLeft, RECENTS_LINE_HEIGHT);
|
|
}
|
|
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "No bookmarks saved", !searchSelected);
|
|
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y + LINE_HEIGHT, "Search...", searchSelected);
|
|
return;
|
|
}
|
|
|
|
const auto pageStartIndex = selectorIndex / pageItems * pageItems;
|
|
|
|
// Draw selection highlight
|
|
renderer.fillRect(bezelLeft, CONTENT_START_Y + (selectorIndex % pageItems) * RECENTS_LINE_HEIGHT - 2,
|
|
pageWidth - RIGHT_MARGIN - bezelLeft, RECENTS_LINE_HEIGHT);
|
|
|
|
// Draw items (similar to Recent tab but with bookmark count)
|
|
for (int i = pageStartIndex; i < bookCount && i < pageStartIndex + pageItems; i++) {
|
|
const auto& book = bookmarkedBooks[i];
|
|
const int y = CONTENT_START_Y + (i % pageItems) * RECENTS_LINE_HEIGHT;
|
|
const bool isSelected = (i == selectorIndex);
|
|
|
|
// Line 1: Title
|
|
std::string title = book.title;
|
|
if (title.empty()) {
|
|
title = book.path;
|
|
const size_t lastSlash = title.find_last_of('/');
|
|
if (lastSlash != std::string::npos) {
|
|
title = title.substr(lastSlash + 1);
|
|
}
|
|
}
|
|
auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, title.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN);
|
|
renderer.drawText(UI_12_FONT_ID, LEFT_MARGIN, y + 2, truncatedTitle.c_str(), !isSelected);
|
|
|
|
// Line 2: Bookmark count
|
|
std::string countText = std::to_string(book.bookmarkCount) + " bookmark" + (book.bookmarkCount != 1 ? "s" : "");
|
|
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y + 32, countText.c_str(), !isSelected);
|
|
}
|
|
|
|
// Draw "Search..." shortcut if it's on the current page
|
|
const int searchIndex = bookCount; // Last item
|
|
if (searchIndex >= pageStartIndex && searchIndex < pageStartIndex + pageItems) {
|
|
const int y = CONTENT_START_Y + (searchIndex % pageItems) * RECENTS_LINE_HEIGHT;
|
|
const bool isSelected = (selectorIndex == searchIndex);
|
|
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y + 2, "Search...", !isSelected);
|
|
}
|
|
}
|
|
|
|
void MyLibraryActivity::renderSearchTab() const {
|
|
const auto pageWidth = renderer.getScreenWidth();
|
|
const int pageItems = getPageItems();
|
|
const int resultCount = static_cast<int>(searchResults.size());
|
|
|
|
// Calculate bezel-adjusted margins
|
|
const int bezelTop = renderer.getBezelOffsetTop();
|
|
const int bezelLeft = renderer.getBezelOffsetLeft();
|
|
const int bezelRight = renderer.getBezelOffsetRight();
|
|
const int CONTENT_START_Y = BASE_CONTENT_START_Y + bezelTop;
|
|
const int LEFT_MARGIN = BASE_LEFT_MARGIN + bezelLeft;
|
|
const int RIGHT_MARGIN = BASE_RIGHT_MARGIN + bezelRight;
|
|
|
|
// Layout: Character picker -> Query -> Results
|
|
// Character picker height: ~30px
|
|
// Query line height: ~25px
|
|
constexpr int PICKER_HEIGHT = 30;
|
|
constexpr int QUERY_HEIGHT = 25;
|
|
|
|
// Draw character picker at top
|
|
const int pickerY = CONTENT_START_Y;
|
|
renderCharacterPicker(pickerY);
|
|
|
|
// Draw query string below picker
|
|
const int queryY = pickerY + PICKER_HEIGHT;
|
|
std::string displayQuery = searchQuery.empty() ? "(select characters above)" : searchQuery;
|
|
if (!searchInResults) {
|
|
displayQuery = searchQuery + "_"; // Show cursor when in picker
|
|
}
|
|
auto truncatedQuery = renderer.truncatedText(UI_10_FONT_ID, displayQuery.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN);
|
|
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, queryY, truncatedQuery.c_str());
|
|
|
|
// Draw results below query
|
|
const int resultsStartY = queryY + QUERY_HEIGHT;
|
|
|
|
// Draw results section
|
|
if (resultCount == 0) {
|
|
if (searchQuery.empty()) {
|
|
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, resultsStartY, "Select characters to search");
|
|
} else {
|
|
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, resultsStartY, "No results found");
|
|
}
|
|
return;
|
|
}
|
|
|
|
const auto pageStartIndex = selectorIndex / pageItems * pageItems;
|
|
|
|
// Draw items - only show selection when in results mode
|
|
for (int i = pageStartIndex; i < resultCount && i < pageStartIndex + pageItems; i++) {
|
|
const auto& result = searchResults[i];
|
|
const int y = resultsStartY + (i % pageItems) * RECENTS_LINE_HEIGHT;
|
|
const bool isSelected = searchInResults && (i == selectorIndex);
|
|
|
|
// Draw selection highlight only when in results
|
|
if (isSelected) {
|
|
renderer.fillRect(bezelLeft, y - 2, pageWidth - RIGHT_MARGIN - bezelLeft, RECENTS_LINE_HEIGHT);
|
|
}
|
|
|
|
// Calculate available text width
|
|
const int baseAvailableWidth = pageWidth - LEFT_MARGIN - RIGHT_MARGIN;
|
|
|
|
// Extract tags for badges (only when NOT selected)
|
|
constexpr int badgeSpacing = 4;
|
|
constexpr int badgePadding = 10;
|
|
constexpr int badgeToEdgeGap = 8;
|
|
int totalBadgeWidth = 0;
|
|
BookTags tags;
|
|
|
|
if (!isSelected) {
|
|
tags = StringUtils::extractBookTags(result.path);
|
|
if (!tags.extensionTag.empty()) {
|
|
totalBadgeWidth += renderer.getTextWidth(SMALL_FONT_ID, tags.extensionTag.c_str()) + badgePadding;
|
|
}
|
|
if (!tags.suffixTag.empty()) {
|
|
if (totalBadgeWidth > 0) {
|
|
totalBadgeWidth += badgeSpacing;
|
|
}
|
|
totalBadgeWidth += renderer.getTextWidth(SMALL_FONT_ID, tags.suffixTag.c_str()) + badgePadding;
|
|
}
|
|
}
|
|
|
|
// Reserve space for badges when not selected
|
|
const int badgeReservedWidth = totalBadgeWidth > 0 ? (totalBadgeWidth + badgeSpacing + badgeToEdgeGap) : 0;
|
|
const int availableWidth = isSelected ? baseAvailableWidth : (baseAvailableWidth - badgeReservedWidth);
|
|
|
|
// Line 1: Title
|
|
auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, result.title.c_str(), availableWidth);
|
|
renderer.drawText(UI_12_FONT_ID, LEFT_MARGIN, y + 2, truncatedTitle.c_str(), !isSelected);
|
|
|
|
// Draw badges right-aligned - only when NOT selected
|
|
if (!isSelected && totalBadgeWidth > 0) {
|
|
const int badgeAreaRight = LEFT_MARGIN + baseAvailableWidth - badgeToEdgeGap;
|
|
int badgeX = badgeAreaRight - totalBadgeWidth;
|
|
|
|
const int titleLineHeight = renderer.getLineHeight(UI_12_FONT_ID);
|
|
const int badgeLineHeight = renderer.getLineHeight(SMALL_FONT_ID);
|
|
constexpr int badgeVerticalPadding = 4;
|
|
const int badgeHeight = badgeLineHeight + badgeVerticalPadding;
|
|
const int badgeY = y + 2 + (titleLineHeight - badgeHeight) / 2;
|
|
|
|
if (!tags.extensionTag.empty()) {
|
|
int badgeWidth = ScreenComponents::drawPillBadge(renderer, badgeX, badgeY, tags.extensionTag.c_str(),
|
|
SMALL_FONT_ID, false);
|
|
badgeX += badgeWidth + badgeSpacing;
|
|
}
|
|
if (!tags.suffixTag.empty()) {
|
|
ScreenComponents::drawPillBadge(renderer, badgeX, badgeY, tags.suffixTag.c_str(), SMALL_FONT_ID, false);
|
|
}
|
|
}
|
|
|
|
// Line 2: Author or path
|
|
std::string secondLine = result.author.empty() ? result.path : result.author;
|
|
auto truncatedSecond = renderer.truncatedText(UI_10_FONT_ID, secondLine.c_str(), baseAvailableWidth);
|
|
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y + 32, truncatedSecond.c_str(), !isSelected);
|
|
}
|
|
}
|
|
|
|
void MyLibraryActivity::renderCharacterPicker(int y) const {
|
|
const auto pageWidth = renderer.getScreenWidth();
|
|
const int bezelLeft = renderer.getBezelOffsetLeft();
|
|
const int bezelRight = renderer.getBezelOffsetRight();
|
|
|
|
constexpr int charSpacing = 6; // Spacing between characters
|
|
constexpr int specialKeyPadding = 8; // Extra padding around special keys
|
|
constexpr int overflowIndicatorWidth = 16; // Space reserved for < > indicators
|
|
|
|
// Calculate total width needed
|
|
const int charCount = static_cast<int>(searchCharacters.size());
|
|
const int totalItems = charCount + 3; // +3 for SPC, <-, CLR
|
|
|
|
// Calculate character widths
|
|
int totalWidth = 0;
|
|
for (char c : searchCharacters) {
|
|
std::string label(1, c);
|
|
totalWidth += renderer.getTextWidth(UI_10_FONT_ID, label.c_str()) + charSpacing;
|
|
}
|
|
// Add special keys width
|
|
totalWidth += renderer.getTextWidth(UI_10_FONT_ID, "SPC") + specialKeyPadding;
|
|
totalWidth += renderer.getTextWidth(UI_10_FONT_ID, "<-") + specialKeyPadding;
|
|
totalWidth += renderer.getTextWidth(UI_10_FONT_ID, "CLR") + specialKeyPadding;
|
|
|
|
// Calculate visible window - we'll scroll the character row
|
|
const int availableWidth = pageWidth - bezelLeft - bezelRight - 40; // 40 for margins (20 each side)
|
|
|
|
// Determine scroll offset to keep selected character visible
|
|
int scrollOffset = 0;
|
|
int selectedX = 0;
|
|
int currentX = 0;
|
|
|
|
// Calculate position of selected item
|
|
for (int i = 0; i < totalItems; i++) {
|
|
int itemWidth;
|
|
if (i < charCount) {
|
|
std::string label(1, searchCharacters[i]);
|
|
itemWidth = renderer.getTextWidth(UI_10_FONT_ID, label.c_str()) + charSpacing;
|
|
} else if (i == charCount) {
|
|
itemWidth = renderer.getTextWidth(UI_10_FONT_ID, "SPC") + specialKeyPadding;
|
|
} else if (i == charCount + 1) {
|
|
itemWidth = renderer.getTextWidth(UI_10_FONT_ID, "<-") + specialKeyPadding;
|
|
} else {
|
|
itemWidth = renderer.getTextWidth(UI_10_FONT_ID, "CLR") + specialKeyPadding;
|
|
}
|
|
|
|
if (i == searchCharIndex) {
|
|
selectedX = currentX;
|
|
// Center the selected item in the visible area
|
|
scrollOffset = selectedX - availableWidth / 2 + itemWidth / 2;
|
|
if (scrollOffset < 0) scrollOffset = 0;
|
|
if (scrollOffset > totalWidth - availableWidth) {
|
|
scrollOffset = std::max(0, totalWidth - availableWidth);
|
|
}
|
|
break;
|
|
}
|
|
currentX += itemWidth;
|
|
}
|
|
|
|
// Draw separator line
|
|
renderer.drawLine(bezelLeft + 20, y + 22, pageWidth - bezelRight - 20, y + 22);
|
|
|
|
// Calculate visible area boundaries (leave room for overflow indicators)
|
|
const bool hasLeftOverflow = scrollOffset > 0;
|
|
const bool hasRightOverflow = totalWidth > availableWidth && scrollOffset < totalWidth - availableWidth;
|
|
const int visibleLeft = bezelLeft + 20 + (hasLeftOverflow ? overflowIndicatorWidth : 0);
|
|
const int visibleRight = pageWidth - bezelRight - 20 - (hasRightOverflow ? overflowIndicatorWidth : 0);
|
|
|
|
// Draw characters
|
|
const int startX = bezelLeft + 20 - scrollOffset;
|
|
currentX = startX;
|
|
const bool showSelection = !searchInResults && !inTabBar; // Only show selection when in picker (not tab bar or results)
|
|
|
|
for (int i = 0; i < totalItems; i++) {
|
|
std::string label;
|
|
int itemWidth;
|
|
bool isSpecial = false;
|
|
|
|
if (i < charCount) {
|
|
label = std::string(1, searchCharacters[i]);
|
|
itemWidth = renderer.getTextWidth(UI_10_FONT_ID, label.c_str());
|
|
} else if (i == charCount) {
|
|
label = "SPC";
|
|
itemWidth = renderer.getTextWidth(UI_10_FONT_ID, label.c_str());
|
|
isSpecial = true;
|
|
} else if (i == charCount + 1) {
|
|
label = "<-";
|
|
itemWidth = renderer.getTextWidth(UI_10_FONT_ID, label.c_str());
|
|
isSpecial = true;
|
|
} else {
|
|
label = "CLR";
|
|
itemWidth = renderer.getTextWidth(UI_10_FONT_ID, label.c_str());
|
|
isSpecial = true;
|
|
}
|
|
|
|
// Only draw if visible (accounting for overflow indicator space)
|
|
const int drawX = currentX + (isSpecial ? specialKeyPadding / 2 : 0);
|
|
if (drawX + itemWidth > visibleLeft && drawX < visibleRight) {
|
|
const bool isSelected = showSelection && (i == searchCharIndex);
|
|
|
|
if (isSelected) {
|
|
// Draw inverted background for selection
|
|
constexpr int padding = 2;
|
|
const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
|
|
renderer.fillRect(drawX - padding, y - 2, itemWidth + padding * 2, lineHeight + 2);
|
|
// Draw text inverted (white on black)
|
|
renderer.drawText(UI_10_FONT_ID, drawX, y, label.c_str(), false);
|
|
} else {
|
|
renderer.drawText(UI_10_FONT_ID, drawX, y, label.c_str());
|
|
}
|
|
}
|
|
|
|
currentX += itemWidth + (isSpecial ? specialKeyPadding : charSpacing);
|
|
}
|
|
|
|
// Draw overflow indicators if content extends beyond visible area
|
|
if (totalWidth > availableWidth) {
|
|
constexpr int triangleHeight = 12; // Height of the triangle (vertical)
|
|
constexpr int triangleWidth = 6; // Width of the triangle (horizontal) - thin/elongated
|
|
const int pickerLineHeight = renderer.getLineHeight(UI_10_FONT_ID);
|
|
const int triangleCenterY = y + pickerLineHeight / 2;
|
|
|
|
// Left overflow indicator (more content to the left) - thin triangle pointing left
|
|
if (hasLeftOverflow) {
|
|
// Clear background behind indicator to hide any overlapping text
|
|
renderer.fillRect(bezelLeft, y - 2, overflowIndicatorWidth + 4, pickerLineHeight + 4, false);
|
|
// Draw left-pointing triangle: point on left, base on right
|
|
const int tipX = bezelLeft + 2;
|
|
for (int i = 0; i < triangleWidth; ++i) {
|
|
// Scale height based on position (0 at tip, full height at base)
|
|
const int lineHalfHeight = (triangleHeight * i) / (triangleWidth * 2);
|
|
renderer.drawLine(tipX + i, triangleCenterY - lineHalfHeight,
|
|
tipX + i, triangleCenterY + lineHalfHeight);
|
|
}
|
|
}
|
|
// Right overflow indicator (more content to the right) - thin triangle pointing right
|
|
if (hasRightOverflow) {
|
|
// Clear background behind indicator to hide any overlapping text
|
|
renderer.fillRect(pageWidth - bezelRight - overflowIndicatorWidth - 4, y - 2, overflowIndicatorWidth + 4, pickerLineHeight + 4, false);
|
|
// Draw right-pointing triangle: base on left, point on right
|
|
const int baseX = pageWidth - bezelRight - 2 - triangleWidth;
|
|
for (int i = 0; i < triangleWidth; ++i) {
|
|
// Scale height based on position (full height at base, 0 at tip)
|
|
const int lineHalfHeight = (triangleHeight * (triangleWidth - 1 - i)) / (triangleWidth * 2);
|
|
renderer.drawLine(baseX + i, triangleCenterY - lineHalfHeight,
|
|
baseX + i, triangleCenterY + lineHalfHeight);
|
|
}
|
|
}
|
|
}
|
|
}
|