#include "FileBrowserActivity.h" #include #include #include #include #include #include #include "../util/ConfirmationActivity.h" #include "CrossPointSettings.h" #include "MappedInputManager.h" #include "components/UITheme.h" #include "fontIds.h" namespace { constexpr unsigned long GO_HOME_MS = 1000; } // namespace void sortFileList(std::vector& strs) { std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) { // Directories first bool isDir1 = str1.back() == '/'; bool isDir2 = str2.back() == '/'; if (isDir1 != isDir2) return isDir1; // Start naive natural sort const char* s1 = str1.c_str(); const char* s2 = str2.c_str(); // Iterate while both strings have characters while (*s1 && *s2) { // Check if both are at the start of a number if (isdigit(*s1) && isdigit(*s2)) { // Skip leading zeros and track them const char* start1 = s1; const char* start2 = s2; while (*s1 == '0') s1++; while (*s2 == '0') s2++; // Count digits to compare lengths first int len1 = 0, len2 = 0; while (isdigit(s1[len1])) len1++; while (isdigit(s2[len2])) len2++; // Different length so return smaller integer value if (len1 != len2) return len1 < len2; // Same length so compare digit by digit for (int i = 0; i < len1; i++) { if (s1[i] != s2[i]) return s1[i] < s2[i]; } // Numbers equal so advance pointers s1 += len1; s2 += len2; } else { // Regular case-insensitive character comparison char c1 = tolower(*s1); char c2 = tolower(*s2); if (c1 != c2) return c1 < c2; s1++; s2++; } } // One string is prefix of other return *s1 == '\0' && *s2 != '\0'; }); } void FileBrowserActivity::loadFiles() { files.clear(); auto root = Storage.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 ((!SETTINGS.showHiddenFiles && name[0] == '.') || strcmp(name, "System Volume Information") == 0) { file.close(); continue; } if (file.isDirectory()) { files.emplace_back(std::string(name) + "/"); } else { std::string_view filename{name}; if (FsHelpers::hasEpubExtension(filename) || FsHelpers::hasXtcExtension(filename) || FsHelpers::hasTxtExtension(filename) || FsHelpers::hasMarkdownExtension(filename) || FsHelpers::hasBmpExtension(filename)) { files.emplace_back(filename); } } file.close(); } root.close(); sortFileList(files); } void FileBrowserActivity::onEnter() { Activity::onEnter(); selectorIndex = 0; auto root = Storage.open(basepath.c_str()); if (!root) { basepath = "/"; loadFiles(); } else if (!root.isDirectory()) { root.close(); lockLongPressBack = mappedInput.isPressed(MappedInputManager::Button::Back); const std::string oldPath = basepath; basepath = FsHelpers::extractFolderPath(basepath); loadFiles(); const auto pos = oldPath.find_last_of('/'); const std::string fileName = oldPath.substr(pos + 1); selectorIndex = findEntry(fileName); } else { root.close(); loadFiles(); } requestUpdate(); } void FileBrowserActivity::onExit() { Activity::onExit(); files.clear(); } void FileBrowserActivity::clearFileMetadata(const std::string& fullPath) { // Only clear cache for .epub files if (FsHelpers::hasEpubExtension(fullPath)) { Epub(fullPath, "/.crosspoint").clearCache(); LOG_DBG("FileBrowser", "Cleared metadata cache for: %s", fullPath.c_str()); } } void FileBrowserActivity::loop() { // Long press BACK (1s+) goes to root folder // but Long press BACK (1s+) from ReaderActivity sends us here with the MappedInput already set. // So ignore it the first time. if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= GO_HOME_MS && basepath != "/" && !lockLongPressBack) { basepath = "/"; loadFiles(); selectorIndex = 0; requestUpdate(); return; } if (lockLongPressBack && mappedInput.wasReleased(MappedInputManager::Button::Back)) { lockLongPressBack = false; return; } const int pathReserved = renderer.getLineHeight(SMALL_FONT_ID) + UITheme::getInstance().getMetrics().verticalSpacing; const int pageItems = UITheme::getNumberOfItemsPerPage(renderer, true, false, true, false, pathReserved); if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (files.empty()) return; const std::string& entry = files[selectorIndex]; bool isDirectory = (entry.back() == '/'); if (mappedInput.getHeldTime() >= GO_HOME_MS && !isDirectory) { // --- LONG PRESS ACTION: DELETE FILE --- std::string cleanBasePath = basepath; if (cleanBasePath.back() != '/') cleanBasePath += "/"; const std::string fullPath = cleanBasePath + entry; auto handler = [this, fullPath](const ActivityResult& res) { if (!res.isCancelled) { LOG_DBG("FileBrowser", "Attempting to delete: %s", fullPath.c_str()); clearFileMetadata(fullPath); if (Storage.remove(fullPath.c_str())) { LOG_DBG("FileBrowser", "Deleted successfully"); loadFiles(); if (files.empty()) { selectorIndex = 0; } else if (selectorIndex >= files.size()) { // Move selection to the new "last" item selectorIndex = files.size() - 1; } requestUpdate(true); } else { LOG_ERR("FileBrowser", "Failed to delete file: %s", fullPath.c_str()); } } else { LOG_DBG("FileBrowser", "Delete cancelled by user"); } }; std::string heading = tr(STR_DELETE) + std::string("? "); startActivityForResult(std::make_unique(renderer, mappedInput, heading, entry), handler); return; } else { // --- SHORT PRESS ACTION: OPEN/NAVIGATE --- if (basepath.back() != '/') basepath += "/"; if (isDirectory) { basepath += entry.substr(0, entry.length() - 1); loadFiles(); selectorIndex = 0; requestUpdate(); } else { onSelectBook(basepath + entry); } } return; } if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { // Short press: go up one directory, or go home if at root if (mappedInput.getHeldTime() < GO_HOME_MS) { if (basepath != "/") { const std::string oldPath = basepath; basepath.replace(basepath.find_last_of('/'), std::string::npos, ""); if (basepath.empty()) basepath = "/"; loadFiles(); const auto pos = oldPath.find_last_of('/'); const std::string dirName = oldPath.substr(pos + 1) + "/"; selectorIndex = findEntry(dirName); requestUpdate(); } else { onGoHome(); } } } int listSize = static_cast(files.size()); buttonNavigator.onNextRelease([this, listSize] { selectorIndex = ButtonNavigator::nextIndex(static_cast(selectorIndex), listSize); requestUpdate(); }); buttonNavigator.onPreviousRelease([this, listSize] { selectorIndex = ButtonNavigator::previousIndex(static_cast(selectorIndex), listSize); requestUpdate(); }); buttonNavigator.onNextContinuous([this, listSize, pageItems] { selectorIndex = ButtonNavigator::nextPageIndex(static_cast(selectorIndex), listSize, pageItems); requestUpdate(); }); buttonNavigator.onPreviousContinuous([this, listSize, pageItems] { selectorIndex = ButtonNavigator::previousPageIndex(static_cast(selectorIndex), listSize, pageItems); requestUpdate(); }); } std::string getFileName(std::string filename) { if (filename.back() == '/') { filename.pop_back(); if (!UITheme::getInstance().getTheme().showsFileIcons()) { return "[" + filename + "]"; } return filename; } const auto pos = filename.rfind('.'); return filename.substr(0, pos); } std::string getFileExtension(std::string filename) { if (filename.back() == '/') { return ""; } const auto pos = filename.rfind('.'); return filename.substr(pos); } void FileBrowserActivity::render(RenderLock&&) { renderer.clearScreen(); const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); const auto& metrics = UITheme::getInstance().getMetrics(); std::string folderName = (basepath == "/") ? tr(STR_SD_CARD) : basepath.substr(basepath.rfind('/') + 1); GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, folderName.c_str()); const int pathLineHeight = renderer.getLineHeight(SMALL_FONT_ID); const int pathReserved = pathLineHeight + metrics.verticalSpacing; const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing - pathReserved; if (files.empty()) { renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding, contentTop + 20, tr(STR_NO_FILES_FOUND)); } else { GUI.drawList( renderer, Rect{0, contentTop, pageWidth, contentHeight}, files.size(), selectorIndex, [this](int index) { return getFileName(files[index]); }, nullptr, [this](int index) { return UITheme::getFileIcon(files[index]); }, [this](int index) { return getFileExtension(files[index]); }, false); } // Full path display { const int pathY = pageHeight - metrics.buttonHintsHeight - metrics.verticalSpacing - pathLineHeight; const int separatorY = pathY - metrics.verticalSpacing / 2; renderer.drawLine(0, separatorY, pageWidth - 1, separatorY, 3, true); const int pathMaxWidth = pageWidth - metrics.contentSidePadding * 2; // Left-truncate so the deepest directory is always visible const char* pathStr = basepath.c_str(); const char* pathDisplay = pathStr; char leftTruncBuf[256]; if (renderer.getTextWidth(SMALL_FONT_ID, pathStr) > pathMaxWidth) { const char ellipsis[] = "\xe2\x80\xa6"; // UTF-8 ellipsis (…) const int ellipsisWidth = renderer.getTextWidth(SMALL_FONT_ID, ellipsis); const int available = pathMaxWidth - ellipsisWidth; // Walk forward from the start until the suffix fits, skipping UTF-8 continuation bytes const char* p = pathStr; while (*p) { if (renderer.getTextWidth(SMALL_FONT_ID, p) <= available) break; ++p; while (*p && (static_cast(*p) & 0xC0) == 0x80) ++p; } snprintf(leftTruncBuf, sizeof(leftTruncBuf), "%s%s", ellipsis, p); pathDisplay = leftTruncBuf; } renderer.drawText(SMALL_FONT_ID, metrics.contentSidePadding, pathY, pathDisplay); } // Help text const auto labels = mappedInput.mapLabels(basepath == "/" ? tr(STR_HOME) : tr(STR_BACK), files.empty() ? "" : tr(STR_OPEN), files.empty() ? "" : tr(STR_DIR_UP), files.empty() ? "" : tr(STR_DIR_DOWN)); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); } size_t FileBrowserActivity::findEntry(const std::string& name) const { for (size_t i = 0; i < files.size(); i++) if (files[i] == name) return i; return 0; }