Files
Crosspoint/src/activities/home/FileBrowserActivity.cpp
T
Justin Mitchell 526cf1b6f9 Add touch gesture support and LilyGo T5 S3 board
Implements bottom-edge swipe-up gesture detection and delayed touch-select with tracking state. Adds isTouchTapCandidate() to distinguish taps from swipes. Includes LilyGo T5 S3 board configuration with USB CDC logging transport for boards where Serial operator bool reports false incorrectly.
2026-06-19 13:39:10 -04:00

443 lines
15 KiB
C++

#include "FileBrowserActivity.h"
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <I18n.h>
#include <Memory.h>
#include <algorithm>
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "activities/util/ConfirmationActivity.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/BookCacheUtils.h"
namespace {
constexpr unsigned long GO_HOME_MS = 1000;
constexpr size_t NAME_BUFFER_SIZE = 500;
} // namespace
void FileBrowserActivity::loadFiles() {
files.clear();
auto root = Storage.open(basepath.c_str());
if (!root || !root.isDirectory()) {
return;
}
root.rewindDirectory();
if (!fileNameBuffer) {
LOG_ERR("FileBrowser", "fileNameBuffer not allocated");
root.close();
return;
}
for (auto file = root.openNextFile(); file; file = root.openNextFile()) {
file.getName(fileNameBuffer.get(), NAME_BUFFER_SIZE);
if ((!SETTINGS.showHiddenFiles && fileNameBuffer[0] == '.') ||
strcmp(fileNameBuffer.get(), "System Volume Information") == 0) {
continue;
}
if (file.isDirectory()) {
files.emplace_back(std::string(fileNameBuffer.get()) + "/");
} else {
std::string_view filename{fileNameBuffer.get()};
if (mode == Mode::PickFirmware) {
// Firmware picker: only show .bin files.
if (FsHelpers::checkFileExtension(filename, ".bin")) {
files.emplace_back(filename);
}
} else if (FsHelpers::hasEpubExtension(filename) || FsHelpers::hasXtcExtension(filename) ||
FsHelpers::hasTxtExtension(filename) || FsHelpers::hasMarkdownExtension(filename) ||
FsHelpers::hasBmpExtension(filename)) {
files.emplace_back(filename);
}
}
}
root.close();
FsHelpers::sortFileList(files);
}
void FileBrowserActivity::onEnter() {
Activity::onEnter();
fileNameBuffer = makeUniqueNoThrow<char[]>(NAME_BUFFER_SIZE);
if (!fileNameBuffer) {
LOG_ERR("FileBrowser", "malloc failed for name buffer");
return;
}
selectorIndex = 0;
// If Confirm was held while this activity opened (typical when launched from a menu), ignore
// its release — otherwise we'd immediately auto-open whatever is at index 0.
lockNextConfirmRelease = mappedInput.isPressed(MappedInputManager::Button::Confirm);
auto root = Storage.open(basepath.c_str());
if (!root) {
basepath = "/";
loadFiles();
} else if (!root.isDirectory()) {
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 {
loadFiles();
}
requestUpdate();
}
void FileBrowserActivity::onExit() {
Activity::onExit();
files.clear();
fileNameBuffer.reset();
}
// To avoid traversing directories twice (once for cache clearing, once for deletion),
// we do both in one pass here, instead of using Storage.removeDir
bool FileBrowserActivity::removeDirFile(const std::string& fullPath) {
auto file = Storage.open(fullPath.c_str());
if (!file) {
LOG_ERR("FileBrowser", "Failed to open for metadata clearing: %s", fullPath.c_str());
return false;
}
if (!file.isDirectory()) {
file.close();
clearBookCache(fullPath);
return Storage.remove(fullPath.c_str());
}
file.close();
if (!fileNameBuffer) {
LOG_ERR("FileBrowser", "fileNameBuffer not allocated");
return false;
}
// Stack of (dirPath, postOrder): postOrder=true means rmdir this path after children are processed.
std::vector<std::pair<std::string, bool>> stack;
stack.reserve(16);
stack.push_back({fullPath, false});
while (!stack.empty()) {
auto [currentPath, postOrder] = std::move(stack.back());
stack.pop_back();
if (postOrder) {
if (!Storage.rmdir(currentPath.c_str())) {
LOG_ERR("FileBrowser", "Failed to rmdir: %s", currentPath.c_str());
return false;
}
continue;
}
auto dir = Storage.open(currentPath.c_str());
if (!dir) {
LOG_ERR("FileBrowser", "Failed to open dir: %s", currentPath.c_str());
return false;
}
if (!dir.isDirectory()) {
LOG_ERR("FileBrowser", "Not a directory: %s", currentPath.c_str());
return false;
}
// Push this dir for post-order rmdir (after all children are processed).
stack.push_back({currentPath, true});
dir.rewindDirectory();
for (auto entry = dir.openNextFile(); entry; entry = dir.openNextFile()) {
entry.getName(fileNameBuffer.get(), NAME_BUFFER_SIZE);
if (strcmp(fileNameBuffer.get(), ".") == 0 || strcmp(fileNameBuffer.get(), "..") == 0) {
continue;
}
std::string entryPath = currentPath;
if (entryPath.back() != '/') {
entryPath += "/";
}
entryPath += fileNameBuffer.get();
const bool isDir = entry.isDirectory();
entry.close();
if (isDir) {
stack.push_back({std::move(entryPath), false});
} else {
clearBookCache(entryPath);
if (!Storage.remove(entryPath.c_str())) {
LOG_ERR("FileBrowser", "Failed to remove file: %s", entryPath.c_str());
return false;
}
}
}
}
return true;
}
void FileBrowserActivity::loop() {
// Long press BACK (1s+) goes to root folder (Books mode only).
// In firmware-pick mode we keep navigation simple: short Back = up dir / cancel.
if (mode == Mode::Books && 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);
// Vertical swipe page-scrolls the list (touch nav without the side buttons).
int scrollIdx = static_cast<int>(selectorIndex);
if (mappedInput.wasListScroll(scrollIdx, static_cast<int>(files.size()), pageItems)) {
selectorIndex = scrollIdx;
requestUpdate();
return;
}
int downId = -1;
if (mappedInput.wasItemTouchedDown(downId) && downId >= 0 && downId < static_cast<int>(files.size())) {
selectorIndex = downId;
requestUpdate();
}
// A tap opens the tapped entry (held-time is 0 on a tap, so it takes the short-press
// open path below, never the long-press delete).
int tappedId = -1;
const bool tapped = mappedInput.wasItemTapped(tappedId);
if (tapped && tappedId >= 0 && tappedId < static_cast<int>(files.size())) {
selectorIndex = tappedId;
}
if (tapped || mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (lockNextConfirmRelease) {
lockNextConfirmRelease = false;
return;
}
if (files.empty()) return;
const std::string& entry = files[selectorIndex];
bool isDirectory = (entry.back() == '/');
// Firmware picker: select file -> return path; navigate into directories normally.
if (mode == Mode::PickFirmware && !isDirectory) {
std::string cleanBasePath = basepath;
if (cleanBasePath.back() != '/') cleanBasePath += "/";
ActivityResult res{FilePathResult{cleanBasePath + entry}};
res.isCancelled = false;
setResult(std::move(res));
finish();
return;
}
if (mode == Mode::Books && mappedInput.getHeldTime() >= GO_HOME_MS) {
// --- LONG PRESS ACTION: DELETE FILE OR DIRECTORY ---
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());
if (removeDirFile(fullPath)) {
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: %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 if (mode == Mode::PickFirmware) {
// Firmware picker at root: cancel back to caller instead of going home.
ActivityResult res;
res.isCancelled = true;
setResult(std::move(res));
finish();
} 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 =
(mode == Mode::PickFirmware)
? std::string(tr(STR_SELECT_FIRMWARE_FILE))
: ((basepath == "/") ? std::string(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()) {
const char* emptyMsg = (mode == Mode::PickFirmware) ? tr(STR_NO_BIN_FILES) : tr(STR_NO_FILES_FOUND);
renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding, contentTop + 20, emptyMsg);
} 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 char* backLabel = (basepath == "/") ? (mode == Mode::PickFirmware ? tr(STR_BACK) : tr(STR_HOME)) : tr(STR_BACK);
// In PickFirmware mode, Confirm on a .bin returns the path to the caller (not "open"); show
// STR_SELECT instead. Directories in the same picker still descend, so keep STR_OPEN there.
const bool selectingFirmwareFile = mode == Mode::PickFirmware && !files.empty() && files[selectorIndex].back() != '/';
const char* confirmLabel = files.empty() ? "" : (selectingFirmwareFile ? tr(STR_SELECT) : tr(STR_OPEN));
const auto labels = mappedInput.mapLabels(backLabel, confirmLabel, 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;
}