diff --git a/lib/FsHelpers/FsHelpers.cpp b/lib/FsHelpers/FsHelpers.cpp index 6b315633..6f686688 100644 --- a/lib/FsHelpers/FsHelpers.cpp +++ b/lib/FsHelpers/FsHelpers.cpp @@ -79,6 +79,53 @@ std::string normalisePath(const std::string& path) { return result; } +bool naturalLess(const std::string& str1, const std::string& str2) { + // Naive natural sort: numeric-aware, case-insensitive + const char* s1 = str1.c_str(); + const char* s2 = str2.c_str(); + + // ctype functions require unsigned char values: passing a negative char (UTF-8 + // bytes above 0x7f with signed char) is undefined behavior + const auto isDigit = [](const char c) { return isdigit(static_cast(c)) != 0; }; + + // 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 + 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 + const int c1 = tolower(static_cast(*s1)); + const int c2 = tolower(static_cast(*s2)); + if (c1 != c2) return c1 < c2; + s1++; + s2++; + } + } + + // One string is prefix of other + return *s1 == '\0' && *s2 != '\0'; +} + void sortFileList(std::vector& strs) { std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) { // Directories first @@ -86,46 +133,7 @@ void sortFileList(std::vector& strs) { 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 - 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'; + return naturalLess(str1, str2); }); } diff --git a/lib/FsHelpers/FsHelpers.h b/lib/FsHelpers/FsHelpers.h index 728b190f..87abd544 100644 --- a/lib/FsHelpers/FsHelpers.h +++ b/lib/FsHelpers/FsHelpers.h @@ -11,6 +11,10 @@ std::string decodeUriEscapes(const std::string& path); std::string normalisePath(const std::string& path); +// Numeric-aware, case-insensitive comparison ("2" < "10"). Returns true when str1 orders +// before str2. Same ordering sortFileList applies within the file/directory groups. +bool naturalLess(const std::string& str1, const std::string& str2); + void sortFileList(std::vector& strs); /** diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index d9174a2a..569646a4 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -70,6 +70,8 @@ STR_IMAGES: "Images" STR_IMAGES_DISPLAY: "Display" STR_IMAGES_PLACEHOLDER: "Placeholder" STR_IMAGES_SUPPRESS: "Suppress" +STR_EOB_HOME: "Home" +STR_EOB_CONTINUE_WITH: "Continue with" STR_SHORT_PWR_BTN: "Short Power Button Click" STR_ORIENTATION: "Reading Orientation" STR_SIDE_BTN_LAYOUT: "Side Button Layout (reader)" diff --git a/src/activities/reader/EndOfBookOptions.cpp b/src/activities/reader/EndOfBookOptions.cpp new file mode 100644 index 00000000..e76b0e9c --- /dev/null +++ b/src/activities/reader/EndOfBookOptions.cpp @@ -0,0 +1,115 @@ +#include "EndOfBookOptions.h" + +#include +#include +#include + +#include "CrossPointSettings.h" +#include "ReaderUtils.h" +#include "components/UITheme.h" +#include "fontIds.h" +#include "util/ButtonNavigator.h" +#include "util/NextBookFinder.h" + +namespace { +// Display name without the file extension, mirroring the file browser rows +std::string displayName(const std::string& filename) { + const auto pos = filename.rfind('.'); + return filename.substr(0, pos); +} +} // namespace + +void EndOfBookOptions::loadOnce(const std::string& currentBookPath) { + if (isLoaded.load(std::memory_order_acquire)) { + return; + } + folder = FsHelpers::extractFolderPath(currentBookPath); + names = NextBookFinder::findNextBooks(currentBookPath, MAX_SUGGESTIONS); + selector = 0; + // Release-publish so the main task, which gates all access on isLoaded, never + // observes a partially built list + isLoaded.store(true, std::memory_order_release); +} + +bool EndOfBookOptions::menuActive() const { return isLoaded.load(std::memory_order_acquire) && !names.empty(); } + +std::string EndOfBookOptions::fullPath(const size_t index) const { + if (index >= names.size()) { + return {}; + } + return folder == "/" ? "/" + names[index] : folder + "/" + names[index]; +} + +EndOfBookOptions::Action EndOfBookOptions::handleMenuInput(const MappedInputManager& input, std::string* openPath) { + if (input.wasReleased(MappedInputManager::Button::Confirm)) { + if (selector < static_cast(names.size())) { + if (openPath) { + *openPath = fullPath(selector); + } + return Action::OpenBook; + } + return Action::GoHome; // "Home" entry selected + } + + // Short-press Back returns to the last page; a long press falls through to the + // reader's own handler (file browser). Home is reached through the list's Home entry. + if (input.wasReleased(MappedInputManager::Button::Back) && input.getHeldTime() < ReaderUtils::GO_HOME_MS) { + return Action::LastPage; + } + + // Selection movement on the standard list navigation buttons (side Up/Down plus front + // Left/Right, orientation swap included). It follows the reader's page-turn semantics + // (press-triggered by default, release-triggered when a long-press behavior is + // configured, same rule as ReaderUtils::detectPageTurn). This matters on entry: with + // press-triggered turns, the press that turned the final page already fired in the + // reader, and its release must not double-fire into this menu. + const bool usePress = SETTINGS.longPressButtonBehavior == CrossPointSettings::OFF; + const auto triggered = [&](const MappedInputManager::Button button) { + return usePress ? input.wasPressed(button) : input.wasReleased(button); + }; + const int itemCount = static_cast(names.size()) + 1; // + "Home" entry + if (triggered(MappedInputManager::Button::NavPrevious)) { + selector = ButtonNavigator::previousIndex(selector, itemCount); // wraps to the bottom + return Action::Redraw; + } + if (triggered(MappedInputManager::Button::NavNext)) { + selector = ButtonNavigator::nextIndex(selector, itemCount); // wraps to the top + return Action::Redraw; + } + return Action::None; +} + +void EndOfBookOptions::render(GfxRenderer& renderer, const MappedInputManager& input) const { + const auto& metrics = UITheme::getInstance().getMetrics(); + + if (!menuActive()) { + // No suggestions: the historical plain end screen. 3/8 of the screen height matches + // the previous fixed position on the 480x800 panel and scales to other resolutions. + renderer.drawCenteredText(UI_12_FONT_ID, renderer.getScreenHeight() * 3 / 8, tr(STR_END_OF_BOOK), true, + EpdFontFamily::BOLD); + return; + } + + // Suggestion menu: title, list (+ Home entry) and button hints. The hints are drawn at + // the physical front buttons, which is a logical side/top edge in the rotated + // orientations — lay out inside the safe area so nothing hides behind them. Vertical + // positions derive from the safe-area height and font line heights so other panel + // resolutions scale (review request on #2532). + const Rect safe = UITheme::getInstance().getScreenSafeArea(renderer, true, false); + const int titleY = safe.y + safe.height / 8; + const int subtitleY = titleY + renderer.getLineHeight(UI_12_FONT_ID) + metrics.verticalSpacing; + const int listTop = subtitleY + renderer.getLineHeight(UI_10_FONT_ID) + metrics.verticalSpacing * 2; + + UITheme::drawCenteredText(renderer, safe, UI_12_FONT_ID, titleY, tr(STR_END_OF_BOOK), true, EpdFontFamily::BOLD); + UITheme::drawCenteredText(renderer, safe, UI_10_FONT_ID, subtitleY, tr(STR_EOB_CONTINUE_WITH)); + + const int listHeight = safe.y + safe.height - listTop - metrics.verticalSpacing; + GUI.drawList(renderer, Rect{safe.x, listTop, safe.width, listHeight}, static_cast(names.size()) + 1, selector, + [this](const int index) { + return index < static_cast(names.size()) ? displayName(names[index]) + : std::string(tr(STR_EOB_HOME)); + }); + + const auto labels = input.mapLabels(tr(STR_BACK), tr(STR_OPEN), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); +} diff --git a/src/activities/reader/EndOfBookOptions.h b/src/activities/reader/EndOfBookOptions.h new file mode 100644 index 00000000..ed0ff940 --- /dev/null +++ b/src/activities/reader/EndOfBookOptions.h @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include + +class GfxRenderer; +class MappedInputManager; + +// Shared End-of-Book next-book menu for the EPUB and XTC readers. Collects up to +// MAX_SUGGESTIONS sibling books once per reader session, handles the menu input, and +// draws the end screen. With no suggestions the end screen keeps its historical +// plain-title look and behavior. +class EndOfBookOptions { + public: + enum class Action { None, Redraw, OpenBook, GoHome, LastPage }; + + static constexpr size_t MAX_SUGGESTIONS = 3; + + // Scans the book's folder for suggestions; no-op when already loaded. Call ONLY from + // the reader's render() (the render task, serialized by RenderLock) — the loaded flag + // is the release/acquire publication point that lets the main task read the finished + // list safely. + void loadOnce(const std::string& currentBookPath); + + // True when the suggestion menu is showing and should own the reader's input. + bool menuActive() const; + + // Menu input handling, following the standard list idiom: side Up/Down and front + // Left/Right move the selection (wrapping), Confirm opens it (or Home), and a short + // Back press returns to the last page of the book. Fills openPath when the result is + // OpenBook. Returns Action::None when nothing relevant was pressed; callers continue + // their normal input path (keeping long-press Back to the file browser working). + Action handleMenuInput(const MappedInputManager& input, std::string* openPath); + + // Draws the full end screen (plain title, or the suggestion menu) onto a cleared buffer. + void render(GfxRenderer& renderer, const MappedInputManager& input) const; + + private: + std::string folder; + // Written by the render task in loadOnce(), immutable afterwards; the main task only + // reads it after isLoaded is observed true (acquire), so no further locking is needed. + std::vector names; + int selector = 0; + std::atomic isLoaded{false}; + + std::string fullPath(size_t index) const; +}; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index e7f47deb..dcc75cd2 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -231,6 +231,30 @@ void EpubReaderActivity::onExit() { } } +void EpubReaderActivity::openReaderMenu() { + const int currentPage = section ? section->currentPage + 1 : 0; + const int totalPages = section ? section->estimatedTotalPages() : 0; + float bookProgress = 0.0f; + if (epub->getBookSize() > 0 && section && section->estimatedTotalPages() > 0) { + const float chapterProgress = + static_cast(section->currentPage) / static_cast(section->estimatedTotalPages()); + bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f; + } + const int bookProgressPercent = clampPercent(static_cast(bookProgress + 0.5f)); + startActivityForResult(std::make_unique( + renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent, + SETTINGS.orientation, !currentPageFootnotes.empty(), !cachedBookmarks.empty()), + [this](const ActivityResult& result) { + // Always apply orientation change even if the menu was cancelled + const auto& menu = std::get(result.data); + applyOrientation(menu.orientation); + toggleAutoPageTurn(menu.pageTurnOption); + if (!result.isCancelled) { + onReaderMenuConfirm(static_cast(menu.action)); + } + }); +} + void EpubReaderActivity::loop() { if (!epub) { // Should never happen @@ -325,6 +349,35 @@ void EpubReaderActivity::loop() { requestUpdate(); } + // While the end screen suggestion menu is showing it owns Confirm/Back/navigation + // input. Anything it doesn't handle (e.g. long-press Back to the file browser) falls + // through to the regular handlers below; page turns are absorbed by the end-of-book + // block. A Confirm release after a long-press function (bookmark/sync) fired is left + // to the regular Confirm handler below, which consumes it via ignoreNextConfirmRelease. + if (atEndOfBook && endOfBookOptions.menuActive() && + !(ignoreNextConfirmRelease && mappedInput.wasReleased(MappedInputManager::Button::Confirm))) { + std::string openPath; + switch (endOfBookOptions.handleMenuInput(mappedInput, &openPath)) { + case EndOfBookOptions::Action::OpenBook: + activityManager.goToReader(openPath); + return; + case EndOfBookOptions::Action::GoHome: + onGoHome(); + return; + case EndOfBookOptions::Action::LastPage: + currentSpineIndex = std::max(epub->getSpineItemsCount() - 1, 0); + nextPageNumber = 0; + pendingPageJump = std::numeric_limits::max(); + requestUpdate(); + return; + case EndOfBookOptions::Action::Redraw: + requestUpdate(); + return; + case EndOfBookOptions::Action::None: + break; + } + } + // Enter reader menu activity on short-press Confirm. A long-press that fired a bound // function (bookmark or KOReader sync) sets ignoreNextConfirmRelease so the release // following the hold does not also open the menu. @@ -332,27 +385,7 @@ void EpubReaderActivity::loop() { if (ignoreNextConfirmRelease) { ignoreNextConfirmRelease = false; } else { - const int currentPage = section ? section->currentPage + 1 : 0; - const int totalPages = section ? section->estimatedTotalPages() : 0; - float bookProgress = 0.0f; - if (epub->getBookSize() > 0 && section && section->estimatedTotalPages() > 0) { - const float chapterProgress = - static_cast(section->currentPage) / static_cast(section->estimatedTotalPages()); - bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f; - } - const int bookProgressPercent = clampPercent(static_cast(bookProgress + 0.5f)); - startActivityForResult(std::make_unique( - renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent, - SETTINGS.orientation, !currentPageFootnotes.empty(), !cachedBookmarks.empty()), - [this](const ActivityResult& result) { - // Always apply orientation change even if the menu was cancelled - const auto& menu = std::get(result.data); - applyOrientation(menu.orientation); - toggleAutoPageTurn(menu.pageTurnOption); - if (!result.isCancelled) { - onReaderMenuConfirm(static_cast(menu.action)); - } - }); + openReaderMenu(); } } @@ -433,8 +466,14 @@ void EpubReaderActivity::loop() { return; } - // At end of the book, forward button goes home and back button returns to last page + // At end of the book with no suggestion menu, forward button goes home and back + // button returns to last page if (currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount()) { + if (endOfBookOptions.menuActive()) { + // Selection movement was handled above; absorb leftover page-turn triggers so + // e.g. "previous" at the top of the list doesn't jump back into the book + return; + } if (nextTriggered) { onGoHome(); } else { @@ -865,8 +904,11 @@ void EpubReaderActivity::render(RenderLock&& lock) { // Show end of book screen if (currentSpineIndex == epub->getSpineItemsCount()) { + // Sole load site: runs on the render task (serialized by RenderLock); the main + // task only reads the suggestions once the loaded flag is published + endOfBookOptions.loadOnce(epub->getPath()); renderer.clearScreen(); - renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_END_OF_BOOK), true, EpdFontFamily::BOLD); + endOfBookOptions.render(renderer, mappedInput); renderer.displayBuffer(); automaticPageTurnActive = false; showPendingSyncSaveError(); diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 691a0b07..503bb23e 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -6,6 +6,7 @@ #include #include "BookmarkEntry.h" +#include "EndOfBookOptions.h" #include "EpubReaderMenuActivity.h" #include "ProgressMapper.h" #include "activities/Activity.h" @@ -45,6 +46,8 @@ class EpubReaderActivity final : public Activity { // Set when the reader is left at end-of-book and SETTINGS.moveFinishedToReadFolder is on. // Consumed in onExit() to relocate the finished book into /Read/. bool pendingReadFolderMove = false; + // Next-book suggestion menu for the End-of-Book screen + EndOfBookOptions endOfBookOptions; // Footnote support std::vector currentPageFootnotes; @@ -88,6 +91,8 @@ class EpubReaderActivity final : public Activity { // Jump to a percentage of the book (0-100), mapping it to spine and page. void jumpToPercent(int percent); void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action); + // Opens the reader menu for the current position (short-press Confirm) + void openReaderMenu(); // Returns true if sync acted (launched, or surfaced a save error); false if it was a no-op // because no KOReader credentials are stored. bool launchKOReaderSync(); diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index d4af3e9b..1d1923eb 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -53,18 +53,52 @@ void XtcReaderActivity::onExit() { xtc.reset(); } +void XtcReaderActivity::openChapterSelection() { + if (xtc && xtc->hasChapters() && !xtc->getChapters().empty()) { + startActivityForResult(std::make_unique(renderer, mappedInput, xtc, currentPage), + [this](const ActivityResult& result) { + if (!result.isCancelled) { + currentPage = std::get(result.data).page; + } + }); + } +} + void XtcReaderActivity::loop() { + if (!xtc) { + return; + } + + const bool atEndOfBook = currentPage >= xtc->getPageCount(); + + // While the end screen suggestion menu is showing it owns Confirm/Back/navigation + // input. Anything it doesn't handle (e.g. long-press Back to the file browser) falls + // through to the regular handlers below; page turns are absorbed by the end-of-book + // block. + if (atEndOfBook && endOfBookOptions.menuActive()) { + std::string openPath; + switch (endOfBookOptions.handleMenuInput(mappedInput, &openPath)) { + case EndOfBookOptions::Action::OpenBook: + activityManager.goToReader(openPath); + return; + case EndOfBookOptions::Action::GoHome: + onGoHome(); + return; + case EndOfBookOptions::Action::LastPage: + currentPage = xtc->getPageCount() > 0 ? xtc->getPageCount() - 1 : 0; + requestUpdate(); + return; + case EndOfBookOptions::Action::Redraw: + requestUpdate(); + return; + case EndOfBookOptions::Action::None: + break; + } + } + // Enter chapter selection activity if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { - if (xtc && xtc->hasChapters() && !xtc->getChapters().empty()) { - startActivityForResult( - std::make_unique(renderer, mappedInput, xtc, currentPage), - [this](const ActivityResult& result) { - if (!result.isCancelled) { - currentPage = std::get(result.data).page; - } - }); - } + openChapterSelection(); } // Long press BACK (1s+) goes to file selection @@ -85,8 +119,14 @@ void XtcReaderActivity::loop() { return; } - // At end of the book, forward button goes home and back button returns to last page + // At end of the book with no suggestion menu, forward button goes home and back + // button returns to last page if (currentPage >= xtc->getPageCount()) { + if (endOfBookOptions.menuActive()) { + // Selection movement was handled above; absorb leftover page-turn triggers so + // e.g. "previous" at the top of the list doesn't jump back into the book + return; + } if (nextTriggered) { onGoHome(); } else { @@ -123,9 +163,11 @@ void XtcReaderActivity::render(RenderLock&&) { // Bounds check if (currentPage >= xtc->getPageCount()) { - // Show end of book screen + // Show end of book screen. Sole load site: runs on the render task (serialized by + // RenderLock); the main task only reads the suggestions once the flag is published. + endOfBookOptions.loadOnce(xtc->getPath()); renderer.clearScreen(); - renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_END_OF_BOOK), true, EpdFontFamily::BOLD); + endOfBookOptions.render(renderer, mappedInput); renderer.displayBuffer(); return; } diff --git a/src/activities/reader/XtcReaderActivity.h b/src/activities/reader/XtcReaderActivity.h index f020b0b0..872b20c7 100644 --- a/src/activities/reader/XtcReaderActivity.h +++ b/src/activities/reader/XtcReaderActivity.h @@ -12,6 +12,7 @@ #include #include +#include "EndOfBookOptions.h" #include "activities/Activity.h" class XtcReaderActivity final : public Activity { @@ -19,6 +20,8 @@ class XtcReaderActivity final : public Activity { uint32_t currentPage = 0; int pagesUntilFullRefresh = 0; + // Next-book suggestion menu for the End-of-Book screen + EndOfBookOptions endOfBookOptions; enum class StatusBarOverlayPosition { Bottom, Top }; struct StatusBarInfo { @@ -28,6 +31,8 @@ class XtcReaderActivity final : public Activity { }; void renderPage(); + // Opens chapter selection when the book has chapters (short-press Confirm); no-op otherwise + void openChapterSelection(); void renderStatusBarOverlay(StatusBarOverlayPosition position) const; StatusBarInfo getStatusBarInfo() const; void saveProgress() const; diff --git a/src/util/NextBookFinder.cpp b/src/util/NextBookFinder.cpp new file mode 100644 index 00000000..63fe3f85 --- /dev/null +++ b/src/util/NextBookFinder.cpp @@ -0,0 +1,85 @@ +#include "NextBookFinder.h" + +#include +#include +#include +#include + +#include +#include + +#include "CrossPointSettings.h" + +namespace { +constexpr size_t NAME_BUFFER_SIZE = 500; + +bool isSupportedBookFile(const std::string_view name) { + // Formats ReaderActivity can open (bmp is a viewer, not a book, so it is excluded) + return FsHelpers::hasEpubExtension(name) || FsHelpers::hasXtcExtension(name) || FsHelpers::hasTxtExtension(name) || + FsHelpers::hasMarkdownExtension(name); +} +} // namespace + +std::vector NextBookFinder::findNextBooks(const std::string& currentBookPath, const size_t maxCount) { + std::vector result; + if (maxCount == 0 || currentBookPath.empty()) { + return result; + } + + const std::string folder = FsHelpers::extractFolderPath(currentBookPath); + const auto lastSlash = currentBookPath.find_last_of('/'); + const std::string currentName = + lastSlash == std::string::npos ? currentBookPath : currentBookPath.substr(lastSlash + 1); + + auto dir = Storage.open(folder.c_str()); + if (!dir || !dir.isDirectory()) { + LOG_ERR("NBF", "Cannot open folder: %s", folder.c_str()); + return result; + } + dir.rewindDirectory(); + + const auto nameBuffer = makeUniqueNoThrow(NAME_BUFFER_SIZE); + if (!nameBuffer) { + LOG_ERR("NBF", "OOM: %d bytes", static_cast(NAME_BUFFER_SIZE)); + dir.close(); + return result; + } + + // Heap use is bounded: at most maxCount+1 short filename strings live at once (the + // file browser holds a whole folder in the same std::string form). A failed + // allocation here would abort like any STL growth in this codebase; the reserve + // below makes vector growth a single up-front allocation. + result.reserve(maxCount + 1); + const auto less = [](const std::string& a, const std::string& b) { return FsHelpers::naturalLess(a, b); }; + + for (auto file = dir.openNextFile(); file; file = dir.openNextFile()) { + if (file.isDirectory()) { + continue; + } + file.getName(nameBuffer.get(), NAME_BUFFER_SIZE); + if (!SETTINGS.showHiddenFiles && nameBuffer[0] == '.') { + continue; + } + if (!isSupportedBookFile(nameBuffer.get())) { + continue; + } + std::string name{nameBuffer.get()}; + // Keep only files ordering strictly after the current one; equal names (the book + // itself, or a case-variant of it) compare "not less" both ways and drop out here. + if (!FsHelpers::naturalLess(currentName, name)) { + continue; + } + // Bounded insertion sort: keep the maxCount lowest-ordering candidates + if (result.size() >= maxCount && !less(name, result.back())) { + continue; + } + const auto pos = std::lower_bound(result.begin(), result.end(), name, less); + result.insert(pos, std::move(name)); + if (result.size() > maxCount) { + result.pop_back(); + } + } + dir.close(); + + return result; +} diff --git a/src/util/NextBookFinder.h b/src/util/NextBookFinder.h new file mode 100644 index 00000000..1f6bb0fd --- /dev/null +++ b/src/util/NextBookFinder.h @@ -0,0 +1,15 @@ +#pragma once + +#include +#include + +namespace NextBookFinder { + +// Collects up to maxCount book files that order after currentBookPath's filename +// (natural sort, same ordering as the file browser) within the same folder. +// Returns bare filenames in sorted order; the current file itself is excluded. +// Single directory pass keeping only the maxCount best matches, so memory stays +// bounded regardless of folder size. +std::vector findNextBooks(const std::string& currentBookPath, size_t maxCount); + +} // namespace NextBookFinder