 ## Summary Adds a full path display at the bottom of the file browser with a separator line matching the header style. Path uses the small font, left-truncates to always show the deepest folder when path is too long. Toggleable via Settings > System > Show Full Path (default on). ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**< PARTIALLY >**_ --------- Co-authored-by: Patryk Radtke <patryk@Patryks-MacBook-Pro.local>
354 lines
12 KiB
C++
354 lines
12 KiB
C++
#include "FileBrowserActivity.h"
|
|
|
|
#include <Epub.h>
|
|
#include <FsHelpers.h>
|
|
#include <GfxRenderer.h>
|
|
#include <HalStorage.h>
|
|
#include <I18n.h>
|
|
|
|
#include <algorithm>
|
|
|
|
#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<std::string>& 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<ConfirmationActivity>(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<int>(files.size());
|
|
buttonNavigator.onNextRelease([this, listSize] {
|
|
selectorIndex = ButtonNavigator::nextIndex(static_cast<int>(selectorIndex), listSize);
|
|
requestUpdate();
|
|
});
|
|
|
|
buttonNavigator.onPreviousRelease([this, listSize] {
|
|
selectorIndex = ButtonNavigator::previousIndex(static_cast<int>(selectorIndex), listSize);
|
|
requestUpdate();
|
|
});
|
|
|
|
buttonNavigator.onNextContinuous([this, listSize, pageItems] {
|
|
selectorIndex = ButtonNavigator::nextPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
|
|
requestUpdate();
|
|
});
|
|
|
|
buttonNavigator.onPreviousContinuous([this, listSize, pageItems] {
|
|
selectorIndex = ButtonNavigator::previousPageIndex(static_cast<int>(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<unsigned char>(*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;
|
|
}
|